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