From da6bda9772e85924de3ccb88f9cdd89a0515a65f Mon Sep 17 00:00:00 2001 From: cwj3rcb Date: Sat, 14 Feb 2026 00:51:30 -0600 Subject: [PATCH] loads all shows --- src/core/jellyfin.rs | 229 ++++++++++++++++++++++++++++---------- src/main.rs | 259 +++++++++++++++++++++++++++++++++++++++---- 2 files changed, 411 insertions(+), 77 deletions(-) diff --git a/src/core/jellyfin.rs b/src/core/jellyfin.rs index 84a49f7..b1aa701 100644 --- a/src/core/jellyfin.rs +++ b/src/core/jellyfin.rs @@ -6,6 +6,7 @@ use super::config::Config; pub struct JellyfinClient { base_url: String, client: reqwest::blocking::Client, + token: String, } pub struct LoginResult { @@ -13,70 +14,186 @@ pub struct LoginResult { pub user_id: Option, } -impl JellyfinClient { - pub fn from_config(cfg: &Config) -> Self { - Self { - base_url: cfg.base_url.trim_end_matches('/').to_string(), - client: reqwest::blocking::Client::new(), - } - } - - pub fn login_by_name(&self, username: &str, password: &str) -> Result { - let url = format!("{}/Users/AuthenticateByName", self.base_url); - - // Jellyfin expects this format. :contentReference[oaicite:0]{index=0} - let body = AuthenticateUserByName { - Username: username, - Pw: password, - }; - - // This header identifies the client/device. :contentReference[oaicite:1]{index=1} - let auth_header = r#"MediaBrowser Client="cj-jftui", Device="kitty", DeviceId="cj-jftui", Version="0.1.0""#; - - let resp = self - .client - .post(url) - .header("X-Emby-Authorization", auth_header) - .json(&body) - .send() - .map_err(|e| format!("request failed: {e}"))?; - - if !resp.status().is_success() { - let status = resp.status(); - let text = resp.text().unwrap_or_default(); - return Err(format!("login failed: {status} {text}")); - } - - let parsed: AuthenticationResult = resp - .json() - .map_err(|e| format!("bad response json: {e}"))?; - - let token = parsed - .AccessToken - .ok_or_else(|| "missing AccessToken in response".to_string())?; - - let user_id = parsed.User.and_then(|u| u.Id); - - Ok(LoginResult { - access_token: token, - user_id, - }) - } -} - #[derive(Serialize)] struct AuthenticateUserByName<'a> { - Username: &'a str, - Pw: &'a str, + #[serde(rename = "Username")] + username: &'a str, + #[serde(rename = "Pw")] + pw: &'a str, } #[derive(Deserialize)] struct AuthenticationResult { - AccessToken: Option, - User: Option, + #[serde(rename = "AccessToken")] + access_token: Option, + #[serde(rename = "User")] + user: Option, } #[derive(Deserialize)] struct UserDto { - Id: Option, + #[serde(rename = "Id")] + id: Option, } + + +impl JellyfinClient { + pub fn from_config(cfg: &Config) -> Result { + let token = cfg + .access_token + .clone() + .ok_or_else(|| "missing access_token (need login)".to_string())?; + + Ok(Self { + base_url: cfg.base_url.trim_end_matches('/').to_string(), + client: reqwest::blocking::Client::new(), + token, + }) + } + + pub fn unauthenticated(base_url: &str) -> Self { + Self { + base_url: base_url.trim_end_matches('/').to_string(), + client: reqwest::blocking::Client::new(), + token: String::new(), // unused for login + } + } + + pub fn login_by_name(&self, username: &str, password: &str) -> Result { + let url = format!("{}/Users/AuthenticateByName", self.base_url); + + let auth_header = format!( + "MediaBrowser Client=\"cj-jftui\", Device=\"kitty\", DeviceId=\"cj-jftui\", Version=\"{}\"", + env!("CARGO_PKG_VERSION"), + ); + + let body = AuthenticateUserByName { username, pw: password }; + + let resp = self + .client + .post(url) + .header("X-Emby-Authorization", auth_header) + .json(&body) + .send() + .map_err(|e| format!("request failed: {e}"))?; + + if !resp.status().is_success() { + let status = resp.status(); + let text = resp.text().unwrap_or_default(); + return Err(format!("login failed: {status} {text}")); + } + + let parsed: AuthenticationResult = resp + .json() + .map_err(|e| format!("bad response json: {e}"))?; + + let token = parsed + .access_token + .ok_or_else(|| "missing AccessToken in response".to_string())?; + + let user_id = parsed.user.and_then(|u| u.id); + + Ok(LoginResult { + access_token: token, + user_id, + }) +} + + + fn auth_header_value(&self) -> String { + // Identify as a client and include the token. + format!( + "MediaBrowser Client=\"cj-jftui\", Device=\"kitty\", DeviceId=\"cj-jftui\", Version=\"{}\", Token=\"{}\"", + env!("CARGO_PKG_VERSION"), + self.token + ) + } + + pub fn list_items( + &self, + user_id: &str, + query: LibraryQuery, + start_index: usize, + limit: usize, + ) -> Result { + let url = format!("{}/Users/{}/Items", self.base_url, user_id); + + let mut req = self + .client + .get(url) + .header("X-Emby-Authorization", self.auth_header_value()) + .query(&[ + ("Recursive", "true"), + ("StartIndex", &start_index.to_string()), + ("Limit", &limit.to_string()), + ("Fields", "PrimaryImageAspectRatio,SortName,ProductionYear"), + ("SortBy", "SortName"), + ("SortOrder", "Ascending"), + ]); + + // apply query (movie/series/etc) + if let Some(types) = query.include_item_types.as_deref() { + req = req.query(&[("IncludeItemTypes", types)]); + } + + let resp = req.send().map_err(|e| format!("request failed: {e}"))?; + + if !resp.status().is_success() { + let status = resp.status(); + let text = resp.text().unwrap_or_default(); + return Err(format!("list_items failed: {status} {text}")); + } + + let parsed: ItemsResponse = resp + .json() + .map_err(|e| format!("bad response json: {e}"))?; + + Ok(PagedItems { + items: parsed.items, + total: parsed.total_record_count.unwrap_or(0), + }) + } +} + +/// What kind of library list we want. +#[derive(Clone)] +pub struct LibraryQuery { + pub include_item_types: Option, // e.g. Some("Movie".into()) +} + +impl LibraryQuery { + pub fn movies() -> Self { + Self { include_item_types: Some("Movie".into()) } + } + pub fn shows() -> Self { + Self { include_item_types: Some("Series".into()) } + } + pub fn music() -> Self { + Self { include_item_types: Some("Audio".into()) } + } +} + +pub struct PagedItems { + pub items: Vec, + pub total: usize, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct JfItem { + #[serde(rename = "Id")] + pub id: String, + #[serde(rename = "Name")] + pub name: String, + #[serde(rename = "Type")] + pub item_type: Option, + #[serde(rename = "ProductionYear")] + pub production_year: Option, +} + +#[derive(Deserialize)] +struct ItemsResponse { + #[serde(rename = "Items")] + items: Vec, + #[serde(rename = "TotalRecordCount")] + total_record_count: Option, +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 2468ffd..024498a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,7 @@ mod core; +const PAGE_SIZE: usize = 50; + use std::{ io, time::{Duration, Instant}, @@ -22,7 +24,14 @@ use ratatui::backend::CrosstermBackend; #[derive(Clone)] enum View { Categories, - Placeholder(String), // temporary for testing navigation + Library { + title: String, + query: core::jellyfin::LibraryQuery, + items: Vec, + selected: usize, + start_index: usize, + total: usize, + }, Login, } @@ -180,7 +189,7 @@ fn handle_key(app: &mut App, code: KeyCode) { } }; - let client = core::jellyfin::JellyfinClient::from_config(&cfg); + let client = core::jellyfin::JellyfinClient::unauthenticated(&cfg.base_url); match client.login_by_name(&app.login_user, &app.login_pass) { Ok(res) => { let mut new_cfg = cfg; @@ -224,10 +233,61 @@ fn handle_key(app: &mut App, code: KeyCode) { KeyCode::Enter => { match app.current_view() { View::Categories => { - let selected = app.items[app.selected].clone(); - app.view_stack.push(View::Placeholder(selected.clone())); - app.status = format!("Entered {}", selected); - app.selected = 0; + let cat = app.items[app.selected].clone(); + + // Map your categories to Jellyfin queries (expand later) + let query = match cat.as_str() { + "Movies" => core::jellyfin::LibraryQuery::movies(), + "Shows" => core::jellyfin::LibraryQuery::shows(), + "Music" => core::jellyfin::LibraryQuery::music(), + _ => { + app.status = format!("Not wired yet: {}", cat); + return; + } + }; + + let cfg = match &app.config { + Some(c) => c.clone(), + None => { + app.status = "No config loaded".to_string(); + return; + } + }; + + let user_id = match cfg.user_id.as_deref() { + Some(u) => u, + None => { + app.status = "Missing user_id in config (login again)".to_string(); + return; + } + }; + + let client = match core::jellyfin::JellyfinClient::from_config(&cfg) { + Ok(c) => c, + Err(e) => { + app.status = e; + return; + } + }; + + app.status = format!("Fetching {}...", cat); + + match client.list_items(user_id, query.clone(), 0, PAGE_SIZE) { + Ok(page) => { + app.view_stack.push(View::Library { + title: cat, + query, + items: page.items, + selected: 0, + start_index: 0, + total: page.total, + }); + app.status = "Loaded".to_string(); + } + Err(e) => { + app.status = format!("Fetch failed: {e}"); + } + } } _ => {} } @@ -242,28 +302,128 @@ fn handle_key(app: &mut App, code: KeyCode) { } KeyCode::Up | KeyCode::Char('k') => { - if app.items.is_empty() { - return; + match app.view_stack.last_mut().unwrap() { + View::Categories => { + if app.items.is_empty() { return; } + if app.selected == 0 { app.selected = app.items.len() - 1; } + else { app.selected -= 1; } + app.status = format!("Selected: {}", app.items[app.selected]); + } + View::Library { items, selected, .. } => { + if items.is_empty() { return; } + if *selected == 0 { *selected = items.len() - 1; } + else { *selected -= 1; } + } + _ => {} } - if app.selected == 0 { - app.selected = app.items.len() - 1; - } else { - app.selected -= 1; - } - app.status = format!("Selected: {}", app.items[app.selected]); } KeyCode::Down | KeyCode::Char('j') => { - if app.items.is_empty() { - return; + match app.view_stack.last_mut().unwrap() { + View::Categories => { + if app.items.is_empty() { return; } + app.selected = (app.selected + 1) % app.items.len(); + app.status = format!("Selected: {}", app.items[app.selected]); + } + View::Library { items, selected, total, .. } => { + if items.is_empty() { return; } + + // If we're at the end of what we have loaded… + if *selected + 1 >= items.len() { + // …and there’s more available on the server, fetch next page. + if items.len() < *total { + load_next_library_page(app); + + // After loading more, move down if possible. + if let Some(View::Library { items, selected, .. }) = app.view_stack.last_mut() { + if !items.is_empty() && *selected + 1 < items.len() { + *selected += 1; + } + } + } else { + // wrap or stop—choose behavior: + // stop at end: + // do nothing + // OR wrap: + // *selected = 0; + } + } else { + *selected += 1; + } + } + _ => {} } - app.selected = (app.selected + 1) % app.items.len(); - app.status = format!("Selected: {}", app.items[app.selected]); } + _ => {} } } + +fn load_next_library_page(app: &mut App) { + // Only meaningful if the current view is Library + let (query, next_start, total) = match app.view_stack.last() { + Some(View::Library { query, items, total, start_index, .. }) => { + let loaded = items.len(); + let next_start = start_index + loaded; + (query.clone(), next_start, *total) + } + _ => return, + }; + + // If we already loaded everything, do nothing + let loaded = match app.view_stack.last() { + Some(View::Library { items, .. }) => items.len(), + _ => 0, + }; + if loaded >= total { + return; + } + + let cfg = match &app.config { + Some(c) => c.clone(), + None => { + app.status = "No config loaded".to_string(); + return; + } + }; + + let user_id = match cfg.user_id.as_deref() { + Some(u) => u, + None => { + app.status = "Missing user_id in config (login again)".to_string(); + return; + } + }; + + let client = match core::jellyfin::JellyfinClient::from_config(&cfg) { + Ok(c) => c, + Err(e) => { + app.status = e; + return; + } + }; + + app.status = format!("Loading more... ({}/{})", loaded, total); + + let page = match client.list_items(user_id, query.clone(), next_start, PAGE_SIZE) { + Ok(p) => p, + Err(e) => { + app.status = format!("Fetch failed: {e}"); + return; + } + }; + + // Append the new items into the current library view + if let Some(View::Library { items, total, .. }) = app.view_stack.last_mut() { + items.extend(page.items); + *total = page.total; // keep total fresh in case server changes + } + + app.status = "Loaded more".to_string(); +} + + fn ui(frame: &mut Frame, app: &App) { let area = frame.area(); let panes = layout(area); @@ -277,8 +437,8 @@ fn ui(frame: &mut Frame, app: &App) { View::Categories => { render_categories(frame, app, panes.left_main, panes.right_main); } - View::Placeholder(name) => { - render_placeholder(frame, name, panes.left_main, panes.right_main); + View::Library { title, items, selected, start_index, total, .. } => { + render_library(frame, title, *start_index, *total, items, *selected, panes.left_main, panes.right_main); } View::Login => { render_login(frame, app, panes.left_main, panes.right_main); @@ -368,6 +528,63 @@ fn render_placeholder(frame: &mut Frame, name: &str, left: Rect, right: Rect) { ); } + +fn render_library( + frame: &mut Frame, + title: &str, + start_index: usize, + total: usize, + items: &[core::jellyfin::JfItem], + selected: usize, + left: Rect, + right: Rect, +) { + let viewport = left.height.saturating_sub(2) as usize; + + let len = items.len(); + let selected = selected.min(len.saturating_sub(1)); + + let (start, end) = compute_window(len, selected, viewport); + + let list_items: Vec = items[start..end] + .iter() + .map(|it| { + let year = it.production_year.map(|y| format!(" ({y})")).unwrap_or_default(); + ListItem::new(format!("{}{}", it.name, year)) + }) + .collect(); + + let list = List::new(list_items) + .block( + Block::bordered() + .title(format!("{title} [{}/{}]", items.len(), total)) + .merge_borders(MergeStrategy::Exact), + ) + .highlight_symbol("> ") + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); + + let mut state = ListState::default(); + state.select(if len > 0 { Some(selected - start) } else { None }); + + frame.render_stateful_widget(list, left, &mut state); + + let detail = if len > 0 { + let it = &items[selected]; + format!( + "Name: {}\nId: {}\nType: {:?}\nYear: {:?}", + it.name, it.id, it.item_type, it.production_year + ) + } else { + "No items returned".to_string() + }; + + frame.render_widget( + Paragraph::new(detail) + .block(Block::bordered().title("Details").merge_borders(MergeStrategy::Exact)), + right, + ); +} + struct Panes { top: Rect, left_main: Rect, @@ -388,7 +605,7 @@ fn layout(area: Rect) -> Panes { .areas(bottom); let [left_main, right_main] = - Layout::horizontal([Fill(2), Fill(5)]) + Layout::horizontal([Fill(3), Fill(5)]) .spacing(Spacing::Overlap(1)) .areas(main_area);