Files
jellytau/src-tauri/src/download/mod.rs
T
dtourolle a5535f2941 fix(downloads): stop libraries mixing, make pause/resume real, reap partials, end bitrate corruption
Four defects behind "downloads still flaky", each with its own cause.

Libraries mixed their media (DR-167). Cached items carry no link back to their
library — library_id and parent_id are NULL on every row — so the library branch
of get_downloaded_items matched `EXISTS (SELECT 1 FROM libraries WHERE id = ?)`,
which asserts only that the library exists and never constrains the item to it.
Opening any downloaded library listed every downloaded top-level item on the
server: films under Music, albums under TV. The query deciding which libraries
appear already had the right rule, so the two disagreed about the same question;
that collection_type <-> item_type mapping is now one constant used by both.

Pause and resume did nothing (DR-168). pause_download wrote status = 'paused'
and stopped there — no cancellation existed anywhere in the download stack, so
the streaming task ran on and overwrote the row with completed/failed when it
finished. The row flicked to "paused" and undid itself. resume_download had the
mirror defect: it flipped the row to 'pending' without pumping, and the pump is
not a poller, so a resumed download sat until some unrelated event pumped the
queue. Adds a per-download stop flag the worker reads between chunks and on
retry, returning Stopped — not retryable, not recorded as a failure, and the
.part file is kept because that is what the resume continues from. Registering
returns a fresh flag so a resumed download does not inherit the pause that
stopped it. Cancel and clear_stale_downloads signal it too, so neither deletes a
file still being written.

Partial files were never reaped (DR-169). The worker named its sidecar with
with_extension("part"), which replaces: movie.mp4 became movie.part. Every
cleanup path deleted "{file_path}.part" — movie.mp4.part. They never matched, so
the partial of every cancelled or failed download stayed on disk forever,
invisible to disk-usage totals because no row pointed at it. One partial_path
helper now serves the writer and the cleaners.

Bitrate downloads corrupted themselves (DR-170). Only `original` asks for
Static=true; every other rung requests a transcode, which Jellyfin serves
chunked with no Content-Length and cannot byte-seek — it ignores Range and
answers 200 with the whole stream, not 206 with the tail. The worker sent the
header whenever a .part existed and appended the body regardless, so each retry
concatenated another full copy onto what was on disk. The file grew past its
real size and would not play, which is why bitrate downloads stayed broken after
the videoBitRate casing fix corrected the request. resume_offset now lets the
response decide: append only on 206, otherwise truncate and take it from the top.

docs/requirements.md also carries DR-171/UT-166, written by a parallel session
working in the same tree; its code lands separately.
2026-08-15 23:52:02 +02:00

213 lines
6.9 KiB
Rust

