//! 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>>, } 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>> { 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, pub mime_type: Option, pub status: String, pub progress: f64, pub bytes_downloaded: i64, pub queued_at: String, pub started_at: Option, pub completed_at: Option, pub error_message: Option, pub retry_count: i32, pub priority: i32, // Item metadata for display (audio) pub item_name: Option, pub artist_name: Option, pub album_name: Option, // Video-specific metadata pub series_name: Option, pub season_name: Option, pub episode_number: Option, pub season_number: Option, pub quality_preset: Option, 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\"")); } }