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}