//! Download manager for offline media support
//!
//! This module handles downloading media from Jellyfin servers with:
//! - Priority-based queue management
//! - Progress tracking and event emission
//! - Retry logic with exponential backoff
//! - Resume support via HTTP Range requests
pub mod cache;
pub mod events;
pub mod network;
pub mod stop;
pub mod worker;
use crate::utils::lock::MutexSafe;
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
pub use worker::DownloadWorker;
/// Download manager coordinating downloads across workers
pub struct DownloadManager {
/// Maximum concurrent downloads
max_concurrent: usize,
/// Currently active download IDs
active_downloads: Arc<Mutex<HashSet<i64>>>,
}
impl DownloadManager {
/// Create a new download manager
pub fn new(_media_dir: PathBuf) -> Self {
Self {
max_concurrent: 3,
active_downloads: Arc::new(Mutex::new(HashSet::new())),
}
}
/// Get the maximum concurrent downloads
pub fn max_concurrent(&self) -> usize {
self.max_concurrent
}
/// Set the maximum concurrent downloads
pub fn set_max_concurrent(&mut self, max: usize) {
self.max_concurrent = max.max(1); // At least 1
}
/// Check if a new download can be started based on concurrent limit
pub fn can_start_download(&self) -> bool {
let active = self.active_downloads.lock_safe();
active.len() < self.max_concurrent
}
/// Get the number of currently active downloads
pub fn active_count(&self) -> usize {
self.active_downloads.lock_safe().len()
}
/// Register a download as active
pub fn register_download(&self, download_id: i64) -> bool {
let mut active = self.active_downloads.lock_safe();
if active.len() >= self.max_concurrent {
return false;
}
active.insert(download_id)
}
/// Unregister a download when it completes or fails
pub fn unregister_download(&self, download_id: i64) {
let mut active = self.active_downloads.lock_safe();
active.remove(&download_id);
}
/// Get a clone of the active downloads set (for internal use)
pub fn get_active_downloads(&self) -> Arc<Mutex<HashSet<i64>>> {
self.active_downloads.clone()
}
}
/// Information about a download
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DownloadInfo {
pub id: i64,
pub item_id: String,
pub user_id: String,
pub file_path: String,
pub file_size: Option<i64>,
pub mime_type: Option<String>,
pub status: String,
pub progress: f64,
pub bytes_downloaded: i64,
pub queued_at: String,
pub started_at: Option<String>,
pub completed_at: Option<String>,
pub error_message: Option<String>,
pub retry_count: i32,
pub priority: i32,
// Item metadata for display (audio)
pub item_name: Option<String>,
pub artist_name: Option<String>,
pub album_name: Option<String>,
// Video-specific metadata
pub series_name: Option<String>,
pub season_name: Option<String>,
pub episode_number: Option<i32>,
pub season_number: Option<i32>,
pub quality_preset: Option<String>,
pub media_type: String,
// Download source tracking
pub download_source: String, // 'user' or 'auto'
}
/// Download task for workers
#[derive(Debug, Clone)]
pub struct DownloadTask {
pub url: String,
pub target_path: PathBuf,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_download_manager_set_max_concurrent() {
let media_dir = PathBuf::from("/tmp/jellytau/media");
let mut manager = DownloadManager::new(media_dir);
manager.set_max_concurrent(5);
assert_eq!(manager.max_concurrent(), 5);
// Should clamp to minimum of 1
manager.set_max_concurrent(0);
assert_eq!(manager.max_concurrent(), 1);
}
#[test]
fn test_download_info_serialization() {
let info = DownloadInfo {
id: 1,
item_id: "test123".to_string(),
user_id: "user1".to_string(),
file_path: "/path/to/file.mp3".to_string(),
file_size: Some(1024000),
mime_type: Some("audio/mpeg".to_string()),
status: "downloading".to_string(),
progress: 0.5,
bytes_downloaded: 512000,
queued_at: "2024-01-01T00:00:00Z".to_string(),
started_at: Some("2024-01-01T00:01:00Z".to_string()),
completed_at: None,
error_message: None,
retry_count: 0,
priority: 0,
item_name: Some("Test Song".to_string()),
artist_name: Some("Test Artist".to_string()),
album_name: Some("Test Album".to_string()),
series_name: None,
season_name: None,
episode_number: None,
season_number: None,
quality_preset: Some("original".to_string()),
media_type: "audio".to_string(),
download_source: "user".to_string(),
};
let json = serde_json::to_string(&info).unwrap();
assert!(json.contains("\"status\":\"downloading\""));
assert!(json.contains("\"progress\":0.5"));
assert!(json.contains("\"itemName\":\"Test Song\""));
assert!(json.contains("\"mediaType\":\"audio\""));
}
#[test]
fn test_video_download_info_serialization() {
let info = DownloadInfo {
id: 2,
item_id: "episode123".to_string(),
user_id: "user1".to_string(),
file_path: "/path/to/ShowName/S01E01_Title.mp4".to_string(),
file_size: Some(1024000000),
mime_type: Some("video/mp4".to_string()),
status: "completed".to_string(),
progress: 1.0,
bytes_downloaded: 1024000000,
queued_at: "2024-01-01T00:00:00Z".to_string(),
started_at: Some("2024-01-01T00:01:00Z".to_string()),
completed_at: Some("2024-01-01T01:00:00Z".to_string()),
error_message: None,
retry_count: 0,
priority: 100,
item_name: Some("Episode Title".to_string()),
artist_name: None,
album_name: None,
series_name: Some("Show Name".to_string()),
season_name: Some("Season 1".to_string()),
episode_number: Some(1),
season_number: Some(1),
quality_preset: Some("high".to_string()),
media_type: "video".to_string(),
download_source: "auto".to_string(),
};
let json = serde_json::to_string(&info).unwrap();
assert!(json.contains("\"mediaType\":\"video\""));
assert!(json.contains("\"seriesName\":\"Show Name\""));
assert!(json.contains("\"episodeNumber\":1"));
assert!(json.contains("\"qualityPreset\":\"high\""));
}
}