From 7dc15dae1b91e3a9263305229fe8859e97a51b60 Mon Sep 17 00:00:00 2001 From: cwj3rcb Date: Mon, 16 Feb 2026 20:24:47 -0600 Subject: [PATCH] full playlist integration almost complete --- src/core/downloads.rs | 143 +++++++++++++++++++++++++++++++++++++++++- src/main.rs | 39 +++++++++++- 2 files changed, 178 insertions(+), 4 deletions(-) diff --git a/src/core/downloads.rs b/src/core/downloads.rs index 4946daa..28227e8 100644 --- a/src/core/downloads.rs +++ b/src/core/downloads.rs @@ -49,16 +49,24 @@ pub struct DownloadItem { // aria2 pub gid: Option, + + pub playlist_links: Vec, } #[derive(Debug)] pub enum DownloadCommand { Enqueue(Vec), - Cancel { index: usize }, // cancel by queue index (including current 0) - RemoveQueued { index: usize }, // remove if queued (not current) + EnqueuePlaylistItem { + item: JfItem, + playlist_name: String, + playlist_index: usize, + }, + Cancel { index: usize }, + RemoveQueued { index: usize }, Shutdown, } + #[derive(Default)] pub struct DownloadState { pub queue: VecDeque, @@ -78,6 +86,13 @@ pub struct LocalEntry { pub path: PathBuf, } +#[derive(Clone, Debug)] +pub struct PlaylistLink { + pub playlist_name: String, + pub playlist_index: usize, // 0-based + pub title: String, +} + /// 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 { @@ -390,6 +405,66 @@ fn list_all_playlist_items(jf: &JellyfinClient, user_id: &str, playlist_id: &str // ---------- naming ---------- +fn playlist_link_abs_path( + downloads_root: &Path, + playlist_name: &str, + playlist_index: usize, + title: &str, + ext: &str, +) -> PathBuf { + let plist = sanitize_component(playlist_name); + let t = sanitize_component(title); + + let idx = format!("{:03}", playlist_index + 1); + let file = if ext.is_empty() { + format!("{idx} {t}") + } else { + format!("{idx} {t}.{ext}") + }; + + downloads_root + .join("playlists") + .join(plist) + .join(file) +} + +fn ensure_playlist_link( + final_media_path: &Path, + playlist_name: &str, + playlist_index: usize, + title: &str, +) -> Result<(), String> { + let dl_root = downloads_root()?; + + let ext = final_media_path + .extension() + .and_then(|s| s.to_str()) + .unwrap_or(""); + + let link_abs = playlist_link_abs_path(&dl_root, playlist_name, playlist_index, title, ext); + + if let Some(parent) = link_abs.parent() { + fs::create_dir_all(parent).map_err(|e| format!("mk playlist dir: {e}"))?; + } + + // If exists, do nothing (idempotent) + if link_abs.exists() { + return Ok(()); + } + + // Prefer hardlink (fast, no duplication). If cross-device, fall back to copy. + match fs::hard_link(final_media_path, &link_abs) { + Ok(_) => Ok(()), + Err(e) => { + // EXDEV / permission / etc -> fallback copy + fs::copy(final_media_path, &link_abs) + .map(|_| ()) + .map_err(|e2| format!("playlist link failed: {e} / copy failed: {e2}")) + } + } +} + + fn sanitize_component(s: &str) -> String { let mut out = String::with_capacity(s.len()); for ch in s.chars() { @@ -419,6 +494,7 @@ fn make_download_item_movie(item: &JfItem) -> Result { download_speed: 0, eta_secs: None, gid: None, + playlist_links: Vec::new(), }) } @@ -462,6 +538,7 @@ fn make_download_item_episode(item: &JfItem) -> Result { eta_secs: None, gid: None, + playlist_links: Vec::new(), }) } @@ -506,6 +583,7 @@ fn make_download_item_track(item: &JfItem) -> Result { eta_secs: None, gid: None, + playlist_links: Vec::new(), }) } @@ -527,6 +605,7 @@ fn make_download_item_other(item: &JfItem) -> Result { eta_secs: None, gid: None, + playlist_links: Vec::new(), }) } @@ -666,6 +745,61 @@ fn worker_loop(cfg: Config, state: Arc>, rx: mpsc::Receiver } } + DownloadCommand::EnqueuePlaylistItem { item, playlist_name, playlist_index } => { + // If already downloaded, just create the playlist link immediately. + if let Some(local) = local_media_path_for_item_id(&item.id) { + let _ = ensure_playlist_link(&local, &playlist_name, playlist_index, &item.name); + if let Ok(mut st) = state.lock() { + st.last_message = Some("playlist link created".into()); + } + continue; + } + + // Build a DownloadItem like normal, but attach a playlist link intent. + let tt = item.item_type.as_deref().unwrap_or(""); + let mut di = match tt { + "Audio" => make_download_item_track(&item), + "Episode" => make_download_item_episode(&item), + "Movie" => make_download_item_movie(&item), + _ => make_download_item_other(&item), + }; + + let mut di = match di { + Ok(v) => v, + Err(e) => { + if let Ok(mut st) = state.lock() { + st.last_message = Some(format!("enqueue playlist item failed: {e}")); + } + continue; + } + }; + + di.playlist_links.push(PlaylistLink { + playlist_name, + playlist_index, + title: item.name.clone(), + }); + + if let Ok(mut st) = state.lock() { + // If same media already queued, merge playlist link intents. + if let Some(existing) = st.queue.iter_mut().find(|q| q.item_id == di.item_id && q.final_rel_path == di.final_rel_path) { + for pl in di.playlist_links.drain(..) { + let already = existing.playlist_links.iter().any(|x| + x.playlist_name == pl.playlist_name && x.playlist_index == pl.playlist_index + ); + if !already { + existing.playlist_links.push(pl); + } + } + st.last_message = Some("queued (merged playlist link)".into()); + } else { + st.queue.push_back(di); + st.last_message = Some("queued (playlist)".into()); + } + } + } + + DownloadCommand::Shutdown => { shutdown = true; } @@ -941,6 +1075,11 @@ fn finalize_front(state: &Arc>) -> Result<(), String> { // (If marker fails, we still consider download successful.) let _ = write_download_marker(&final_abs, &it.item_id); + // NEW: create playlist links (best-effort) + for pl in &it.playlist_links { + let _ = ensure_playlist_link(&final_abs, &pl.playlist_name, pl.playlist_index, &pl.title); + } + // Cleanup temp folder fully let _ = fs::remove_dir_all(&tmp_dir); diff --git a/src/main.rs b/src/main.rs index e900398..e33b550 100644 --- a/src/main.rs +++ b/src/main.rs @@ -47,6 +47,7 @@ enum View { total: usize, page_size: usize, loading: bool, + playlist_ctx: Option, }, OfflineLibrary { title: String, @@ -57,6 +58,11 @@ enum View { Login, } +#[derive(Clone)] +struct PlaylistCtx { + name: String, +} + #[derive(Clone, Debug, Default)] struct MpvState { eof: bool, @@ -496,6 +502,7 @@ fn handle_key(app: &mut App, code: KeyCode) { total: page.total, page_size: PAGE_SIZE, loading: false, + playlist_ctx: None, }); app.status = "Search loaded".into(); } @@ -1018,6 +1025,7 @@ fn handle_key(app: &mut App, code: KeyCode) { // downloads focus overrides normal navigation // downloads focus overrides normal navigation +/* //come back let mut handled_by_downloads = false; if app.downloads_focus { @@ -1055,6 +1063,7 @@ fn handle_key(app: &mut App, code: KeyCode) { if handled_by_downloads { return; } +*/ match code { KeyCode::Char('d') => { @@ -1067,8 +1076,12 @@ fn handle_key(app: &mut App, code: KeyCode) { None => { app.status = "No user_id (login first)".into(); return; } }; - let (it) = match app.current_view() { - View::Library { items, selected, .. } if !items.is_empty() => items[*selected].clone(), + let (it, playlist_name, playlist_index) = match app.current_view() { + View::Library { items, selected, playlist_ctx, .. } if !items.is_empty() => { + let it = items[*selected].clone(); + let pname = playlist_ctx.as_ref().map(|p| p.name.clone()); + (it, pname, *selected) + } _ => { app.status = "Nothing to download here".into(); return; } }; @@ -1091,6 +1104,24 @@ fn handle_key(app: &mut App, code: KeyCode) { Err(e) => { app.status = format!("downloads: {e}"); return; } }; + if let Some(pname) = playlist_name { + // only allow direct playables from playlists + let ty = it.item_type.as_deref().unwrap_or(""); + let playable = matches!(ty, "Movie" | "Episode" | "Audio"); + if !playable { + app.status = "Playlist downloads only support Movie/Episode/Audio".into(); + return; + } + + dm.send(downloads::DownloadCommand::EnqueuePlaylistItem { + item: it, + playlist_name: pname, + playlist_index, + }); + app.status = "queued 1 (playlist)".into(); + return; + } + match downloads::expand_for_enqueue(&jf, user_id, &it) { Ok(items) => { let n = items.len(); @@ -1166,6 +1197,7 @@ fn handle_key(app: &mut App, code: KeyCode) { total, // cheap total for UI page_size: PAGE_SIZE, loading: false, + playlist_ctx: None, }); app.status = "Loaded".into(); } @@ -1212,6 +1244,7 @@ fn handle_key(app: &mut App, code: KeyCode) { total: page.total, page_size: PAGE_SIZE, loading: false, + playlist_ctx: None, }); app.status = "$".into(); } @@ -1292,6 +1325,7 @@ fn handle_key(app: &mut App, code: KeyCode) { total: page.total, page_size: PAGE_SIZE, loading: false, + playlist_ctx: Some(PlaylistCtx { name: it.name.clone() }), }); app.status = "$".into(); } @@ -2006,6 +2040,7 @@ fn open_children(app: &mut App, title: String, query: core::jellyfin::LibraryQue total: page.total, page_size: PAGE_SIZE, loading: false, + playlist_ctx: None, }); app.status = "$".into(); }