From 56ba176615f665e92bba91e0b822c6bfd13974c4 Mon Sep 17 00:00:00 2001 From: cwj3rcb Date: Sat, 14 Feb 2026 17:26:34 -0600 Subject: [PATCH] library integration --- src/core/jellyfin.rs | 99 ++++++++++++++++++++++++++------------------ src/main.rs | 90 +++++++++++++++++++++++++++------------- 2 files changed, 120 insertions(+), 69 deletions(-) diff --git a/src/core/jellyfin.rs b/src/core/jellyfin.rs index 3dda7b6..de442cc 100644 --- a/src/core/jellyfin.rs +++ b/src/core/jellyfin.rs @@ -36,6 +36,11 @@ struct UserDto { id: Option, } +#[derive(Debug, Clone, Deserialize)] +pub struct ViewsResponse { + #[serde(rename = "Items", default)] + pub items: Vec, +} impl JellyfinClient { pub fn from_config(cfg: &Config) -> Result { @@ -60,45 +65,67 @@ impl JellyfinClient { } pub fn login_by_name(&self, username: &str, password: &str) -> Result { - let url = format!("{}/Users/AuthenticateByName", self.base_url); + 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 auth_header = format!( + "MediaBrowser Client=\"cj-jftui\", Device=\"kitty\", DeviceId=\"cj-jftui\", Version=\"{}\"", + env!("CARGO_PKG_VERSION"), + ); - let body = AuthenticateUserByName { username, pw: password }; + 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}"))?; + 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}")); + 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, + }) } - let parsed: AuthenticationResult = resp - .json() - .map_err(|e| format!("bad response json: {e}"))?; + pub fn list_views(&self, user_id: &str) -> Result, String> { + let url = format!("{}/Users/{}/Views", self.base_url, user_id); - let token = parsed - .access_token - .ok_or_else(|| "missing AccessToken in response".to_string())?; + let resp = self + .client + .get(url) + .header("X-Emby-Authorization", self.auth_header_value()) + .send() + .map_err(|e| format!("Views request failed: {e}"))?; - let user_id = parsed.user.and_then(|u| u.id); + if !resp.status().is_success() { + let status = resp.status(); + let text = resp.text().unwrap_or_default(); + return Err(format!("Views failed: {status} {text}")); + } - Ok(LoginResult { - access_token: token, - user_id, - }) -} + let parsed: ViewsResponse = resp + .json() + .map_err(|e| format!("Views parse failed: {e}"))?; + Ok(parsed.items) + } fn auth_header_value(&self) -> String { // Identify as a client and include the token. @@ -128,12 +155,14 @@ impl JellyfinClient { } } + let recursive = if query.recursive { "true" } else { "false" }; + let mut req = self .client .get(url) .header("X-Emby-Authorization", self.auth_header_value()) .query(&[ - ("Recursive", "true"), + ("Recursive", recursive), ("StartIndex", &start_index.to_string()), ("Limit", &limit.to_string()), ("Fields", "PrimaryImageAspectRatio,SortName,ProductionYear,IndexNumber,ParentIndexNumber"), @@ -154,16 +183,6 @@ impl JellyfinClient { req = req.query(&[("SearchTerm", term)]); } - - // apply query (movie/series/etc) - if let Some(types) = query.include_item_types.as_deref() { - req = req.query(&[("IncludeItemTypes", types)]); - } - - if let Some(term) = query.search_term.as_deref() { - req = req.query(&[("SearchTerm", term)]); - } - let resp = req.send().map_err(|e| format!("request failed: {e}"))?; if !resp.status().is_success() { diff --git a/src/main.rs b/src/main.rs index aec633e..c72fa67 100644 --- a/src/main.rs +++ b/src/main.rs @@ -193,6 +193,7 @@ impl Default for App { "Music".into(), "Playlists".into(), "Collections".into(), + "Libraries".into(), "Continue Watching".into(), "Recently Added".into(), "Downloads".into(), @@ -519,45 +520,58 @@ fn handle_key(app: &mut App, code: KeyCode) { match code { KeyCode::Char('q') => app.should_exit = true, + //Enter code Block KeyCode::Enter => { - let view = app.current_view().clone(); + let view = app.current_view().clone(); match view { View::Categories => { let cat = app.items[app.selected].clone(); - 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; - } + None => { app.status = "No config loaded".into(); 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; - } + None => { app.status = "Missing user_id in config (login again)".into(); return; } }; let client = match core::jellyfin::JellyfinClient::from_config(&cfg) { Ok(c) => c, - Err(e) => { - app.status = e; - return; + Err(e) => { app.status = e; return; } + }; + + // Special case: Libraries uses /Views, not /Items + if cat == "Libraries" { + app.status = "Fetching Libraries...".into(); + match client.list_views(user_id) { + Ok(items) => { + let total = items.len(); + app.view_stack.push(View::Library { + title: "Libraries".into(), + query: core::jellyfin::LibraryQuery::all(), // placeholder + items, + selected: 0, + start_index: 0, + total, // cheap total for UI + page_size: PAGE_SIZE, + loading: false, + }); + app.status = "Loaded".into(); + } + Err(e) => app.status = format!("Views failed: {e}"), } + return; + } + + // Normal categories use /Items + 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; } }; app.status = format!("Fetching {}...", cat); @@ -574,11 +588,9 @@ fn handle_key(app: &mut App, code: KeyCode) { page_size: PAGE_SIZE, loading: false, }); - app.status = "Loaded".to_string(); - } - Err(e) => { - app.status = format!("Fetch failed: {e}"); + app.status = "Loaded".into(); } + Err(e) => app.status = format!("Fetch failed: {e}"), } } @@ -588,6 +600,13 @@ fn handle_key(app: &mut App, code: KeyCode) { let ty = it.item_type.as_deref().unwrap_or(""); + // Libraries (/Views) usually return CollectionFolder. Entering it should list its contents. + if ty == "CollectionFolder" { + let q = core::jellyfin::LibraryQuery::all().with_parent(it.id.clone()); + open_children(app, format!("{} > {}", title, it.name), q); + return; + } + // Shows -> Seasons -> Episodes if ty == "Series" { let q = core::jellyfin::LibraryQuery::all() @@ -619,7 +638,11 @@ fn handle_key(app: &mut App, code: KeyCode) { open_children(app, format!("{} > {}", title, it.name), q); return; } - + if ty == "BoxSet" { + let q = core::jellyfin::LibraryQuery::all().with_parent(it.id.clone()); + open_children(app, format!("{} > {}", title, it.name), q); + return; + } // Playables -> mpv // Playables: do nothing on Enter (use 'p' to play) if ty == "Episode" || ty == "Audio" || ty == "Movie" { @@ -1005,15 +1028,24 @@ fn render_library( let list_items: Vec = items[start..end] .iter() + .filter(|it| { + // Hide the redundant "collections" folder inside the Collections library + if title.ends_with("Collections") { + if it.item_type.as_deref() == Some("Folder") + && it.name.eq_ignore_ascii_case("collections") + { + return false; + } + } + true + }) .map(|it| { let prefix = left_number_prefix(it, show_audio_track_numbers); - let year = if show_year_for(it.item_type.as_deref()) { it.production_year.map(|y| format!(" ({y})")).unwrap_or_default() } else { String::new() }; - ListItem::new(format!("{}{}{}", prefix, it.name, year)) }) .collect();