full playlist integration almost complete
This commit is contained in:
@@ -49,16 +49,24 @@ pub struct DownloadItem {
|
|||||||
|
|
||||||
// aria2
|
// aria2
|
||||||
pub gid: Option<String>,
|
pub gid: Option<String>,
|
||||||
|
|
||||||
|
pub playlist_links: Vec<PlaylistLink>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum DownloadCommand {
|
pub enum DownloadCommand {
|
||||||
Enqueue(Vec<DownloadItem>),
|
Enqueue(Vec<DownloadItem>),
|
||||||
Cancel { index: usize }, // cancel by queue index (including current 0)
|
EnqueuePlaylistItem {
|
||||||
RemoveQueued { index: usize }, // remove if queued (not current)
|
item: JfItem,
|
||||||
|
playlist_name: String,
|
||||||
|
playlist_index: usize,
|
||||||
|
},
|
||||||
|
Cancel { index: usize },
|
||||||
|
RemoveQueued { index: usize },
|
||||||
Shutdown,
|
Shutdown,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct DownloadState {
|
pub struct DownloadState {
|
||||||
pub queue: VecDeque<DownloadItem>,
|
pub queue: VecDeque<DownloadItem>,
|
||||||
@@ -78,6 +86,13 @@ pub struct LocalEntry {
|
|||||||
pub path: PathBuf,
|
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.
|
/// 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.
|
/// Title is taken from marker JSON if present, else from the media filename stem.
|
||||||
pub fn scan_local_entries() -> Vec<LocalEntry> {
|
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 ----------
|
// ---------- 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 {
|
fn sanitize_component(s: &str) -> String {
|
||||||
let mut out = String::with_capacity(s.len());
|
let mut out = String::with_capacity(s.len());
|
||||||
for ch in s.chars() {
|
for ch in s.chars() {
|
||||||
@@ -419,6 +494,7 @@ fn make_download_item_movie(item: &JfItem) -> Result<DownloadItem, String> {
|
|||||||
download_speed: 0,
|
download_speed: 0,
|
||||||
eta_secs: None,
|
eta_secs: None,
|
||||||
gid: None,
|
gid: None,
|
||||||
|
playlist_links: Vec::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,6 +538,7 @@ fn make_download_item_episode(item: &JfItem) -> Result<DownloadItem, String> {
|
|||||||
eta_secs: None,
|
eta_secs: None,
|
||||||
|
|
||||||
gid: None,
|
gid: None,
|
||||||
|
playlist_links: Vec::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -506,6 +583,7 @@ fn make_download_item_track(item: &JfItem) -> Result<DownloadItem, String> {
|
|||||||
eta_secs: None,
|
eta_secs: None,
|
||||||
|
|
||||||
gid: None,
|
gid: None,
|
||||||
|
playlist_links: Vec::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -527,6 +605,7 @@ fn make_download_item_other(item: &JfItem) -> Result<DownloadItem, String> {
|
|||||||
eta_secs: None,
|
eta_secs: None,
|
||||||
|
|
||||||
gid: 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 => {
|
DownloadCommand::Shutdown => {
|
||||||
shutdown = true;
|
shutdown = true;
|
||||||
}
|
}
|
||||||
@@ -941,6 +1075,11 @@ fn finalize_front(state: &Arc<Mutex<DownloadState>>) -> Result<(), String> {
|
|||||||
// (If marker fails, we still consider download successful.)
|
// (If marker fails, we still consider download successful.)
|
||||||
let _ = write_download_marker(&final_abs, &it.item_id);
|
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
|
// Cleanup temp folder fully
|
||||||
let _ = fs::remove_dir_all(&tmp_dir);
|
let _ = fs::remove_dir_all(&tmp_dir);
|
||||||
|
|
||||||
|
|||||||
39
src/main.rs
39
src/main.rs
@@ -47,6 +47,7 @@ enum View {
|
|||||||
total: usize,
|
total: usize,
|
||||||
page_size: usize,
|
page_size: usize,
|
||||||
loading: bool,
|
loading: bool,
|
||||||
|
playlist_ctx: Option<PlaylistCtx>,
|
||||||
},
|
},
|
||||||
OfflineLibrary {
|
OfflineLibrary {
|
||||||
title: String,
|
title: String,
|
||||||
@@ -57,6 +58,11 @@ enum View {
|
|||||||
Login,
|
Login,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct PlaylistCtx {
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default)]
|
#[derive(Clone, Debug, Default)]
|
||||||
struct MpvState {
|
struct MpvState {
|
||||||
eof: bool,
|
eof: bool,
|
||||||
@@ -496,6 +502,7 @@ fn handle_key(app: &mut App, code: KeyCode) {
|
|||||||
total: page.total,
|
total: page.total,
|
||||||
page_size: PAGE_SIZE,
|
page_size: PAGE_SIZE,
|
||||||
loading: false,
|
loading: false,
|
||||||
|
playlist_ctx: None,
|
||||||
});
|
});
|
||||||
app.status = "Search loaded".into();
|
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
|
||||||
// downloads focus overrides normal navigation
|
// downloads focus overrides normal navigation
|
||||||
|
/* //come back
|
||||||
let mut handled_by_downloads = false;
|
let mut handled_by_downloads = false;
|
||||||
|
|
||||||
if app.downloads_focus {
|
if app.downloads_focus {
|
||||||
@@ -1055,6 +1063,7 @@ fn handle_key(app: &mut App, code: KeyCode) {
|
|||||||
if handled_by_downloads {
|
if handled_by_downloads {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
match code {
|
match code {
|
||||||
KeyCode::Char('d') => {
|
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; }
|
None => { app.status = "No user_id (login first)".into(); return; }
|
||||||
};
|
};
|
||||||
|
|
||||||
let (it) = match app.current_view() {
|
let (it, playlist_name, playlist_index) = match app.current_view() {
|
||||||
View::Library { items, selected, .. } if !items.is_empty() => items[*selected].clone(),
|
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; }
|
_ => { 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; }
|
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) {
|
match downloads::expand_for_enqueue(&jf, user_id, &it) {
|
||||||
Ok(items) => {
|
Ok(items) => {
|
||||||
let n = items.len();
|
let n = items.len();
|
||||||
@@ -1166,6 +1197,7 @@ fn handle_key(app: &mut App, code: KeyCode) {
|
|||||||
total, // cheap total for UI
|
total, // cheap total for UI
|
||||||
page_size: PAGE_SIZE,
|
page_size: PAGE_SIZE,
|
||||||
loading: false,
|
loading: false,
|
||||||
|
playlist_ctx: None,
|
||||||
});
|
});
|
||||||
app.status = "Loaded".into();
|
app.status = "Loaded".into();
|
||||||
}
|
}
|
||||||
@@ -1212,6 +1244,7 @@ fn handle_key(app: &mut App, code: KeyCode) {
|
|||||||
total: page.total,
|
total: page.total,
|
||||||
page_size: PAGE_SIZE,
|
page_size: PAGE_SIZE,
|
||||||
loading: false,
|
loading: false,
|
||||||
|
playlist_ctx: None,
|
||||||
});
|
});
|
||||||
app.status = "$".into();
|
app.status = "$".into();
|
||||||
}
|
}
|
||||||
@@ -1292,6 +1325,7 @@ fn handle_key(app: &mut App, code: KeyCode) {
|
|||||||
total: page.total,
|
total: page.total,
|
||||||
page_size: PAGE_SIZE,
|
page_size: PAGE_SIZE,
|
||||||
loading: false,
|
loading: false,
|
||||||
|
playlist_ctx: Some(PlaylistCtx { name: it.name.clone() }),
|
||||||
});
|
});
|
||||||
app.status = "$".into();
|
app.status = "$".into();
|
||||||
}
|
}
|
||||||
@@ -2006,6 +2040,7 @@ fn open_children(app: &mut App, title: String, query: core::jellyfin::LibraryQue
|
|||||||
total: page.total,
|
total: page.total,
|
||||||
page_size: PAGE_SIZE,
|
page_size: PAGE_SIZE,
|
||||||
loading: false,
|
loading: false,
|
||||||
|
playlist_ctx: None,
|
||||||
});
|
});
|
||||||
app.status = "$".into();
|
app.status = "$".into();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user