First working POC
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
//! Smart caching engine for predictive downloads
|
||||
|
||||
use log::{debug, info};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||
|
||||
/// Smart caching configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CacheConfig {
|
||||
/// Enable queue pre-caching
|
||||
pub queue_precache_enabled: bool,
|
||||
/// Number of tracks to pre-cache from queue
|
||||
pub queue_precache_count: usize,
|
||||
/// Enable album affinity detection
|
||||
pub album_affinity_enabled: bool,
|
||||
/// Threshold for album affinity (tracks played before caching)
|
||||
pub album_affinity_threshold: usize,
|
||||
/// Storage limit in bytes (0 = unlimited)
|
||||
pub storage_limit: u64,
|
||||
/// Only cache on WiFi
|
||||
pub wifi_only: bool,
|
||||
}
|
||||
|
||||
impl Default for CacheConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
queue_precache_enabled: true,
|
||||
queue_precache_count: 3, // Preload next 3 tracks by default
|
||||
album_affinity_enabled: true,
|
||||
album_affinity_threshold: 3,
|
||||
storage_limit: 10 * 1024 * 1024 * 1024, // 10GB
|
||||
wifi_only: false, // Allow preloading on any connection by default
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Smart caching engine
|
||||
#[derive(Clone)]
|
||||
pub struct SmartCache {
|
||||
config: Arc<Mutex<CacheConfig>>,
|
||||
/// Track recently played items per album
|
||||
album_play_history: Arc<Mutex<HashMap<String, Vec<String>>>>,
|
||||
}
|
||||
|
||||
impl SmartCache {
|
||||
pub fn new(config: CacheConfig) -> Self {
|
||||
Self {
|
||||
config: Arc::new(Mutex::new(config)),
|
||||
album_play_history: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Update configuration
|
||||
pub fn update_config(&self, config: CacheConfig) {
|
||||
if let Ok(mut cfg) = self.config.lock() {
|
||||
*cfg = config;
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if should pre-cache queue items
|
||||
pub fn should_precache_queue(&self) -> bool {
|
||||
self.config
|
||||
.lock()
|
||||
.map(|cfg| cfg.queue_precache_enabled && !cfg.wifi_only)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Get number of queue items to pre-cache
|
||||
pub fn queue_precache_count(&self) -> usize {
|
||||
self.config
|
||||
.lock()
|
||||
.map(|cfg| cfg.queue_precache_count)
|
||||
.unwrap_or(5)
|
||||
}
|
||||
|
||||
/// Track that an item was played
|
||||
pub fn track_play(&self, item_id: &str, album_id: Option<&str>) {
|
||||
if let Some(album) = album_id {
|
||||
if let Ok(mut history) = self.album_play_history.lock() {
|
||||
let plays = history.entry(album.to_string()).or_insert_with(Vec::new);
|
||||
if !plays.contains(&item_id.to_string()) {
|
||||
plays.push(item_id.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if album affinity threshold reached for caching
|
||||
pub fn should_cache_album(&self, album_id: &str) -> Option<bool> {
|
||||
let config = self.config.lock().ok()?;
|
||||
if !config.album_affinity_enabled {
|
||||
return Some(false);
|
||||
}
|
||||
|
||||
let history = self.album_play_history.lock().ok()?;
|
||||
let play_count = history.get(album_id).map(|v| v.len()).unwrap_or(0);
|
||||
|
||||
Some(play_count >= config.album_affinity_threshold)
|
||||
}
|
||||
|
||||
/// Get configuration
|
||||
pub fn get_config(&self) -> Option<CacheConfig> {
|
||||
self.config.lock().ok().map(|cfg| cfg.clone())
|
||||
}
|
||||
|
||||
/// Get all tracked albums with their play counts
|
||||
/// Returns Vec<(album_id, unique_tracks_played)>
|
||||
pub fn get_album_play_history(&self) -> Vec<(String, usize)> {
|
||||
self.album_play_history
|
||||
.lock()
|
||||
.ok()
|
||||
.map(|history| {
|
||||
history
|
||||
.iter()
|
||||
.map(|(album_id, tracks)| (album_id.clone(), tracks.len()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
// ============= Async versions for DatabaseService =============
|
||||
|
||||
/// Get total download size for a user (async version)
|
||||
pub async fn get_total_download_size_async<S: DatabaseService>(
|
||||
&self,
|
||||
db_service: &Arc<S>,
|
||||
user_id: &str,
|
||||
) -> Result<u64, String> {
|
||||
let query = Query::with_params(
|
||||
"SELECT COALESCE(SUM(file_size), 0) FROM downloads
|
||||
WHERE user_id = ? AND status = 'completed'",
|
||||
vec![QueryParam::String(user_id.to_string())],
|
||||
);
|
||||
|
||||
let size: i64 = db_service
|
||||
.query_one(query, |row| row.get(0))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(size as u64)
|
||||
}
|
||||
|
||||
/// Check if storage limit allows download (async version)
|
||||
pub async fn can_download_async<S: DatabaseService>(
|
||||
&self,
|
||||
db_service: &Arc<S>,
|
||||
user_id: &str,
|
||||
new_size: u64,
|
||||
) -> bool {
|
||||
// Clone config to avoid holding lock across await
|
||||
let storage_limit = {
|
||||
match self.config.lock() {
|
||||
Ok(cfg) => cfg.storage_limit,
|
||||
Err(_) => return true,
|
||||
}
|
||||
};
|
||||
|
||||
if storage_limit == 0 {
|
||||
return true; // Unlimited
|
||||
}
|
||||
|
||||
let current_size = self
|
||||
.get_total_download_size_async(db_service, user_id)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
current_size + new_size <= storage_limit
|
||||
}
|
||||
|
||||
/// Evict least recently used items to make space (async version)
|
||||
pub async fn evict_lru_async<S: DatabaseService>(
|
||||
&self,
|
||||
db_service: &Arc<S>,
|
||||
user_id: &str,
|
||||
space_needed: u64,
|
||||
) -> Result<u64, String> {
|
||||
let current_size = self
|
||||
.get_total_download_size_async(db_service, user_id)
|
||||
.await?;
|
||||
|
||||
// Get limit without holding lock across await
|
||||
let limit = {
|
||||
let config = self.config.lock().map_err(|e| e.to_string())?;
|
||||
config.storage_limit
|
||||
};
|
||||
|
||||
if limit == 0 || current_size + space_needed <= limit {
|
||||
return Ok(0); // No eviction needed
|
||||
}
|
||||
|
||||
let to_free = (current_size + space_needed) - limit;
|
||||
let mut freed: u64 = 0;
|
||||
|
||||
// Get downloads ordered by last access (oldest first)
|
||||
let query = Query::with_params(
|
||||
"SELECT id, file_size, file_path FROM downloads
|
||||
WHERE user_id = ? AND status = 'completed'
|
||||
ORDER BY completed_at ASC",
|
||||
vec![QueryParam::String(user_id.to_string())],
|
||||
);
|
||||
|
||||
let downloads: Vec<(i64, i64, String)> = db_service
|
||||
.query_many(query, |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
for (id, size, file_path) in downloads {
|
||||
if freed >= to_free {
|
||||
break;
|
||||
}
|
||||
|
||||
// Delete file
|
||||
let _ = std::fs::remove_file(&file_path);
|
||||
debug!("[SmartCache] Evicted: {} ({} bytes)", file_path, size);
|
||||
|
||||
// Delete from database
|
||||
let delete_query = Query::with_params(
|
||||
"DELETE FROM downloads WHERE id = ?",
|
||||
vec![QueryParam::Int64(id)],
|
||||
);
|
||||
db_service.execute(delete_query).await.map_err(|e| e.to_string())?;
|
||||
|
||||
freed += size as u64;
|
||||
}
|
||||
|
||||
info!("[SmartCache] Freed {} bytes ({} needed)", freed, to_free);
|
||||
Ok(freed)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_default_config() {
|
||||
let config = CacheConfig::default();
|
||||
assert_eq!(config.queue_precache_count, 3);
|
||||
assert_eq!(config.album_affinity_threshold, 3);
|
||||
assert!(!config.wifi_only); // wifi_only is false by default for easier preloading
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_album_affinity_tracking() {
|
||||
let cache = SmartCache::new(CacheConfig::default());
|
||||
|
||||
// Track plays from same album
|
||||
cache.track_play("track1", Some("album1"));
|
||||
cache.track_play("track2", Some("album1"));
|
||||
|
||||
// Below threshold
|
||||
assert!(!cache.should_cache_album("album1").unwrap_or(false));
|
||||
|
||||
cache.track_play("track3", Some("album1"));
|
||||
|
||||
// At threshold - should cache
|
||||
assert!(cache.should_cache_album("album1").unwrap_or(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_queue_precache_config() {
|
||||
let mut config = CacheConfig::default();
|
||||
config.queue_precache_enabled = false;
|
||||
|
||||
let cache = SmartCache::new(config);
|
||||
assert!(!cache.should_precache_queue());
|
||||
|
||||
let mut new_config = CacheConfig::default();
|
||||
new_config.wifi_only = false;
|
||||
cache.update_config(new_config);
|
||||
|
||||
assert!(cache.should_precache_queue());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_storage_limit_check() {
|
||||
use crate::storage::db_service::RusqliteService;
|
||||
use rusqlite::Connection;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute(
|
||||
"CREATE TABLE downloads (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_id TEXT,
|
||||
status TEXT,
|
||||
file_size INTEGER
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let conn_arc = Arc::new(Mutex::new(conn));
|
||||
let db_service = Arc::new(RusqliteService::new(conn_arc.clone()));
|
||||
|
||||
let config = CacheConfig {
|
||||
storage_limit: 1000,
|
||||
..Default::default()
|
||||
};
|
||||
let cache = SmartCache::new(config);
|
||||
|
||||
// Empty - can download
|
||||
assert!(cache.can_download_async(&db_service, "user1", 500).await);
|
||||
|
||||
// Add some downloads
|
||||
{
|
||||
let conn_guard = conn_arc.lock().unwrap();
|
||||
conn_guard.execute(
|
||||
"INSERT INTO downloads (user_id, status, file_size) VALUES ('user1', 'completed', 600)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Total would be 1100 > 1000
|
||||
assert!(!cache.can_download_async(&db_service, "user1", 500).await);
|
||||
|
||||
// Smaller size fits
|
||||
assert!(cache.can_download_async(&db_service, "user1", 300).await);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
//! Download events for progress tracking and status updates
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Events emitted during download operations
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum DownloadEvent {
|
||||
/// Download has been queued
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Queued {
|
||||
download_id: i64,
|
||||
item_id: String,
|
||||
},
|
||||
/// Download has started
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Started {
|
||||
download_id: i64,
|
||||
item_id: String,
|
||||
},
|
||||
/// Download progress update
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Progress {
|
||||
download_id: i64,
|
||||
item_id: String,
|
||||
bytes_downloaded: i64,
|
||||
total_bytes: Option<i64>,
|
||||
progress: f64, // 0.0 to 1.0
|
||||
},
|
||||
/// Download completed successfully
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Completed {
|
||||
download_id: i64,
|
||||
item_id: String,
|
||||
file_path: String,
|
||||
},
|
||||
/// Download failed with error
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Failed {
|
||||
download_id: i64,
|
||||
item_id: String,
|
||||
error: String,
|
||||
},
|
||||
/// Download paused
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Paused {
|
||||
download_id: i64,
|
||||
item_id: String,
|
||||
},
|
||||
/// Download cancelled
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Cancelled {
|
||||
download_id: i64,
|
||||
item_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_download_event_serialization_roundtrip() {
|
||||
let event = DownloadEvent::Progress {
|
||||
download_id: 1,
|
||||
item_id: "test123".to_string(),
|
||||
bytes_downloaded: 1024,
|
||||
total_bytes: Some(2048),
|
||||
progress: 0.5,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
let deserialized: DownloadEvent = serde_json::from_str(&json).unwrap();
|
||||
|
||||
match deserialized {
|
||||
DownloadEvent::Progress {
|
||||
download_id,
|
||||
item_id,
|
||||
progress,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(download_id, 1);
|
||||
assert_eq!(item_id, "test123");
|
||||
assert_eq!(progress, 0.5);
|
||||
}
|
||||
_ => panic!("Wrong variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_download_event_completed() {
|
||||
let event = DownloadEvent::Completed {
|
||||
download_id: 42,
|
||||
item_id: "song456".to_string(),
|
||||
file_path: "/path/to/file.mp3".to_string(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
assert!(json.contains("\"type\":\"completed\""));
|
||||
// Verify camelCase field names
|
||||
assert!(json.contains("\"downloadId\":42"), "Expected downloadId (camelCase), got: {}", json);
|
||||
assert!(json.contains("\"itemId\":\"song456\""), "Expected itemId (camelCase), got: {}", json);
|
||||
assert!(json.contains("\"filePath\":"), "Expected filePath (camelCase), got: {}", json);
|
||||
|
||||
// Verify roundtrip
|
||||
let deserialized: DownloadEvent = serde_json::from_str(&json).unwrap();
|
||||
match deserialized {
|
||||
DownloadEvent::Completed { file_path, .. } => {
|
||||
assert_eq!(file_path, "/path/to/file.mp3");
|
||||
}
|
||||
_ => panic!("Wrong variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_download_event_failed() {
|
||||
let event = DownloadEvent::Failed {
|
||||
download_id: 10,
|
||||
item_id: "failed_item".to_string(),
|
||||
error: "Network timeout".to_string(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
assert!(json.contains("\"type\":\"failed\""));
|
||||
|
||||
// Verify roundtrip
|
||||
let deserialized: DownloadEvent = serde_json::from_str(&json).unwrap();
|
||||
match deserialized {
|
||||
DownloadEvent::Failed { error, .. } => {
|
||||
assert_eq!(error, "Network timeout");
|
||||
}
|
||||
_ => panic!("Wrong variant"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
//! 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 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().unwrap();
|
||||
active.len() < self.max_concurrent
|
||||
}
|
||||
|
||||
/// Get the number of currently active downloads
|
||||
pub fn active_count(&self) -> usize {
|
||||
self.active_downloads.lock().unwrap().len()
|
||||
}
|
||||
|
||||
/// Register a download as active
|
||||
pub fn register_download(&self, download_id: i64) -> bool {
|
||||
let mut active = self.active_downloads.lock().unwrap();
|
||||
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().unwrap();
|
||||
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\""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
//! Download worker for HTTP streaming with progress tracking and retry logic
|
||||
|
||||
use log::warn;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::StreamExt;
|
||||
use tokio::fs;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
use super::DownloadTask;
|
||||
|
||||
/// Download worker that handles individual download tasks
|
||||
pub struct DownloadWorker {
|
||||
/// HTTP client for downloads
|
||||
client: reqwest::Client,
|
||||
/// Maximum retry attempts
|
||||
max_retries: u32,
|
||||
}
|
||||
|
||||
impl DownloadWorker {
|
||||
pub fn new() -> Self {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(300)) // 5 minute timeout
|
||||
.build()
|
||||
.expect("Failed to create HTTP client");
|
||||
|
||||
Self {
|
||||
client,
|
||||
max_retries: 3,
|
||||
}
|
||||
}
|
||||
|
||||
/// Download a file with retry logic and progress tracking
|
||||
pub async fn download(
|
||||
&self,
|
||||
task: &DownloadTask,
|
||||
) -> Result<DownloadResult, DownloadError> {
|
||||
let mut retries = 0;
|
||||
|
||||
loop {
|
||||
match self.try_download(task).await {
|
||||
Ok(result) => return Ok(result),
|
||||
Err(e) if retries < self.max_retries && e.is_retryable() => {
|
||||
retries += 1;
|
||||
let delay = Self::exponential_backoff(retries);
|
||||
warn!(
|
||||
"Download failed (attempt {}/{}), retrying in {:?}: {}",
|
||||
retries, self.max_retries, delay, e
|
||||
);
|
||||
tokio::time::sleep(delay).await;
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt a single download
|
||||
async fn try_download(&self, task: &DownloadTask) -> Result<DownloadResult, DownloadError> {
|
||||
// Create parent directories
|
||||
if let Some(parent) = task.target_path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.await
|
||||
.map_err(|e| DownloadError::FileSystem(e.to_string()))?;
|
||||
}
|
||||
|
||||
// Check for partial download
|
||||
let temp_path = task.target_path.with_extension("part");
|
||||
let existing_bytes = if temp_path.exists() {
|
||||
fs::metadata(&temp_path)
|
||||
.await
|
||||
.map(|m| m.len())
|
||||
.unwrap_or(0)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
// Build HTTP request with Range header for resume support
|
||||
let mut request = self.client.get(&task.url);
|
||||
if existing_bytes > 0 {
|
||||
request = request.header("Range", format!("bytes={}-", existing_bytes));
|
||||
}
|
||||
|
||||
// Send request
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| DownloadError::Network(e.to_string()))?;
|
||||
|
||||
// Check status
|
||||
if !response.status().is_success() && response.status().as_u16() != 206 {
|
||||
return Err(DownloadError::Http(response.status().as_u16()));
|
||||
}
|
||||
|
||||
// Get content length
|
||||
let _total_bytes = response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_LENGTH)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
.map(|len| if existing_bytes > 0 { len + existing_bytes } else { len });
|
||||
|
||||
// Open file for appending
|
||||
let mut file = if existing_bytes > 0 {
|
||||
fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(&temp_path)
|
||||
.await
|
||||
} else {
|
||||
fs::File::create(&temp_path).await
|
||||
}
|
||||
.map_err(|e| DownloadError::FileSystem(e.to_string()))?;
|
||||
|
||||
// Stream download with progress tracking
|
||||
let mut downloaded = existing_bytes;
|
||||
let mut stream = response.bytes_stream();
|
||||
let mut last_progress_emit = std::time::Instant::now();
|
||||
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|e| DownloadError::Network(e.to_string()))?;
|
||||
|
||||
file.write_all(&chunk)
|
||||
.await
|
||||
.map_err(|e| DownloadError::FileSystem(e.to_string()))?;
|
||||
|
||||
downloaded += chunk.len() as u64;
|
||||
|
||||
// Emit progress every 500ms or every MB
|
||||
if last_progress_emit.elapsed() > Duration::from_millis(500)
|
||||
|| downloaded % (1024 * 1024) == 0
|
||||
{
|
||||
last_progress_emit = std::time::Instant::now();
|
||||
// Progress events will be emitted by the manager
|
||||
}
|
||||
}
|
||||
|
||||
file.sync_all()
|
||||
.await
|
||||
.map_err(|e| DownloadError::FileSystem(e.to_string()))?;
|
||||
|
||||
// Move from .part to final location
|
||||
fs::rename(&temp_path, &task.target_path)
|
||||
.await
|
||||
.map_err(|e| DownloadError::FileSystem(e.to_string()))?;
|
||||
|
||||
Ok(DownloadResult {
|
||||
bytes_downloaded: downloaded,
|
||||
})
|
||||
}
|
||||
|
||||
/// Calculate exponential backoff delay
|
||||
fn exponential_backoff(retry_count: u32) -> Duration {
|
||||
let base_delay = 5; // 5 seconds
|
||||
let delay_secs = base_delay * 3u64.pow(retry_count - 1); // 5s, 15s, 45s
|
||||
Duration::from_secs(delay_secs)
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a successful download
|
||||
#[derive(Debug)]
|
||||
pub struct DownloadResult {
|
||||
pub bytes_downloaded: u64,
|
||||
}
|
||||
|
||||
/// Download error types
|
||||
#[derive(Debug)]
|
||||
pub enum DownloadError {
|
||||
Network(String),
|
||||
Http(u16),
|
||||
FileSystem(String),
|
||||
}
|
||||
|
||||
impl DownloadError {
|
||||
/// Check if this error is retryable
|
||||
fn is_retryable(&self) -> bool {
|
||||
match self {
|
||||
DownloadError::Network(_) => true,
|
||||
DownloadError::Http(status) => *status >= 500, // Retry server errors
|
||||
DownloadError::FileSystem(_) => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DownloadError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
DownloadError::Network(msg) => write!(f, "Network error: {}", msg),
|
||||
DownloadError::Http(status) => write!(f, "HTTP error {}", status),
|
||||
DownloadError::FileSystem(msg) => write!(f, "File system error: {}", msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for DownloadError {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_exponential_backoff() {
|
||||
assert_eq!(DownloadWorker::exponential_backoff(1), Duration::from_secs(5));
|
||||
assert_eq!(DownloadWorker::exponential_backoff(2), Duration::from_secs(15));
|
||||
assert_eq!(DownloadWorker::exponential_backoff(3), Duration::from_secs(45));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_retryable() {
|
||||
assert!(DownloadError::Network("timeout".to_string()).is_retryable());
|
||||
assert!(DownloadError::Http(500).is_retryable());
|
||||
assert!(DownloadError::Http(503).is_retryable());
|
||||
assert!(!DownloadError::Http(404).is_retryable());
|
||||
assert!(!DownloadError::FileSystem("disk full".to_string()).is_retryable());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user