Workstream A — poison-tolerant locking: - Add utils/lock.rs with MutexSafe/RwLockSafe extension traits that recover a poisoned std::sync lock instead of panicking, plus unit tests. - Replace all 153 .lock().unwrap() and 4 .read()/.write().unwrap() production sites with _safe variants across 14 files, eliminating the player crash-cascade class. Tokio async mutexes are unchanged. Workstream B — graceful backend init: - create_player_backend no longer panics when MPV/ExoPlayer fail to initialize; it falls back to NullBackend and emits a backend-init-failed event so the UI can show "playback unavailable" instead of the app crashing. Fatal DB-setup panics are kept. Workstream F — doc reconciliation: - Rewrite software-architecture.md's inaccurate "thin UI / ~800 lines" claims to reflect reality (~20.5k non-test frontend) and document the events+polling hybrid plus the new locking/backend-init behavior.
212 lines
6.8 KiB
Rust
212 lines
6.8 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 worker;
|
|
|
|
use crate::utils::lock::MutexSafe;
|
|
use std::path::PathBuf;
|
|
use std::collections::HashSet;
|
|
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(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::*;
|
|
use crate::player::MediaType;
|
|
|
|
#[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\""));
|
|
}
|
|
}
|