almost downloading complete
This commit is contained in:
706
src/core/downloads.rs
Normal file
706
src/core/downloads.rs
Normal file
@@ -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<i32>, episode: Option<i32> },
|
||||||
|
Track { artist: Option<String>, album: Option<String>, track_no: Option<i32> },
|
||||||
|
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<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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)
|
||||||
|
Shutdown,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct DownloadState {
|
||||||
|
pub queue: VecDeque<DownloadItem>,
|
||||||
|
pub active_pid: Option<u32>,
|
||||||
|
pub last_message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DownloadManager {
|
||||||
|
tx: mpsc::Sender<DownloadCommand>,
|
||||||
|
pub state: Arc<Mutex<DownloadState>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DownloadManager {
|
||||||
|
pub fn start(cfg: Config) -> Self {
|
||||||
|
let state = Arc::new(Mutex::new(DownloadState::default()));
|
||||||
|
let (tx, rx) = mpsc::channel::<DownloadCommand>();
|
||||||
|
|
||||||
|
// 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<PathBuf, String> {
|
||||||
|
let dir = paths::app_config_dir().ok_or_else(|| "no config dir".to_string())?;
|
||||||
|
Ok(dir.join("downloads"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn temp_root() -> Result<PathBuf, String> {
|
||||||
|
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<Vec<DownloadItem>, 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<Vec<JfItem>, 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<Vec<JfItem>, 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<DownloadItem, String> {
|
||||||
|
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<DownloadItem, String> {
|
||||||
|
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<DownloadItem, String> {
|
||||||
|
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<DownloadItem, String> {
|
||||||
|
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<PathBuf, String> {
|
||||||
|
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<PathBuf, String> {
|
||||||
|
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<Mutex<DownloadState>>, rx: mpsc::Receiver<DownloadCommand>) {
|
||||||
|
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<Child> = 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<PathBuf, String> {
|
||||||
|
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<Mutex<DownloadState>>,
|
||||||
|
) -> Result<Child, String> {
|
||||||
|
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<Mutex<DownloadState>>) {
|
||||||
|
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<Mutex<DownloadState>>) -> 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(())
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod jellyfin;
|
pub mod jellyfin;
|
||||||
pub mod paths;
|
pub mod paths;
|
||||||
|
pub mod downloads;
|
||||||
@@ -32,4 +32,13 @@ pub fn app_cache_dir() -> Option<PathBuf> {
|
|||||||
|
|
||||||
pub fn posters_cache_dir() -> Option<PathBuf> {
|
pub fn posters_cache_dir() -> Option<PathBuf> {
|
||||||
app_cache_dir().map(|d| d.join("posters"))
|
app_cache_dir().map(|d| d.join("posters"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn downloads_dir() -> Option<PathBuf> {
|
||||||
|
app_config_dir().map(|d| d.join("downloads"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn temp_downloads_dir() -> Option<PathBuf> {
|
||||||
|
app_config_dir().map(|d| d.join("temp"))
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
214
src/main.rs
214
src/main.rs
@@ -16,6 +16,9 @@ use std::os::unix::net::UnixStream;
|
|||||||
|
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::core::downloads;
|
||||||
|
use crate::core::downloads::{DownloadCommand, DownloadManager, DownloadStatus};
|
||||||
|
|
||||||
use crossterm::{
|
use crossterm::{
|
||||||
event::{self, Event, KeyCode, KeyEventKind},
|
event::{self, Event, KeyCode, KeyEventKind},
|
||||||
execute,
|
execute,
|
||||||
@@ -26,6 +29,7 @@ use ratatui::{
|
|||||||
symbols::merge::MergeStrategy,
|
symbols::merge::MergeStrategy,
|
||||||
widgets::{Block, List, ListItem, ListState, Paragraph, Wrap, Gauge},
|
widgets::{Block, List, ListItem, ListState, Paragraph, Wrap, Gauge},
|
||||||
style::{Modifier, Style},
|
style::{Modifier, Style},
|
||||||
|
text::Line,
|
||||||
Frame, Terminal,
|
Frame, Terminal,
|
||||||
};
|
};
|
||||||
use ratatui::backend::CrosstermBackend;
|
use ratatui::backend::CrosstermBackend;
|
||||||
@@ -175,6 +179,9 @@ struct App {
|
|||||||
mpv_state_rx: Option<mpsc::Receiver<MpvState>>,
|
mpv_state_rx: Option<mpsc::Receiver<MpvState>>,
|
||||||
debug_mpv: bool,
|
debug_mpv: bool,
|
||||||
mpv_now_playing: Option<String>,
|
mpv_now_playing: Option<String>,
|
||||||
|
downloads: Option<downloads::DownloadManager>,
|
||||||
|
downloads_focus: bool,
|
||||||
|
downloads_selected: usize,
|
||||||
|
|
||||||
autoplay: bool,
|
autoplay: bool,
|
||||||
autoplay_view_depth: usize,
|
autoplay_view_depth: usize,
|
||||||
@@ -246,6 +253,9 @@ impl Default for App {
|
|||||||
autoplay_next_index: None,
|
autoplay_next_index: None,
|
||||||
autoplay_eof_latched: false,
|
autoplay_eof_latched: false,
|
||||||
autoplay_prev_time_pos: None,
|
autoplay_prev_time_pos: None,
|
||||||
|
downloads: None,
|
||||||
|
downloads_focus: false,
|
||||||
|
downloads_selected: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -335,6 +345,11 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::Result<
|
|||||||
last_tick = Instant::now();
|
last_tick = Instant::now();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(dm) = &app.downloads {
|
||||||
|
dm.send(downloads::DownloadCommand::Shutdown);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -502,14 +517,20 @@ fn handle_key(app: &mut App, code: KeyCode) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if matches!(code, KeyCode::Char('D')) {
|
if matches!(code, KeyCode::Char('D')) {
|
||||||
app.debug_mpv = !app.debug_mpv;
|
app.downloads_focus = !app.downloads_focus;
|
||||||
app.status = format!("mpv debug: {}", if app.debug_mpv { "ON" } else { "OFF" });
|
app.downloads_selected = 0;
|
||||||
|
app.status = if app.downloads_focus { "downloads: focus".into() } else { "downloads: unfocus".into() };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// mpv controls (only when not typing into search/login)
|
// mpv controls (only when not typing into search/login)
|
||||||
if app.search.is_none() && !matches!(app.current_view(), View::Login) {
|
if app.search.is_none() && !matches!(app.current_view(), View::Login) {
|
||||||
match code {
|
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') => {
|
KeyCode::Char('a') => {
|
||||||
if let Some(mpv) = &app.mpv {
|
if let Some(mpv) = &app.mpv {
|
||||||
if let Err(e) = mpv_cycle_audio(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 {
|
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,
|
KeyCode::Char('q') => app.should_exit = true,
|
||||||
|
|
||||||
//Enter code Block
|
//Enter code Block
|
||||||
@@ -1358,6 +1478,8 @@ fn ui(frame: &mut Frame, app: &App) {
|
|||||||
Paragraph::new(line).block(Block::bordered().merge_borders(MergeStrategy::Exact)),
|
Paragraph::new(line).block(Block::bordered().merge_borders(MergeStrategy::Exact)),
|
||||||
panes.left_output,
|
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() {
|
} else if app.mpv.is_some() {
|
||||||
// mpv panel takes over the whole bottom-left area
|
// mpv panel takes over the whole bottom-left area
|
||||||
render_mpv_panel(frame, app, panes.left_output);
|
render_mpv_panel(frame, app, panes.left_output);
|
||||||
@@ -1382,7 +1504,10 @@ fn ui(frame: &mut Frame, app: &App) {
|
|||||||
.unwrap_or("-");
|
.unwrap_or("-");
|
||||||
|
|
||||||
let right_text = format!(
|
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(
|
frame.render_widget(
|
||||||
@@ -2208,6 +2333,89 @@ fn mpv_progress(st: &MpvState) -> (u16, String) {
|
|||||||
(p_u16, format!("{cur} / {dur} ({pct:0.1}%)"))
|
(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<ListItem> = 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) {
|
fn render_mpv_panel(frame: &mut Frame, app: &App, area: Rect) {
|
||||||
// Make the bar exactly 1 row and don't depend on bordered blocks.
|
// Make the bar exactly 1 row and don't depend on bordered blocks.
|
||||||
let [c0, c1, c2, c3, c4, c5] = Layout::vertical([
|
let [c0, c1, c2, c3, c4, c5] = Layout::vertical([
|
||||||
|
|||||||
Reference in New Issue
Block a user