diff --git a/src/core/downloads.rs b/src/core/downloads.rs new file mode 100644 index 0000000..183fbe6 --- /dev/null +++ b/src/core/downloads.rs @@ -0,0 +1,706 @@ + +use std::{ + collections::VecDeque, + fs, + path::{Path, PathBuf}, + process::{Child, Command, Stdio}, + sync::{mpsc, Arc, Mutex}, + thread, + time::{Duration, Instant}, +}; + +use crate::core::{config::Config, jellyfin::{JellyfinClient, JfItem, LibraryQuery, PagedItems}, paths}; + +#[derive(Clone, Debug)] +pub enum DownloadKind { + Movie, + Episode { series: String, season: Option, episode: Option }, + Track { artist: Option, album: Option, track_no: Option }, + Other, +} + +#[derive(Clone, Debug)] +pub enum DownloadStatus { + Queued, + Downloading, + Finalizing, + Completed, + Failed(String), + Cancelled, +} + +#[derive(Clone, Debug)] +pub struct DownloadItem { + pub item_id: String, + pub title: String, + pub kind: DownloadKind, + pub final_rel_path: PathBuf, // relative to downloads root + pub status: DownloadStatus, + pub added_at: Instant, + pub bytes_downloaded: u64, + pub bytes_total: Option, +} + +#[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) + Shutdown, +} + +#[derive(Default)] +pub struct DownloadState { + pub queue: VecDeque, + pub active_pid: Option, + pub last_message: Option, +} + +pub struct DownloadManager { + tx: mpsc::Sender, + pub state: Arc>, +} + +impl DownloadManager { + pub fn start(cfg: Config) -> Self { + let state = Arc::new(Mutex::new(DownloadState::default())); + let (tx, rx) = mpsc::channel::(); + + // cleanup temp on start + let _ = cleanup_temp_dir(); + + let state_bg = Arc::clone(&state); + thread::spawn(move || worker_loop(cfg, state_bg, rx)); + + Self { tx, state } + } + + pub fn send(&self, cmd: DownloadCommand) { + let _ = self.tx.send(cmd); + } +} + +// ---------- directory helpers ---------- + +pub fn downloads_root() -> Result { + let dir = paths::app_config_dir().ok_or_else(|| "no config dir".to_string())?; + Ok(dir.join("downloads")) +} + +pub fn temp_root() -> Result { + let dir = paths::app_config_dir().ok_or_else(|| "no config dir".to_string())?; + Ok(dir.join("temp")) +} + +pub fn ensure_dirs() -> Result<(), String> { + fs::create_dir_all(downloads_root().map_err(|e| e.to_string())?).map_err(|e| e.to_string())?; + fs::create_dir_all(temp_root().map_err(|e| e.to_string())?).map_err(|e| e.to_string())?; + Ok(()) +} + +pub fn cleanup_temp_dir() -> Result<(), String> { + let tmp = temp_root()?; + if tmp.exists() { + fs::remove_dir_all(&tmp).map_err(|e| format!("remove temp: {e}"))?; + } + fs::create_dir_all(&tmp).map_err(|e| format!("create temp: {e}"))?; + Ok(()) +} + +// ---------- queue building (expansion) ---------- + +pub fn expand_for_enqueue( + jf: &JellyfinClient, + user_id: &str, + item: &JfItem, +) -> Result, String> { + let t = item.item_type.as_deref().unwrap_or(""); + match t { + "Movie" => Ok(vec![make_download_item_movie(item)?]), + "Episode" => Ok(vec![make_download_item_episode(item)?]), + "Audio" => Ok(vec![make_download_item_track(item)?]), + + // Containers: + "Season" => { + // episodes under Season (non-recursive) + list_all_items(jf, user_id, LibraryQuery::all().with_parent(item.id.clone()).with_item_types("Episode"), false)? + .into_iter() + .map(|ep| make_download_item_episode(&ep)) + .collect() + } + "Series" => { + // episodes under Series (recursive) + let mut q = LibraryQuery::all() + .with_parent(item.id.clone()) + .with_item_types("Episode"); + q.recursive = true; + list_all_items(jf, user_id, q, true)? + .into_iter() + .map(|ep| make_download_item_episode(&ep)) + .collect() + } + "MusicAlbum" => { + // tracks under album + list_all_items(jf, user_id, LibraryQuery::all().with_parent(item.id.clone()).with_item_types("Audio"), false)? + .into_iter() + .map(|tr| make_download_item_track(&tr)) + .collect() + } + "Playlist" => { + // playlist items via special endpoint + list_all_playlist_items(jf, user_id, &item.id)? + .into_iter() + .map(|it| { + let tt = it.item_type.as_deref().unwrap_or(""); + match tt { + "Audio" => make_download_item_track(&it), + "Episode" => make_download_item_episode(&it), + "Movie" => make_download_item_movie(&it), + _ => make_download_item_other(&it), + } + }) + .collect() + } + + _ => Ok(vec![make_download_item_other(item)?]), + } +} + +fn list_all_items( + jf: &JellyfinClient, + user_id: &str, + query: LibraryQuery, + _maybe_large: bool, +) -> Result, String> { + // user-driven action, so paging here is fine + const LIMIT: usize = 200; + let mut start = 0usize; + let mut out = Vec::new(); + loop { + let page = jf.list_items(user_id, query.clone(), start, LIMIT)?; + out.extend(page.items.into_iter()); + if out.len() >= page.total || page.total == 0 { + break; + } + start += LIMIT; + } + Ok(out) +} + +fn list_all_playlist_items(jf: &JellyfinClient, user_id: &str, playlist_id: &str) -> Result, String> { + const LIMIT: usize = 200; + let mut start = 0usize; + let mut out = Vec::new(); + loop { + let page = jf.playlist_items(user_id, playlist_id, start, LIMIT)?; + out.extend(page.items.into_iter()); + if out.len() >= page.total || page.total == 0 { + break; + } + start += LIMIT; + } + Ok(out) +} + +// ---------- naming ---------- + +fn sanitize_component(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for ch in s.chars() { + // conservative sanitization for Linux/Windows compatibility + if matches!(ch, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' ) { + out.push('_'); + } else { + out.push(ch); + } + } + let out = out.trim().to_string(); + if out.is_empty() { "_".into() } else { out.chars().take(100).collect() } +} + +fn make_download_item_movie(item: &JfItem) -> Result { + let name = sanitize_component(&item.name); + let rel = PathBuf::from("movies").join(format!("{name}.watch")); + Ok(DownloadItem { + item_id: item.id.clone(), + title: item.name.clone(), + kind: DownloadKind::Movie, + final_rel_path: rel, + status: DownloadStatus::Queued, + added_at: Instant::now(), + bytes_downloaded: 0, + bytes_total: None, + }) +} + +fn make_download_item_episode(item: &JfItem) -> Result { + let series = item.series_name.clone().unwrap_or_else(|| "Unknown Show".into()); + let series_s = sanitize_component(&series); + let season_no = item.parent_index_number; + let ep_no = item.index_number; + + let season_folder = season_no.map(|n| format!("Season-{}", n)).unwrap_or_else(|| "Season-0".into()); + let title_s = sanitize_component(&item.name); + let filename = match (season_no, ep_no) { + (Some(s), Some(e)) => format!("S{:02}E{:02} - {title_s}.watch", s, e), + _ => format!("{title_s}.watch"), + }; + + let rel = PathBuf::from("shows").join(series_s).join(season_folder).join(filename); + + Ok(DownloadItem { + item_id: item.id.clone(), + title: format!("{series} - {}", item.name), + kind: DownloadKind::Episode { series, season: season_no, episode: ep_no }, + final_rel_path: rel, + status: DownloadStatus::Queued, + added_at: Instant::now(), + bytes_downloaded: 0, + bytes_total: None, + }) +} + +fn make_download_item_track(item: &JfItem) -> Result { + let artist = item + .album_artist + .clone() + .or_else(|| item.artists.get(0).cloned()); + let album = item.album.clone(); + let track_no = item.index_number; + + let artist_s = sanitize_component(artist.as_deref().unwrap_or("Unknown Artist")); + let album_s = sanitize_component(album.as_deref().unwrap_or("Unknown Album")); + let title_s = sanitize_component(&item.name); + + let filename = match track_no { + Some(n) => format!("{:02} - {title_s}.listen", n), + None => format!("{title_s}.listen"), + }; + + let rel = PathBuf::from("music").join(artist_s).join(album_s).join(filename); + + Ok(DownloadItem { + item_id: item.id.clone(), + title: item.name.clone(), + kind: DownloadKind::Track { artist, album, track_no }, + final_rel_path: rel, + status: DownloadStatus::Queued, + added_at: Instant::now(), + bytes_downloaded: 0, + bytes_total: None, + }) +} + +fn make_download_item_other(item: &JfItem) -> Result { + let name = sanitize_component(&item.name); + let rel = PathBuf::from("other").join(format!("{name}.watch")); + Ok(DownloadItem { + item_id: item.id.clone(), + title: item.name.clone(), + kind: DownloadKind::Other, + final_rel_path: rel, + status: DownloadStatus::Queued, + added_at: Instant::now(), + bytes_downloaded: 0, + bytes_total: None, + }) +} + +fn tmp_dir_of_item(it: &DownloadItem) -> Result { + let tmp_path = temp_dir_for(it)?; + let tmp_dir = tmp_path + .parent() + .ok_or_else(|| "bad temp dir".to_string())? + .to_path_buf(); + Ok(tmp_dir) +} + +// Find the actual downloaded media file in tmp dir. +// We ignore aria2 metadata (*.aria2) and partials (*.part). +fn find_real_downloaded_file(tmp_dir: &Path) -> Result { + let mut best: Option<(u64, PathBuf)> = None; + + let rd = fs::read_dir(tmp_dir).map_err(|e| format!("read temp dir: {e}"))?; + for ent in rd { + let p = ent.map_err(|e| format!("read dir entry: {e}"))?.path(); + if !p.is_file() { + continue; + } + let name = p.file_name().and_then(|s| s.to_str()).unwrap_or(""); + + if name.ends_with(".aria2") || name.ends_with(".part") { + continue; + } + + let sz = fs::metadata(&p).map_err(|e| format!("stat temp file: {e}"))?.len(); + match &best { + None => best = Some((sz, p)), + Some((best_sz, _)) => { + if sz > *best_sz { + best = Some((sz, p)); + } + } + } + } + + best.map(|(_, p)| p) + .ok_or_else(|| "no downloaded media file found in temp dir".to_string()) +} + + + +// ---------- worker loop ---------- + +fn worker_loop(cfg: Config, state: Arc>, rx: mpsc::Receiver) { + let _ = ensure_dirs(); + + let jf = match JellyfinClient::from_config(&cfg) { + Ok(c) => c, + Err(e) => { + if let Ok(mut st) = state.lock() { + st.last_message = Some(format!("downloads disabled: {e}")); + } + return; + } + }; + + let token = cfg.access_token.clone().unwrap_or_default(); + + let mut shutdown = false; + let mut current_child: Option = None; + + while !shutdown { + // handle commands (non-blocking) + while let Ok(cmd) = rx.try_recv() { + match cmd { + DownloadCommand::Enqueue(mut items) => { + if let Ok(mut st) = state.lock() { + // dedupe by item_id + final_rel_path + for mut it in items.drain(..) { + let already = st.queue.iter().any(|q| q.item_id == it.item_id && q.final_rel_path == it.final_rel_path); + if !already { + st.queue.push_back(it); + } + } + st.last_message = Some("queued".into()); + } + } + DownloadCommand::Cancel { index } => { + // Cancel active (index 0) or any queued item. + if index == 0 { + // 1) Kill aria2c + if let Some(child) = current_child.as_mut() { + let _ = child.kill(); + } + + // 2) Mark status + delete temp dir for the active item + let active_it = { + let st = state.lock().ok(); + st.and_then(|s| s.queue.front().cloned()) + }; + + if let Some(it) = active_it { + if let Ok(mut st) = state.lock() { + if let Some(front) = st.queue.front_mut() { + front.status = DownloadStatus::Cancelled; + } + st.last_message = Some("cancelled active download".into()); + } + let _ = delete_temp_for(&it); + } + } else { + // queued cancellation + if let Ok(mut st) = state.lock() { + if index < st.queue.len() { + let mut v: Vec<_> = st.queue.drain(..).collect(); + if index < v.len() { + let mut it = v.remove(index); + it.status = DownloadStatus::Cancelled; + let _ = delete_temp_for(&it); + } + st.queue = v.into(); + st.last_message = Some("removed from queue".into()); + } + } + } + } + DownloadCommand::RemoveQueued { index } => { + if let Ok(mut st) = state.lock() { + if index > 0 && index < st.queue.len() { + let mut v: Vec<_> = st.queue.drain(..).collect(); + let mut it = v.remove(index); + it.status = DownloadStatus::Cancelled; + let _ = delete_temp_for(&it); + st.queue = v.into(); + } + } + } + DownloadCommand::Shutdown => { + shutdown = true; + } + } + } + + // if no active child, start next queued + let next = { + let mut st = match state.lock() { + Ok(s) => s, + Err(_) => break, + }; + if st.queue.is_empty() { + None + } else { + // Ensure first is queued; we keep active at front with status Downloading/Finalizing + Some(st.queue.front().cloned().unwrap()) + } + }; + + if current_child.is_none() { + if let Some(mut it) = next { + // mark downloading + { + if let Ok(mut st) = state.lock() { + if let Some(front) = st.queue.front_mut() { + front.status = DownloadStatus::Downloading; + } + } + } + + match run_one_download(&cfg.base_url, &token, &it, &state) { + Ok(child) => { + if let Ok(mut st) = state.lock() { + st.active_pid = Some(child.id()); + } + current_child = Some(child); + } + Err(e) => { + if let Ok(mut st) = state.lock() { + if let Some(front) = st.queue.front_mut() { + front.status = DownloadStatus::Failed(e.clone()); + } + st.last_message = Some(format!("download failed: {e}")); + } + // drop failed item + if let Ok(mut st) = state.lock() { + let _ = st.queue.pop_front(); + st.active_pid = None; + } + continue; + } + } + } else { + thread::sleep(Duration::from_millis(120)); + continue; + } + } + + // update progress + handle completion + if let Some(child) = current_child.as_mut() { + // update bytes downloaded by looking at file in temp + update_progress_for_front(&state); + + match child.try_wait() { + Ok(Some(status)) => { + // finished + if status.success() { + // finalize/move + if let Ok(mut st) = state.lock() { + if let Some(front) = st.queue.front_mut() { + front.status = DownloadStatus::Finalizing; + } + } + if let Err(e) = finalize_front(&state) { + if let Ok(mut st) = state.lock() { + if let Some(front) = st.queue.front_mut() { + front.status = DownloadStatus::Failed(e.clone()); + } + st.last_message = Some(e); + } + // drop item + if let Ok(mut st) = state.lock() { + let _ = st.queue.pop_front(); + st.active_pid = None; + } + } else { + if let Ok(mut st) = state.lock() { + let _ = st.queue.pop_front(); + st.active_pid = None; + st.last_message = Some("download complete".into()); + } + } + } else { + let msg = format!("aria2 exited: {status}"); + if let Ok(mut st) = state.lock() { + if let Some(front) = st.queue.front_mut() { + front.status = DownloadStatus::Failed(msg.clone()); + } + st.last_message = Some(msg); + let _ = st.queue.pop_front(); + st.active_pid = None; + } + } + + current_child = None; + } + Ok(None) => { + thread::sleep(Duration::from_millis(200)); + } + Err(_) => { + thread::sleep(Duration::from_millis(200)); + } + } + } + } + + // shutdown cleanup: kill child, clear temp + if let Some(mut child) = current_child { + let _ = child.kill(); + let _ = child.wait(); + } + let _ = cleanup_temp_dir(); +} + +fn temp_dir_for(it: &DownloadItem) -> Result { + let tmp = temp_root()?; + Ok(tmp.join(&it.final_rel_path)) +} + +fn delete_temp_for(it: &DownloadItem) -> Result<(), String> { + let dir = tmp_dir_of_item(it)?; + if dir.exists() { + fs::remove_dir_all(&dir).map_err(|e| format!("rm temp: {e}"))?; + } + Ok(()) +} + +fn run_one_download( + base_url: &str, + token: &str, + it: &DownloadItem, + state: &Arc>, +) -> Result { + let base = base_url.trim_end_matches('/'); + let url = format!("{}/Items/{}/Download", base, it.item_id); + + // We download into a dedicated temp folder for this item. + // Keep your existing folder structure by deriving tmp from final_rel_path. + let tmp_path = temp_dir_for(it)?; + let tmp_dir = tmp_path + .parent() + .ok_or_else(|| "bad temp dir".to_string())? + .to_path_buf(); + + // Ensure temp dir exists and is empty-ish for robustness + if tmp_dir.exists() { + // If a previous attempt left junk, clear it so we don't pick wrong file on finalize. + let _ = fs::remove_dir_all(&tmp_dir); + } + fs::create_dir_all(&tmp_dir).map_err(|e| format!("mk temp dir: {e}"))?; + + // NOTE: + // - We do NOT use --out anymore. + // - We enable --content-disposition so aria2c uses server-provided filename (real extension). + let mut cmd = Command::new("aria2c"); + cmd.arg("--allow-overwrite=true") + .arg("--auto-file-renaming=false") + .arg("--continue=true") + .arg("--check-certificate=true") + .arg("--max-tries=0") + .arg("--retry-wait=5") + .arg("--timeout=60") + .arg("--connect-timeout=10") + .arg("--summary-interval=1") + .arg("--console-log-level=warn") + .arg("--file-allocation=none") + .arg("--content-disposition=true") + .arg("--dir") + .arg(&tmp_dir) + .arg("--header") + .arg(format!("X-Emby-Token: {token}")) + .arg(&url) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + + let child = cmd + .spawn() + .map_err(|e| format!("spawn aria2c failed (is aria2c installed?): {e}"))?; + + if let Ok(mut st) = state.lock() { + st.last_message = Some(format!("downloading {}", it.title)); + } + + Ok(child) +} + + +fn update_progress_for_front(state: &Arc>) { + let (tmp_file, idx) = { + let st = match state.lock() { + Ok(s) => s, + Err(_) => return, + }; + if st.queue.is_empty() { return; } + let it = st.queue.front().unwrap(); + let tmp_dir = match temp_dir_for(it) { Ok(p) => p.parent().unwrap().to_path_buf(), Err(_) => return }; + (tmp_dir.join(format!("{}.part", it.item_id)), 0usize) + }; + + if let Ok(meta) = fs::metadata(&tmp_file) { + if let Ok(mut st) = state.lock() { + if let Some(front) = st.queue.get_mut(idx) { + front.bytes_downloaded = meta.len(); + } + } + } +} + +fn finalize_front(state: &Arc>) -> Result<(), String> { + let (it, tmp_dir, final_abs_watch) = { + let st = state.lock().map_err(|_| "lock poisoned".to_string())?; + let it = st.queue.front().ok_or_else(|| "no front".to_string())?.clone(); + let tmp_dir = tmp_dir_of_item(&it)?; + let dl_root = downloads_root()?; + let final_abs_watch = dl_root.join(&it.final_rel_path); + (it, tmp_dir, final_abs_watch) + }; + + // Ensure final dir exists + let final_parent = final_abs_watch + .parent() + .ok_or_else(|| "no final parent".to_string())?; + fs::create_dir_all(final_parent).map_err(|e| format!("mk final dir: {e}"))?; + + // Find real downloaded file (with real extension) inside tmp_dir + 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. + let real_ext = real_file + .extension() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_string(); + + let stem = final_abs_watch + .file_stem() + .and_then(|s| s.to_str()) + .ok_or_else(|| "bad final filename stem".to_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}")) + }; + + // Move file into place + fs::rename(&real_file, &final_abs).map_err(|e| format!("move to final failed: {e}"))?; + + // Cleanup temp folder fully + let _ = fs::remove_dir_all(&tmp_dir); + + Ok(()) +} \ No newline at end of file diff --git a/src/core/mod.rs b/src/core/mod.rs index e357c3e..cd21547 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -1,3 +1,4 @@ pub mod config; pub mod jellyfin; -pub mod paths; \ No newline at end of file +pub mod paths; +pub mod downloads; \ No newline at end of file diff --git a/src/core/paths.rs b/src/core/paths.rs index d527cba..6b581f0 100644 --- a/src/core/paths.rs +++ b/src/core/paths.rs @@ -32,4 +32,13 @@ pub fn app_cache_dir() -> Option { pub fn posters_cache_dir() -> Option { app_cache_dir().map(|d| d.join("posters")) -} \ No newline at end of file +} + +pub fn downloads_dir() -> Option { + app_config_dir().map(|d| d.join("downloads")) +} + +pub fn temp_downloads_dir() -> Option { + app_config_dir().map(|d| d.join("temp")) +} + diff --git a/src/main.rs b/src/main.rs index 54f4c72..3e7ea69 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,9 @@ use std::os::unix::net::UnixStream; use serde_json::Value; +use crate::core::downloads; +use crate::core::downloads::{DownloadCommand, DownloadManager, DownloadStatus}; + use crossterm::{ event::{self, Event, KeyCode, KeyEventKind}, execute, @@ -26,6 +29,7 @@ use ratatui::{ symbols::merge::MergeStrategy, widgets::{Block, List, ListItem, ListState, Paragraph, Wrap, Gauge}, style::{Modifier, Style}, + text::Line, Frame, Terminal, }; use ratatui::backend::CrosstermBackend; @@ -175,6 +179,9 @@ struct App { mpv_state_rx: Option>, debug_mpv: bool, mpv_now_playing: Option, + downloads: Option, + downloads_focus: bool, + downloads_selected: usize, autoplay: bool, autoplay_view_depth: usize, @@ -246,6 +253,9 @@ impl Default for App { autoplay_next_index: None, autoplay_eof_latched: false, autoplay_prev_time_pos: None, + downloads: None, + downloads_focus: false, + downloads_selected: 0, } } } @@ -335,6 +345,11 @@ fn run_app(terminal: &mut Terminal>) -> io::Result< last_tick = Instant::now(); } } + + if let Some(dm) = &app.downloads { + dm.send(downloads::DownloadCommand::Shutdown); + } + Ok(()) } @@ -502,14 +517,20 @@ fn handle_key(app: &mut App, code: KeyCode) { } if matches!(code, KeyCode::Char('D')) { - app.debug_mpv = !app.debug_mpv; - app.status = format!("mpv debug: {}", if app.debug_mpv { "ON" } else { "OFF" }); + app.downloads_focus = !app.downloads_focus; + app.downloads_selected = 0; + app.status = if app.downloads_focus { "downloads: focus".into() } else { "downloads: unfocus".into() }; return; } // mpv controls (only when not typing into search/login) if app.search.is_none() && !matches!(app.current_view(), View::Login) { match code { + KeyCode::Char('M') => { + app.debug_mpv = !app.debug_mpv; + app.status = format!("mpv debug: {}", if app.debug_mpv { "ON" } else { "OFF" }); + return; + } KeyCode::Char('a') => { if let Some(mpv) = &app.mpv { if let Err(e) = mpv_cycle_audio(mpv) { @@ -754,7 +775,106 @@ fn handle_key(app: &mut App, code: KeyCode) { } } + // downloads focus overrides normal navigation + let mut handled_by_downloads = false; + + if app.downloads_focus { + match code { + KeyCode::Up | KeyCode::Char('k') => { + if app.downloads_selected > 0 { + app.downloads_selected -= 1; + } + handled_by_downloads = true; + } + KeyCode::Down | KeyCode::Char('j') => { + let len = app + .downloads + .as_ref() + .and_then(|dm| dm.state.lock().ok()) + .map(|st| st.queue.len()) + .unwrap_or(0); + + if len > 0 && app.downloads_selected + 1 < len { + app.downloads_selected += 1; + } + handled_by_downloads = true; + } + KeyCode::Esc => { + app.downloads_focus = false; + handled_by_downloads = true; + } + _ => {} + } + } + + // If downloads handled the key, skip normal navigation handling + if handled_by_downloads { + return; + } + match code { + KeyCode::Char('d') => { + // downloads: focus => cancel/remove, otherwise enqueue selected item + if app.downloads_focus { + if let Some(dm) = &app.downloads { + let idx = app.downloads_selected; + if idx == 0 { + dm.send(downloads::DownloadCommand::Cancel { index: 0 }); + app.status = "download: cancel active".into(); + } else { + dm.send(downloads::DownloadCommand::RemoveQueued { index: idx }); + app.status = "download: removed".into(); + } + } else { + app.status = "downloads not available".into(); + } + return; + } + + let cfg = match &app.config { + Some(c) => c.clone(), + None => { app.status = "No config loaded".into(); return; } + }; + let user_id = match cfg.user_id.as_deref() { + Some(u) => u, + 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(), + _ => { app.status = "Nothing to download here".into(); return; } + }; + + let dm = match &app.downloads { + Some(dm) => dm, + None => { + // try start downloads now if we have a token + if cfg.access_token.is_some() { + app.downloads = Some(downloads::DownloadManager::start(cfg.clone())); + app.downloads.as_ref().unwrap() + } else { + app.status = "Not connected".into(); + return; + } + } + }; + + let jf = match core::jellyfin::JellyfinClient::from_config(&cfg) { + Ok(c) => c, + Err(e) => { app.status = format!("downloads: {e}"); return; } + }; + + match downloads::expand_for_enqueue(&jf, user_id, &it) { + Ok(items) => { + let n = items.len(); + dm.send(downloads::DownloadCommand::Enqueue(items)); + app.status = format!("queued {n}"); + } + Err(e) => app.status = format!("queue failed: {e}"), + } + return; + } + KeyCode::Char('q') => app.should_exit = true, //Enter code Block @@ -1358,6 +1478,8 @@ fn ui(frame: &mut Frame, app: &App) { Paragraph::new(line).block(Block::bordered().merge_borders(MergeStrategy::Exact)), panes.left_output, ); + } else if app.downloads_focus || app.downloads.as_ref().map(|d| d.state.lock().map(|s| !s.queue.is_empty()).unwrap_or(false)).unwrap_or(false) { + render_downloads_panel(frame, app, panes.left_output); } else if app.mpv.is_some() { // mpv panel takes over the whole bottom-left area render_mpv_panel(frame, app, panes.left_output); @@ -1382,7 +1504,10 @@ fn ui(frame: &mut Frame, app: &App) { .unwrap_or("-"); let right_text = format!( - "{connected}\n\np/P: play/autoplay\nr/R: resume/autoplay\nq: quit\n/help: more help" + "{connected}\n\np/P: play/autoplay +r/R: resume/autoplay +d: download / cancel +D: focus downloads\nq: quit\n/help: more help" ); frame.render_widget( @@ -2208,6 +2333,89 @@ fn mpv_progress(st: &MpvState) -> (u16, String) { (p_u16, format!("{cur} / {dur} ({pct:0.1}%)")) } +fn render_downloads_panel(frame: &mut Frame, app: &App, area: Rect) { + // Build rows + let mut rows: Vec = Vec::new(); + + if let Some(dm) = &app.downloads { + match dm.state.lock() { + Ok(st) => { + if st.queue.is_empty() { + rows.push(ListItem::new("(empty)")); + } else { + // Fit to available height inside borders + let max_rows = area.height.saturating_sub(2) as usize; + + for (i, it) in st.queue.iter().enumerate().take(max_rows) { + let status = match &it.status { + DownloadStatus::Queued => "queued", + DownloadStatus::Downloading => "downloading", + DownloadStatus::Finalizing => "finalizing", + DownloadStatus::Completed => "done", + DownloadStatus::Cancelled => "cancelled", + DownloadStatus::Failed(_) => "failed", + }; + + let active_prefix = if i == 0 { "▶ " } else { " " }; + + let prog = if matches!(it.status, DownloadStatus::Downloading) { + format!(" {}B", it.bytes_downloaded) + } else { + String::new() + }; + + rows.push(ListItem::new(format!( + "{active_prefix}{status}: {}{prog}", + it.title + ))); + } + + // Optional footer message (only if we still have room) + if let Some(msg) = &st.last_message { + if rows.len() < max_rows { + rows.push(ListItem::new(format!("— {msg}"))); + } + } + } + } + Err(_) => rows.push(ListItem::new("(downloads lock poisoned)")), + } + } else { + rows.push(ListItem::new("(downloads disabled)")); + } + + // Create the list widget + let title = if app.downloads_focus { + "Downloads (focus)" + } else { + "Downloads" + }; + + let list = List::new(rows) + .block(Block::bordered().title(title).merge_borders(MergeStrategy::Exact)) + .highlight_symbol(">> ") + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); + + // THIS is the missing piece: selection state for the list + let mut state = ListState::default(); + + // clamp selection so it never goes out of bounds + let len = app + .downloads + .as_ref() + .and_then(|dm| dm.state.lock().ok().map(|st| st.queue.len())) + .unwrap_or(0); + + if len == 0 { + state.select(None); + } else { + let sel = app.downloads_selected.min(len.saturating_sub(1)); + state.select(Some(sel)); + } + + frame.render_stateful_widget(list, area, &mut state); +} + fn render_mpv_panel(frame: &mut Frame, app: &App, area: Rect) { // Make the bar exactly 1 row and don't depend on bordered blocks. let [c0, c1, c2, c3, c4, c5] = Layout::vertical([