From a1e1ae7e7d8487e9a3a17a6a50742429d2ce52f8 Mon Sep 17 00:00:00 2001 From: cwj3rcb Date: Mon, 16 Feb 2026 13:50:36 -0600 Subject: [PATCH] Cleaned --- 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 -------------------------------------- 5 files changed, 3180 deletions(-) delete mode 100644 old_src/core/config.rs delete mode 100644 old_src/core/jellyfin.rs delete mode 100644 old_src/core/mod.rs delete mode 100644 old_src/core/paths.rs delete mode 100644 old_src/main.rs diff --git a/old_src/core/config.rs b/old_src/core/config.rs deleted file mode 100644 index 1ec0b8b..0000000 --- a/old_src/core/config.rs +++ /dev/null @@ -1,131 +0,0 @@ -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 deleted file mode 100644 index 652baeb..0000000 --- a/old_src/core/jellyfin.rs +++ /dev/null @@ -1,398 +0,0 @@ -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 deleted file mode 100644 index e357c3e..0000000 --- a/old_src/core/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index d527cba..0000000 --- a/old_src/core/paths.rs +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index 98d8265..0000000 --- a/old_src/main.rs +++ /dev/null @@ -1,2613 +0,0 @@ -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