From c8c371230f864ee64a5a393a6048c11ac6438ad8 Mon Sep 17 00:00:00 2001 From: servius Date: Fri, 16 Jan 2026 11:33:55 +0530 Subject: [PATCH] feat(api): return (Self, AuthenticationResult) from authenticate --- api/src/lib.rs | 29 ++++- ui-iced/src/lib.rs | 124 ++++++++++++------ ui-iced/src/settings.rs | 272 +++++++++++++++++++++++++++++++++++----- 3 files changed, 354 insertions(+), 71 deletions(-) diff --git a/api/src/lib.rs b/api/src/lib.rs index ed52a16..17928cb 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -34,10 +34,10 @@ impl JellyfinClient { username: impl AsRef, password: impl AsRef, config: JellyfinConfig, - ) -> Result { + ) -> Result<(Self, jellyfin::AuthenticationResult)> { let url = format!("{}/Users/AuthenticateByName", config.server_url); let client = reqwest::Client::new(); - let token = client + let auth_result = client .post(url) .json(&jellyfin::AuthenticateUserByName { username: Some(username.as_ref().to_string()), @@ -47,10 +47,14 @@ impl JellyfinClient { .await? .error_for_status()? .json::() - .await? + .await?; + + let token = auth_result .access_token + .as_ref() .ok_or_else(|| std::io::Error::other("No field access_token in auth response"))?; - Self::pre_authenticated(token, config) + + Ok((Self::pre_authenticated(token, config)?, auth_result)) } pub fn pre_authenticated(token: impl AsRef, config: JellyfinConfig) -> Result { @@ -80,6 +84,10 @@ impl JellyfinClient { } } + pub fn access_token(&self) -> Option<&str> { + self.access_token.as_deref().map(|s| &*s) + } + pub async fn save_token(&self, path: impl AsRef) -> std::io::Result<()> { if let Some(token) = &self.access_token { tokio::fs::write(path, &**token).await @@ -104,6 +112,19 @@ impl JellyfinClient { self.access_token = Some(token.as_ref().into()); } + pub async fn me(&self) -> Result { + let uri = "Users/Me"; + let text = self + .request_builder(reqwest::Method::GET, uri) + .send() + .await? + .error_for_status()? + .text() + .await?; + let out: jellyfin::UserDto = serde_json::from_str(&text)?; + Ok(out) + } + pub fn request_builder( &self, method: reqwest::Method, diff --git a/ui-iced/src/lib.rs b/ui-iced/src/lib.rs index 5ed259d..647b41e 100644 --- a/ui-iced/src/lib.rs +++ b/ui-iced/src/lib.rs @@ -141,6 +141,7 @@ struct State { settings: settings::SettingsState, is_authenticated: bool, video: Option>>, + user: Option, } impl State { @@ -159,12 +160,14 @@ impl State { // password_input: String::new(), is_authenticated: false, video: None, + user: None, } } } -#[derive(Debug, Clone)] +#[derive(Clone, Debug)] pub enum Message { + Noop, Settings(settings::SettingsMessage), Refresh, Search, @@ -175,17 +178,30 @@ pub enum Message { SetToken(String), Back, Home, - // Login { - // username: String, - // password: String, - // config: api::JellyfinConfig, - // }, - // LoginSuccess(String), - // LoadedClient(api::JellyfinClient, bool), - // Logout, Video(video::VideoMessage), } +// impl std::fmt::Debug for Message { +// fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +// match self { +// Message::Settings(msg) => f.debug_tuple("Settings").field(msg).finish(), +// Message::Refresh => f.write_str("Refresh"), +// Message::Search => f.write_str("Search"), +// Message::SearchQueryChanged(q) => f.debug_tuple("SearchQueryChanged").field(q).finish(), +// Message::OpenItem(id) => f.debug_tuple("OpenItem").field(id).finish(), +// Message::LoadedItem(id, items) => { +// f.debug_tuple("LoadedItem").field(id).field(items).finish() +// } +// Message::Error(e) => f.debug_tuple("Error").field(e).finish(), +// Message::SetToken(_t) => f.debug_tuple("SetToken").field(&"...").finish(), // Mask token +// Message::Back => f.write_str("Back"), +// Message::Home => f.write_str("Home"), +// Message::Video(msg) => f.debug_tuple("Video").field(msg).finish(), +// Message::Noop => f.write_str("Noop"), +// } +// } +// } + fn update(state: &mut State, message: Message) -> Task { match message { Message::Settings(msg) => settings::update(state, msg), @@ -288,14 +304,40 @@ fn update(state: &mut State, message: Message) -> Task { } } Message::Video(msg) => video::update(state, msg), - _ => todo!(), + Message::Noop => Task::none(), } } fn view(state: &State) -> Element<'_, Message> { - match state.screen { - Screen::Settings => settings::settings(state), - Screen::Home | _ => home(state), + let content = home(state); + + if matches!(state.screen, Screen::Settings) { + stack![ + content, + mouse_area( + container(mouse_area(settings::settings(state)).on_press(Message::Refresh)) + .width(Length::Fill) + .height(Length::Fill) + .align_x(Alignment::Center) + .align_y(Alignment::Center) + .style(|_theme| { + container::Style { + background: Some( + iced::Color { + a: 0.3, + ..iced::Color::BLACK + } + .into(), + ), + ..container::Style::default() + } + }) + ) + .on_press(Message::Settings(settings::SettingsMessage::Close)), + ] + .into() + } else { + content } } @@ -328,27 +370,31 @@ fn body(state: &State) -> Element<'_, Message> { } fn header(state: &State) -> Element<'_, Message> { - row([ - container( - Button::new( - Text::new( - state - .jellyfin_client - .as_ref() - .map(|c| c.config.server_url.as_str()) - .unwrap_or("No Server"), - ) - .align_x(Alignment::Start), - ) - .on_press(Message::Home), + let mut left_content = row![ + text( + state + .jellyfin_client + .as_ref() + .map(|c| c.config.server_url.as_str()) + .unwrap_or("No Server"), ) - .padding(10) - .width(Length::Fill) - .height(Length::Fill) - .align_x(Alignment::Start) - .align_y(Alignment::Center) - .style(container::rounded_box) - .into(), + .align_x(Alignment::Start), + ]; + + if let Some(user) = &state.user { + left_content = + left_content.push(text(format!(" | {}", user.name.as_deref().unwrap_or("?")))); + } + + row([ + container(Button::new(left_content).on_press(Message::Home)) + .padding(10) + .width(Length::Fill) + .height(Length::Fill) + .align_x(Alignment::Start) + .align_y(Alignment::Center) + .style(container::rounded_box) + .into(), search(state), container( row([ @@ -474,8 +520,14 @@ fn init() -> (State, Task) { match std::fs::read_to_string(".session") { Ok(token) => { let client = api::JellyfinClient::pre_authenticated(token.trim(), config)?; + + // We need to fetch the current user to fully restore state + // let _user_id = client.user_id.unwrap_or_default(); // user_id field doesn't exist on client + + // We will fetch the user info in the chain if we are authenticated Ok((client, true)) } + Err(_) => { // No cached token, create unauthenticated client let client = api::JellyfinClient::new_with_config(config); @@ -484,12 +536,10 @@ fn init() -> (State, Task) { } }, |result: Result<_, api::JellyfinApiError>| match result { - // Ok((client, is_authenticated)) => Message::LoadedClient(client, is_authenticated), + Ok((client, is_auth)) => Message::LoadedClient(client, is_auth), Err(e) => Message::Error(format!("Initialization failed: {}", e)), - _ => Message::Error("Login Unimplemented".to_string()), }, - ) - .chain(Task::done(Message::Refresh)), + ), // .chain(Task::done(Message::Refresh)), ) } diff --git a/ui-iced/src/settings.rs b/ui-iced/src/settings.rs index c7b9b9f..0bad62e 100644 --- a/ui-iced/src/settings.rs +++ b/ui-iced/src/settings.rs @@ -10,20 +10,61 @@ pub fn update(state: &mut State, message: SettingsMessage) -> Task { SettingsMessage::Open => { tracing::trace!("Opening settings"); state.screen = Screen::Settings; + Task::none() } SettingsMessage::Close => { tracing::trace!("Closing settings"); state.screen = Screen::Home; + Task::none() } SettingsMessage::Select(screen) => { tracing::trace!("Switching settings screen to {:?}", screen); state.settings.screen = screen; + Task::none() + } + // LoginResult(Result<(api::JellyfinClient, api::jellyfin::AuthenticationResult), String>), + // LoadedClient(api::JellyfinClient, bool), + // UserLoaded(Result), + // ConfigSaved(Result), + // Logout, + SettingsMessage::User(user) => { + if let UserMessage::Add = user { + // Handle adding user / login + let username = state.settings.login_form.username.clone(); + let password = state.settings.login_form.password.clone(); + + let mut config = api::JellyfinConfig { + server_url: "http://localhost:8096".parse().unwrap(), // Default fallback + device_id: "jello-iced".to_string(), + device_name: "Jello Iced".to_string(), + client_name: "Jello".to_string(), + version: "0.1.0".to_string(), + }; + + // Try to use existing config if possible + if let Some(client) = &state.jellyfin_client { + config = client.config.as_ref().clone(); + } else if let Ok(config_str) = std::fs::read_to_string("config.toml") { + if let Ok(loaded_config) = toml::from_str(&config_str) { + config = loaded_config; + } + } + + return Task::perform( + async move { + api::JellyfinClient::authenticate(username, password, config) + .await + .map_err(|e| e.to_string()) + }, + Message::LoginResult, + ); + } + state.settings.login_form.update(user); + Task::none() } - SettingsMessage::User(user) => state.settings.login_form.update(user), SettingsMessage::Server(server) => state.settings.server_form.update(server), } - Task::none() } pub fn empty() -> Element<'static, Message> { @@ -32,9 +73,9 @@ pub fn empty() -> Element<'static, Message> { #[derive(Debug, Clone, Default)] pub struct SettingsState { - login_form: LoginForm, - server_form: ServerForm, - screen: SettingsScreen, + pub login_form: LoginForm, + pub server_form: ServerForm, + pub screen: SettingsScreen, } #[derive(Debug, Clone)] @@ -54,6 +95,7 @@ pub enum UserMessage { // Edit(uuid::Uuid), // Delete(uuid::Uuid), Clear, + Error(String), } #[derive(Debug, Clone)] @@ -90,8 +132,9 @@ pub struct UserItem { #[derive(Debug, Clone, Default)] pub struct LoginForm { - username: String, - password: String, + pub username: String, + pub password: String, + pub error: Option, } impl LoginForm { @@ -99,9 +142,11 @@ impl LoginForm { match message { UserMessage::UsernameChanged(data) => { self.username = data; + self.error = None; // Clear error on input } UserMessage::PasswordChanged(data) => { self.password = data; + self.error = None; // Clear error on input } UserMessage::Add => { // Handle adding user @@ -109,30 +154,48 @@ impl LoginForm { UserMessage::Clear => { self.username.clear(); self.password.clear(); + self.error = None; + } + UserMessage::Error(msg) => { + self.error = Some(msg); } } } - pub fn view(&self) -> Element<'_, Message> { - iced::widget::column![ - text("Login Form"), - text_input("Enter Username", &self.username).on_input(|data| { - Message::Settings(SettingsMessage::User(UserMessage::UsernameChanged(data))) - }), - text_input("Enter Password", &self.password) - .secure(true) - .on_input(|data| { - Message::Settings(SettingsMessage::User(UserMessage::PasswordChanged(data))) + pub fn view(&self, is_authenticated: bool) -> Element<'_, Message> { + let mut col = iced::widget::column![text("Login Form"),]; + + if !is_authenticated { + let mut inputs = iced::widget::column![ + text_input("Enter Username", &self.username).on_input(|data| { + Message::Settings(SettingsMessage::User(UserMessage::UsernameChanged(data))) }), - row![ - button(text("Add User")).on_press_maybe(self.validate()), - button(text("Cancel")) - .on_press(Message::Settings(SettingsMessage::User(UserMessage::Clear))), + text_input("Enter Password", &self.password) + .secure(true) + .on_input(|data| { + Message::Settings(SettingsMessage::User(UserMessage::PasswordChanged(data))) + }), ] - .spacing(10), - ] - .spacing(10) - .padding([10, 0]) - .into() + .spacing(10); + + if let Some(err) = &self.error { + inputs = inputs.push(text(err).style(|_| text::Style { + color: Some(iced::Color::from_rgb(0.8, 0.0, 0.0)), + })); + } + + col = col.push(inputs).push( + row![ + button(text("Login")).on_press_maybe(self.validate()), + button(text("Cancel")) + .on_press(Message::Settings(SettingsMessage::User(UserMessage::Clear))), + ] + .spacing(10), + ); + } else { + col = col.push(row![button(text("Logout")).on_press(Message::Logout)].spacing(10)); + } + + col.spacing(10).padding([10, 0]).into() } pub fn validate(&self) -> Option { @@ -148,7 +211,7 @@ pub struct ServerForm { } impl ServerForm { - pub fn update(&mut self, message: ServerMessage) { + pub fn update(&mut self, message: ServerMessage) -> Task { match message { ServerMessage::NameChanged(data) => { self.name = data; @@ -157,7 +220,36 @@ impl ServerForm { self.url = data; } ServerMessage::Add => { - // Handle adding server + // Handle adding server (saving config) + let name = self.name.clone(); + let url_str = self.url.clone(); + + return Task::perform( + async move { + // Try to parse the URL + let url_parsed = url::Url::parse(&url_str) + .or_else(|_| url::Url::parse(&format!("http://{}", url_str))) + .map_err(|e| format!("Invalid URL: {}", e))?; + + // Create new config + let config = api::JellyfinConfig { + server_url: url_parsed.to_string().parse().unwrap(), + device_id: "jello-iced".to_string(), + device_name: name, + client_name: "Jello".to_string(), + version: "0.1.0".to_string(), + }; + + // Save to config.toml + let toml_str = toml::to_string(&config) + .map_err(|e| format!("Failed to serialize config: {}", e))?; + std::fs::write("config.toml", toml_str) + .map_err(|e| format!("Failed to write config file: {}", e))?; + + Ok(config) + }, + Message::ConfigSaved, + ); } ServerMessage::Clear => { self.name.clear(); @@ -165,6 +257,7 @@ impl ServerForm { } _ => {} } + Task::none() } pub fn view(&self) -> Element<'_, Message> { iced::widget::column![ @@ -197,7 +290,15 @@ impl ServerForm { mod screens { use super::*; pub fn settings(state: &State) -> Element<'_, Message> { - row([settings_list(state), settings_screen(state)]).into() + container( + row([settings_list(state), settings_screen(state)]) + .spacing(20) + .width(Length::Fixed(800.0)) + .height(Length::Fixed(600.0)), + ) + .padding(20) + .style(container::rounded_box) + .into() } pub fn settings_screen(state: &State) -> Element<'_, Message> { @@ -256,10 +357,24 @@ mod screens { .into() } pub fn user(state: &State) -> Element<'_, Message> { + let user_display = if let Some(user) = &state.user { + iced::widget::column![ + text(format!( + "Logged in as: {}", + user.name.as_deref().unwrap_or("Unknown") + )), + // We could add an avatar here if we had image loading for it + ] + .spacing(10) + } else { + iced::widget::column![].into() + }; + container( Column::new() .push(text("User Settings")) - .push(state.settings.login_form.view()) + .push(user_display) + .push(state.settings.login_form.view(state.is_authenticated)) // .push(userlist(&state)) .spacing(20) .padding(20), @@ -274,3 +389,100 @@ pub fn center_text(content: &str) -> Element<'_, Message> { .width(Length::Fill) .into() } + +// Message::ConfigSaved(result) => { +// match result { +// Ok(config) => { +// tracing::info!("Configuration saved successfully."); +// state.messages.push("Configuration saved.".to_string()); +// +// // Re-initialize client with new config +// // This invalidates the current session as the server might have changed +// state.jellyfin_client = Some(api::JellyfinClient::new_with_config(config)); +// state.is_authenticated = false; +// state.user = None; +// +// // Clear session file as it likely belongs to the old server +// let _ = std::fs::remove_file(".session"); +// +// // Reset cache +// state.cache = ItemCache::default(); +// state.current = None; +// state.history.clear(); +// +// Task::none() +// } +// Err(e) => { +// tracing::error!("Failed to save configuration: {}", e); +// state.messages.push(format!("Failed to save config: {}", e)); +// Task::none() +// } +// } +// } +// Message::LoadedClient(client, is_auth) => { +// state.jellyfin_client = Some(client.clone()); +// state.is_authenticated = is_auth; +// if is_auth { +// // Fetch user if authenticated +// Task::perform( +// async move { client.me().await.map_err(|e| e.to_string()) }, +// Message::UserLoaded, +// ) +// } else { +// Task::done(Message::Refresh) +// } +// } +// Message::UserLoaded(result) => { +// match result { +// Ok(user) => { +// state.user = Some(user); +// } +// Err(e) => { +// tracing::warn!("Failed to load user profile: {}", e); +// } +// } +// Task::done(Message::Refresh) +// } +// Message::LoginResult(result) => { +// match result { +// Ok((client, auth)) => { +// if let Some(token) = client.access_token() { +// if let Err(e) = std::fs::write(".session", token) { +// tracing::error!("Failed to save session token: {}", e); +// } +// } +// // Fetch user here too since authentication just succeeded +// state.jellyfin_client = Some(client.clone()); +// state.is_authenticated = true; +// state.settings.login_form = settings::LoginForm::default(); +// +// // We can use the auth.user if present, or fetch it. +// if let Some(user_dto) = auth.user { +// state.user = Some(user_dto); +// Task::none() +// } else { +// Task::perform( +// async move { client.me().await.map_err(|e| e.to_string()) }, +// Message::UserLoaded, +// ) +// } +// } +// Err(e) => { +// tracing::error!("Login failed: {}", e); +// state.messages.push(format!("Login failed: {}", e)); +// // Pass the error to the settings/login form so it can be displayed +// state +// .settings +// .login_form +// .update(settings::UserMessage::Error(e)); +// Task::none() +// } +// } +// } +// Message::Logout => { +// state.jellyfin_client = None; +// state.is_authenticated = false; +// state.user = None; +// let _ = std::fs::remove_file(".session"); +// Task::none() +// }