Skip to main content

#
jellytau_lib/download/
mod.rs

    1//! Download manager for offline media support
    2//!
    3//! This module handles downloading media from Jellyfin servers with:
    4//! - Priority-based queue management
    5//! - Progress tracking and event emission
    6//! - Retry logic with exponential backoff
    7//! - Resume support via HTTP Range requests
    8
    9pub mod cache;
    10pub mod events;
    11pub mod network;
    12pub mod stop;
    13pub mod worker;
    14
    15use crate::utils::lock::MutexSafe;
    16use std::collections::HashSet;
    17use std::path::PathBuf;
    18use std::sync::{Arc, Mutex};
    19
    20pub use worker::DownloadWorker;
    21
    22/// Download manager coordinating downloads across workers
    23pub struct DownloadManager {
    24    /// Maximum concurrent downloads
    25    max_concurrent: usize,
    26    /// Currently active download IDs
    27    active_downloads: Arc<Mutex<HashSet<i64>>>,
    28}
    29
    30impl DownloadManager {
    31    /// Create a new download manager
    32    pub fn new(_media_dir: PathBuf) -> Self {
    33        Self {
    34            max_concurrent: 3,
    35            active_downloads: Arc::new(Mutex::new(HashSet::new())),
    36        }
    37    }
    38
    39    /// Get the maximum concurrent downloads
    40    pub fn max_concurrent(&self) -> usize {
    41        self.max_concurrent
    42    }
    43
    44    /// Set the maximum concurrent downloads
    45    pub fn set_max_concurrent(&mut self, max: usize) {
    46        self.max_concurrent = max.max(1); // At least 1
    47    }
    48
    49    /// Check if a new download can be started based on concurrent limit
    50    pub fn can_start_download(&self) -> bool {
    51        let active = self.active_downloads.lock_safe();
    52        active.len() < self.max_concurrent
    53    }
    54
    55    /// Get the number of currently active downloads
    56    pub fn active_count(&self) -> usize {
    57        self.active_downloads.lock_safe().len()
    58    }
    59
    60    /// Register a download as active
    61    pub fn register_download(&self, download_id: i64) -> bool {
    62        let mut active = self.active_downloads.lock_safe();
    63        if active.len() >= self.max_concurrent {
    64            return false;
    65        }
    66        active.insert(download_id)
    67    }
    68
    69    /// Unregister a download when it completes or fails
    70    pub fn unregister_download(&self, download_id: i64) {
    71        let mut active = self.active_downloads.lock_safe();
    72        active.remove(&download_id);
    73    }
    74
    75    /// Get a clone of the active downloads set (for internal use)
    76    pub fn get_active_downloads(&self) -> Arc<Mutex<HashSet<i64>>> {
    77        self.active_downloads.clone()
    78    }
    79}
    80
    81/// Information about a download
    82#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
    83#[serde(rename_all = "camelCase")]
    84pub struct DownloadInfo {
    85    pub id: i64,
    86    pub item_id: String,
    87    pub user_id: String,
    88    pub file_path: String,
    89    pub file_size: Option<i64>,
    90    pub mime_type: Option<String>,
    91    pub status: String,
    92    pub progress: f64,
    93    pub bytes_downloaded: i64,
    94    pub queued_at: String,
    95    pub started_at: Option<String>,
    96    pub completed_at: Option<String>,
    97    pub error_message: Option<String>,
    98    pub retry_count: i32,
    99    pub priority: i32,
    100    // Item metadata for display (audio)
    101    pub item_name: Option<String>,
    102    pub artist_name: Option<String>,
    103    pub album_name: Option<String>,
    104    // Video-specific metadata
    105    pub series_name: Option<String>,
    106    pub season_name: Option<String>,
    107    pub episode_number: Option<i32>,
    108    pub season_number: Option<i32>,
    109    pub quality_preset: Option<String>,
    110    pub media_type: String,
    111    // Download source tracking
    112    pub download_source: String, // 'user' or 'auto'
    113}
    114
    115/// Download task for workers
    116#[derive(Debug, Clone)]
    117pub struct DownloadTask {
    118    pub url: String,
    119    pub target_path: PathBuf,
    120}
    121
    122#[cfg(test)]
    123mod tests {
    124    use super::*;
    125
    126    #[test]
    127    fn test_download_manager_set_max_concurrent() {
    128        let media_dir = PathBuf::from("/tmp/jellytau/media");
    129        let mut manager = DownloadManager::new(media_dir);
    130
    131        manager.set_max_concurrent(5);
    132        assert_eq!(manager.max_concurrent(), 5);
    133
    134        // Should clamp to minimum of 1
    135        manager.set_max_concurrent(0);
    136        assert_eq!(manager.max_concurrent(), 1);
    137    }
    138
    139    #[test]
    140    fn test_download_info_serialization() {
    141        let info = DownloadInfo {
    142            id: 1,
    143            item_id: "test123".to_string(),
    144            user_id: "user1".to_string(),
    145            file_path: "/path/to/file.mp3".to_string(),
    146            file_size: Some(1024000),
    147            mime_type: Some("audio/mpeg".to_string()),
    148            status: "downloading".to_string(),
    149            progress: 0.5,
    150            bytes_downloaded: 512000,
    151            queued_at: "2024-01-01T00:00:00Z".to_string(),
    152            started_at: Some("2024-01-01T00:01:00Z".to_string()),
    153            completed_at: None,
    154            error_message: None,
    155            retry_count: 0,
    156            priority: 0,
    157            item_name: Some("Test Song".to_string()),
    158            artist_name: Some("Test Artist".to_string()),
    159            album_name: Some("Test Album".to_string()),
    160            series_name: None,
    161            season_name: None,
    162            episode_number: None,
    163            season_number: None,
    164            quality_preset: Some("original".to_string()),
    165            media_type: "audio".to_string(),
    166            download_source: "user".to_string(),
    167        };
    168
    169        let json = serde_json::to_string(&info).unwrap();
    170        assert!(json.contains("\"status\":\"downloading\""));
    171        assert!(json.contains("\"progress\":0.5"));
    172        assert!(json.contains("\"itemName\":\"Test Song\""));
    173        assert!(json.contains("\"mediaType\":\"audio\""));
    174    }
    175
    176    #[test]
    177    fn test_video_download_info_serialization() {
    178        let info = DownloadInfo {
    179            id: 2,
    180            item_id: "episode123".to_string(),
    181            user_id: "user1".to_string(),
    182            file_path: "/path/to/ShowName/S01E01_Title.mp4".to_string(),
    183            file_size: Some(1024000000),
    184            mime_type: Some("video/mp4".to_string()),
    185            status: "completed".to_string(),
    186            progress: 1.0,
    187            bytes_downloaded: 1024000000,
    188            queued_at: "2024-01-01T00:00:00Z".to_string(),
    189            started_at: Some("2024-01-01T00:01:00Z".to_string()),
    190            completed_at: Some("2024-01-01T01:00:00Z".to_string()),
    191            error_message: None,
    192            retry_count: 0,
    193            priority: 100,
    194            item_name: Some("Episode Title".to_string()),
    195            artist_name: None,
    196            album_name: None,
    197            series_name: Some("Show Name".to_string()),
    198            season_name: Some("Season 1".to_string()),
    199            episode_number: Some(1),
    200            season_number: Some(1),
    201            quality_preset: Some("high".to_string()),
    202            media_type: "video".to_string(),
    203            download_source: "auto".to_string(),
    204        };
    205
    206        let json = serde_json::to_string(&info).unwrap();
    207        assert!(json.contains("\"mediaType\":\"video\""));
    208        assert!(json.contains("\"seriesName\":\"Show Name\""));
    209        assert!(json.contains("\"episodeNumber\":1"));
    210        assert!(json.contains("\"qualityPreset\":\"high\""));
    211    }
    212}