full playlist integration almost complete

This commit is contained in:
2026-02-16 20:24:47 -06:00
parent 1d2a7d5268
commit 7dc15dae1b
2 changed files with 178 additions and 4 deletions
+141 -2
View File
@@ -49,16 +49,24 @@ pub struct DownloadItem {
// aria2
pub gid: Option<String>,
pub playlist_links: Vec<PlaylistLink>,
}
#[derive(Debug)]
pub enum DownloadCommand {
Enqueue(Vec<DownloadItem>),
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<DownloadItem>,
@@ -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<LocalEntry> {
@@ -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<DownloadItem, String> {
download_speed: 0,
eta_secs: None,
gid: None,
playlist_links: Vec::new(),
})
}
@@ -462,6 +538,7 @@ fn make_download_item_episode(item: &JfItem) -> Result<DownloadItem, String> {
eta_secs: None,
gid: None,
playlist_links: Vec::new(),
})
}
@@ -506,6 +583,7 @@ fn make_download_item_track(item: &JfItem) -> Result<DownloadItem, String> {
eta_secs: None,
gid: None,
playlist_links: Vec::new(),
})
}
@@ -527,6 +605,7 @@ fn make_download_item_other(item: &JfItem) -> Result<DownloadItem, String> {
eta_secs: None,
gid: None,
playlist_links: Vec::new(),
})
}
@@ -666,6 +745,61 @@ fn worker_loop(cfg: Config, state: Arc<Mutex<DownloadState>>, 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<Mutex<DownloadState>>) -> 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);