diff --git a/src/core/jellyfin.rs b/src/core/jellyfin.rs index dbbddc7..34188ba 100644 --- a/src/core/jellyfin.rs +++ b/src/core/jellyfin.rs @@ -118,21 +118,34 @@ impl JellyfinClient { ) -> Result { let url = format!("{}/Users/{}/Items", self.base_url, user_id); + let mut sort_by = "SortName"; + + if let Some(types) = query.include_item_types.as_deref() { + if types.contains("Episode") { + sort_by = "ParentIndexNumber,IndexNumber,SortName"; + } else if types.contains("Audio") { + sort_by = "IndexNumber,SortName"; + } + } + let mut req = self .client .get(url) .header("X-Emby-Authorization", self.auth_header_value()) .query(&[ - ("Recursive", if query.recursive { "true" } else { "false" }), + ("Recursive", "true"), ("StartIndex", &start_index.to_string()), ("Limit", &limit.to_string()), - ("Fields", "PrimaryImageAspectRatio,SortName,ProductionYear"), - ("SortBy", "SortName"), + ("Fields", "PrimaryImageAspectRatio,SortName,ProductionYear,IndexNumber,ParentIndexNumber"), + ("SortBy", sort_by), ("SortOrder", "Ascending"), ]); if let Some(types) = query.include_item_types.as_deref() { - req = req.query(&[("IncludeItemTypes", types)]); + // Jellyfin expects IncludeItemTypes as repeated query params, not CSV + for t in types.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()) { + req = req.query(&[("IncludeItemTypes", t)]); + } } if let Some(parent) = query.parent_id.as_deref() { req = req.query(&[("ParentId", parent)]); @@ -248,6 +261,11 @@ pub struct JfItem { pub item_type: Option, #[serde(rename = "ProductionYear")] pub production_year: Option, + + #[serde(rename = "IndexNumber")] + pub index_number: Option, // track # OR episode # + #[serde(rename = "ParentIndexNumber")] + pub parent_index_number: Option, // season # (for episodes) } #[derive(Deserialize)] diff --git a/src/main.rs b/src/main.rs index 6bb72fb..10b2a0a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ const PAGE_SIZE: usize = 50; use std::{ io, time::{Duration, Instant}, + process::Command, }; use crossterm::{ @@ -131,8 +132,17 @@ struct App { login_error: Option, search: Option, + pending_play: Option, } +#[derive(Clone, Debug)] +struct PlayRequest { + item_id: String, + item_type: Option, + name: String, +} + + impl Default for App { fn default() -> Self { let loaded = core::config::load_config().ok(); @@ -178,6 +188,8 @@ impl Default for App { login_focus: LoginFocus::User, login_error: None, search: None, + pending_play: None, + } } } @@ -224,6 +236,31 @@ fn run_app(terminal: &mut Terminal>) -> io::Result< } } + // If we need to play something, suspend the TUI and run mpv + if let Some(req) = app.pending_play.take() { + // Restore terminal to normal before launching mpv + disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + terminal.show_cursor()?; + + // Run mpv (blocking until it exits) + let result = match &app.config { + Some(cfg) => play_in_mpv(cfg, &req).map_err(|e| e), + None => Err("No config loaded".to_string()), + }; + + // Re-enter TUI mode after mpv closes + enable_raw_mode()?; + execute!(terminal.backend_mut(), EnterAlternateScreen)?; + terminal.clear()?; + + // Update status + match result { + Ok(_) => app.status = format!("Finished: {}", req.name), + Err(e) => app.status = format!("Play failed: {}", e), + } + } + if last_tick.elapsed() >= tick_rate { // tick-based updates go here later (smooth nav, download polling, etc.) last_tick = Instant::now(); @@ -233,6 +270,15 @@ fn run_app(terminal: &mut Terminal>) -> io::Result< } fn handle_key(app: &mut App, code: KeyCode) { + // Normalize arrow navigation + let code = match code { + KeyCode::Right => KeyCode::Enter, + KeyCode::Left => KeyCode::Esc, + KeyCode::Char('l') => KeyCode::Enter, + KeyCode::Char('h') => KeyCode::Esc, + other => other, + }; + // Special input handling in Login view if matches!(app.current_view(), View::Login) { match code { @@ -372,110 +418,126 @@ fn handle_key(app: &mut App, code: KeyCode) { KeyCode::Char('q') => app.should_exit = true, KeyCode::Enter => { - match app.current_view() { - View::Categories => { - let cat = app.items[app.selected].clone(); + let view = app.current_view().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; - } - }; + match view { + View::Categories => { + let cat = app.items[app.selected].clone(); - 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, - page_size: PAGE_SIZE, - loading: false, - }); - app.status = "Loaded".to_string(); - } - Err(e) => { - app.status = format!("Fetch failed: {e}"); - } - } + 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; } - View::Library { title, items, selected, .. } => { - if items.is_empty() { return; } - let it = &items[*selected]; + }; - let ty = it.item_type.as_deref().unwrap_or(""); - - // Shows -> Seasons -> Episodes - if ty == "Series" { - let q = core::jellyfin::LibraryQuery::all() - .with_parent(it.id.clone()) - .with_item_types("Season"); - open_children(app, format!("{} > {}", title, it.name), q); - return; - } - if ty == "Season" { - let q = core::jellyfin::LibraryQuery::all() - .with_parent(it.id.clone()) - .with_item_types("Episode"); - open_children(app, format!("{} > {}", title, it.name), q); - return; - } - - // MusicArtist -> MusicAlbum -> Audio - if ty == "MusicArtist" { - let q = core::jellyfin::LibraryQuery::all() - .with_parent(it.id.clone()) - .with_item_types("MusicAlbum"); - open_children(app, format!("{} > {}", title, it.name), q); - return; - } - if ty == "MusicAlbum" { - let q = core::jellyfin::LibraryQuery::all() - .with_parent(it.id.clone()) - .with_item_types("Audio"); - open_children(app, format!("{} > {}", title, it.name), q); - return; - } - - // Otherwise: later we can make Enter “play”, “details”, etc. - app.status = format!("No drilldown for type: {}", ty); + 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, + page_size: PAGE_SIZE, + loading: false, + }); + app.status = "Loaded".to_string(); + } + Err(e) => { + app.status = format!("Fetch failed: {e}"); } - _ => {} } } + + View::Library { title, items, selected, .. } => { + if items.is_empty() { return; } + let it = &items[selected]; + + let ty = it.item_type.as_deref().unwrap_or(""); + + // Shows -> Seasons -> Episodes + if ty == "Series" { + let q = core::jellyfin::LibraryQuery::all() + .with_parent(it.id.clone()) + .with_item_types("Season"); + open_children(app, format!("{} > {}", title, it.name), q); + return; + } + if ty == "Season" { + let q = core::jellyfin::LibraryQuery::all() + .with_parent(it.id.clone()) + .with_item_types("Episode"); + open_children(app, format!("{} > {}", title, it.name), q); + return; + } + + // MusicArtist -> MusicAlbum -> Audio + if ty == "MusicArtist" { + let q = core::jellyfin::LibraryQuery::all() + .with_parent(it.id.clone()) + .with_item_types("MusicAlbum"); + open_children(app, format!("{} > {}", title, it.name), q); + return; + } + if ty == "MusicAlbum" { + let q = core::jellyfin::LibraryQuery::all() + .with_parent(it.id.clone()) + .with_item_types("Audio"); + open_children(app, format!("{} > {}", title, it.name), q); + return; + } + + // Playables -> mpv + if ty == "Episode" || ty == "Audio" || ty == "Movie" { + let name = it.name.clone(); + app.pending_play = Some(PlayRequest { + item_id: it.id.clone(), + item_type: it.item_type.clone(), + name: name.clone(), + }); + app.status = format!("Launching mpv: {}", name); + return; + } + + app.status = format!("No drilldown for type: {}", ty); + } + + _ => {} + } +} + + KeyCode::Esc | KeyCode::Backspace => { if app.view_stack.len() > 1 { app.view_stack.pop(); @@ -803,19 +865,42 @@ fn render_library( matches!(item_type, Some("Movie" | "Series" | "MusicAlbum")) } + fn left_number_prefix(it: &core::jellyfin::JfItem) -> String { + match it.item_type.as_deref() { + Some("Episode") => { + // season x episode (01x02) + match (it.parent_index_number, it.index_number) { + (Some(season), Some(ep)) => format!("{:02}x{:02} ", season, ep), + (_, Some(ep)) => format!("{:02} ", ep), + _ => String::new(), + } + } + Some("Audio") => { + // track number + match it.index_number { + Some(n) => format!("{:02}. ", n), + None => String::new(), + } + } + _ => String::new(), + } + } + let list_items: Vec = items[start..end] .iter() .map(|it| { + let prefix = left_number_prefix(it); + 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!("{}{}", it.name, year)) + ListItem::new(format!("{}{}{}", prefix, it.name, year)) }) .collect(); - + let list = List::new(list_items) .block( Block::bordered() @@ -881,6 +966,34 @@ fn open_children(app: &mut App, title: String, query: core::jellyfin::LibraryQue } } +fn play_in_mpv(cfg: &core::config::Config, req: &PlayRequest) -> Result<(), String> { + let token = cfg + .access_token + .as_deref() + .ok_or_else(|| "Missing access_token (login again)".to_string())?; + + // Simple “download” endpoint works well for mpv. + // (Jellyfin supports this route; it returns the media file/stream.) + let base = cfg.base_url.trim_end_matches('/'); + let url = format!("{}/Items/{}/Download", base, req.item_id); + + // mpv can send HTTP headers; Jellyfin accepts X-Emby-Token + let header = format!("X-Emby-Token: {}", token); + + let status = Command::new("mpv") + .arg(url) + .arg("--force-window=yes") + .arg(format!("--http-header-fields={}", header)) + .status() + .map_err(|e| format!("Failed to launch mpv: {e}"))?; + + if !status.success() { + return Err(format!("mpv exited with status: {status}")); + } + + Ok(()) +} + struct Panes { top: Rect, left_main: Rect,