diff --git a/src/core/downloads.rs b/src/core/downloads.rs index da5ec3e..d1cbcad 100644 --- a/src/core/downloads.rs +++ b/src/core/downloads.rs @@ -1,6 +1,6 @@ use std::{ - collections::VecDeque, + collections::{VecDeque, HashSet}, fs, path::{Path, PathBuf}, process::{Child, Command, Stdio}, @@ -92,6 +92,89 @@ impl DownloadManager { // ---------- directory helpers ---------- +const DOWNLOAD_MARKER_EXT: &str = "cj-jftui.json"; + +fn marker_path_for_media(media_path: &Path) -> PathBuf { + // Example: + // Movie.mkv -> Movie.mkv.cj-jftui.json + let file_name = media_path + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("download"); + + media_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .join(format!("{file_name}.{DOWNLOAD_MARKER_EXT}")) +} + +fn write_download_marker(media_path: &Path, item_id: &str) -> Result<(), String> { + let marker = marker_path_for_media(media_path); + + let payload = json!({ + "item_id": item_id, + "saved_at_unix": std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .ok() + .map(|d| d.as_secs()) + .unwrap_or(0), + "media_file": media_path.file_name().and_then(|s| s.to_str()).unwrap_or(""), + }); + + fs::write(&marker, payload.to_string()) + .map_err(|e| format!("write marker failed: {e}"))?; + + Ok(()) +} + +fn scan_dir_for_markers(dir: &Path, out: &mut HashSet) -> Result<(), String> { + if !dir.exists() { + return Ok(()); + } + + for ent in fs::read_dir(dir).map_err(|e| format!("read_dir failed: {e}"))? { + let p = ent.map_err(|e| format!("dir entry failed: {e}"))?.path(); + + if p.is_dir() { + scan_dir_for_markers(&p, out)?; + continue; + } + + // Look for *.cj-jftui.json + let name = p.file_name().and_then(|s| s.to_str()).unwrap_or(""); + if !name.ends_with(DOWNLOAD_MARKER_EXT) { + continue; + } + + let raw = match fs::read_to_string(&p) { + Ok(s) => s, + Err(_) => continue, + }; + + let v: Value = match serde_json::from_str(raw.trim()) { + Ok(v) => v, + Err(_) => continue, + }; + + if let Some(id) = v.get("item_id").and_then(|x| x.as_str()) { + if !id.trim().is_empty() { + out.insert(id.to_string()); + } + } + } + + Ok(()) +} + +/// Public: scan the downloads root and return set of downloaded Jellyfin item ids. +pub fn scan_downloaded_ids() -> Result, String> { + let root = downloads_root()?; + let mut out = HashSet::new(); + scan_dir_for_markers(&root, &mut out)?; + Ok(out) +} + + pub fn downloads_root() -> Result { let dir = paths::app_config_dir().ok_or_else(|| "no config dir".to_string())?; Ok(dir.join("downloads")) @@ -719,8 +802,6 @@ fn poll_progress_for_front(state: &Arc>) { } - - fn finalize_front(state: &Arc>) -> Result<(), String> { let (it, tmp_dir, final_abs_watch) = { let st = state.lock().map_err(|_| "lock poisoned".to_string())?; @@ -741,8 +822,8 @@ fn finalize_front(state: &Arc>) -> Result<(), String> { let real_file = find_real_downloaded_file(&tmp_dir)?; // Build final filename: - // Your final_rel_path ends in "...something.watch" or "...something.listen". - // We keep the same STEM and replace extension with the actual downloaded extension. + // final_rel_path ends in "...something.watch/.listen" + // Keep stem, replace extension with actual media extension let real_ext = real_file .extension() .and_then(|s| s.to_str()) @@ -756,7 +837,6 @@ fn finalize_front(state: &Arc>) -> Result<(), String> { .to_string(); let final_abs = if real_ext.is_empty() { - // fallback: no extension detected; keep .watch path as-is final_abs_watch.clone() } else { final_parent.join(format!("{stem}.{real_ext}")) @@ -765,6 +845,10 @@ fn finalize_front(state: &Arc>) -> Result<(), String> { // Move file into place fs::rename(&real_file, &final_abs).map_err(|e| format!("move to final failed: {e}"))?; + // ✅ NEW: write marker so we can detect "downloaded" later + // (If marker fails, we still consider download successful.) + let _ = write_download_marker(&final_abs, &it.item_id); + // Cleanup temp folder fully let _ = fs::remove_dir_all(&tmp_dir); diff --git a/src/main.rs b/src/main.rs index f2da5be..265fe84 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ use std::{ path::PathBuf, process::{Child, Command, Stdio}, time::{Duration, Instant}, + collections::HashSet, thread, }; @@ -182,6 +183,8 @@ struct App { downloads: Option, downloads_focus: bool, downloads_selected: usize, + downloaded_ids: HashSet, + last_download_message: Option, autoplay: bool, autoplay_view_depth: usize, @@ -256,6 +259,8 @@ impl Default for App { downloads: None, downloads_focus: false, downloads_selected: 0, + downloaded_ids: downloads::scan_downloaded_ids().unwrap_or_default(), + last_download_message: None, } } } @@ -336,6 +341,21 @@ fn run_app(terminal: &mut Terminal>) -> io::Result< app.mpv_state = MpvState::default(); } + // Refresh local-download index when downloads complete + if let Some(dm) = &app.downloads { + if let Ok(st) = dm.state.lock() { + let msg = st.last_message.clone(); + + if msg != app.last_download_message { + app.last_download_message = msg.clone(); + + if matches!(msg.as_deref(), Some("download complete")) { + app.downloaded_ids = downloads::scan_downloaded_ids().unwrap_or_default(); + } + } + } + } + // Option A: always tick autoplay by polling eof-reached (window stays open) autoplay_tick(&mut app); @@ -2699,9 +2719,10 @@ 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_downloaded(app: &App, it: &core::jellyfin::JfItem) -> bool { + // Basic: only exact item-id matches. + // (No container inference for Series/Season/Album right now.) + app.downloaded_ids.contains(&it.id) } fn is_played(it: &core::jellyfin::JfItem) -> bool { @@ -2777,15 +2798,30 @@ fn is_in_progress_fast(it: &core::jellyfin::JfItem) -> bool { } } +fn emoji_prefix_for(app: &App, it: &core::jellyfin::JfItem) -> String { + // Keep your existing behavior: no emojis for music items (optional). + // If you DO want 💾 for music too, remove this early return. + if is_music_item(it) { + return String::new(); + } + let downloaded = is_downloaded(app, it); + let played = is_played(it); + let inprog = is_in_progress_fast(it); + let mut s = String::new(); -fn emoji_prefix_for(_app: &App, it: &core::jellyfin::JfItem) -> &'static str { - if is_music_item(it) { return ""; } + if downloaded { + s.push_str("💾 "); + } - if is_played(it) { return "✅ "; } - if is_in_progress_fast(it) { return "🟠 "; } - "" + if played { + s.push_str("✅ "); + } else if inprog { + s.push_str("🟠 "); + } + + s } /*