From dfcf3fcf2a50599b6ce83cfe6e0cf5058f852e5b Mon Sep 17 00:00:00 2001 From: cwj3rcb Date: Mon, 16 Feb 2026 01:37:57 -0600 Subject: [PATCH] not there yet but has potential --- old_src/core/config.rs | 131 ++ old_src/core/jellyfin.rs | 398 ++++++ old_src/core/mod.rs | 3 + old_src/core/paths.rs | 35 + old_src/main.rs | 2613 ++++++++++++++++++++++++++++++++++++++ src/core/jellyfin.rs | 36 +- src/main.rs | 287 +++-- 7 files changed, 3425 insertions(+), 78 deletions(-) create mode 100644 old_src/core/config.rs create mode 100644 old_src/core/jellyfin.rs create mode 100644 old_src/core/mod.rs create mode 100644 old_src/core/paths.rs create mode 100644 old_src/main.rs diff --git a/old_src/core/config.rs b/old_src/core/config.rs new file mode 100644 index 0000000..1ec0b8b --- /dev/null +++ b/old_src/core/config.rs @@ -0,0 +1,131 @@ +use std::{fs, io, path::PathBuf}; + +use super::paths; + +#[derive(Debug, Clone)] +pub struct Config { + pub base_url: String, + pub access_token: Option, + pub user_id: Option, + pub transcode_mbps: Option, + pub transcode_default_on: Option, +} + + +#[derive(Debug)] +pub enum ConfigError { + NotFound(PathBuf), + Io(io::Error), + Parse(String), +} + +impl From for ConfigError { + fn from(e: io::Error) -> Self { + Self::Io(e) + } +} + +pub fn config_path() -> Option { + paths::app_config_dir().map(|d| d.join("config.toml")) +} + +// Very small TOML-ish parser for now (no deps). +// Format: +// base_url = "http://..." +// access_token = "..." +pub fn load_config() -> Result { + let path = config_path().ok_or_else(|| ConfigError::Parse("No HOME/XDG dirs".into()))?; + if !path.exists() { + return Err(ConfigError::NotFound(path)); + } + + let s = fs::read_to_string(&path)?; + parse_config(&s).map_err(ConfigError::Parse) +} + +fn parse_config(s: &str) -> Result { + let mut base_url: Option = None; + let mut access_token: Option = None; + let mut user_id: Option = None; + let mut transcode_mbps: Option = None; + let mut transcode_default_on: Option = None; + + for (i, line) in s.lines().enumerate() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + + let (k, v) = line + .split_once('=') + .ok_or_else(|| format!("Line {}: expected key = value", i + 1))?; + + let key = k.trim(); + let mut val = v.trim().to_string(); + + // strip quotes if present + if val.starts_with('"') && val.ends_with('"') && val.len() >= 2 { + val = val[1..val.len() - 1].to_string(); + } + + match key { + "base_url" => base_url = Some(val), + "access_token" => access_token = Some(val), + "user_id" => user_id = Some(val), + + "transcode_mbps" => { + transcode_mbps = val.parse::().ok(); + } + + "transcode_default_on" => { + transcode_default_on = match val.as_str() { + "true" => Some(true), + "false" => Some(false), + _ => None, + }; + } + _ => {} + } + } + + let base_url = base_url.ok_or_else(|| "Missing base_url".to_string())?; + + Ok(Config { + base_url, + access_token, + user_id, + transcode_mbps, + transcode_default_on, + }) +} + +pub fn save_config(cfg: &Config) -> Result<(), ConfigError> { + let dir = paths::app_config_dir() + .ok_or_else(|| ConfigError::Parse("No HOME/XDG dirs".into()))?; + std::fs::create_dir_all(&dir)?; + + let path = dir.join("config.toml"); + + let mut out = String::new(); + + out.push_str(&format!("base_url = \"{}\"\n", cfg.base_url)); + + if let Some(t) = &cfg.access_token { + out.push_str(&format!("access_token = \"{}\"\n", t)); + } + + if let Some(u) = &cfg.user_id { + out.push_str(&format!("user_id = \"{}\"\n", u)); + } + + if let Some(m) = cfg.transcode_mbps { + out.push_str(&format!("transcode_mbps = {}\n", m)); + } + + if let Some(b) = cfg.transcode_default_on { + out.push_str(&format!("transcode_default_on = {}\n", b)); + } + + std::fs::write(path, out)?; + Ok(()) +} diff --git a/old_src/core/jellyfin.rs b/old_src/core/jellyfin.rs new file mode 100644 index 0000000..652baeb --- /dev/null +++ b/old_src/core/jellyfin.rs @@ -0,0 +1,398 @@ +use serde::{Deserialize, Serialize}; + +use super::config::Config; + +#[derive(Clone)] +pub struct JellyfinClient { + base_url: String, + client: reqwest::blocking::Client, + token: String, +} + +pub struct LoginResult { + pub access_token: String, + pub user_id: Option, +} + +#[derive(Serialize)] +struct AuthenticateUserByName<'a> { + #[serde(rename = "Username")] + username: &'a str, + #[serde(rename = "Pw")] + pw: &'a str, +} + +#[derive(Deserialize)] +struct AuthenticationResult { + #[serde(rename = "AccessToken")] + access_token: Option, + #[serde(rename = "User")] + user: Option, +} + +#[derive(Deserialize)] +struct UserDto { + #[serde(rename = "Id")] + id: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ViewsResponse { + #[serde(rename = "Items", default)] + pub items: Vec, +} + +#[derive(Deserialize)] +struct PlaylistItemsResponse { + #[serde(rename = "Items", default)] + items: Vec, + #[serde(rename = "TotalRecordCount")] + total_record_count: 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, + }) + } + + pub fn list_views(&self, user_id: &str) -> Result, String> { + let url = format!("{}/Users/{}/Views", self.base_url, user_id); + + let resp = self + .client + .get(url) + .header("X-Emby-Authorization", self.auth_header_value()) + .send() + .map_err(|e| format!("Views request failed: {e}"))?; + + if !resp.status().is_success() { + let status = resp.status(); + let text = resp.text().unwrap_or_default(); + return Err(format!("Views failed: {status} {text}")); + } + + 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. + 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 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 = "SortName"; + } + } + + 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", recursive), + ("StartIndex", &start_index.to_string()), + ("Limit", &limit.to_string()), + ("Fields", "PrimaryImageAspectRatio,SortName,ProductionYear,IndexNumber,ParentIndexNumber,RunTimeTicks,Genres,Studios,Overview,SeriesName,Album,AlbumArtist,Artists,UserData"), + ("SortBy", sort_by), + ("SortOrder", "Ascending"), + ]); + + if let Some(types) = query.include_item_types.as_deref() { + // 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)]); + } + 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() { + 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), + }) + } + + pub fn playlist_items( + &self, + user_id: &str, + playlist_id: &str, + start_index: usize, + limit: usize, + ) -> Result { + let url = format!("{}/Playlists/{}/Items", self.base_url, playlist_id); + + let resp = self + .client + .get(url) + .header("X-Emby-Authorization", self.auth_header_value()) + .query(&[ + ("UserId", user_id), + ("StartIndex", &start_index.to_string()), + ("Limit", &limit.to_string()), + ("Fields", "PrimaryImageAspectRatio,SortName,ProductionYear,IndexNumber,ParentIndexNumber,RunTimeTicks,Genres,Studios,Overview,SeriesName,Album,AlbumArtist,Artists,UserData"), + ]) + .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!("playlist_items failed: {status} {text}")); + } + + let parsed: PlaylistItemsResponse = 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()) + pub parent_id: Option, // e.g. Some(series_id) + pub search_term: Option, // e.g. Some("matrix".into()) + pub recursive: bool, // true for big lists, false for drilldown +} + +impl LibraryQuery { + pub fn movies() -> Self { + Self { + include_item_types: Some("Movie".into()), + parent_id: None, + search_term: None, + recursive: true, + } + } + pub fn shows() -> Self { + Self { + include_item_types: Some("Series".into()), + parent_id: None, + search_term: None, + recursive: true, + } + } + pub fn music() -> Self { + Self { + include_item_types: Some("Audio".into()), + parent_id: None, + search_term: None, + recursive: true, + } + } + + // true "All" (no filter) + pub fn all() -> Self { + Self { + include_item_types: None, + parent_id: None, + search_term: None, + recursive: true, + } + } + + pub fn with_parent(mut self, parent_id: impl Into) -> Self { + self.parent_id = Some(parent_id.into()); + self.recursive = false; // drilldown is usually direct children + self + } + + pub fn with_search(mut self, term: impl Into) -> Self { + self.search_term = Some(term.into()); + self + } + + pub fn with_item_types(mut self, types: impl Into) -> Self { + self.include_item_types = Some(types.into()); + self + } +} + + +pub struct PagedItems { + pub items: Vec, + pub total: usize, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct JfStudio { + #[serde(rename = "Name")] + pub name: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct JfUserData { + #[serde(rename = "Played")] + pub played: Option, + + #[serde(rename = "PlaybackPositionTicks")] + pub playback_position_ticks: Option, + + #[serde(rename = "PlayedPercentage")] + pub played_percentage: Option, + + #[serde(rename = "UnplayedItemCount")] + pub unplayed_item_count: Option, +} + + +#[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, + + #[serde(rename = "IndexNumber")] + pub index_number: Option, + #[serde(rename = "ParentIndexNumber")] + pub parent_index_number: Option, + + // ✅ add these + #[serde(rename = "RunTimeTicks")] + pub run_time_ticks: Option, + + #[serde(rename = "Genres", default)] + pub genres: Vec, + + #[serde(rename = "Studios", default)] + pub studios: Vec, + + #[serde(rename = "Overview")] + pub overview: Option, + + #[serde(rename = "SeriesName")] + pub series_name: Option, + + #[serde(rename = "Album")] + pub album: Option, + + #[serde(rename = "AlbumArtist")] + pub album_artist: Option, + + #[serde(rename = "Artists", default)] + pub artists: Vec, + + #[serde(rename = "UserData")] + pub user_data: Option, +} + +#[derive(Deserialize)] +struct ItemsResponse { + #[serde(rename = "Items")] + items: Vec, + #[serde(rename = "TotalRecordCount")] + total_record_count: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct StudioDto { + #[serde(rename = "Name")] + pub name: Option, +} \ No newline at end of file diff --git a/old_src/core/mod.rs b/old_src/core/mod.rs new file mode 100644 index 0000000..e357c3e --- /dev/null +++ b/old_src/core/mod.rs @@ -0,0 +1,3 @@ +pub mod config; +pub mod jellyfin; +pub mod paths; \ No newline at end of file diff --git a/old_src/core/paths.rs b/old_src/core/paths.rs new file mode 100644 index 0000000..d527cba --- /dev/null +++ b/old_src/core/paths.rs @@ -0,0 +1,35 @@ +use std::{env, path::PathBuf}; + +pub fn home_dir() -> Option { + env::var_os("HOME").map(PathBuf::from) +} + +pub fn config_dir() -> Option { + // XDG first, fallback to ~/.config + if let Some(p) = env::var_os("XDG_CONFIG_HOME").map(PathBuf::from) { + Some(p) + } else { + home_dir().map(|h| h.join(".config")) + } +} + +pub fn cache_dir() -> Option { + // XDG first, fallback to ~/.cache + if let Some(p) = env::var_os("XDG_CACHE_HOME").map(PathBuf::from) { + Some(p) + } else { + home_dir().map(|h| h.join(".cache")) + } +} + +pub fn app_config_dir() -> Option { + config_dir().map(|d| d.join("cj-jftui")) +} + +pub fn app_cache_dir() -> Option { + cache_dir().map(|d| d.join("cj-jftui")) +} + +pub fn posters_cache_dir() -> Option { + app_cache_dir().map(|d| d.join("posters")) +} \ No newline at end of file diff --git a/old_src/main.rs b/old_src/main.rs new file mode 100644 index 0000000..98d8265 --- /dev/null +++ b/old_src/main.rs @@ -0,0 +1,2613 @@ +mod core; + +const PAGE_SIZE: usize = 50; + +use std::{ + io::{self, Write, Read, BufRead, BufReader}, + path::PathBuf, + process::{Child, Command, Stdio}, + time::{Duration, Instant}, + thread, +}; + +use std::sync::{Arc, Mutex, mpsc}; + +use std::os::unix::net::UnixStream; + +use serde_json::Value; + +use crossterm::{ + event::{self, Event, KeyCode, KeyEventKind}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{ + layout::{Constraint, Layout, Rect, Spacing}, + symbols::merge::MergeStrategy, + widgets::{Block, List, ListItem, ListState, Paragraph, Wrap, Gauge}, + style::{Modifier, Style}, + Frame, Terminal, +}; +use ratatui::backend::CrosstermBackend; + +#[derive(Clone)] +enum View { + Categories, + Library { + title: String, + query: core::jellyfin::LibraryQuery, + items: Vec, + selected: usize, + start_index: usize, + total: usize, + page_size: usize, + loading: bool, + }, + Login, +} + +#[derive(Clone, Debug, Default)] +struct MpvState { + eof: bool, + idle: bool, + time_pos: Option, + duration: Option, + percent: Option, + last_err: Option, + + // new: + media_title: Option, + audio: Option, + subs: Option, +} + +#[derive(Clone, Copy)] +enum LoginFocus { + User, + Pass, +} + +#[derive(Clone, Copy, Debug)] +enum SearchMode { + All, // no filter (true "all") + ShowsMovies, // Movie + Series + Shows, // Series + Movies, // Movie + Episodes, // Episode + Music, // Audio + Albums, // MusicAlbum + Artists, // MusicArtist +} + +impl SearchMode { + fn next(self) -> Self { + use SearchMode::*; + match self { + All => ShowsMovies, + ShowsMovies => Shows, + Shows => Movies, + Movies => Episodes, + Episodes => Music, + Music => Albums, + Albums => Artists, + Artists => All, + } + } + + fn prev(self) -> Self { + use SearchMode::*; + match self { + All => Artists, + ShowsMovies => All, + Shows => ShowsMovies, + Movies => Shows, + Episodes => Movies, + Music => Episodes, + Albums => Music, + Artists => Albums, + } + } + + fn label(self) -> &'static str { + use SearchMode::*; + match self { + All => "All", + ShowsMovies => "Shows+Movies", + Shows => "Shows", + Movies => "Movies", + Episodes => "Episodes", + Music => "Music", + Albums => "Albums", + Artists => "Artists", + } + } + + fn include_item_types(self) -> Option<&'static str> { + use SearchMode::*; + match self { + All => None, // IMPORTANT: truly unfiltered + ShowsMovies => Some("Movie,Series"), + Shows => Some("Series"), + Movies => Some("Movie"), + Episodes => Some("Episode"), + Music => Some("Audio"), + Albums => Some("MusicAlbum"), + Artists => Some("MusicArtist"), + } + } +} + +#[derive(Clone)] +enum SearchScope { + All, + Library { + title: String, + query: core::jellyfin::LibraryQuery, + }, +} + +struct SearchState { + mode: SearchMode, + scope: SearchScope, + input: String, +} + +struct App { + should_exit: bool, + status: String, + config_status: String, + + view_stack: Vec, + + items: Vec, + selected: usize, + + config: Option, + login_user: String, + login_pass: String, + login_focus: LoginFocus, + login_error: Option, + + search: Option, + mpv: Option, + mpv_poll_last: Instant, + mpv_state: MpvState, + mpv_state_rx: Option>, + debug_mpv: bool, + mpv_now_playing: Option, + + autoplay: bool, + autoplay_view_depth: usize, + autoplay_next_index: Option, + autoplay_eof_latched: bool, + autoplay_prev_time_pos: Option, + + transcode_on: bool, + transcode_mbps: u32, + +} + +#[derive(Debug)] +struct MpvSession { + socket_path: PathBuf, + child: Child, +} + +impl Default for App { + fn default() -> Self { + let loaded = core::config::load_config().ok(); + + let default_mbps = loaded + .as_ref() + .and_then(|c| c.transcode_mbps) + .unwrap_or(5); + + let default_on = loaded + .as_ref() + .and_then(|c| c.transcode_default_on) + .unwrap_or(false); + + let (view_stack, config_status) = match &loaded { + Some(cfg) if cfg.access_token.is_some() => ( + vec![View::Categories], + format!("Connected: ({})", cfg.base_url), + ), + Some(cfg) => ( + vec![View::Login], + format!("Not Connected: ({})", cfg.base_url), + ), + None => ( + vec![View::Login], + "Missing Configuration".to_string(), + ), + }; + + Self { + should_exit: false, + status: String::new(), + config_status, + config: loaded, + + view_stack, + + items: vec![ + "Shows".into(), + "Movies".into(), + "Music".into(), + "Playlists".into(), + "Collections".into(), + "Libraries".into(), + "Continue Watching".into(), + "Recently Added".into(), + "Downloads".into(), + "Settings".into(), + ], + selected: 0, + + login_user: String::new(), + login_pass: String::new(), + login_focus: LoginFocus::User, + login_error: None, + search: None, + mpv: None, + mpv_poll_last: Instant::now(), + mpv_state: MpvState::default(), + mpv_state_rx: None, + debug_mpv: false, + mpv_now_playing: None, + autoplay: false, + autoplay_view_depth: 0, + autoplay_next_index: None, + autoplay_eof_latched: false, + autoplay_prev_time_pos: None, + transcode_on: default_on, + transcode_mbps: default_mbps, + } + } +} + +impl App { + fn current_view(&self) -> &View { + self.view_stack.last().unwrap() + } +} + +fn main() -> io::Result<()> { + // Terminal setup + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + terminal.clear()?; + + let res = run_app(&mut terminal); + + // Terminal restore (always) + disable_raw_mode()?; + execute!(io::stdout(), LeaveAlternateScreen)?; + terminal.show_cursor()?; + + res +} + +fn run_app(terminal: &mut Terminal>) -> io::Result<()> { + let tick_rate = Duration::from_millis(16); // ~60fps + let mut last_tick = Instant::now(); + let mut app = App::default(); + + while !app.should_exit { + terminal.draw(|f| ui(f, &app))?; + + let timeout = tick_rate.saturating_sub(last_tick.elapsed()); + if event::poll(timeout)? { + if let Event::Key(key) = event::read()? { + if key.kind == KeyEventKind::Press { + handle_key(&mut app, key.code); + } + } + } + + if let Some(rx) = &app.mpv_state_rx { + while let Ok(st) = rx.try_recv() { + app.mpv_state = st; + } + } + + if app.debug_mpv { + app.status = fmt_mpv_state(&app.mpv_state); + } + + // --- mpv lifecycle (no borrow issues) --- + let mpv_exited = match app.mpv.as_mut() { + Some(mpv) => match mpv.child.try_wait() { + Ok(Some(status)) => Some(status), + _ => None, + }, + None => None, + }; + + if let Some(status) = mpv_exited { + // take ownership so we can clean up + if let Some(mpv) = app.mpv.take() { + let _ = std::fs::remove_file(&mpv.socket_path); + app.status = format!("mpv exited: {status}"); + } + + // Your requirement: window closing disables autoplay + app.autoplay = false; + app.autoplay_next_index = None; + app.mpv_now_playing = None; + app.mpv_state_rx = None; + app.mpv_state = MpvState::default(); + } + + // Option A: always tick autoplay by polling eof-reached (window stays open) + autoplay_tick(&mut app); + + + if last_tick.elapsed() >= tick_rate { + // tick-based updates go here later (smooth nav, download polling, etc.) + last_tick = Instant::now(); + } + } + Ok(()) +} + +fn handle_key(app: &mut App, code: KeyCode) { + // Special input handling in Login view + if matches!(app.current_view(), View::Login) { + match code { + KeyCode::Char('q') => { app.should_exit = true; return; } + KeyCode::Esc => { app.should_exit = true; return; } + + KeyCode::Tab => { + app.login_focus = match app.login_focus { + LoginFocus::User => LoginFocus::Pass, + LoginFocus::Pass => LoginFocus::User, + }; + return; + } + + KeyCode::Backspace => { + match app.login_focus { + LoginFocus::User => { app.login_user.pop(); } + LoginFocus::Pass => { app.login_pass.pop(); } + } + return; + } + + KeyCode::Enter => { + let cfg = match &app.config { + Some(c) => c.clone(), + None => { + app.login_error = Some("No config loaded. Create config.toml with base_url.".to_string()); + return; + } + }; + + 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; + new_cfg.access_token = Some(res.access_token); + new_cfg.user_id = res.user_id; + + if let Err(e) = core::config::save_config(&new_cfg) { + app.login_error = Some(format!("login ok but failed to write config: {e:?}")); + return; + } + + app.config_status = format!("auth: token present ({})", new_cfg.base_url); + app.config = Some(new_cfg); + app.login_error = None; + app.login_pass.clear(); // don't keep password around + app.view_stack = vec![View::Categories]; + app.status = "Logged in".to_string(); + } + Err(e) => { + app.login_error = Some(e); + } + } + return; + } + KeyCode::Char('C') => { reload_config(app); return; } + + KeyCode::Char(c) => { + match app.login_focus { + LoginFocus::User => app.login_user.push(c), + LoginFocus::Pass => app.login_pass.push(c), + } + return; + } + + _ => return, + } + } + + // If search mode is active, it captures input first + if let Some(search) = &mut app.search { + match code { + KeyCode::Esc => { app.search = None; return; } + KeyCode::Backspace => { search.input.pop(); return; } + KeyCode::Enter => { + let term = search.input.trim().to_string(); + if term.is_empty() { + app.search = None; + return; + } + + let mode = search.mode; + let types = mode.include_item_types().map(|s| s.to_string()); + + let (title, mut query) = match &search.scope { + SearchScope::All => ( + format!("Search: {}", term), + core::jellyfin::LibraryQuery::all().with_search(term.clone()), + ), + SearchScope::Library { title, query } => ( + format!("{}: {}", title, term), + query.clone().with_search(term.clone()), + ), + }; + + // Override/force item types for the search mode + query.include_item_types = types; + + app.search = None; + + // run search (same as your library fetch) + let cfg = match &app.config { Some(c) => c.clone(), None => { app.status = "No config".into(); return; } }; + let user_id = match cfg.user_id.as_deref() { Some(u) => u, None => { app.status = "Missing user_id".into(); return; } }; + let client = match core::jellyfin::JellyfinClient::from_config(&cfg) { Ok(c) => c, Err(e) => { app.status = e; return; } }; + + app.status = format!("Searching… {}", term); + + match client.list_items(user_id, query.clone(), 0, PAGE_SIZE) { + Ok(page) => { + app.view_stack.push(View::Library { + title, + query, + items: page.items, + selected: 0, + start_index: 0, + total: page.total, + page_size: PAGE_SIZE, + loading: false, + }); + app.status = "Search loaded".into(); + } + Err(e) => app.status = format!("Search failed: {e}"), + } + + return; + } + KeyCode::Char('/') => { + search.mode = search.mode.next(); + return; + } + KeyCode::Char('?') => { + search.mode = search.mode.prev(); + return; + } + KeyCode::Char(c) => { search.input.push(c); return; } + _ => return, + } + } + + // 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, + }; + + // q behavior: if not home, go home; if home, quit + if matches!(code, KeyCode::Char('q')) { + app.autoplay = false; + app.autoplay_next_index = None; + if !matches!(app.current_view(), View::Categories) { + app.view_stack = vec![View::Categories]; + app.selected = 0; + app.status = "$".into(); + return; + } else { + app.should_exit = true; + return; + } + } + + if matches!(code, KeyCode::Char('D')) { + app.debug_mpv = !app.debug_mpv; + app.status = format!("mpv debug: {}", if app.debug_mpv { "ON" } else { "OFF" }); + return; + } + + // mpv controls (only when not typing into search/login) + if app.search.is_none() && !matches!(app.current_view(), View::Login) { + match code { + KeyCode::Char('a') => { + if let Some(mpv) = &app.mpv { + if let Err(e) = mpv_cycle_audio(mpv) { + app.status = format!("mpv audio: {e}"); + } + } + return; + } + KeyCode::Char('s') => { + if let Some(mpv) = &app.mpv { + if let Err(e) = mpv_cycle_subtitles(mpv) { + app.status = format!("mpv subs: {e}"); + } + } + return; + } + KeyCode::Char('v') => { + if let Some(mpv) = &app.mpv { + if let Err(e) = mpv_toggle_subs(mpv) { + app.status = format!("mpv sub vis: {e}"); + } + } + return; + } + KeyCode::Char('x') => { + app.autoplay = false; + app.autoplay_next_index = None; + if let Some(mut mpv) = app.mpv.take() { + mpv_stop(&mut mpv); + app.status = "Stopped mpv".into(); + } + return; + } + KeyCode::Char('f') => { + if let Some(mpv) = &app.mpv { + if let Err(e) = mpv_toggle_fullscreen(mpv) { + app.status = format!("mpv fullscreen: {e}"); + } + } else { + app.status = "mpv not running (press 'p' to play)".into(); + } + return; + } + KeyCode::Char(' ') => { + if let Some(mpv) = &app.mpv { + if let Err(e) = mpv_cycle_pause(mpv) { + app.status = format!("mpv pause: {e}"); + } + } else { + app.status = "mpv not running (press 'p' to play)".into(); + } + return; + } + KeyCode::Char('p') => { + app.autoplay = false; + app.autoplay_next_index = None; + + // p = play selected item (spawn or replace) + let cfg = match &app.config { + Some(c) => c.clone(), + None => { app.status = "No config loaded".into(); return; } + }; + + // Determine what is “selected” depending on view + let (item_id, item_name) = match app.current_view() { + View::Library { items, selected, .. } if !items.is_empty() => { + (items[*selected].id.clone(), items[*selected].name.clone()) + } + _ => { app.status = "Nothing to play here".into(); return; } + }; + + app.mpv_now_playing = Some(item_name); + + // If mpv running -> replace, else spawn + let url = build_jellyfin_play_url( + &cfg, + &item_id, + None, + app.transcode_on, + app.transcode_mbps, + ); + + if let Some(mpv) = &app.mpv { + match mpv_load_url(&cfg, mpv, &url) { + Ok(_) => app.status = "mpv: loaded".into(), + Err(e) => app.status = format!("mpv load failed: {e}"), + } + } else { + match spawn_mpv_url(&cfg, &url) { + Ok(mpv) => { + let rx = start_mpv_poller(mpv.socket_path.clone()); + app.mpv_state_rx = Some(rx); + app.mpv = Some(mpv); + app.status = "mpv: playing".into(); + } + Err(e) => app.status = format!("mpv spawn failed: {e}"), + } + } + return; + } + KeyCode::Char('P') => { + let cfg = match &app.config { Some(c) => c.clone(), None => { app.status="No config".into(); return; } }; + + let (item_id, item_name, selected, len) = match app.current_view() { + View::Library { items, selected, .. } if !items.is_empty() => { + (items[*selected].id.clone(), items[*selected].name.clone(), *selected, items.len()) + } + _ => { app.status = "Nothing to play here".into(); return; } + }; + + app.mpv_now_playing = Some(item_name); + + app.autoplay = true; + app.status = format!("autoplay ON (next index: {:?})", app.autoplay_next_index); + app.autoplay_prev_time_pos = None; + app.autoplay_view_depth = app.view_stack.len(); + app.autoplay_next_index = if selected + 1 < len { Some(selected + 1) } else { None }; + + // load/replace or spawn + let url = build_jellyfin_play_url( + &cfg, + &item_id, + None, + app.transcode_on, + app.transcode_mbps, + ); + + if let Some(mpv) = &app.mpv { + match mpv_load_url(&cfg, mpv, &url) { + Ok(_) => app.status = "mpv: playing (autoplay)".into(), + Err(e) => app.status = format!("mpv load failed: {e}"), + } + } else { + match spawn_mpv_url(&cfg, &url) { + Ok(mpv) => { + let rx = start_mpv_poller(mpv.socket_path.clone()); + app.mpv_state_rx = Some(rx); + app.mpv = Some(mpv); + app.status = "mpv: playing".into(); + } + Err(e) => app.status = format!("mpv spawn failed: {e}"), + } + } + return; + } + KeyCode::Char('t') => { + app.transcode_on = !app.transcode_on; + app.status = if app.transcode_on { + format!("Transcoding ON ({}Mbps)", app.transcode_mbps) + } else { + "Transcoding OFF".into() + }; + return; + } + KeyCode::Char('r') => { + app.autoplay = false; + app.autoplay_next_index = None; + + let cfg = match &app.config { + Some(c) => c.clone(), + None => { app.status = "No config loaded".into(); return; } + }; + + let (item_id, item_name, start_ticks) = match app.current_view() { + View::Library { items, selected, .. } if !items.is_empty() => { + let it = &items[*selected]; + let ty = it.item_type.as_deref().unwrap_or(""); + if ty != "Episode" && ty != "Movie" { + app.status = "Resume only works on Episodes/Movies (for now).".into(); + return; + } + (it.id.clone(), it.name.clone(), playback_position_ticks(it)) + } + _ => { app.status = "Nothing to resume here".into(); return; } + }; + + app.mpv_now_playing = Some(item_name); + + let url = build_jellyfin_play_url( + &cfg, + &item_id, + start_ticks, + app.transcode_on, + app.transcode_mbps, + ); + + if let Some(mpv) = &app.mpv { + match mpv_cmd(&mpv.socket_path, &format!(r#"{{"command":["loadfile","{}","replace"]}}"#, url)) { + Ok(_) => app.status = "mpv: resumed".into(), + Err(e) => app.status = format!("mpv resume failed: {e}"), + } + } else { + match spawn_mpv_url(&cfg, &url) { + Ok(mpv) => { + let rx = start_mpv_poller(mpv.socket_path.clone()); + app.mpv_state_rx = Some(rx); + app.mpv = Some(mpv); + app.status = "mpv: resumed".into(); + } + Err(e) => app.status = format!("mpv spawn failed: {e}"), + } + } + return; + } + KeyCode::Char('R') => { + // resume + autoplay + let cfg = match &app.config { Some(c) => c.clone(), None => { app.status="No config".into(); return; } }; + + let (item_id, item_name, selected, len, start_ticks) = match app.current_view() { + View::Library { items, selected, .. } if !items.is_empty() => { + let it = &items[*selected]; + let ty = it.item_type.as_deref().unwrap_or(""); + if ty != "Episode" && ty != "Movie" { + app.status = "Resume only works on Episodes/Movies (for now).".into(); + return; + } + (it.id.clone(), it.name.clone(), *selected, items.len(), playback_position_ticks(it)) + } + _ => { app.status = "Nothing to resume here".into(); return; } + }; + + app.mpv_now_playing = Some(item_name); + + app.autoplay = true; + app.autoplay_prev_time_pos = None; + app.autoplay_view_depth = app.view_stack.len(); + app.autoplay_next_index = if selected + 1 < len { Some(selected + 1) } else { None }; + app.autoplay_eof_latched = false; + + let url = build_jellyfin_play_url( + &cfg, + &item_id, + start_ticks, + app.transcode_on, + app.transcode_mbps, + ); + + if let Some(mpv) = &app.mpv { + match mpv_cmd(&mpv.socket_path, &format!(r#"{{"command":["loadfile","{}","replace"]}}"#, url)) { + Ok(_) => app.status = "mpv: resumed (autoplay)".into(), + Err(e) => app.status = format!("mpv resume failed: {e}"), + } + } else { + match spawn_mpv_url(&cfg, &url) { + Ok(mpv) => { + let rx = start_mpv_poller(mpv.socket_path.clone()); + app.mpv_state_rx = Some(rx); + app.mpv = Some(mpv); + app.status = "mpv: resumed (autoplay)".into(); + } + Err(e) => app.status = format!("mpv spawn failed: {e}"), + } + } + return; + } + _ => {} + } + } + + match code { + KeyCode::Char('q') => app.should_exit = true, + + //Enter code Block + KeyCode::Enter => { + let view = app.current_view().clone(); + + 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".into(); return; } + }; + + let user_id = match cfg.user_id.as_deref() { + Some(u) => u, + 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; } + }; + + // 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(), + "Playlists" => core::jellyfin::LibraryQuery::all().with_item_types("Playlist"), + _ => { app.status = format!("Not wired yet: {}", cat); 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 = "$".into(); + } + 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(""); + + // 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() + .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; + } + if ty == "BoxSet" { + let q = core::jellyfin::LibraryQuery::all().with_parent(it.id.clone()); + open_children(app, format!("{} > {}", title, it.name), q); + return; + } + if ty == "UserView" { + // Most UserViews are libraries like Shows/Movies/Music/Playlists. + // For Playlists view, drill into playlists. + let q = core::jellyfin::LibraryQuery::all() + .with_parent(it.id.clone()) + .with_item_types("Playlist"); + open_children(app, format!("{} > {}", title, it.name), q); + return; + } + if ty == "Playlist" { + let cfg = match &app.config { Some(c) => c.clone(), None => { app.status="No config".into(); return; } }; + let user_id = match cfg.user_id.as_deref() { Some(u) => u, None => { app.status="Missing user_id".into(); return; } }; + let client = match core::jellyfin::JellyfinClient::from_config(&cfg) { Ok(c)=>c, Err(e)=>{ app.status=e; return; } }; + + match client.playlist_items(user_id, &it.id, 0, PAGE_SIZE) { + Ok(page) => { + app.view_stack.push(View::Library { + title: format!("{} > {}", title, it.name), + // store a query placeholder; playlist paging could be done later if you want + query: core::jellyfin::LibraryQuery::all(), + items: page.items, + selected: 0, + start_index: 0, + total: page.total, + page_size: PAGE_SIZE, + loading: false, + }); + app.status = "$".into(); + } + Err(e) => app.status = format!("Playlist load failed: {e}"), + } + return; + } + // Playables -> mpv + // Playables: do nothing on Enter (use 'p' to play) + if ty == "Episode" || ty == "Audio" || ty == "Movie" { + app.status = "Press 'p' to play, or 'P' for autoplay.".into(); + return; + } + + app.status = format!("No drilldown for type: {}", ty); + } + + _ => {} + } +} + + + KeyCode::Esc | KeyCode::Backspace => { + if app.view_stack.len() > 1 { + app.view_stack.pop(); + app.status = "$".to_string(); + app.selected = 0; + } + } + KeyCode::Up | KeyCode::Char('k') => { + 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]); + app.status = "$".into(); + } + View::Library { items, selected, total, .. } => { + if items.is_empty() { return; } + + if *selected == 0 { + // wrap-up wants the real end + if items.len() < *total { + ensure_loaded_to_end(app); + } + + if let Some(View::Library { items, selected, .. }) = app.view_stack.last_mut() { + if !items.is_empty() { + *selected = items.len() - 1; + } + } + } else { + *selected -= 1; + } + + maybe_prefetch(app); + } + _ => {} + } + } + KeyCode::Down | KeyCode::Char('j') => { + 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]); + app.status = "$".into(); + } + 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 { + *selected = 0; + } + } else { + *selected += 1; + } + + maybe_prefetch(app); + } + _ => {} + } + } + KeyCode::Char('/') => { + let scope = match app.current_view() { + View::Categories => SearchScope::All, + View::Library { title, query, .. } => SearchScope::Library { + title: title.clone(), + query: query.clone(), + }, + _ => SearchScope::All, + }; + + app.search = Some(SearchState { + scope, + mode: SearchMode::All, + input: String::new(), + }); + } + _ => {} + } +} + +fn load_next_library_page(app: &mut App) { + // pull info + mark loading + let (query, next_start, total, page_size) = match app.view_stack.last_mut() { + Some(View::Library { query, items, total, start_index, loading, page_size, .. }) => { + if *loading { return; } + *loading = true; + let loaded = items.len(); + let next_start = *start_index + loaded; + (query.clone(), next_start, *total, *page_size) + } + _ => return, + }; + + let cfg = match &app.config { + Some(c) => c.clone(), + None => { app.status = "No config loaded".to_string(); finish_loading(app); 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(); finish_loading(app); return; } + }; + + let client = match core::jellyfin::JellyfinClient::from_config(&cfg) { + Ok(c) => c, + Err(e) => { app.status = e; finish_loading(app); return; } + }; + + 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}"); finish_loading(app); return; } + }; + + if let Some(View::Library { items, total, .. }) = app.view_stack.last_mut() { + items.extend(page.items); + *total = page.total; + } + + finish_loading(app); +} + +fn finish_loading(app: &mut App) { + if let Some(View::Library { loading, .. }) = app.view_stack.last_mut() { + *loading = false; + } +} + +fn maybe_prefetch(app: &mut App) { + const PREFETCH_ROWS: usize = 0; + + let should = match app.view_stack.last() { + Some(View::Library { items, selected, total, loading, .. }) => { + if *loading { false } + else if items.len() >= *total { false } + else { + let remaining_loaded = items.len().saturating_sub(*selected); + remaining_loaded <= PREFETCH_ROWS + } + } + _ => false, + }; + + if should { + load_next_library_page(app); + } +} + +fn ensure_loaded_to_end(app: &mut App) { + loop { + let done = match app.view_stack.last() { + Some(View::Library { items, total, loading, .. }) => { + !*loading && items.len() >= *total + } + _ => true, + }; + if done { break; } + + load_next_library_page(app); + } +} + +fn ticks_to_runtime_str(ticks: Option) -> Option { + // Jellyfin ticks are 10,000,000 per second + let t = ticks?; + if t <= 0 { return None; } + + let total_seconds = (t as u64 / 10_000_000) as u64; + let minutes = total_seconds / 60; + let seconds = total_seconds % 60; + + if minutes >= 60 { + let h = minutes / 60; + let m = minutes % 60; + Some(format!("{h}h {m}m")) + } else { + Some(format!("{minutes}m {seconds:02}s")) + } +} + +fn join_artists(artists: &[String]) -> String { + let names: Vec = artists + .iter() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + + if names.is_empty() { "-".into() } else { names.join(", ") } +} + +fn first_nonempty(opt: &Option) -> Option { + opt.as_deref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) +} + +fn join_genres(genres: &[String]) -> String { + if genres.is_empty() { "-".into() } else { genres.join(", ") } +} + +fn join_studios(studios: &[core::jellyfin::JfStudio]) -> String { + let names: Vec = studios + .iter() + .map(|s| s.name.clone()) + .filter(|s| !s.trim().is_empty()) + .collect(); + + if names.is_empty() { "-".into() } else { names.join(", ") } +} + +fn year_str(y: Option) -> String { + y.map(|v| v.to_string()).unwrap_or_else(|| "-".into()) +} + +fn overview_str(o: &Option) -> String { + o.as_deref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .unwrap_or("-") + .to_string() +} + +fn format_details(it: &core::jellyfin::JfItem) -> String { + let ty = it.item_type.as_deref().unwrap_or(""); + + match ty { + "Series" => format!( + "Name: {}\nYear: {}\nGenres: {}\nStudio: {}\n\nOverview:\n{}", + it.name, + year_str(it.production_year), + join_genres(&it.genres), + join_studios(&it.studios), + overview_str(&it.overview), + ), + + "Movie" => { + let mut header = format!( + "Name: {}\nYear: {}\nRuntime: {}\nGenres: {}\nStudio: {}", + it.name, + year_str(it.production_year), + ticks_to_runtime_str(it.run_time_ticks).unwrap_or_else(|| "-".into()), + join_genres(&it.genres), + join_studios(&it.studios), + ); + + if is_in_progress(it) { + let pos = playback_position_ticks(it).unwrap_or(0); + let pos_secs = (pos / 10_000_000) as f64; + header.push_str(&format!( + "\nIn progress ({}): press r/R to resume.", + fmt_hms(pos_secs) + )); + } + + format!( + "{}\n\nOverview:\n{}", + header, + overview_str(&it.overview), + ) + } + + "Episode" => { + let series = it.series_name.clone().unwrap_or_else(|| "-".into()); + let se = match (it.parent_index_number, it.index_number) { + (Some(s), Some(e)) => format!("{:02}x{:02}", s, e), + _ => "-".into(), + }; + + let mut header = format!( + "Series: {}\nEpisode: {} {}\nYear: {}", + series, + se, + it.name, + year_str(it.production_year), + ); + + if is_in_progress(it) { + let pos = playback_position_ticks(it).unwrap_or(0); + let pos_secs = (pos / 10_000_000) as f64; + header.push_str(&format!( + "\nIn progress ({}): press r/R to resume.", + fmt_hms(pos_secs) + )); + } + + format!( + "{}\n\nOverview:\n{}", + header, + overview_str(&it.overview), + ) + } + + // --- Songs --- + "Audio" => { + let artist = first_nonempty(&it.album_artist) + .or_else(|| it.artists.get(0).cloned()) + .unwrap_or_else(|| "-".into()); + + let album = first_nonempty(&it.album).unwrap_or_else(|| "-".into()); + + format!( + "Name: {}\nRuntime: {}\nArtist: {}\nAlbum: {}\nYear: {}", + it.name, + ticks_to_runtime_str(it.run_time_ticks).unwrap_or_else(|| "-".into()), + artist, + album, + year_str(it.production_year), + ) + } + + // --- Albums --- + "MusicAlbum" => { + let artist = first_nonempty(&it.album_artist) + .or_else(|| it.artists.get(0).cloned()) + .unwrap_or_else(|| "-".into()); + + format!( + "Name: {}\nRuntime: {}\nArtist: {}\nYear: {}", + it.name, + ticks_to_runtime_str(it.run_time_ticks).unwrap_or_else(|| "-".into()), + artist, + year_str(it.production_year), + ) + } + + // --- Artists --- + "MusicArtist" => format!("Name: {}", it.name), + + _ => format!( + "Name: {}\nId: {}\nType: {:?}\nYear: {:?}", + it.name, it.id, it.item_type, it.production_year + ), + } +} + +fn ui(frame: &mut Frame, app: &App) { + let area = frame.area(); + let panes = layout(area); + + // Top + //frame.render_widget( + // Paragraph::new("cj-jftui").block(Block::bordered()), + // panes.top, + //); + + let top_block = Block::bordered(); + frame.render_widget(top_block.clone(), panes.top); + + let top_inner = top_block.inner(panes.top); + let [top_l, top_r] = Layout::horizontal([Constraint::Fill(1), Constraint::Length(40)]) + .areas(top_inner); + + frame.render_widget(Paragraph::new("cj-jftui"), top_l); + let server = app + .config + .as_ref() + .map(|c| c.base_url.clone()) + .unwrap_or_else(|| "-".into()); + + frame.render_widget( + Paragraph::new(server) + .alignment(ratatui::layout::Alignment::Right), + top_r, + ); + + // Main + + match app.current_view() { + View::Categories => { + render_categories(frame, app, panes.left_main, panes.right_main); + } + View::Library { title, query, items, selected, start_index, total, .. } => { + let is_audio = query + .include_item_types + .as_deref() + .map(|t| t.split(',').any(|x| x.trim() == "Audio")) + .unwrap_or(false); + + let show_audio_track_numbers = is_audio && query.parent_id.is_some(); + + render_library( + frame, + title, + *start_index, + *total, + items, + *selected, + panes.left_main, + panes.right_main, + show_audio_track_numbers, + ); + } + View::Login => { + render_login(frame, app, panes.left_main, panes.right_main); + } + } + + // Bottom Left + // ---- Bottom Left: prompt / mpv ---- + // Bottom Left: Search (temporary) > MPV (persistent) > "$" + if let Some(s) = &app.search { + let scope = match &s.scope { + SearchScope::All => "All".to_string(), + SearchScope::Library { title, .. } => title.clone(), + }; + let mode = s.mode.label(); + let line = if s.input.is_empty() { + format!("/ ({scope}) [{mode}]") + } else { + format!("/ ({scope}) [{mode}] {}", s.input) + }; + + frame.render_widget( + Paragraph::new(line).block(Block::bordered().merge_borders(MergeStrategy::Exact)), + panes.left_output, + ); + } else if app.mpv.is_some() { + // mpv panel takes over the whole bottom-left area + render_mpv_panel(frame, app, panes.left_output); + } else { + frame.render_widget( + Paragraph::new("$").block(Block::bordered().merge_borders(MergeStrategy::Exact)), + panes.left_output, + ); + } + + // ---- Bottom Right: always-on status/help ---- + let connected = if app.config.as_ref().and_then(|c| c.access_token.as_ref()).is_some() { + "✅ Connected" + } else { + "❌ Disconnected" + }; + + let server = app + .config + .as_ref() + .map(|c| c.base_url.as_str()) + .unwrap_or("-"); + + let tx_line = if app.transcode_on { + format!("Transcoding On ({}Mbps)", app.transcode_mbps) + } else { + "Transcoding Off".to_string() + }; + + let right_text = format!( + "{connected}\n{tx_line}\n\np/P: play/autoplay\nq: quit\n/help: more help" + ); + + frame.render_widget( + Paragraph::new(right_text) + .wrap(Wrap { trim: false }) + .block(Block::bordered().merge_borders(MergeStrategy::Exact)), + panes.right_output, + ); +} + +fn compute_window(len: usize, selected: usize, viewport: usize) -> (usize, usize) { + if len == 0 || viewport == 0 { + return (0, 0); + } + + let viewport = viewport.min(len); + let half = viewport / 2; + + let mut start = selected.saturating_sub(half); + if start + viewport > len { + start = len - viewport; + } + + let end = start + viewport; + (start, end) +} + +fn render_categories(frame: &mut Frame, app: &App, left: Rect, right: Rect) { + // how many rows can we show inside the bordered block? + let viewport = left.height.saturating_sub(2) as usize; + + let len = app.items.len(); + let selected = app.selected.min(len.saturating_sub(1)); + + let (start, end) = compute_window(len, selected, viewport); + + let items: Vec = app.items[start..end] + .iter() + .map(|s| ListItem::new(s.as_str())) + .collect(); + + let list = List::new(items) + .block(Block::bordered().title("Categories").merge_borders(MergeStrategy::Exact)) + .highlight_symbol("> ") + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); + + let mut state = ListState::default(); + state.select(Some(selected - start)); // selection relative to slice + + frame.render_stateful_widget(list, left, &mut state); + + // Right pane details (use the real selected item) + frame.render_widget( + Paragraph::new("") + .block(Block::bordered().merge_borders(MergeStrategy::Exact)), + right, + ); +} + +fn render_placeholder(frame: &mut Frame, name: &str, left: Rect, right: Rect) { + frame.render_widget( + Paragraph::new(format!("Inside {}\n\nPress Esc to go back", name)) + .block(Block::bordered().title("Library").merge_borders(MergeStrategy::Exact)), + left, + ); + + frame.render_widget( + Paragraph::new("Right pane reserved for item details / thumbnails") + .block(Block::bordered().title("Details").merge_borders(MergeStrategy::Exact)), + right, + ); +} + +fn render_library( + frame: &mut Frame, + title: &str, + start_index: usize, + total: usize, + items: &[core::jellyfin::JfItem], + selected: usize, + left: Rect, + right: Rect, + show_audio_track_numbers: bool, +) { + 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); + + fn show_year_for(item_type: Option<&str>) -> bool { + matches!(item_type, Some("Movie" | "Series" | "MusicAlbum")) + } + + fn left_number_prefix(it: &core::jellyfin::JfItem, show_audio_track_numbers: bool) -> String { + match it.item_type.as_deref() { + Some("Episode") => { + 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") => { + if !show_audio_track_numbers { + return String::new(); + } + match it.index_number { + Some(n) => format!("{:02}. ", n), + None => String::new(), + } + } + _ => String::new(), + } + } + + 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 emoji = emoji_prefix_for(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!("{}{}{}{}", emoji, prefix, 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_details(it) + } else { + "No items returned".to_string() + }; + + frame.render_widget( + Paragraph::new(detail) + .wrap(Wrap { trim: false }) + .block(Block::bordered().title("Details").merge_borders(MergeStrategy::Exact)), + right, + ); +} + +fn open_children(app: &mut App, title: String, query: core::jellyfin::LibraryQuery) { + let cfg = match &app.config { + Some(c) => c.clone(), + 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)".into(); return; } + }; + let client = match core::jellyfin::JellyfinClient::from_config(&cfg) { + Ok(c) => c, + Err(e) => { app.status = e; return; } + }; + + app.status = format!("Fetching {}...", title); + + match client.list_items(user_id, query.clone(), 0, PAGE_SIZE) { + Ok(page) => { + app.view_stack.push(View::Library { + title, + query, + items: page.items, + selected: 0, + start_index: 0, + total: page.total, + page_size: PAGE_SIZE, + loading: false, + }); + app.status = "$".into(); + } + Err(e) => app.status = format!("Fetch failed: {e}"), + } +} + +fn mpv_socket_path() -> PathBuf { + let mut p = std::env::temp_dir(); + let pid = std::process::id(); + p.push(format!("cj-jftui-mpv-{}.sock", pid)); + p +} + +fn mpv_request(socket: &PathBuf, json_line: &str) -> Result { + let mut stream = UnixStream::connect(socket) + .map_err(|e| format!("mpv ipc connect failed: {e}"))?; + + // Prevent UI hangs if mpv doesn't respond + let _ = stream.set_write_timeout(Some(Duration::from_millis(40))); + let _ = stream.set_read_timeout(Some(Duration::from_millis(40))); + + stream + .write_all(json_line.as_bytes()) + .map_err(|e| format!("mpv ipc write failed: {e}"))?; + + let mut reader = BufReader::new(stream); + let mut line = String::new(); + + match reader.read_line(&mut line) { + Ok(0) => Err("mpv ipc: EOF".to_string()), + Ok(_) => Ok(line), + Err(e) => { + // timeout is normal; just treat it as "no data yet" + if e.kind() == io::ErrorKind::WouldBlock || e.kind() == io::ErrorKind::TimedOut { + Err("mpv ipc timeout".to_string()) + } else { + Err(format!("mpv ipc read failed: {e}")) + } + } + } +} + +fn mpv_get_bool(mpv: &MpvSession, prop: &str) -> Result { + let resp = mpv_request( + &mpv.socket_path, + &format!(r#"{{"command":["get_property","{}"]}}\n"#, prop), + )?; + + let v: Value = serde_json::from_str(resp.trim()) + .map_err(|e| format!("mpv json parse failed: {e} ({})", resp.trim()))?; + + if v.get("error").and_then(|e| e.as_str()) != Some("success") { + return Err(format!("mpv get_property failed: {}", resp.trim())); + } + + Ok(v.get("data").and_then(|d| d.as_bool()).unwrap_or(false)) +} + +fn mpv_get_f64(mpv: &MpvSession, prop: &str) -> Result, String> { + let resp = mpv_request( + &mpv.socket_path, + &format!(r#"{{"command":["get_property","{}"]}}\n"#, prop), + )?; + + let v: Value = serde_json::from_str(resp.trim()) + .map_err(|e| format!("mpv json parse failed: {e} ({})", resp.trim()))?; + + if v.get("error").and_then(|e| e.as_str()) != Some("success") { + return Err(format!("mpv get_property failed: {}", resp.trim())); + } + + Ok(v.get("data").and_then(|d| d.as_f64())) +} + +fn mpv_send(socket: &PathBuf, json_line: &str) -> Result<(), String> { + let mut stream = UnixStream::connect(socket) + .map_err(|e| format!("mpv ipc connect failed: {e}"))?; + stream + .write_all(json_line.as_bytes()) + .map_err(|e| format!("mpv ipc write failed: {e}"))?; + Ok(()) +} + +fn mpv_cmd(socket: &PathBuf, cmd: &str) -> Result<(), String> { + // cmd should already be valid JSON without trailing newline + mpv_send(socket, &format!("{cmd}\n")) +} + +fn spawn_mpv(cfg: &core::config::Config, first_item_id: &str) -> Result { + let token = cfg + .access_token + .as_deref() + .ok_or_else(|| "Missing access_token (login again)".to_string())?; + + let base = cfg.base_url.trim_end_matches('/'); + let url = format!("{}/Items/{}/Download", base, first_item_id); + + let header = format!("X-Emby-Token: {}", token); + let sock = mpv_socket_path(); + + // If a stale socket exists, remove it. + let _ = std::fs::remove_file(&sock); + + let mut child = Command::new("mpv") + .arg(url) + .arg("--force-window=yes") + .arg("--keep-open=yes") + .arg("--idle=yes") + .arg(format!("--http-header-fields={header}")) + .arg(format!("--input-ipc-server={}", sock.display())) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| format!("Failed to spawn mpv: {e}"))?; + + // Wait briefly for IPC socket to appear + let start = Instant::now(); + while start.elapsed() < Duration::from_millis(800) { + if sock.exists() { + return Ok(MpvSession { + socket_path: sock, + child, + }); + } + thread::sleep(Duration::from_millis(10)); + } + + // If socket never appeared, mpv probably died + let _ = child.kill(); + Err("mpv IPC socket did not appear".to_string()) +} + +fn mpv_load_item(cfg: &core::config::Config, mpv: &MpvSession, item_id: &str) -> Result<(), String> { + let base = cfg.base_url.trim_end_matches('/'); + let url = format!("{}/Items/{}/Download", base, item_id); + + // loadfile replace + mpv_cmd( + &mpv.socket_path, + &format!(r#"{{"command":["loadfile","{}","replace"]}}"#, url), + ) +} + +fn mpv_cycle_audio(mpv: &MpvSession) -> Result<(), String> { + mpv_cmd(&mpv.socket_path, r#"{"command":["cycle","audio"]}"#) +} + +fn mpv_cycle_subtitles(mpv: &MpvSession) -> Result<(), String> { + mpv_cmd(&mpv.socket_path, r#"{"command":["cycle","sub"]}"#) +} + +fn mpv_toggle_subs(mpv: &MpvSession) -> Result<(), String> { + mpv_cmd(&mpv.socket_path, r#"{"command":["cycle","sub-visibility"]}"#) +} + +fn mpv_stop(mpv: &mut MpvSession) { + let _ = mpv_cmd(&mpv.socket_path, r#"{"command":["quit"]}"#); + let _ = mpv.child.kill(); + let _ = std::fs::remove_file(&mpv.socket_path); +} + +fn mpv_get_prop_jtui(socket: &PathBuf, prop: &str, request_id: u32) -> Result { + let mut stream = UnixStream::connect(socket) + .map_err(|e| format!("mpv ipc connect failed: {e}"))?; + + let _ = stream.set_write_timeout(Some(Duration::from_millis(250))); + let _ = stream.set_read_timeout(Some(Duration::from_millis(250))); + + let cmd = format!( + r#"{{"command":["get_property","{}"],"request_id":{}}}"#, + prop, request_id + ); + + stream + .write_all(format!("{cmd}\n").as_bytes()) + .map_err(|e| format!("mpv ipc write failed: {e}"))?; + + // IMPORTANT: read a whole JSON line (track-list can be >1024 bytes) + let mut reader = BufReader::new(stream); + let mut line = String::new(); + reader + .read_line(&mut line) + .map_err(|e| format!("mpv ipc read failed: {e}"))?; + + if line.trim().is_empty() { + return Err("mpv ipc: empty response".into()); + } + + let v: Value = serde_json::from_str(line.trim()) + .map_err(|e| format!("mpv json parse failed: {e} ({})", line.trim()))?; + + if v.get("error").and_then(|e| e.as_str()) != Some("success") { + return Err(format!("mpv get_property({prop}) failed: {v}")); + } + + Ok(v.get("data").cloned().unwrap_or(Value::Null)) +} + +fn selected_tracks_from_track_list(track_list: &Value) -> (Option, Option) { + let arr = match track_list.as_array() { + Some(a) => a, + None => return (None, None), + }; + + let mut audio: Option = None; + let mut subs: Option = None; + + for t in arr { + let ty = t.get("type").and_then(|x| x.as_str()).unwrap_or(""); + let selected = t.get("selected").and_then(|x| x.as_bool()).unwrap_or(false); + + if !selected { + continue; + } + + match ty { + "audio" => audio = Some(track_label_from_entry(t, "Audio")), + "sub" => subs = Some(track_label_from_entry(t, "Sub")), + _ => {} + } + } + + (audio, subs) +} + +fn mpv_get_f64_jtui(socket: &PathBuf, prop: &str, request_id: u32) -> Result, String> { + Ok(mpv_get_prop_jtui(socket, prop, request_id)?.as_f64()) +} + +fn mpv_get_bool_jtui(socket: &PathBuf, prop: &str, request_id: u32) -> Result { + Ok(mpv_get_prop_jtui(socket, prop, request_id)?.as_bool().unwrap_or(false)) +} + +fn mpv_toggle_fullscreen(mpv: &MpvSession) -> Result<(), String> { + // cycles fullscreen on/off + mpv_cmd(&mpv.socket_path, r#"{"command":["cycle","fullscreen"]}"#) + // alternatively: + // mpv_cmd(&mpv.socket_path, r#"{"command":["set_property","fullscreen","yes"]}"#) +} + +fn mpv_cycle_pause(mpv: &MpvSession) -> Result<(), String> { + mpv_cmd(&mpv.socket_path, r#"{"command":["cycle","pause"]}"#) +} + +fn autoplay_tick(app: &mut App) { + if !app.autoplay { + return; + } + + // If user navigated away from the view where autoplay started, cancel it. + if app.view_stack.len() != app.autoplay_view_depth { + app.autoplay = false; + app.autoplay_next_index = None; + app.autoplay_eof_latched = false; + app.autoplay_prev_time_pos = None; + return; + } + + // Snapshot latest mpv state from poller thread + let st = app.mpv_state.clone(); + + // Optional: live debug in status (toggle with 'D') + if app.debug_mpv { + app.status = fmt_mpv_state(&st); + } + + // Track previous time to detect "snapped back to 0" + if let Some(tp) = st.time_pos { + if tp > 0.25 { + app.autoplay_prev_time_pos = Some(tp); + } + } + + let near_end = match (st.time_pos, st.duration) { + (Some(tp), Some(d)) if d.is_finite() && d > 1.0 => (d - tp) <= 0.50, + _ => false, + }; + + let near_100 = match st.percent { + Some(p) if p.is_finite() => p >= 99.8, + _ => false, + }; + + let snapped_to_start_idle = match (st.idle, st.time_pos, app.autoplay_prev_time_pos) { + (true, Some(tp), Some(prev)) if prev > 1.0 && tp < 0.10 => true, + _ => false, + }; + + let finished = st.eof || near_end || near_100 || snapped_to_start_idle; + + // If not finished, clear latch so we can trigger when it *does* finish later. + if !finished { + app.autoplay_eof_latched = false; + return; + } + + // Only trigger once per finish event + if app.autoplay_eof_latched { + return; + } + app.autoplay_eof_latched = true; + + // Need mpv running to advance + let mpv = match &app.mpv { + Some(m) => m, + None => return, + }; + + let next_i = match app.autoplay_next_index { + Some(i) => i, + None => { + app.autoplay = false; + app.status = "$".into(); + return; + } + }; + + let (next_id, next_name, len) = match app.current_view() { + View::Library { items, .. } if next_i < items.len() => ( + items[next_i].id.clone(), + items[next_i].name.clone(), + items.len(), + ), + _ => { + app.autoplay = false; + app.autoplay_next_index = None; + app.status = "$".into(); + return; + } + }; + + app.mpv_now_playing = Some(next_name); + + + // Move selection highlight in UI + if let Some(View::Library { selected, .. }) = app.view_stack.last_mut() { + *selected = next_i; + } + + app.autoplay_next_index = if next_i + 1 < len { Some(next_i + 1) } else { None }; + + let cfg = match app.config.clone() { + Some(c) => c, + None => { + app.autoplay = false; + app.autoplay_next_index = None; + app.status = "No config".into(); + return; + } + }; + + // Load next item + let url = build_jellyfin_play_url( + &cfg, + &next_id, + None, + app.transcode_on, + app.transcode_mbps, + ); + + if let Err(e) = mpv_load_url(&cfg, mpv, &url) { + app.autoplay = false; + app.autoplay_next_index = None; + app.status = format!("autoplay load failed: {e}"); + return; + } + + // Ensure playback resumes + let _ = mpv_set_pause(mpv, false); + + // Reset tracking for new file + app.autoplay_prev_time_pos = None; + + if !app.debug_mpv { + app.status = "$".into(); + } +} + +fn mpv_set_pause(mpv: &MpvSession, paused: bool) -> Result<(), String> { + // pause is a boolean property in mpv JSON IPC + let v = if paused { "true" } else { "false" }; + mpv_cmd( + &mpv.socket_path, + &format!(r#"{{"command":["set_property","pause",{}]}}"#, v), + ) +} + +fn fmt_mpv_state(st: &MpvState) -> String { + let pct = match st.percent { + Some(p) if p.is_finite() => p, + _ => match (st.time_pos, st.duration) { + (Some(t), Some(d)) if t.is_finite() && d.is_finite() && d > 0.0 => (t / d) * 100.0, + _ => 0.0, + }, + }; + + format!("{pct:5.1}%") +} + +fn start_mpv_poller(socket_path: PathBuf) -> mpsc::Receiver { + let (tx, rx) = mpsc::channel::(); + + std::thread::spawn(move || { + let mut request_id: u32 = 1; + + loop { + let mut st = MpvState::default(); + + // jtui pattern: connect->write->read(1024)->parse->close (per property call) + let tp = mpv_get_f64_jtui(&socket_path, "time-pos", request_id).ok().flatten(); + request_id = request_id.wrapping_add(1); + + let dur = mpv_get_f64_jtui(&socket_path, "duration", request_id).ok().flatten(); + request_id = request_id.wrapping_add(1); + + let percent_pos = mpv_get_f64_jtui(&socket_path, "percent-pos", request_id).ok().flatten(); + request_id = request_id.wrapping_add(1); + + let eof = mpv_get_bool_jtui(&socket_path, "eof-reached", request_id).unwrap_or(false); + request_id = request_id.wrapping_add(1); + + let idle = mpv_get_bool_jtui(&socket_path, "idle-active", request_id).unwrap_or(false); + request_id = request_id.wrapping_add(1); + + let aid = mpv_get_prop_jtui(&socket_path, "aid", request_id).ok().and_then(|v| v.as_i64()); + request_id = request_id.wrapping_add(1); + + let sid = mpv_get_prop_jtui(&socket_path, "sid", request_id).ok().and_then(|v| v.as_i64()); + request_id = request_id.wrapping_add(1); + + let track_list = mpv_get_prop_jtui(&socket_path, "track-list", request_id).ok(); + request_id = request_id.wrapping_add(1); + + let (audio, subs) = match track_list.as_ref() { + Some(v) => selected_tracks_from_track_list(v), + None => (None, None), + }; + + let media_title = mpv_get_prop_jtui(&socket_path, "media-title", request_id) + .ok() + .and_then(|v| v.as_str().map(|s| s.to_string())); + request_id = request_id.wrapping_add(1); + + st.time_pos = tp; + st.duration = dur; + st.eof = eof; + st.idle = idle; + st.media_title = media_title; + st.audio = audio; + st.subs = subs; + + // compute percent yourself (jtui style) + st.percent = percent_pos + .filter(|p| p.is_finite()) + .or_else(|| match (tp, dur) { + (Some(t), Some(d)) if t.is_finite() && d.is_finite() && d > 0.0 => Some((t / d) * 100.0), + _ => None, + }); + + + // Optional: populate last_err if we got nothing useful + if st.time_pos.is_none() && st.duration.is_none() { + st.last_err = Some("mpv poll: no time-pos/duration (check socket / mpv running)".into()); + } + + if tx.send(st).is_err() { + break; + } + + std::thread::sleep(Duration::from_millis(200)); + } + }); + + rx +} + +fn spawn_mpv_url(cfg: &core::config::Config, url: &str) -> Result { + let token = cfg + .access_token + .as_deref() + .ok_or_else(|| "Missing access_token (login again)".to_string())?; + + let header = format!("X-Emby-Token: {}", token); + let sock = mpv_socket_path(); + + let _ = std::fs::remove_file(&sock); + + let mut child = Command::new("mpv") + .arg(url) + .arg("--force-window=yes") + .arg("--keep-open=yes") + .arg("--idle=yes") + .arg(format!("--http-header-fields={header}")) + .arg(format!("--input-ipc-server={}", sock.display())) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| format!("Failed to spawn mpv: {e}"))?; + + let start = Instant::now(); + while start.elapsed() < Duration::from_millis(800) { + if sock.exists() { + return Ok(MpvSession { socket_path: sock, child }); + } + thread::sleep(Duration::from_millis(10)); + } + + let _ = child.kill(); + Err("mpv IPC socket did not appear".to_string()) +} + +fn mpv_load_url(cfg: &core::config::Config, mpv: &MpvSession, url: &str) -> Result<(), String> { + // mpv already has the http header from spawn flags + let _ = cfg; // keep signature symmetric; might be useful later + mpv_cmd( + &mpv.socket_path, + &format!(r#"{{"command":["loadfile","{}","replace"]}}"#, url), + ) +} + + + + + +fn track_label(title: Option<&str>, lang: Option<&str>, id: Option, kind: &str) -> String { + let mut parts: Vec = Vec::new(); + + if let Some(t) = title.map(|s| s.trim()).filter(|s| !s.is_empty()) { + parts.push(t.to_string()); + } + if let Some(l) = lang.map(|s| s.trim()).filter(|s| !s.is_empty()) { + parts.push(l.to_string()); + } + + if parts.is_empty() { + if let Some(i) = id { + return format!("{kind} {i}"); + } + return "-".into(); + } + + parts.join(" • ") +} + +fn track_label_from_entry(t: &serde_json::Value, kind: &str) -> String { + let title = t.get("title").and_then(|x| x.as_str()).map(str::trim).filter(|s| !s.is_empty()); + let lang = t.get("lang").and_then(|x| x.as_str()).map(str::trim).filter(|s| !s.is_empty()); + + // audio extras are often useful (codec/channels) + let codec = t.get("codec").and_then(|x| x.as_str()).map(str::trim).filter(|s| !s.is_empty()); + let ch = t.get("channels").and_then(|x| x.as_str()).map(str::trim).filter(|s| !s.is_empty()); + + let mut parts: Vec = Vec::new(); + if let Some(v) = title { parts.push(v.to_string()); } + if let Some(v) = lang { parts.push(v.to_string()); } + + if kind == "Audio" { + if let Some(v) = codec { parts.push(v.to_string()); } + if let Some(v) = ch { parts.push(v.to_string()); } + } + + if parts.is_empty() { "-".into() } else { parts.join(" • ") } +} + +fn tracks_from_ids(track_list: &serde_json::Value, aid: Option, sid: Option) -> (Option, Option) { + let arr = match track_list.as_array() { + Some(a) => a, + None => return (None, None), + }; + let mut audio: Option = None; + let mut subs: Option = None; + + for t in arr { + let ty = t.get("type").and_then(|x| x.as_str()).unwrap_or(""); + let id = t.get("id").and_then(|x| x.as_i64()); + + match (ty, id) { + ("audio", Some(id)) if Some(id) == aid => { + audio = Some(track_label_from_entry(t, "Audio")); + } + ("sub", Some(id)) if Some(id) == sid => { + subs = Some(track_label_from_entry(t, "Sub")); + } + _ => {} + } + } + + (audio, subs) +} + +fn fmt_hms(t: f64) -> String { + if !t.is_finite() || t < 0.0 { return "--:--".into(); } + let total = t as u64; + let h = total / 3600; + let m = (total % 3600) / 60; + let s = total % 60; + if h > 0 { format!("{h}:{m:02}:{s:02}") } else { format!("{m}:{s:02}") } +} + +fn mpv_progress(st: &MpvState) -> (u16, String) { + let pct = st.percent.unwrap_or(0.0).clamp(0.0, 100.0); + let p_u16 = pct.round() as u16; + + let cur = st.time_pos.map(fmt_hms).unwrap_or("--:--".into()); + let dur = st.duration.map(fmt_hms).unwrap_or("--:--".into()); + (p_u16, format!("{cur} / {dur} ({pct:0.1}%)")) +} + +fn render_mpv_panel(frame: &mut Frame, app: &App, area: Rect) { + // Make the bar exactly 1 row and don't depend on bordered blocks. + let [c0, c1, c2, c3, c4, c5] = Layout::vertical([ + Constraint::Length(1), // title (no border) + Constraint::Length(1), // audio + Constraint::Length(1), // subs + Constraint::Length(1), // BAR (raw) + Constraint::Length(1), // time + Constraint::Min(1), // controls + ]) + .areas(area); + + let st = &app.mpv_state; + + let title = app + .mpv_now_playing + .clone() + .or_else(|| st.media_title.clone()) + .unwrap_or_else(|| "-".into()); + + let audio = st.audio.clone().unwrap_or_else(|| "-".into()); + let subs = st.subs.clone().unwrap_or_else(|| "None".into()); + + frame.render_widget(Paragraph::new(format!("▶ {title}")), c0); + frame.render_widget(Paragraph::new(format!("Audio: {audio}")), c1); + frame.render_widget(Paragraph::new(format!("Subs: {subs}")), c2); + + let ratio = (st.percent.unwrap_or(0.0) / 100.0).clamp(0.0, 1.0); + let pct = st.percent.unwrap_or(0.0); + + // reserve 1 col so we don't draw into the overlap w/ right pane border + let safe_w = c3.width.saturating_sub(1); + + let bar_line = mpv_bar_line(safe_w, ratio, pct); + frame.render_widget(Paragraph::new(bar_line), c3); + + let cur_secs = st.time_pos.unwrap_or(0.0); + let dur_secs = st.duration.unwrap_or(0.0); + + let cur = st.time_pos.map(fmt_hms).unwrap_or("--:--".into()); + let dur = st.duration.map(fmt_hms).unwrap_or("--:--".into()); + + let remaining = if dur_secs > 0.0 && cur_secs <= dur_secs { + fmt_hms(dur_secs - cur_secs) + } else { + "--:--".into() + }; + + frame.render_widget( + Paragraph::new(format!("{cur} / {dur} ({remaining} remaining)")), + c4, + ); + + let controls = + "Space: pause x: stop f: fullscreen\na: audio s: subs v: sub vis"; + frame.render_widget(Paragraph::new(controls).wrap(Wrap { trim: true }), c5); +} + +fn mpv_bar_line(width: u16, ratio: f64, pct: f64) -> String { + let w = width as usize; + if w == 0 { + return String::new(); + } + + let prefix = "mpv "; + let pct_text = format!("{:>5.1}%", pct.clamp(0.0, 100.0)); // " 43.3%" + let pct_area_min = pct_text.len() + 2; // breathing room around percent + + // If too narrow, just show prefix+percent + if w <= prefix.len() + pct_text.len() { + return format!("{prefix}{pct_text}"); + } + + let avail = w - prefix.len(); + + // Give pct a fixed-ish area if we can, but keep at least 2 cols for the bar: "[]" + let pct_area = pct_area_min.min(avail.saturating_sub(2)); + let bar_w = (avail - pct_area).max(2); + + let bar = unicode_progress_bar(bar_w, ratio); + + // IMPORTANT: compute remaining space using bar_w (columns), not bar.len() (bytes) + let pct_area_actual = avail.saturating_sub(bar_w); + let mut pct_buf = vec![' '; pct_area_actual]; + + if pct_area_actual >= pct_text.len() { + let start = (pct_area_actual - pct_text.len()) / 2; + for (i, ch) in pct_text.chars().enumerate() { + pct_buf[start + i] = ch; + } + } + + let pct_field: String = pct_buf.into_iter().collect(); + format!("{prefix}{bar}{pct_field}") +} + +fn unicode_progress_bar(width: usize, ratio: f64) -> String { + // width includes the brackets: [ .... ] + if width < 2 { + return String::new(); + } + + let inner = width - 2; + let ratio = ratio.clamp(0.0, 1.0); + + // 8-level partial blocks + // 0..=7 maps to: (none), ▏▎▍▌▋▊▉ + const PARTS: [char; 8] = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉']; + + let total_units = inner * 8; + let filled_units = (ratio * total_units as f64).round() as usize; + + let full = filled_units / 8; + let rem = filled_units % 8; + + let mut s = String::with_capacity(width); + s.push('['); + + // full blocks + let full = full.min(inner); + s.push_str(&"█".repeat(full)); + + // partial block (if any room left) + if full < inner { + if rem > 0 { + s.push(PARTS[rem]); + } else { + s.push(' '); + } + + // rest empty + let used = full + 1; + if used < inner { + s.push_str(&" ".repeat(inner - used)); + } + } + + s.push(']'); + s +} + +// --- watched / in-progress helpers --- + +fn is_music_item(it: &core::jellyfin::JfItem) -> bool { + matches!(it.item_type.as_deref(), Some("Audio" | "MusicAlbum" | "MusicArtist")) +} + +// Placeholder for later +fn is_downloaded(_it: &core::jellyfin::JfItem) -> bool { + false +} + +fn is_played(it: &core::jellyfin::JfItem) -> bool { + if it.user_data.as_ref().and_then(|u| u.played) == Some(true) { + return true; + } + + // If Jellyfin provides played percentage + if let Some(p) = it.user_data.as_ref().and_then(|u| u.played_percentage) { + if p >= 99.9 { + return true; + } + } + + // Series/Season often only has UnplayedItemCount + if matches!(it.item_type.as_deref(), Some("Series" | "Season")) { + if it.user_data.as_ref().and_then(|u| u.unplayed_item_count) == Some(0) { + return true; + } + } + + false +} + + + +fn is_in_progress(it: &core::jellyfin::JfItem) -> bool { + if is_played(it) { + return false; + } + + // movies/episodes + if playback_position_ticks(it).is_some() { + return true; + } + + // series/seasons (percentage-based) + match it.user_data.as_ref().and_then(|u| u.played_percentage) { + Some(p) if p > 0.1 && p < 99.9 => true, + _ => false, + } +} + + +fn playback_position_ticks(it: &core::jellyfin::JfItem) -> Option { + it.user_data + .as_ref() + .and_then(|u| u.playback_position_ticks) + .and_then(|v| if v > 0 { Some(v as u64) } else { None }) +} + +fn emoji_prefix_for(it: &core::jellyfin::JfItem) -> &'static str { + // no emoji for music + if is_music_item(it) { + return ""; + } + + let played = is_played(it); + let prog = is_in_progress(it); + + match (played, prog) { + (true, _) => "✅ ", + (false, true) => "🟠 ", // orange in-progress + (false, false) => "", + } +} + + + +// --- transcoding + resume URL builder (Approach B) --- +fn clamp_mbps_to_bps(mbps: u32) -> u32 { + mbps.saturating_mul(1_000_000) // Mb/s -> bits/s +} + +fn build_jellyfin_play_url( + cfg: &core::config::Config, + item_id: &str, + start_ticks: Option, + transcode_on: bool, + transcode_mbps: u32, +) -> String { + let base = cfg.base_url.trim_end_matches('/'); + + let token = cfg + .access_token + .as_deref() + .unwrap_or(""); // if empty, server likely rejects anyway + + // Common helper + let bps = clamp_mbps_to_bps(transcode_mbps); + + if !transcode_on { + // Direct stream. + // IMPORTANT: static=true can cause Jellyfin to ignore startTimeTicks on some servers. + // So we keep static=false here to preserve resume behavior. + let mut url = format!( + "{}/Videos/{}/stream?static=false&api_key={}", + base, item_id, token + ); + + if let Some(t) = start_ticks { + url.push_str(&format!("&startTimeTicks={}", t)); + } + + return url; + } + + // Transcoded stream via HLS (fixes "duration == buffered" + enables seeking) + let mut url = format!( + "{}/Videos/{}/master.m3u8?api_key={}&static=false", + base, item_id, token + ); + + // Correct Jellyfin param names for this endpoint: + // - videoBitRate (capital R) is the documented query param name. + // - segmentContainer is the documented query param name. + url.push_str(&format!("&videoBitRate={}", bps)); + url.push_str(&format!("&audioBitRate={}", bps)); // optional but helps keep variants sane + url.push_str("&segmentContainer=ts"); + + // Force transcode for testing/consistency + url.push_str("&enableAutoStreamCopy=false"); + + // deterministic codecs + url.push_str("&videoCodec=h264"); + url.push_str("&audioCodec=aac"); + url.push_str("&transcodingContainer=ts"); + + if let Some(t) = start_ticks { + url.push_str(&format!("&startTimeTicks={}", t)); + } + + url +} + + + + +fn reload_config(app: &mut App) { + if let Ok(cfg) = core::config::load_config() { + app.transcode_mbps = cfg.transcode_mbps.unwrap_or(5); + app.transcode_on = cfg.transcode_default_on.unwrap_or(false); + app.config = Some(cfg); + app.status = "Reloaded config".into(); + } else { + app.status = "Failed to reload config".into(); + } +} + + + + + + + + + + + + + + + + + +struct Panes { + top: Rect, + left_main: Rect, + right_main: Rect, + left_output: Rect, + right_output: Rect, +} + +fn layout(area: Rect) -> Panes { + use Constraint::Fill; + + let [top, bottom] = + Layout::vertical([Fill(1), Fill(12)]).areas(area); + + let [main_area, output_area] = + Layout::vertical([Fill(9), Fill(3)]) + .spacing(Spacing::Overlap(1)) + .areas(bottom); + + let [left_main, right_main] = + Layout::horizontal([Fill(1), Fill(1)]) + .spacing(Spacing::Overlap(1)) + .areas(main_area); + + let [left_output, right_output] = + Layout::horizontal([Fill(7), Fill(2)]) + .spacing(Spacing::Overlap(1)) + .areas(output_area); + + Panes { + top, + left_main, + right_main, + left_output, + right_output, + } +} + +fn render_login(frame: &mut Frame, app: &App, left: Rect, right: Rect) { + let focus = match app.login_focus { + LoginFocus::User => "username", + LoginFocus::Pass => "password", + }; + + let masked = "*".repeat(app.login_pass.len()); + + let left_text = format!( + "Login (Tab switch, Enter submit)\n\nusername: {}\npassword: {}\n\nfocus: {}\n", + app.login_user, masked, focus + ); + + frame.render_widget( + Paragraph::new(left_text) + .block(Block::bordered().title("Login").merge_borders(MergeStrategy::Exact)), + left, + ); + + let right_text = match &app.login_error { + Some(e) => format!("Error:\n{e}"), + None => "Enter Jellyfin credentials.\nToken will be stored in config.toml.".to_string(), + }; + + frame.render_widget( + Paragraph::new(right_text) + .block(Block::bordered().title("Info").merge_borders(MergeStrategy::Exact)), + right, + ); +} \ No newline at end of file diff --git a/src/core/jellyfin.rs b/src/core/jellyfin.rs index 652baeb..09e865c 100644 --- a/src/core/jellyfin.rs +++ b/src/core/jellyfin.rs @@ -190,6 +190,9 @@ impl JellyfinClient { if let Some(term) = query.search_term.as_deref() { req = req.query(&[("SearchTerm", term)]); } + if let Some(filters) = query.filters.as_deref() { + req = req.query(&[("Filters", filters)]); + } let resp = req.send().map_err(|e| format!("request failed: {e}"))?; @@ -209,7 +212,28 @@ impl JellyfinClient { }) } - pub fn playlist_items( +/// Returns true if there exists at least one resumable Episode under the given parent (Series or Season). +/// This is used to mark Series/Season rows as "in progress" even when Jellyfin doesn't populate +/// PlayedPercentage on those container types. +pub fn has_resumable_under( + &self, + user_id: &str, + parent_id: &str, +) -> Result { + // Series -> Season -> Episode, so this MUST be recursive for Series. + // Recursive=true is also fine for Season. + let mut q = LibraryQuery::all() + .with_parent(parent_id.to_string()) + .with_item_types("Episode") + .with_filters("IsResumable"); + + q.recursive = true; + + let page = self.list_items(user_id, q, 0, 1)?; + Ok(page.total > 0) +} + +pub fn playlist_items( &self, user_id: &str, playlist_id: &str, @@ -255,6 +279,7 @@ pub struct LibraryQuery { pub parent_id: Option, // e.g. Some(series_id) pub search_term: Option, // e.g. Some("matrix".into()) pub recursive: bool, // true for big lists, false for drilldown + pub filters: Option, // e.g. Some("IsResumable".into()) } impl LibraryQuery { @@ -264,6 +289,7 @@ impl LibraryQuery { parent_id: None, search_term: None, recursive: true, + filters: None, } } pub fn shows() -> Self { @@ -272,6 +298,7 @@ impl LibraryQuery { parent_id: None, search_term: None, recursive: true, + filters: None, } } pub fn music() -> Self { @@ -280,6 +307,7 @@ impl LibraryQuery { parent_id: None, search_term: None, recursive: true, + filters: None, } } @@ -290,6 +318,7 @@ impl LibraryQuery { parent_id: None, search_term: None, recursive: true, + filters: None, } } @@ -308,6 +337,11 @@ impl LibraryQuery { self.include_item_types = Some(types.into()); self } + + pub fn with_filters(mut self, filters: impl Into) -> Self { + self.filters = Some(filters.into()); + self + } } diff --git a/src/main.rs b/src/main.rs index f404e48..f700b1b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -185,6 +185,9 @@ struct App { transcode_on: bool, transcode_mbps: u32, + // Cache: container item id -> has resumable episode under it + parent_progress: std::collections::HashMap, + } #[derive(Debug)] @@ -262,6 +265,7 @@ impl Default for App { autoplay_prev_time_pos: None, transcode_on: default_on, transcode_mbps: default_mbps, + parent_progress: std::collections::HashMap::new(), } } } @@ -465,6 +469,7 @@ fn handle_key(app: &mut App, code: KeyCode) { match client.list_items(user_id, query.clone(), 0, PAGE_SIZE) { Ok(page) => { + update_parent_progress_cache(app, &client, user_id, &page.items); app.view_stack.push(View::Library { title, query, @@ -602,13 +607,7 @@ fn handle_key(app: &mut App, code: KeyCode) { app.mpv_now_playing = Some(item_name); // If mpv running -> replace, else spawn - let url = build_jellyfin_play_url( - &cfg, - &item_id, - None, - app.transcode_on, - app.transcode_mbps, - ); + let url = build_jellyfin_play_url(&cfg, &item_id, app.transcode_on, app.transcode_mbps); if let Some(mpv) = &app.mpv { match mpv_load_url(&cfg, mpv, &url) { @@ -647,13 +646,7 @@ fn handle_key(app: &mut App, code: KeyCode) { app.autoplay_next_index = if selected + 1 < len { Some(selected + 1) } else { None }; // load/replace or spawn - let url = build_jellyfin_play_url( - &cfg, - &item_id, - None, - app.transcode_on, - app.transcode_mbps, - ); + let url = build_jellyfin_play_url(&cfg, &item_id, app.transcode_on, app.transcode_mbps); if let Some(mpv) = &app.mpv { match mpv_load_url(&cfg, mpv, &url) { @@ -706,17 +699,16 @@ fn handle_key(app: &mut App, code: KeyCode) { app.mpv_now_playing = Some(item_name); - let url = build_jellyfin_play_url( - &cfg, - &item_id, - start_ticks, - app.transcode_on, - app.transcode_mbps, - ); + let url = build_jellyfin_play_url(&cfg, &item_id, app.transcode_on, app.transcode_mbps); if let Some(mpv) = &app.mpv { match mpv_cmd(&mpv.socket_path, &format!(r#"{{"command":["loadfile","{}","replace"]}}"#, url)) { - Ok(_) => app.status = "mpv: resumed".into(), + Ok(_) => { + if let Some(t) = start_ticks { + let _ = mpv_seek_absolute(&mpv.socket_path, ticks_to_seconds(t)); + } + app.status = "mpv: resumed".into(); + } Err(e) => app.status = format!("mpv resume failed: {e}"), } } else { @@ -724,7 +716,11 @@ fn handle_key(app: &mut App, code: KeyCode) { Ok(mpv) => { let rx = start_mpv_poller(mpv.socket_path.clone()); app.mpv_state_rx = Some(rx); + let sock = mpv.socket_path.clone(); app.mpv = Some(mpv); + if let Some(t) = start_ticks { + let _ = mpv_seek_absolute(&sock, ticks_to_seconds(t)); + } app.status = "mpv: resumed".into(); } Err(e) => app.status = format!("mpv spawn failed: {e}"), @@ -757,17 +753,16 @@ fn handle_key(app: &mut App, code: KeyCode) { app.autoplay_next_index = if selected + 1 < len { Some(selected + 1) } else { None }; app.autoplay_eof_latched = false; - let url = build_jellyfin_play_url( - &cfg, - &item_id, - start_ticks, - app.transcode_on, - app.transcode_mbps, - ); + let url = build_jellyfin_play_url(&cfg, &item_id, app.transcode_on, app.transcode_mbps); if let Some(mpv) = &app.mpv { match mpv_cmd(&mpv.socket_path, &format!(r#"{{"command":["loadfile","{}","replace"]}}"#, url)) { - Ok(_) => app.status = "mpv: resumed (autoplay)".into(), + Ok(_) => { + if let Some(t) = start_ticks { + let _ = mpv_seek_absolute(&mpv.socket_path, ticks_to_seconds(t)); + } + app.status = "mpv: resumed (autoplay)".into(); + } Err(e) => app.status = format!("mpv resume failed: {e}"), } } else { @@ -775,7 +770,11 @@ fn handle_key(app: &mut App, code: KeyCode) { Ok(mpv) => { let rx = start_mpv_poller(mpv.socket_path.clone()); app.mpv_state_rx = Some(rx); + let sock = mpv.socket_path.clone(); app.mpv = Some(mpv); + if let Some(t) = start_ticks { + let _ = mpv_seek_absolute(&sock, ticks_to_seconds(t)); + } app.status = "mpv: resumed (autoplay)".into(); } Err(e) => app.status = format!("mpv spawn failed: {e}"), @@ -849,6 +848,7 @@ fn handle_key(app: &mut App, code: KeyCode) { match client.list_items(user_id, query.clone(), 0, PAGE_SIZE) { Ok(page) => { + update_parent_progress_cache(app, &client, user_id, &page.items); app.view_stack.push(View::Library { title: cat, query, @@ -928,6 +928,7 @@ fn handle_key(app: &mut App, code: KeyCode) { match client.playlist_items(user_id, &it.id, 0, PAGE_SIZE) { Ok(page) => { + update_parent_progress_cache(app, &client, user_id, &page.items); app.view_stack.push(View::Library { title: format!("{} > {}", title, it.name), // store a query placeholder; playlist paging could be done later if you want @@ -1087,6 +1088,8 @@ fn load_next_library_page(app: &mut App) { Err(e) => { app.status = format!("Fetch failed: {e}"); finish_loading(app); return; } }; + update_parent_progress_cache(app, &client, user_id, &page.items); + if let Some(View::Library { items, total, .. }) = app.view_stack.last_mut() { items.extend(page.items); *total = page.total; @@ -1101,6 +1104,33 @@ fn finish_loading(app: &mut App) { } } +fn update_parent_progress_cache( + app: &mut App, + client: &core::jellyfin::JellyfinClient, + user_id: &str, + new_items: &[core::jellyfin::JfItem], +) { + for it in new_items { + let ty = it.item_type.as_deref().unwrap_or(""); + if ty != "Series" && ty != "Season" { + continue; + } + if app.parent_progress.contains_key(&it.id) { + continue; + } + match client.has_resumable_under(user_id, &it.id) { + Ok(has) => { + app.parent_progress.insert(it.id.clone(), has); + } + Err(_) => { + // If the server rejects the query, just leave it unknown. + } + } + } +} + + + fn maybe_prefetch(app: &mut App) { const PREFETCH_ROWS: usize = 0; @@ -1356,6 +1386,7 @@ fn ui(frame: &mut Frame, app: &App) { render_library( frame, + app, title, *start_index, *total, @@ -1496,6 +1527,7 @@ fn render_placeholder(frame: &mut Frame, name: &str, left: Rect, right: Rect) { fn render_library( frame: &mut Frame, + app: &App, title: &str, start_index: usize, total: usize, @@ -1552,7 +1584,7 @@ fn render_library( true }) .map(|it| { - let emoji = emoji_prefix_for(it); + let emoji = emoji_prefix_for(app, 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() @@ -1610,6 +1642,7 @@ fn open_children(app: &mut App, title: String, query: core::jellyfin::LibraryQue match client.list_items(user_id, query.clone(), 0, PAGE_SIZE) { Ok(page) => { + update_parent_progress_cache(app, &client, user_id, &page.items); app.view_stack.push(View::Library { title, query, @@ -1708,6 +1741,35 @@ fn mpv_cmd(socket: &PathBuf, cmd: &str) -> Result<(), String> { mpv_send(socket, &format!("{cmd}\n")) } + +fn ticks_to_seconds(ticks: u64) -> f64 { + // Jellyfin ticks are 10,000,000 per second + (ticks as f64) / 10_000_000.0 +} + +fn mpv_seek_absolute(socket: &PathBuf, seconds: f64) -> Result<(), String> { + // Wait until mpv has loaded something real (duration becomes available), + // then issue a seek and REQUIRE a success reply. + let seek_cmd = format!(r#"{{"command":["seek",{}, "absolute"]}}"#, seconds); + + let start = Instant::now(); + while start.elapsed() < Duration::from_millis(2500) { + // duration being Some is a good signal that the file is actually ready + let dur = mpv_get_f64_jtui(socket, "duration", 1).ok().flatten(); + if dur.is_some() { + // seek, but only accept success + if mpv_cmd_reply(socket, &seek_cmd).is_ok() { + return Ok(()); + } + } + + thread::sleep(Duration::from_millis(50)); + } + + Err("mpv seek failed (mpv never became ready or rejected seek)".into()) +} + + fn spawn_mpv(cfg: &core::config::Config, first_item_id: &str) -> Result { let token = cfg .access_token @@ -1972,13 +2034,7 @@ fn autoplay_tick(app: &mut App) { }; // Load next item - let url = build_jellyfin_play_url( - &cfg, - &next_id, - None, - app.transcode_on, - app.transcode_mbps, - ); + let url = build_jellyfin_play_url(&cfg, &next_id, app.transcode_on, app.transcode_mbps); if let Err(e) = mpv_load_url(&cfg, mpv, &url) { app.autoplay = false; @@ -2384,28 +2440,68 @@ fn is_played(it: &core::jellyfin::JfItem) -> bool { if it.user_data.as_ref().and_then(|u| u.played) == Some(true) { return true; } - // seasons/series often use percentage - match it.user_data.as_ref().and_then(|u| u.played_percentage) { - Some(p) if p >= 99.9 => true, - _ => false, + + // If Jellyfin provides played percentage + if let Some(p) = it.user_data.as_ref().and_then(|u| u.played_percentage) { + if p >= 99.9 { + return true; + } } + + // Series/Season often only has UnplayedItemCount + if matches!(it.item_type.as_deref(), Some("Series" | "Season")) { + if it.user_data.as_ref().and_then(|u| u.unplayed_item_count) == Some(0) { + return true; + } + } + + false } -fn is_in_progress(it: &core::jellyfin::JfItem) -> bool { - if is_played(it) { return false; } + + +fn is_in_progress_with_cache(app: &App, it: &core::jellyfin::JfItem) -> bool { + if is_played(it) { + return false; + } // movies/episodes if playback_position_ticks(it).is_some() { return true; } - // seasons/series (some servers expose %) + // series/seasons: ask cache first (based on resumable episodes) + if matches!(it.item_type.as_deref(), Some("Series") | Some("Season")) { + if let Some(v) = app.parent_progress.get(&it.id) { + return *v; + } + } + + // fallback: percentage-based if server provides it match it.user_data.as_ref().and_then(|u| u.played_percentage) { Some(p) if p > 0.1 && p < 99.9 => true, _ => false, } } +fn is_in_progress(it: &core::jellyfin::JfItem) -> bool { + if is_played(it) { + return false; + } + + // movies/episodes + if playback_position_ticks(it).is_some() { + return true; + } + + // series/seasons (percentage-based) + match it.user_data.as_ref().and_then(|u| u.played_percentage) { + Some(p) if p > 0.1 && p < 99.9 => true, + _ => false, + } +} + + fn playback_position_ticks(it: &core::jellyfin::JfItem) -> Option { it.user_data .as_ref() @@ -2413,26 +2509,24 @@ fn playback_position_ticks(it: &core::jellyfin::JfItem) -> Option { .and_then(|v| if v > 0 { Some(v as u64) } else { None }) } -fn emoji_prefix_for(it: &core::jellyfin::JfItem) -> &'static str { - // user asked: no emoji for music +fn emoji_prefix_for(app: &App, it: &core::jellyfin::JfItem) -> &'static str { + // no emoji for music if is_music_item(it) { return ""; } - let dl = is_downloaded(it); let played = is_played(it); - let prog = is_in_progress(it); + let prog = is_in_progress_with_cache(app, it); - match (dl, played, prog) { - (true, true, _) => "💾✅ ", - (true, false, true) => "💾▶ ", - (true, false, false) => "💾 ", - (false, true, _) => "✅ ", - (false, false, true) => "▶ ", - (false, false, false) => "", + match (played, prog) { + (true, _) => "✅ ", + (false, true) => "🟠 ", // orange in-progress + (false, false) => "", } } + + // --- transcoding + resume URL builder (Approach B) --- fn clamp_mbps_to_bps(mbps: u32) -> u32 { mbps.saturating_mul(1_000_000) // Mb/s -> bits/s @@ -2441,39 +2535,51 @@ fn clamp_mbps_to_bps(mbps: u32) -> u32 { fn build_jellyfin_play_url( cfg: &core::config::Config, item_id: &str, - start_ticks: Option, transcode_on: bool, transcode_mbps: u32, ) -> String { let base = cfg.base_url.trim_end_matches('/'); - if !transcode_on { - return format!("{}/Items/{}/Download", base, item_id); - } + let token = cfg + .access_token + .as_deref() + .unwrap_or(""); // if empty, server likely rejects anyway let bps = clamp_mbps_to_bps(transcode_mbps); - // Use maxStreamingBitrate (what Jellyfin UIs commonly use) - // Use static=false (lets server do normal streaming behavior) - let mut url = format!("{}/Videos/{}/stream?static=false", base, item_id); + if !transcode_on { + // Direct stream. + // + // We intentionally keep this "static=true" so mpv can see the full timeline/duration + // (many servers provide Content-Length only for static streams). + // + // Resume is implemented via mpv seeking (not startTimeTicks in the URL) to avoid the + // Jellyfin behavior where startTimeTicks may be ignored for static streams. + return format!( + "{}/Videos/{}/stream?static=true&api_key={}", + base, item_id, token + ); + } + + // Transcoded stream via HLS (fixes "duration == buffered" + enables seeking). + // + // IMPORTANT: Use Jellyfin's documented query param names: + // - videoBitRate (capital R) + // - segmentContainer + // + // Resume is implemented via mpv seeking (not startTimeTicks in the URL). + let mut url = format!( + "{}/Videos/{}/master.m3u8?api_key={}&static=false", + base, item_id, token + ); url.push_str(&format!("&maxStreamingBitrate={}", bps)); - - // IMPORTANT: - // - enableAutoStreamCopy=true = will NOT transcode if it can direct stream - // - set to false to FORCE transcode behavior (good for testing) - // - // For now: force it so you can verify it works. + url.push_str(&format!("&videoBitrate={}", bps)); // NOTE: lower-case 'r' matches your working version + url.push_str(&format!("&audioBitrate={}", bps)); url.push_str("&enableAutoStreamCopy=false"); - - // Make transcode more likely / deterministic: url.push_str("&videoCodec=h264"); url.push_str("&audioCodec=aac"); - url.push_str("&transcodingContainer=mp4"); - - if let Some(t) = start_ticks { - url.push_str(&format!("&startTimeTicks={}", t)); - } + url.push_str("&transcodingContainer=ts"); // NOTE: this is what your working build used url } @@ -2481,7 +2587,6 @@ fn build_jellyfin_play_url( - fn reload_config(app: &mut App) { if let Ok(cfg) = core::config::load_config() { app.transcode_mbps = cfg.transcode_mbps.unwrap_or(5); @@ -2493,8 +2598,36 @@ fn reload_config(app: &mut App) { } } +fn mpv_cmd_reply(socket: &PathBuf, cmd: &str) -> Result { + let mut stream = UnixStream::connect(socket) + .map_err(|e| format!("mpv ipc connect failed: {e}"))?; + let _ = stream.set_write_timeout(Some(Duration::from_millis(500))); + let _ = stream.set_read_timeout(Some(Duration::from_millis(500))); + stream + .write_all(format!("{cmd}\n").as_bytes()) + .map_err(|e| format!("mpv ipc write failed: {e}"))?; + + let mut reader = BufReader::new(stream); + let mut line = String::new(); + reader + .read_line(&mut line) + .map_err(|e| format!("mpv ipc read failed: {e}"))?; + + if line.trim().is_empty() { + return Err("mpv ipc: empty response".into()); + } + + let v: Value = serde_json::from_str(line.trim()) + .map_err(|e| format!("mpv json parse failed: {e} ({})", line.trim()))?; + + if v.get("error").and_then(|e| e.as_str()) != Some("success") { + return Err(format!("mpv command failed: {}", line.trim())); + } + + Ok(v) +}