diff --git a/src/core/downloads.rs b/src/core/downloads.rs index d3f2dd5..4946daa 100644 --- a/src/core/downloads.rs +++ b/src/core/downloads.rs @@ -71,6 +71,98 @@ pub struct DownloadManager { pub state: Arc>, } +#[derive(Clone, Debug)] +pub struct LocalEntry { + pub item_id: String, + pub title: String, + pub path: PathBuf, +} + +/// Scan downloads root for marker files and return local playable entries. +/// Title is taken from marker JSON if present, else from the media filename stem. +pub fn scan_local_entries() -> Vec { + fn walk(dir: &Path, out: &mut Vec) { + let rd = match std::fs::read_dir(dir) { + Ok(r) => r, + Err(_) => return, + }; + + for ent in rd.flatten() { + let p = ent.path(); + + if p.is_dir() { + walk(&p, out); + continue; + } + + let name = match p.file_name().and_then(|s| s.to_str()) { + Some(s) => s, + None => continue, + }; + + if !name.ends_with(DOWNLOAD_MARKER_EXT) { + continue; + } + + let raw = match std::fs::read_to_string(&p) { + Ok(s) => s, + Err(_) => continue, + }; + + let v: serde_json::Value = match serde_json::from_str(raw.trim()) { + Ok(v) => v, + Err(_) => continue, + }; + + let item_id = match v.get("item_id").and_then(|x| x.as_str()) { + Some(s) if !s.is_empty() => s.to_string(), + _ => continue, + }; + + // derive media path: "." -> "" + let suffix = format!(".{DOWNLOAD_MARKER_EXT}"); + if !name.ends_with(&suffix) { + continue; + } + let media_name = &name[..name.len() - suffix.len()]; + let media_path = p.with_file_name(media_name); + if !media_path.exists() { + continue; + } + + // title: prefer json field if you ever add it, else from filename + let title = v + .get("title") + .and_then(|x| x.as_str()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| { + media_path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(media_name) + .to_string() + }); + + out.push(LocalEntry { + item_id, + title, + path: media_path, + }); + } + } + + let mut out = Vec::new(); + if let Ok(root) = downloads_root() { + walk(&root, &mut out); + } + + // Stable sort: title + out.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase())); + out +} + + impl DownloadManager { pub fn start(cfg: Config) -> Self { let state = Arc::new(Mutex::new(DownloadState::default())); diff --git a/src/core/jellyfin.rs b/src/core/jellyfin.rs index bdcc9bf..733a5e9 100644 --- a/src/core/jellyfin.rs +++ b/src/core/jellyfin.rs @@ -2,6 +2,8 @@ use serde::{Deserialize, Serialize}; use super::config::Config; +use std::time::Duration; + #[derive(Clone)] pub struct JellyfinClient { base_url: String, @@ -112,6 +114,35 @@ impl JellyfinClient { }) } + pub fn ping_base_url(base_url: &str) -> bool { + // Fast, unauthenticated check. Latch result at startup. + // We try a couple endpoints because Jellyfin setups vary. + let base = base_url.trim_end_matches('/'); + + let client = match reqwest::blocking::Client::builder() + .timeout(Duration::from_millis(900)) + .build() + { + Ok(c) => c, + Err(_) => return false, + }; + + let candidates = [ + format!("{base}/System/Ping"), + format!("{base}/System/Info/Public"), + ]; + + for url in candidates { + if let Ok(resp) = client.get(url).send() { + if resp.status().is_success() { + return true; + } + } + } + + false + } + pub fn list_views(&self, user_id: &str) -> Result, String> { let url = format!("{}/Users/{}/Views", self.base_url, user_id); diff --git a/src/main.rs b/src/main.rs index 80f6851..08a974b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -48,6 +48,11 @@ enum View { page_size: usize, loading: bool, }, + OfflineLibrary { + title: String, + items: Vec, + selected: usize, + }, Login, } @@ -172,6 +177,7 @@ struct App { login_pass: String, login_focus: LoginFocus, login_error: Option, + offline: bool, search: Option, mpv: Option, @@ -203,19 +209,25 @@ impl Default for App { fn default() -> Self { let loaded = core::config::load_config().ok(); - 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(), - ), + let (view_stack, config_status, offline) = match &loaded { + Some(cfg) if cfg.access_token.is_some() => { + let online = core::jellyfin::JellyfinClient::ping_base_url(&cfg.base_url); + if online { + (vec![View::Categories], format!("✅ Online: ({})", cfg.base_url), false) + } else { + (vec![View::Categories], format!("📴 Offline: ({})", cfg.base_url), true) + } + } + Some(cfg) => { + // no token: treat as "not connected", but still allow offline browsing + let online = core::jellyfin::JellyfinClient::ping_base_url(&cfg.base_url); + if online { + (vec![View::Login], format!("Not Connected: ({})", cfg.base_url), false) + } else { + (vec![View::Categories], format!("📴 Offline (no token): ({})", cfg.base_url), true) + } + } + None => (vec![View::Login], "Missing Configuration".to_string(), false), }; Self { @@ -225,19 +237,27 @@ impl Default for App { config: loaded, view_stack, + offline, - items: vec![ - "Shows".into(), - "Movies".into(), - "Music".into(), - "Playlists".into(), - "Collections".into(), - "Libraries".into(), - "Continue Watching".into(), - "Recently Added".into(), - "Downloads".into(), - "Settings".into(), - ], + items: if offline { + vec![ + "Downloaded".into(), + "Settings".into(), + ] + } else { + vec![ + "Shows".into(), + "Movies".into(), + "Music".into(), + "Playlists".into(), + "Collections".into(), + "Libraries".into(), + "Continue Watching".into(), + "Recently Added".into(), + "Downloaded".into(), + "Settings".into(), + ] + }, selected: 0, login_user: String::new(), @@ -261,6 +281,7 @@ impl Default for App { downloads_selected: 0, downloaded_ids: downloads::scan_downloaded_ids().unwrap_or_default(), last_download_message: None, + } } } @@ -688,6 +709,9 @@ fn handle_key(app: &mut App, code: KeyCode) { View::Library { items, selected, .. } if !items.is_empty() => { (items[*selected].id.clone(), items[*selected].name.clone()) } + View::OfflineLibrary { items, selected, .. } if !items.is_empty() => { + (items[*selected].item_id.clone(), items[*selected].title.clone()) + } _ => { app.status = "Nothing to play here".into(); return; } }; @@ -719,6 +743,10 @@ fn handle_key(app: &mut App, code: KeyCode) { return; } KeyCode::Char('P') => { + if matches!(app.current_view(), View::OfflineLibrary { .. }) { + app.status = "Offline: autoplay disabled (use 'p')".into(); + return; + } 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() { @@ -968,6 +996,23 @@ fn handle_key(app: &mut App, code: KeyCode) { View::Categories => { let cat = app.items[app.selected].clone(); + // OFFLINE: local-only library + if app.offline { + if cat == "Downloaded" { + let items = crate::core::downloads::scan_local_entries(); + + app.view_stack.push(View::OfflineLibrary { + title: "Downloaded".into(), + items, + selected: 0, + }); + app.status = "$".into(); + return; + } + + // Everything else offline can remain as-is (Downloads/Settings etc) + } + let cfg = match &app.config { Some(c) => c.clone(), None => { app.status = "No config loaded".into(); return; } @@ -1182,6 +1227,11 @@ fn handle_key(app: &mut App, code: KeyCode) { maybe_prefetch(app); } + View::OfflineLibrary { items, selected, .. } => { + if items.is_empty() { return; } + if *selected == 0 { *selected = items.len() - 1; } + else { *selected -= 1; } + } _ => {} } } @@ -1217,6 +1267,11 @@ fn handle_key(app: &mut App, code: KeyCode) { maybe_prefetch(app); } + View::OfflineLibrary { items, selected, .. } => { + if items.is_empty() { return; } + *selected = (*selected + 1) % items.len(); + } + _ => {} } } @@ -1528,6 +1583,9 @@ fn ui(frame: &mut Frame, app: &App) { // Main match app.current_view() { + View::OfflineLibrary { title, items, selected } => { + render_offline_library(frame, title, items, *selected, panes.left_main, panes.right_main); + } View::Categories => { render_categories(frame, app, panes.left_main, panes.right_main); } @@ -2985,4 +3043,52 @@ fn render_login(frame: &mut Frame, app: &App, left: Rect, right: Rect) { .block(Block::bordered().title("Info").merge_borders(MergeStrategy::Exact)), right, ); -} \ No newline at end of file +} + +fn render_offline_library( + frame: &mut Frame, + title: &str, + items: &[core::downloads::LocalEntry], + selected: usize, + left: Rect, + right: Rect, +) { + let viewport = left.height.saturating_sub(2) as usize; + let len = items.len(); + let selected = selected.min(len.saturating_sub(1)); + + let (start, end) = compute_window(len, selected, viewport); + + let list_items: Vec = items[start..end] + .iter() + .map(|it| ListItem::new(format!("💾 {}", it.title))) + .collect(); + + let list = List::new(list_items) + .block( + Block::bordered() + .title(format!("{title} [{}/{}]", items.len(), items.len())) + .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!("Title: {}\nItemId: {}\nPath:\n{}", it.title, it.item_id, it.path.display()) + } else { + "No downloaded items found".to_string() + }; + + frame.render_widget( + Paragraph::new(detail) + .wrap(Wrap { trim: false }) + .block(Block::bordered().title("Details").merge_borders(MergeStrategy::Exact)), + right, + ); +}