//! Tauri commands for download operations #[cfg(test)] use crate::utils::lock::MutexSafe; use std::path::PathBuf; use std::sync::{Arc, Mutex}; use tauri::{Manager, State}; use log::{debug, error, info, warn}; use crate::download::{DownloadInfo, DownloadManager}; use crate::storage::db_service::{DatabaseService, Query, QueryParam}; use super::{DatabaseWrapper, SmartCacheWrapper}; // Cohesive command clusters in their own submodules, re-exported so the command // names remain at `commands::download::*` (invoke_handler unchanged). mod pinning; mod smart_cache; pub use pinning::*; pub use smart_cache::*; /// Wrapper for DownloadManager to be used as Tauri state pub struct DownloadManagerWrapper(pub Mutex); /// Download statistics computed server-side #[allow(dead_code)] #[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct DownloadStats { pub total: usize, pub active_count: usize, pub queued_count: usize, pub completed_count: usize, pub failed_count: usize, pub paused_count: usize, } /// Enhanced response with pre-computed stats #[allow(dead_code)] #[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct DownloadsResponse { pub downloads: Vec, pub stats: DownloadStats, } /// Sanitize filename by removing invalid characters fn sanitize_filename(name: &str) -> String { name.chars() .map(|c| match c { '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', _ => c, }) .collect() } /// Request payload for download_item_and_start (bundled to stay within specta's /// 10-argument command limit). #[derive(Debug, specta::Type, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct DownloadItemAndStartRequest { pub item_id: String, pub user_id: String, pub stream_url: String, pub target_dir: String, pub item_name: Option, pub artist_name: Option, pub album_name: Option, } /// Request payload for download_item. #[derive(Debug, specta::Type, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct DownloadItemRequest { pub item_id: String, pub user_id: String, pub file_path: String, pub mime_type: Option, pub priority: Option, pub item_name: Option, pub artist_name: Option, pub album_name: Option, pub expected_size: Option, } /// Request payload for download_video. #[derive(Debug, specta::Type, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct DownloadVideoRequest { pub item_id: String, pub user_id: String, pub file_path: String, pub mime_type: Option, pub priority: Option, pub item_name: Option, pub quality_preset: Option, pub series_name: Option, pub season_name: Option, pub episode_number: Option, pub season_number: Option, } /// Queue and start a download in a single atomic operation /// This simplifies the frontend flow by combining multiple steps #[tauri::command] #[specta::specta] pub async fn download_item_and_start( db: State<'_, DatabaseWrapper>, smart_cache: State<'_, SmartCacheWrapper>, download_manager: State<'_, DownloadManagerWrapper>, app: tauri::AppHandle, request: DownloadItemAndStartRequest, ) -> Result { let DownloadItemAndStartRequest { item_id, user_id, stream_url, target_dir, item_name, artist_name, album_name, } = request; // Sanitize filename let safe_name = sanitize_filename(item_name.as_deref().unwrap_or(&item_id)); let file_path = format!("downloads/{}.mp3", safe_name); // Queue the download let download_id = download_item( db.clone(), smart_cache.clone(), DownloadItemRequest { item_id, user_id, file_path, mime_type: None, priority: None, item_name, artist_name, album_name, expected_size: None, }, ).await?; // Start the download immediately start_download( db, download_manager, app, download_id, stream_url, target_dir, ).await?; Ok(download_id) } /// Queue a media item for download #[tauri::command] #[specta::specta] pub async fn download_item( db: State<'_, DatabaseWrapper>, smart_cache: State<'_, SmartCacheWrapper>, request: DownloadItemRequest, ) -> Result { let DownloadItemRequest { item_id, user_id, file_path, mime_type, priority, item_name, artist_name, album_name, expected_size, } = request; let db_service = { let database = db.0.lock().map_err(|e| e.to_string())?; Arc::new(database.service()) }; // Check storage limit if size is known if let Some(size) = expected_size { // Clone Arc to avoid holding lock during async operations let cache_arc = { let guard = smart_cache.0.lock().map_err(|e| e.to_string())?; guard.clone() }; // Check if we have space let can_download = cache_arc.can_download_async(&db_service, &user_id, size as u64).await; if !can_download { warn!("Storage limit reached. Attempting to free space..."); // Try to evict LRU items to make space match cache_arc.evict_lru_async(&db_service, &user_id, size as u64).await { Ok(freed) if freed > 0 => { info!("Freed {} bytes, proceeding with download", freed); } Ok(_) => { let storage_limit = cache_arc.get_config().map(|c| c.storage_limit).unwrap_or(0); return Err(format!( "Storage limit reached ({} bytes). Unable to free enough space.", storage_limit )); } Err(e) => { error!("Failed to evict items: {}", e); return Err(format!("Storage limit reached. Eviction failed: {}", e)); } } } } // Insert or update download record with metadata let insert_query = Query::with_params( "INSERT INTO downloads (item_id, user_id, file_path, mime_type, status, priority, queued_at, item_name, artist_name, album_name) VALUES (?, ?, ?, ?, 'pending', ?, CURRENT_TIMESTAMP, ?, ?, ?) ON CONFLICT(item_id, user_id) DO UPDATE SET priority = excluded.priority, status = 'pending', queued_at = CURRENT_TIMESTAMP, item_name = COALESCE(excluded.item_name, downloads.item_name), artist_name = COALESCE(excluded.artist_name, downloads.artist_name), album_name = COALESCE(excluded.album_name, downloads.album_name)", vec![ QueryParam::String(item_id.clone()), QueryParam::String(user_id.clone()), QueryParam::String(file_path), mime_type.map(QueryParam::String).unwrap_or(QueryParam::Null), QueryParam::Int(priority.unwrap_or(0)), item_name.map(QueryParam::String).unwrap_or(QueryParam::Null), artist_name.map(QueryParam::String).unwrap_or(QueryParam::Null), album_name.map(QueryParam::String).unwrap_or(QueryParam::Null), ], ); db_service.execute(insert_query).await.map_err(|e| e.to_string())?; // Query for the download ID by unique constraint columns // NOTE: last_insert_rowid() doesn't work reliably with UPSERT - it only updates on INSERT, not UPDATE let id_query = Query::with_params( "SELECT id FROM downloads WHERE item_id = ? AND user_id = ?", vec![QueryParam::String(item_id), QueryParam::String(user_id)], ); let download_id: i64 = db_service .query_one(id_query, |row| row.get(0)) .await .map_err(|e| e.to_string())?; Ok(download_id) } /// Queue an entire album for download #[tauri::command] #[specta::specta] pub async fn download_album( db: State<'_, DatabaseWrapper>, album_id: String, user_id: String, base_path: String, ) -> Result, String> { let db_service = { let database = db.0.lock().map_err(|e| e.to_string())?; Arc::new(database.service()) }; // Get all tracks in the album with metadata let tracks_query = Query::with_params( "SELECT id, name, artists, album_name FROM items WHERE album_id = ? AND item_type = 'Audio' ORDER BY index_number", vec![QueryParam::String(album_id)], ); let tracks: Vec<(String, String, Option, Option)> = db_service .query_many(tracks_query, |row| { Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)) }) .await .map_err(|e| e.to_string())?; let mut download_ids = Vec::new(); // Queue each track with album priority (100) and metadata for (track_id, track_name, artist_name, album_name) in tracks { let file_path = format!("{}/{}.mp3", base_path, sanitize_filename(&track_name)); let insert_query = Query::with_params( "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at, item_name, artist_name, album_name) VALUES (?, ?, ?, 'pending', 100, CURRENT_TIMESTAMP, ?, ?, ?) ON CONFLICT(item_id, user_id) DO UPDATE SET priority = 100, status = 'pending', item_name = COALESCE(excluded.item_name, downloads.item_name), artist_name = COALESCE(excluded.artist_name, downloads.artist_name), album_name = COALESCE(excluded.album_name, downloads.album_name)", vec![ QueryParam::String(track_id.clone()), QueryParam::String(user_id.clone()), QueryParam::String(file_path), QueryParam::String(track_name), artist_name.map(QueryParam::String).unwrap_or(QueryParam::Null), album_name.map(QueryParam::String).unwrap_or(QueryParam::Null), ], ); db_service.execute(insert_query).await.map_err(|e| e.to_string())?; // Query for the actual download ID (last_insert_rowid doesn't work with UPSERT) let id_query = Query::with_params( "SELECT id FROM downloads WHERE item_id = ? AND user_id = ?", vec![QueryParam::String(track_id), QueryParam::String(user_id.clone())], ); let download_id: i64 = db_service .query_one(id_query, |row| row.get(0)) .await .map_err(|e| e.to_string())?; download_ids.push(download_id); } Ok(download_ids) } /// Queue a video item (movie or episode) for download with quality preset #[tauri::command] #[specta::specta] pub async fn download_video( db: State<'_, DatabaseWrapper>, request: DownloadVideoRequest, ) -> Result { let DownloadVideoRequest { item_id, user_id, file_path, mime_type, priority, item_name, quality_preset, series_name, season_name, episode_number, season_number, } = request; let db_service = { let database = db.0.lock().map_err(|e| e.to_string())?; Arc::new(database.service()) }; let quality = quality_preset.unwrap_or_else(|| "original".to_string()); // Insert or update download record with video metadata let insert_query = Query::with_params( "INSERT INTO downloads (item_id, user_id, file_path, mime_type, status, priority, queued_at, item_name, quality_preset, media_type, series_name, season_name, episode_number, season_number) VALUES (?, ?, ?, ?, 'pending', ?, CURRENT_TIMESTAMP, ?, ?, 'video', ?, ?, ?, ?) ON CONFLICT(item_id, user_id) DO UPDATE SET priority = excluded.priority, status = 'pending', queued_at = CURRENT_TIMESTAMP, quality_preset = excluded.quality_preset, item_name = COALESCE(excluded.item_name, downloads.item_name), series_name = COALESCE(excluded.series_name, downloads.series_name), season_name = COALESCE(excluded.season_name, downloads.season_name), episode_number = COALESCE(excluded.episode_number, downloads.episode_number), season_number = COALESCE(excluded.season_number, downloads.season_number)", vec![ QueryParam::String(item_id.clone()), QueryParam::String(user_id.clone()), QueryParam::String(file_path), mime_type.map(QueryParam::String).unwrap_or(QueryParam::Null), QueryParam::Int(priority.unwrap_or(0)), item_name.map(QueryParam::String).unwrap_or(QueryParam::Null), QueryParam::String(quality), series_name.map(QueryParam::String).unwrap_or(QueryParam::Null), season_name.map(QueryParam::String).unwrap_or(QueryParam::Null), episode_number.map(QueryParam::Int).unwrap_or(QueryParam::Null), season_number.map(QueryParam::Int).unwrap_or(QueryParam::Null), ], ); db_service.execute(insert_query).await.map_err(|e| e.to_string())?; // Query for the download ID by unique constraint columns let id_query = Query::with_params( "SELECT id FROM downloads WHERE item_id = ? AND user_id = ?", vec![QueryParam::String(item_id), QueryParam::String(user_id)], ); let download_id: i64 = db_service .query_one(id_query, |row| row.get(0)) .await .map_err(|e| e.to_string())?; Ok(download_id) } /// Queue all episodes of a series for download #[tauri::command] #[specta::specta] pub async fn download_series( db: State<'_, DatabaseWrapper>, series_id: String, series_name: String, user_id: String, base_path: String, quality_preset: Option, ) -> Result, String> { let db_service = { let database = db.0.lock().map_err(|e| e.to_string())?; Arc::new(database.service()) }; let quality = quality_preset.unwrap_or_else(|| "original".to_string()); // Get all episodes for this series, ordered by season and episode number let episodes_query = Query::with_params( "SELECT id, name, season_name, index_number, parent_index_number FROM items WHERE series_id = ? AND item_type = 'Episode' ORDER BY parent_index_number, index_number", vec![QueryParam::String(series_id)], ); let episodes: Vec<(String, String, Option, Option, Option)> = db_service .query_many(episodes_query, |row| { Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?)) }) .await .map_err(|e| e.to_string())?; let mut download_ids = Vec::new(); // Queue each episode with descending priority (first episodes download first) // Priority starts high and decreases so earlier episodes finish first let total_episodes = episodes.len() as i32; for (idx, (episode_id, episode_name, season_name, episode_number, season_number)) in episodes.into_iter().enumerate() { let priority = 1000 - idx as i32; // High priority for first episodes // Create path like: videos/SeriesName/S01E01_Title.mp4 let season_num = season_number.unwrap_or(1); let episode_num = episode_number.unwrap_or(1); let file_name = format!( "S{:02}E{:02}_{}.mp4", season_num, episode_num, sanitize_filename(&episode_name) ); let file_path = format!("{}/{}/{}", base_path, sanitize_filename(&series_name), file_name); let insert_query = Query::with_params( "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at, item_name, quality_preset, media_type, series_name, season_name, episode_number, season_number) VALUES (?, ?, ?, 'pending', ?, CURRENT_TIMESTAMP, ?, ?, 'video', ?, ?, ?, ?) ON CONFLICT(item_id, user_id) DO UPDATE SET priority = excluded.priority, status = 'pending', queued_at = CURRENT_TIMESTAMP, quality_preset = excluded.quality_preset, item_name = COALESCE(excluded.item_name, downloads.item_name), series_name = COALESCE(excluded.series_name, downloads.series_name), season_name = COALESCE(excluded.season_name, downloads.season_name), episode_number = COALESCE(excluded.episode_number, downloads.episode_number), season_number = COALESCE(excluded.season_number, downloads.season_number)", vec![ QueryParam::String(episode_id.clone()), QueryParam::String(user_id.clone()), QueryParam::String(file_path), QueryParam::Int(priority), QueryParam::String(episode_name), QueryParam::String(quality.clone()), QueryParam::String(series_name.clone()), season_name.map(QueryParam::String).unwrap_or(QueryParam::Null), episode_number.map(QueryParam::Int).unwrap_or(QueryParam::Null), season_number.map(QueryParam::Int).unwrap_or(QueryParam::Null), ], ); db_service.execute(insert_query).await.map_err(|e| e.to_string())?; let id_query = Query::with_params( "SELECT id FROM downloads WHERE item_id = ? AND user_id = ?", vec![QueryParam::String(episode_id), QueryParam::String(user_id.clone())], ); let download_id: i64 = db_service .query_one(id_query, |row| row.get(0)) .await .map_err(|e| e.to_string())?; download_ids.push(download_id); } info!("[download_series] Queued {} episodes for series '{}'", total_episodes, series_name); Ok(download_ids) } /// Queue all episodes of a specific season for download #[tauri::command] #[specta::specta] pub async fn download_season( db: State<'_, DatabaseWrapper>, season_id: String, series_name: String, season_name: String, season_number: i32, user_id: String, base_path: String, quality_preset: Option, ) -> Result, String> { let db_service = { let database = db.0.lock().map_err(|e| e.to_string())?; Arc::new(database.service()) }; let quality = quality_preset.unwrap_or_else(|| "original".to_string()); // Get all episodes for this season, ordered by episode number let episodes_query = Query::with_params( "SELECT id, name, index_number FROM items WHERE parent_id = ? AND item_type = 'Episode' ORDER BY index_number", vec![QueryParam::String(season_id)], ); let episodes: Vec<(String, String, Option)> = db_service .query_many(episodes_query, |row| { Ok((row.get(0)?, row.get(1)?, row.get(2)?)) }) .await .map_err(|e| e.to_string())?; let mut download_ids = Vec::new(); // Queue each episode with priority based on episode number for (idx, (episode_id, episode_name, episode_number)) in episodes.into_iter().enumerate() { let priority = 1000 - idx as i32; let episode_num = episode_number.unwrap_or(1); let file_name = format!( "S{:02}E{:02}_{}.mp4", season_number, episode_num, sanitize_filename(&episode_name) ); let file_path = format!("{}/{}/{}", base_path, sanitize_filename(&series_name), file_name); let insert_query = Query::with_params( "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at, item_name, quality_preset, media_type, series_name, season_name, episode_number, season_number) VALUES (?, ?, ?, 'pending', ?, CURRENT_TIMESTAMP, ?, ?, 'video', ?, ?, ?, ?) ON CONFLICT(item_id, user_id) DO UPDATE SET priority = excluded.priority, status = 'pending', queued_at = CURRENT_TIMESTAMP, quality_preset = excluded.quality_preset", vec![ QueryParam::String(episode_id.clone()), QueryParam::String(user_id.clone()), QueryParam::String(file_path), QueryParam::Int(priority), QueryParam::String(episode_name), QueryParam::String(quality.clone()), QueryParam::String(series_name.clone()), QueryParam::String(season_name.clone()), QueryParam::Int(episode_num), QueryParam::Int(season_number), ], ); db_service.execute(insert_query).await.map_err(|e| e.to_string())?; let id_query = Query::with_params( "SELECT id FROM downloads WHERE item_id = ? AND user_id = ?", vec![QueryParam::String(episode_id), QueryParam::String(user_id.clone())], ); let download_id: i64 = db_service .query_one(id_query, |row| row.get(0)) .await .map_err(|e| e.to_string())?; download_ids.push(download_id); } info!("[download_season] Queued {} episodes for {} - {}", download_ids.len(), series_name, season_name); Ok(download_ids) } /// Helper to compute download statistics from a list of downloads #[allow(dead_code)] fn compute_download_stats(downloads: &[DownloadInfo]) -> DownloadStats { let mut stats = DownloadStats { total: downloads.len(), active_count: 0, queued_count: 0, completed_count: 0, failed_count: 0, paused_count: 0, }; for download in downloads { match download.status.as_str() { "downloading" => stats.active_count += 1, "pending" => stats.queued_count += 1, "completed" => stats.completed_count += 1, "failed" => stats.failed_count += 1, "paused" => stats.paused_count += 1, _ => {} } } stats } /// Get all downloads for a user, optionally filtered by status #[tauri::command] #[specta::specta] pub async fn get_downloads( db: State<'_, DatabaseWrapper>, user_id: String, status_filter: Option>, ) -> Result { let db_service = { let database = db.0.lock().map_err(|e| e.to_string())?; Arc::new(database.service()) }; // Use COALESCE to prefer stored metadata over LEFT JOIN results // This ensures correct display even when items aren't synced locally let (query_str, params) = if let Some(ref statuses) = status_filter { let placeholders = statuses.iter().map(|_| "?").collect::>().join(","); let sql = format!( "SELECT d.id, d.item_id, d.user_id, d.file_path, d.file_size, d.mime_type, d.status, d.progress, d.bytes_downloaded, d.queued_at, d.started_at, d.completed_at, d.error_message, d.retry_count, d.priority, COALESCE(d.item_name, i.name) as item_name, COALESCE(d.artist_name, i.artists) as artist_name, COALESCE(d.album_name, i.album_name) as album_name, COALESCE(d.series_name, i.series_name) as series_name, COALESCE(d.season_name, i.season_name) as season_name, COALESCE(d.episode_number, i.index_number) as episode_number, COALESCE(d.season_number, i.parent_index_number) as season_number, d.quality_preset, COALESCE(d.media_type, 'audio') as media_type, COALESCE(d.download_source, 'user') as download_source FROM downloads d LEFT JOIN items i ON d.item_id = i.id WHERE d.user_id = ? AND d.status IN ({}) ORDER BY d.priority DESC, d.queued_at ASC", placeholders ); let mut p = vec![QueryParam::String(user_id.clone())]; p.extend(statuses.iter().map(|s| QueryParam::String(s.clone()))); (sql, p) } else { let sql = "SELECT d.id, d.item_id, d.user_id, d.file_path, d.file_size, d.mime_type, d.status, d.progress, d.bytes_downloaded, d.queued_at, d.started_at, d.completed_at, d.error_message, d.retry_count, d.priority, COALESCE(d.item_name, i.name) as item_name, COALESCE(d.artist_name, i.artists) as artist_name, COALESCE(d.album_name, i.album_name) as album_name, COALESCE(d.series_name, i.series_name) as series_name, COALESCE(d.season_name, i.season_name) as season_name, COALESCE(d.episode_number, i.index_number) as episode_number, COALESCE(d.season_number, i.parent_index_number) as season_number, d.quality_preset, COALESCE(d.media_type, 'audio') as media_type, COALESCE(d.download_source, 'user') as download_source FROM downloads d LEFT JOIN items i ON d.item_id = i.id WHERE d.user_id = ? ORDER BY d.priority DESC, d.queued_at ASC" .to_string(); (sql, vec![QueryParam::String(user_id)]) }; let query = Query::with_params(query_str, params); let downloads = db_service .query_many(query, map_download_row) .await .map_err(|e| e.to_string())?; // Compute stats in single pass let stats = compute_download_stats(&downloads); Ok(DownloadsResponse { downloads, stats }) } /// Pause a download #[tauri::command] #[specta::specta] pub async fn pause_download(db: State<'_, DatabaseWrapper>, download_id: i64) -> Result<(), String> { let db_service = { let database = db.0.lock().map_err(|e| e.to_string())?; Arc::new(database.service()) }; let query = Query::with_params( "UPDATE downloads SET status = 'paused' WHERE id = ? AND status = 'downloading'", vec![QueryParam::Int64(download_id)], ); db_service.execute(query).await.map_err(|e| e.to_string())?; Ok(()) } /// Resume a paused download #[tauri::command] #[specta::specta] pub async fn resume_download(db: State<'_, DatabaseWrapper>, download_id: i64) -> Result<(), String> { let db_service = { let database = db.0.lock().map_err(|e| e.to_string())?; Arc::new(database.service()) }; let query = Query::with_params( "UPDATE downloads SET status = 'pending' WHERE id = ? AND status = 'paused'", vec![QueryParam::Int64(download_id)], ); db_service.execute(query).await.map_err(|e| e.to_string())?; Ok(()) } /// Cancel a download #[tauri::command] #[specta::specta] pub async fn cancel_download( db: State<'_, DatabaseWrapper>, download_manager: State<'_, DownloadManagerWrapper>, download_id: i64, ) -> Result<(), String> { let db_service = { let database = db.0.lock().map_err(|e| e.to_string())?; Arc::new(database.service()) }; // Get file path before deleting let file_query = Query::with_params( "SELECT file_path FROM downloads WHERE id = ?", vec![QueryParam::Int64(download_id)], ); let file_path: Option = db_service .query_optional(file_query, |row| row.get(0)) .await .ok() .flatten(); // Delete from database let delete_query = Query::with_params( "DELETE FROM downloads WHERE id = ?", vec![QueryParam::Int64(download_id)], ); db_service.execute(delete_query).await.map_err(|e| e.to_string())?; // Unregister from download manager (in case it was active) { let manager = download_manager.0.lock().map_err(|e| e.to_string())?; manager.unregister_download(download_id); info!("Cancelled download {}. Active downloads: {}", download_id, manager.active_count()); } // Delete partial file if exists if let Some(path) = file_path { let partial_path = format!("{}.part", path); let _ = std::fs::remove_file(&partial_path); // Ignore errors } Ok(()) } /// Mark a download as completed #[tauri::command] #[specta::specta] pub async fn mark_download_completed( db: State<'_, DatabaseWrapper>, download_id: i64, bytes_downloaded: i64, file_path: String, ) -> Result<(), String> { let db_service = { let database = db.0.lock().map_err(|e| e.to_string())?; Arc::new(database.service()) }; let query = Query::with_params( "UPDATE downloads SET status = 'completed', progress = 1.0, bytes_downloaded = ?, file_size = ?, file_path = ?, completed_at = CURRENT_TIMESTAMP WHERE id = ?", vec![ QueryParam::Int64(bytes_downloaded), QueryParam::Int64(bytes_downloaded), QueryParam::String(file_path), QueryParam::Int64(download_id), ], ); db_service.execute(query).await.map_err(|e| e.to_string())?; Ok(()) } /// Mark a download as failed #[tauri::command] #[specta::specta] pub async fn mark_download_failed( db: State<'_, DatabaseWrapper>, download_id: i64, error_message: String, ) -> Result<(), String> { let db_service = { let database = db.0.lock().map_err(|e| e.to_string())?; Arc::new(database.service()) }; let query = Query::with_params( "UPDATE downloads SET status = 'failed', error_message = ? WHERE id = ?", vec![QueryParam::String(error_message), QueryParam::Int64(download_id)], ); db_service.execute(query).await.map_err(|e| e.to_string())?; Ok(()) } /// Start downloading a file immediately /// This command actually downloads the file using the worker #[tauri::command] #[specta::specta] pub async fn start_download( db: State<'_, DatabaseWrapper>, download_manager: State<'_, DownloadManagerWrapper>, app: tauri::AppHandle, download_id: i64, stream_url: String, target_dir: String, ) -> Result<(), String> { use crate::download::events::DownloadEvent; use tauri::Emitter; debug!("start_download called for download_id: {}", download_id); debug!(" stream_url: {}", stream_url); debug!(" target_dir: {}", target_dir); // Check concurrent download limit and register this download { let manager = download_manager.0.lock().map_err(|e| { error!("Failed to lock download manager: {}", e); format!("Failed to lock download manager: {}", e) })?; if !manager.can_start_download() { warn!("Cannot start download: maximum concurrent downloads ({}) reached", manager.max_concurrent()); debug!(" Active downloads: {}", manager.active_count()); return Err(format!( "Maximum concurrent downloads ({}) reached. Please wait for existing downloads to complete.", manager.max_concurrent() )); } // Register this download as active let registered = manager.register_download(download_id); if !registered { warn!("Failed to register download {}: already registered or limit reached", download_id); return Err("Download already in progress or limit reached".to_string()); } info!("Download {} registered. Active downloads: {}/{}", download_id, manager.active_count(), manager.max_concurrent()); } // Get download info from DB let db_service = { let database = db.0.lock().map_err(|e| { error!("Failed to lock database: {}", e); e.to_string() })?; Arc::new(database.service()) }; let info_query = Query::with_params( "SELECT item_id, file_path, file_size FROM downloads WHERE id = ?", vec![QueryParam::Int64(download_id)], ); let (item_id, file_path, file_size): (String, String, Option) = db_service .query_one(info_query, |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))) .await .map_err(|e| { error!("Failed to query download info: {}", e); e.to_string() })?; debug!(" Retrieved: item_id={}, file_path={}, file_size={:?}", item_id, file_path, file_size); // Make a HEAD request to get the file size from Content-Length header debug!("Making HEAD request to get file size..."); let head_response = reqwest::Client::new() .head(&stream_url) .send() .await; let file_size_from_server = match head_response { Ok(response) => { let size = response .headers() .get(reqwest::header::CONTENT_LENGTH) .and_then(|v| v.to_str().ok()) .and_then(|v| v.parse::().ok()); if let Some(size) = size { debug!(" Got file size from server: {} bytes ({} MB)", size, size / 1024 / 1024); } else { warn!(" Server didn't provide Content-Length header"); } size } Err(e) => { warn!(" HEAD request failed: {}, continuing anyway...", e); None } }; // Update status to downloading and save file_size if we got it. // Also persist the resolved stream URL + target dir so the queue pump can // restart/resume this download by itself if needed. let update_query = if let Some(size) = file_size_from_server { Query::with_params( "UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP, file_size = ?, stream_url = ?, target_dir = ? WHERE id = ?", vec![ QueryParam::Int64(size), QueryParam::String(stream_url.clone()), QueryParam::String(target_dir.clone()), QueryParam::Int64(download_id), ], ) } else { Query::with_params( "UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP, stream_url = ?, target_dir = ? WHERE id = ?", vec![ QueryParam::String(stream_url.clone()), QueryParam::String(target_dir.clone()), QueryParam::Int64(download_id), ], ) }; db_service.execute(update_query).await.map_err(|e| e.to_string())?; // Emit started event let started_event = DownloadEvent::Started { download_id, item_id: item_id.clone(), }; debug!("Emitting download-event: {:?}", started_event); debug!(" Serialized: {}", serde_json::to_string(&started_event).unwrap_or_default()); match app.emit("download-event", started_event) { Ok(_) => debug!(" Event emitted successfully"), Err(e) => error!(" Event emit failed: {:?}", e), } // Build target path let target_path = PathBuf::from(&target_dir).join(&file_path); // Get a clone of the active downloads Arc for unregistering later let active_downloads = { let manager = download_manager.0.lock().map_err(|e| e.to_string())?; manager.get_active_downloads() }; // Run the worker in the background; on completion/failure it frees the slot // and pumps the next pending download. spawn_download_worker( app.clone(), download_id, item_id, stream_url, target_path, active_downloads, ); Ok(()) } /// Enqueue a download with its resolved stream URL, then let the queue pump /// start it (or a higher-priority pending item) when a slot is free. /// /// Unlike [`start_download`], this never errors when the concurrency limit is /// reached: the URL is persisted on the row and the pump will pick it up once a /// slot frees. This is the path bulk operations (album/series/season) use so /// every queued item eventually downloads without the frontend re-issuing it. #[tauri::command] #[specta::specta] pub async fn enqueue_download( db: State<'_, DatabaseWrapper>, download_manager: State<'_, DownloadManagerWrapper>, app: tauri::AppHandle, download_id: i64, stream_url: String, target_dir: String, ) -> Result<(), String> { let db_service = { let database = db.0.lock().map_err(|e| e.to_string())?; Arc::new(database.service()) }; // Persist the resolved URL/dir and mark the row pending so the pump can // start it. We don't flip to 'downloading' here — the pump owns that. let update_query = Query::with_params( "UPDATE downloads SET status = 'pending', stream_url = ?, target_dir = ? WHERE id = ?", vec![ QueryParam::String(stream_url), QueryParam::String(target_dir), QueryParam::Int64(download_id), ], ); db_service.execute(update_query).await.map_err(|e| e.to_string())?; // Kick the pump: it will start as many pending downloads as there are slots. let active_downloads = { let manager = download_manager.0.lock().map_err(|e| e.to_string())?; manager.get_active_downloads() }; pump_download_queue(app, db_service, active_downloads).await; Ok(()) } /// Enqueue a batch of already-queued video downloads, resolving each one's /// transcode URL from the repository using the `quality_preset` stored on the /// row. Then let the pump start them subject to the concurrency limit. /// /// This is the bulk video path (series/season): `download_series`/ /// `download_season` insert the rows, then this resolves URLs and enqueues them /// so they actually start. Resolving server-side avoids round-tripping every /// episode URL through the frontend. #[tauri::command] #[specta::specta] pub async fn enqueue_video_downloads( db: State<'_, DatabaseWrapper>, download_manager: State<'_, DownloadManagerWrapper>, repository: State<'_, crate::commands::repository::RepositoryManagerWrapper>, app: tauri::AppHandle, handle: String, download_ids: Vec, target_dir: String, ) -> Result<(), String> { use crate::repository::MediaRepository; let repo = repository.0.get(&handle).ok_or("Repository not found")?; let db_service = { let database = db.0.lock().map_err(|e| e.to_string())?; Arc::new(database.service()) }; for download_id in download_ids { // Read the item + quality preset for this queued download. let info_query = Query::with_params( "SELECT item_id, COALESCE(quality_preset, 'original') FROM downloads WHERE id = ?", vec![QueryParam::Int64(download_id)], ); let (item_id, quality): (String, String) = match db_service .query_one(info_query, |row| Ok((row.get(0)?, row.get(1)?))) .await { Ok(row) => row, Err(e) => { warn!("[enqueue_video] Skipping download {}: {}", download_id, e); continue; } }; // Build the transcode URL (pure URL builder, no server round-trip). let stream_url = repo.as_ref().get_video_download_url(&item_id, &quality, None); let update_query = Query::with_params( "UPDATE downloads SET status = 'pending', stream_url = ?, target_dir = ? WHERE id = ?", vec![ QueryParam::String(stream_url), QueryParam::String(target_dir.clone()), QueryParam::Int64(download_id), ], ); if let Err(e) = db_service.execute(update_query).await { warn!("[enqueue_video] Failed to persist URL for download {}: {}", download_id, e); } } // Pump once: starts up to max_concurrent, the rest drain as slots free. let active_downloads = { let manager = download_manager.0.lock().map_err(|e| e.to_string())?; manager.get_active_downloads() }; pump_download_queue(app, db_service, active_downloads).await; Ok(()) } /// Start as many pending downloads as there are free concurrency slots. /// /// Picks the highest-priority `pending` rows that have a persisted `stream_url` /// (FIFO within a priority), registers each, flips it to `downloading`, and /// spawns a worker. Each spawned worker calls this again on completion/failure, /// so the queue drains itself without any frontend involvement. async fn pump_download_queue( app: tauri::AppHandle, db_service: Arc, active_downloads: Arc>>, ) { use crate::download::events::DownloadEvent; use tauri::Emitter; let max_concurrent = { let manager = app.state::(); let manager = match manager.0.lock() { Ok(m) => m, Err(e) => { error!("[pump] Failed to lock download manager: {}", e); return; } }; manager.max_concurrent() }; loop { // How many slots are free right now? let free_slots = { let active = match active_downloads.lock() { Ok(a) => a, Err(e) => { error!("[pump] Failed to lock active downloads: {}", e); return; } }; max_concurrent.saturating_sub(active.len()) }; if free_slots == 0 { return; } // Find the next pending, startable download (has a stream URL). Exclude // anything already registered as active to avoid double-starting. let next_query = Query::with_params( "SELECT id, item_id, file_path, stream_url, target_dir FROM downloads WHERE status = 'pending' AND stream_url IS NOT NULL AND target_dir IS NOT NULL ORDER BY priority DESC, queued_at ASC", vec![], ); let candidates: Vec<(i64, String, String, String, String)> = match db_service .query_many(next_query, |row| { Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?)) }) .await { Ok(rows) => rows, Err(e) => { error!("[pump] Failed to query pending downloads: {}", e); return; } }; // Pick the first candidate not already active. let next = candidates.into_iter().find(|(id, _, _, _, _)| { active_downloads .lock() .map(|active| !active.contains(id)) .unwrap_or(false) }); let (download_id, item_id, file_path, stream_url, target_dir) = match next { Some(n) => n, None => return, // Nothing pending to start }; // Register the slot. If registration fails (race: another pump filled // the last slot), stop — we'll be re-pumped when a slot frees. { let manager = app.state::(); let manager = match manager.0.lock() { Ok(m) => m, Err(e) => { error!("[pump] Failed to lock download manager: {}", e); return; } }; if !manager.register_download(download_id) { return; } info!( "[pump] Download {} started. Active downloads: {}/{}", download_id, manager.active_count(), manager.max_concurrent() ); } // Mark as downloading and stamp started_at. let update_query = Query::with_params( "UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP WHERE id = ?", vec![QueryParam::Int64(download_id)], ); if let Err(e) = db_service.execute(update_query).await { error!("[pump] Failed to mark download {} downloading: {}", download_id, e); if let Ok(mut a) = active_downloads.lock() { a.remove(&download_id); } continue; } // Emit started event so the UI flips the row. let _ = app.emit( "download-event", DownloadEvent::Started { download_id, item_id: item_id.clone(), }, ); let target_path = PathBuf::from(&target_dir).join(&file_path); spawn_download_worker( app.clone(), download_id, item_id, stream_url, target_path, active_downloads.clone(), ); } } /// Spawn the background worker for one download. On completion or failure it /// unregisters the slot, emits the terminal event, and pumps the queue so the /// next pending download starts automatically. fn spawn_download_worker( app: tauri::AppHandle, download_id: i64, item_id: String, stream_url: String, target_path: std::path::PathBuf, active_downloads: Arc>>, ) { use crate::download::{DownloadTask, DownloadWorker}; use crate::download::events::DownloadEvent; use tauri::Emitter; let task = DownloadTask { url: stream_url, target_path: target_path.clone(), }; tauri::async_runtime::spawn(async move { debug!("Download task started for download_id: {}", download_id); let worker = DownloadWorker::new(); // Progress callback that emits events to the frontend let progress_app = app.clone(); let progress_item_id = item_id.clone(); let on_progress = move |bytes_downloaded: u64, total_bytes: Option| { let progress = total_bytes .filter(|&t| t > 0) .map(|t| bytes_downloaded as f64 / t as f64) .unwrap_or(0.0); let event = DownloadEvent::Progress { download_id, item_id: progress_item_id.clone(), bytes_downloaded: bytes_downloaded as i64, total_bytes: total_bytes.map(|t| t as i64), progress, }; let _ = progress_app.emit("download-event", event); }; let result = worker.download(&task, on_progress).await; // Free the slot before pumping so the next download can take it. if let Ok(mut active) = active_downloads.lock() { active.remove(&download_id); debug!(" Unregistered download {}. Active downloads: {}", download_id, active.len()); } match result { Ok(res) => { info!("Download completed successfully: {} bytes", res.bytes_downloaded); let completed_event = DownloadEvent::Completed { download_id, item_id, file_path: target_path.to_string_lossy().to_string(), }; match app.emit("download-event", completed_event) { Ok(_) => debug!(" Completed event emitted successfully"), Err(e) => error!(" Completed event emit failed: {:?}", e), } } Err(e) => { error!("Download failed: {:?}", e); let failed_event = DownloadEvent::Failed { download_id, item_id, error: e.to_string(), }; match app.emit("download-event", failed_event) { Ok(_) => debug!(" Failed event emitted successfully"), Err(e) => error!(" Failed event emit failed: {:?}", e), } } } // A slot just freed — start the next pending download (if any). let db_service = { let db = app.state::(); let database = match db.0.lock() { Ok(d) => d, Err(e) => { error!("[pump] Failed to lock database after download {}: {}", download_id, e); return; } }; Arc::new(database.service()) }; pump_download_queue(app.clone(), db_service, active_downloads).await; }); } /// Delete a completed download #[tauri::command] #[specta::specta] pub async fn delete_download(db: State<'_, DatabaseWrapper>, download_id: i64) -> Result<(), String> { let db_service = { let database = db.0.lock().map_err(|e| e.to_string())?; Arc::new(database.service()) }; // Get file path let file_query = Query::with_params( "SELECT file_path FROM downloads WHERE id = ?", vec![QueryParam::Int64(download_id)], ); let file_path: Option = db_service .query_optional(file_query, |row| row.get(0)) .await .ok() .flatten(); // Delete from database let delete_query = Query::with_params( "DELETE FROM downloads WHERE id = ?", vec![QueryParam::Int64(download_id)], ); db_service.execute(delete_query).await.map_err(|e| e.to_string())?; // Delete actual file if exists if let Some(path) = file_path { let _ = std::fs::remove_file(&path); // Ignore errors } Ok(()) } /// Helper function to map database row to DownloadInfo fn map_download_row(row: &rusqlite::Row) -> rusqlite::Result { Ok(DownloadInfo { id: row.get(0)?, item_id: row.get(1)?, user_id: row.get(2)?, file_path: row.get(3)?, file_size: row.get(4)?, mime_type: row.get(5)?, status: row.get(6)?, progress: row.get(7)?, bytes_downloaded: row.get(8)?, queued_at: row.get(9)?, started_at: row.get(10)?, completed_at: row.get(11)?, error_message: row.get(12)?, retry_count: row.get(13)?, priority: row.get(14)?, item_name: row.get(15)?, artist_name: row.get(16)?, album_name: row.get(17)?, // Video-specific metadata series_name: row.get(18)?, season_name: row.get(19)?, episode_number: row.get(20)?, season_number: row.get(21)?, quality_preset: row.get(22)?, media_type: row.get::<_, Option>(23)?.unwrap_or_else(|| "audio".to_string()), download_source: row.get::<_, Option>(24)?.unwrap_or_else(|| "user".to_string()), }) } /// Storage statistics for downloads #[derive(specta::Type, Debug, Clone, serde::Serialize)] pub struct StorageStats { pub total_bytes: i64, pub total_items: i64, pub albums: Vec, } /// Storage info for a single album #[derive(specta::Type, Debug, Clone, serde::Serialize)] pub struct AlbumStorageInfo { pub album_id: String, pub album_name: String, pub artist_name: Option, pub bytes_used: i64, pub track_count: i64, } /// Get storage statistics for downloads #[tauri::command] #[specta::specta] pub async fn get_download_storage_stats( db: State<'_, DatabaseWrapper>, user_id: String, ) -> Result { let db_service = { let database = db.0.lock().map_err(|e| e.to_string())?; Arc::new(database.service()) }; // Get total storage and item count let total_query = Query::with_params( "SELECT COALESCE(SUM(file_size), 0), COUNT(*) FROM downloads WHERE user_id = ? AND status = 'completed'", vec![QueryParam::String(user_id.clone())], ); let (total_bytes, total_items): (i64, i64) = db_service .query_one(total_query, |row| Ok((row.get(0)?, row.get(1)?))) .await .map_err(|e| e.to_string())?; // Get storage breakdown by album let albums_query = Query::with_params( "SELECT COALESCE(i.album_id, 'unknown') as album_id, COALESCE(i.album_name, 'Unknown Album') as album_name, i.artists as artist_name, COALESCE(SUM(d.file_size), 0) as bytes_used, COUNT(*) as track_count FROM downloads d LEFT JOIN items i ON d.item_id = i.id WHERE d.user_id = ? AND d.status = 'completed' GROUP BY COALESCE(i.album_id, 'unknown') ORDER BY bytes_used DESC", vec![QueryParam::String(user_id)], ); let albums: Vec = db_service .query_many(albums_query, |row| { Ok(AlbumStorageInfo { album_id: row.get(0)?, album_name: row.get(1)?, artist_name: row.get(2)?, bytes_used: row.get(3)?, track_count: row.get(4)?, }) }) .await .map_err(|e| e.to_string())?; Ok(StorageStats { total_bytes, total_items, albums, }) } /// Delete all downloads for a user #[tauri::command] #[specta::specta] pub async fn delete_all_downloads(db: State<'_, DatabaseWrapper>, user_id: String) -> Result { let db_service = { let database = db.0.lock().map_err(|e| e.to_string())?; Arc::new(database.service()) }; // Get all file paths for completed downloads let file_query = Query::with_params( "SELECT file_path FROM downloads WHERE user_id = ? AND status = 'completed'", vec![QueryParam::String(user_id.clone())], ); let file_paths: Vec = db_service .query_many(file_query, |row| row.get(0)) .await .map_err(|e| e.to_string())?; // Delete all downloads for user let delete_query = Query::with_params( "DELETE FROM downloads WHERE user_id = ?", vec![QueryParam::String(user_id)], ); let deleted_count = db_service .execute(delete_query) .await .map_err(|e| e.to_string())?; // Delete actual files for path in file_paths { let _ = std::fs::remove_file(&path); // Ignore errors let _ = std::fs::remove_file(format!("{}.part", path)); // Also clean up partials } Ok(deleted_count as i64) } /// Clear all stale pending/failed/paused downloads #[tauri::command] #[specta::specta] pub async fn clear_stale_downloads( db: State<'_, DatabaseWrapper>, user_id: String, ) -> Result { let db_service = { let database = db.0.lock().map_err(|e| e.to_string())?; Arc::new(database.service()) }; // Get file paths for stale downloads (pending/paused/failed) let file_query = Query::with_params( "SELECT file_path FROM downloads WHERE user_id = ? AND status IN ('pending', 'paused', 'failed')", vec![QueryParam::String(user_id.clone())], ); let file_paths: Vec = db_service .query_many(file_query, |row| row.get(0)) .await .map_err(|e| e.to_string())?; // Delete all pending, paused, and failed downloads (but keep completed ones) let delete_query = Query::with_params( "DELETE FROM downloads WHERE user_id = ? AND status IN ('pending', 'paused', 'failed')", vec![QueryParam::String(user_id)], ); let deleted_count = db_service .execute(delete_query) .await .map_err(|e| e.to_string())?; // Delete any partial files for path in file_paths { let _ = std::fs::remove_file(&path); let _ = std::fs::remove_file(format!("{}.part", path)); } Ok(deleted_count as i64) } /// Delete all downloads for a specific album #[tauri::command] #[specta::specta] pub async fn delete_album_downloads( db: State<'_, DatabaseWrapper>, album_id: String, user_id: String, ) -> Result { let db_service = { let database = db.0.lock().map_err(|e| e.to_string())?; Arc::new(database.service()) }; // Get file paths for this album's downloads let file_query = Query::with_params( "SELECT d.file_path FROM downloads d JOIN items i ON d.item_id = i.id WHERE d.user_id = ? AND i.album_id = ? AND d.status = 'completed'", vec![QueryParam::String(user_id.clone()), QueryParam::String(album_id.clone())], ); let file_paths: Vec = db_service .query_many(file_query, |row| row.get(0)) .await .map_err(|e| e.to_string())?; // Delete downloads for this album let delete_query = Query::with_params( "DELETE FROM downloads WHERE user_id = ? AND item_id IN (SELECT id FROM items WHERE album_id = ?)", vec![QueryParam::String(user_id), QueryParam::String(album_id)], ); let deleted_count = db_service .execute(delete_query) .await .map_err(|e| e.to_string())?; // Delete actual files for path in file_paths { let _ = std::fs::remove_file(&path); let _ = std::fs::remove_file(format!("{}.part", path)); } Ok(deleted_count as i64) } /// Download manager statistics #[derive(specta::Type, Debug, Clone, serde::Serialize)] pub struct DownloadManagerStats { pub max_concurrent: usize, pub active_count: usize, pub available_slots: usize, } /// Get download manager statistics #[tauri::command] #[specta::specta] pub async fn get_download_manager_stats( download_manager: State<'_, DownloadManagerWrapper>, ) -> Result { let manager = download_manager.0.lock().map_err(|e| e.to_string())?; let active_count = manager.active_count(); let max_concurrent = manager.max_concurrent(); Ok(DownloadManagerStats { max_concurrent, active_count, available_slots: max_concurrent.saturating_sub(active_count), }) } /// Set the maximum concurrent downloads #[tauri::command] #[specta::specta] pub async fn set_max_concurrent_downloads( download_manager: State<'_, DownloadManagerWrapper>, max: usize, ) -> Result<(), String> { let mut manager = download_manager.0.lock().map_err(|e| e.to_string())?; manager.set_max_concurrent(max); info!("Set max concurrent downloads to: {}", max); Ok(()) } // TRACES: UR-011, UR-018 | DR-015, DR-018 | UT-042, UT-043 #[cfg(test)] mod tests { use super::*; use crate::storage::Database; use rusqlite::params; #[test] fn test_sanitize_filename() { assert_eq!(sanitize_filename("normal.mp3"), "normal.mp3"); assert_eq!( sanitize_filename("track/with\\invalid:chars"), "track_with_invalid_chars" ); assert_eq!(sanitize_filename("song?.mp3"), "song_.mp3"); assert_eq!(sanitize_filename("file<>|?.txt"), "file____.txt"); } #[test] fn test_sanitize_filename_preserves_extension() { assert_eq!(sanitize_filename("my:song.mp3"), "my_song.mp3"); assert_eq!(sanitize_filename("track/1.flac"), "track_1.flac"); } /// Helper to set up test database with required foreign key data fn setup_test_db() -> Database { let db = Database::open_in_memory().unwrap(); let conn = db.connection(); let conn = conn.lock_safe(); // Create server conn.execute( "INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)", params!["server1", "Test Server", "http://localhost:8096"], ) .unwrap(); // Create user conn.execute( "INSERT INTO users (id, server_id, username) VALUES (?1, ?2, ?3)", params!["user1", "server1", "testuser"], ) .unwrap(); // Create some items for download testing conn.execute( "INSERT INTO items (id, server_id, name, item_type, album_id, index_number) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", params!["item1", "server1", "Test Song 1", "Audio", "album1", 1], ) .unwrap(); conn.execute( "INSERT INTO items (id, server_id, name, item_type, album_id, index_number) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", params!["item2", "server1", "Test Song 2", "Audio", "album1", 2], ) .unwrap(); conn.execute( "INSERT INTO items (id, server_id, name, item_type, album_id, index_number) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", params!["item3", "server1", "Test Song 3", "Audio", "album1", 3], ) .unwrap(); drop(conn); db } #[test] fn test_download_item_returns_correct_id_on_insert() { let db = setup_test_db(); let conn = db.connection(); let conn = conn.lock_safe(); // Insert a new download conn.execute( "INSERT INTO downloads (item_id, user_id, file_path, mime_type, status, priority, queued_at) VALUES (?1, ?2, ?3, ?4, 'pending', ?5, CURRENT_TIMESTAMP)", params!["item1", "user1", "/path/to/file.mp3", "audio/mpeg", 0], ) .unwrap(); // Query for the download ID (like our fixed code does) let download_id: i64 = conn .query_row( "SELECT id FROM downloads WHERE item_id = ?1 AND user_id = ?2", params!["item1", "user1"], |row| row.get(0), ) .unwrap(); assert!(download_id > 0, "Download ID should be positive"); // Verify the download record exists with correct data let (status, file_path): (String, String) = conn .query_row( "SELECT status, file_path FROM downloads WHERE id = ?1", params![download_id], |row| Ok((row.get(0)?, row.get(1)?)), ) .unwrap(); assert_eq!(status, "pending"); assert_eq!(file_path, "/path/to/file.mp3"); } #[test] fn test_download_item_upsert_returns_correct_id() { let db = setup_test_db(); let conn = db.connection(); let conn = conn.lock_safe(); // First insert conn.execute( "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at) VALUES (?1, ?2, ?3, 'pending', 0, CURRENT_TIMESTAMP)", params!["item1", "user1", "/path/to/file.mp3"], ) .unwrap(); let first_id: i64 = conn .query_row( "SELECT id FROM downloads WHERE item_id = ?1 AND user_id = ?2", params!["item1", "user1"], |row| row.get(0), ) .unwrap(); // Simulate failure - mark as failed conn.execute( "UPDATE downloads SET status = 'failed', error_message = 'Network error' WHERE id = ?1", params![first_id], ) .unwrap(); // Now re-download (UPSERT) - this is the scenario that was broken conn.execute( "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at) VALUES (?1, ?2, ?3, 'pending', 0, CURRENT_TIMESTAMP) ON CONFLICT(item_id, user_id) DO UPDATE SET priority = excluded.priority, status = 'pending', queued_at = CURRENT_TIMESTAMP", params!["item1", "user1", "/path/to/file.mp3"], ) .unwrap(); // The old buggy code used last_insert_rowid() which would return 0 or wrong value // Our fixed code queries by unique constraint let second_id: i64 = conn .query_row( "SELECT id FROM downloads WHERE item_id = ?1 AND user_id = ?2", params!["item1", "user1"], |row| row.get(0), ) .unwrap(); // The ID should be the same as the first insert (UPSERT updated the existing row) assert_eq!(first_id, second_id, "UPSERT should return the same row ID"); // Verify the status was reset to pending let status: String = conn .query_row( "SELECT status FROM downloads WHERE id = ?1", params![second_id], |row| row.get(0), ) .unwrap(); assert_eq!(status, "pending", "Status should be reset to pending after UPSERT"); } #[test] fn test_query_by_unique_constraint_is_reliable() { // This test demonstrates that querying by unique constraint is always reliable, // unlike last_insert_rowid() which has undefined behavior with UPSERT. // // SQLite documentation states that last_insert_rowid() behavior is undefined // when ON CONFLICT triggers an UPDATE instead of INSERT. Some versions return 0, // others return the existing row ID - it's not consistent. // // Our fix: always query by the unique constraint columns (item_id, user_id) // to get the correct download ID, regardless of whether INSERT or UPDATE occurred. let db = setup_test_db(); let conn = db.connection(); let conn = conn.lock_safe(); // First insert conn.execute( "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at) VALUES (?1, ?2, ?3, 'pending', 0, CURRENT_TIMESTAMP)", params!["item1", "user1", "/path/to/file.mp3"], ) .unwrap(); let first_id: i64 = conn .query_row( "SELECT id FROM downloads WHERE item_id = ?1 AND user_id = ?2", params!["item1", "user1"], |row| row.get(0), ) .unwrap(); // UPSERT (triggers UPDATE) conn.execute( "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at) VALUES (?1, ?2, ?3, 'pending', 0, CURRENT_TIMESTAMP) ON CONFLICT(item_id, user_id) DO UPDATE SET status = 'pending'", params!["item1", "user1", "/path/to/file.mp3"], ) .unwrap(); // Our approach: query by unique constraint - always works! let id_after_upsert: i64 = conn .query_row( "SELECT id FROM downloads WHERE item_id = ?1 AND user_id = ?2", params!["item1", "user1"], |row| row.get(0), ) .unwrap(); // This should ALWAYS be the same ID - our approach is reliable assert_eq!( first_id, id_after_upsert, "Query by unique constraint should always return correct ID" ); } #[test] fn test_download_album_returns_correct_ids() { let db = setup_test_db(); let conn = db.connection(); let conn = conn.lock_safe(); // Insert downloads for multiple items (simulating album download) let track_ids = vec!["item1", "item2", "item3"]; let mut download_ids = Vec::new(); for track_id in &track_ids { conn.execute( "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at) VALUES (?1, ?2, ?3, 'pending', 100, CURRENT_TIMESTAMP) ON CONFLICT(item_id, user_id) DO UPDATE SET priority = 100, status = 'pending'", params![track_id, "user1", format!("/path/{}.mp3", track_id)], ) .unwrap(); // Use our fixed approach - query by unique constraint let download_id: i64 = conn .query_row( "SELECT id FROM downloads WHERE item_id = ?1 AND user_id = ?2", params![track_id, "user1"], |row| row.get(0), ) .unwrap(); download_ids.push(download_id); } // All IDs should be unique and positive assert_eq!(download_ids.len(), 3); for id in &download_ids { assert!(*id > 0, "Download ID should be positive"); } // IDs should be unique let mut sorted_ids = download_ids.clone(); sorted_ids.sort(); sorted_ids.dedup(); assert_eq!(sorted_ids.len(), 3, "All download IDs should be unique"); } #[test] fn test_download_album_upsert_returns_correct_ids() { let db = setup_test_db(); let conn = db.connection(); let conn = conn.lock_safe(); // First download attempt - insert all let track_ids = vec!["item1", "item2", "item3"]; let mut first_ids = Vec::new(); for track_id in &track_ids { conn.execute( "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at) VALUES (?1, ?2, ?3, 'pending', 100, CURRENT_TIMESTAMP)", params![track_id, "user1", format!("/path/{}.mp3", track_id)], ) .unwrap(); let id: i64 = conn .query_row( "SELECT id FROM downloads WHERE item_id = ?1 AND user_id = ?2", params![track_id, "user1"], |row| row.get(0), ) .unwrap(); first_ids.push(id); } // Mark all as failed conn.execute("UPDATE downloads SET status = 'failed'", []) .unwrap(); // Re-download (UPSERT all) let mut second_ids = Vec::new(); for track_id in &track_ids { conn.execute( "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at) VALUES (?1, ?2, ?3, 'pending', 100, CURRENT_TIMESTAMP) ON CONFLICT(item_id, user_id) DO UPDATE SET priority = 100, status = 'pending'", params![track_id, "user1", format!("/path/{}.mp3", track_id)], ) .unwrap(); let id: i64 = conn .query_row( "SELECT id FROM downloads WHERE item_id = ?1 AND user_id = ?2", params![track_id, "user1"], |row| row.get(0), ) .unwrap(); second_ids.push(id); } // IDs should be the same (UPSERT updates existing rows) assert_eq!(first_ids, second_ids, "UPSERT should preserve original IDs"); } #[test] fn test_get_downloads_returns_correct_metadata() { let db = setup_test_db(); let conn = db.connection(); let conn = conn.lock_safe(); // Insert a download conn.execute( "INSERT INTO downloads (item_id, user_id, file_path, status, progress, priority) VALUES (?1, ?2, ?3, 'downloading', 0.5, 10)", params!["item1", "user1", "/path/to/song.mp3"], ) .unwrap(); // Query downloads with item metadata let download: DownloadInfo = conn .query_row( "SELECT d.id, d.item_id, d.user_id, d.file_path, d.file_size, d.mime_type, d.status, d.progress, d.bytes_downloaded, d.queued_at, d.started_at, d.completed_at, d.error_message, d.retry_count, d.priority, COALESCE(d.item_name, i.name) as item_name, COALESCE(d.artist_name, i.artists) as artist_name, COALESCE(d.album_name, i.album_name) as album_name, COALESCE(d.series_name, i.series_name) as series_name, COALESCE(d.season_name, i.season_name) as season_name, COALESCE(d.episode_number, i.index_number) as episode_number, COALESCE(d.season_number, i.parent_index_number) as season_number, d.quality_preset, COALESCE(d.media_type, 'audio') as media_type, COALESCE(d.download_source, 'user') as download_source FROM downloads d LEFT JOIN items i ON d.item_id = i.id WHERE d.user_id = ?1", params!["user1"], map_download_row, ) .unwrap(); assert_eq!(download.item_id, "item1"); assert_eq!(download.status, "downloading"); assert!((download.progress - 0.5).abs() < 0.001); assert_eq!(download.priority, 10); assert_eq!(download.item_name, Some("Test Song 1".to_string())); } #[test] fn test_download_status_transitions() { let db = setup_test_db(); let conn = db.connection(); let conn = conn.lock_safe(); // Insert pending download conn.execute( "INSERT INTO downloads (item_id, user_id, file_path, status) VALUES (?1, ?2, ?3, 'pending')", params!["item1", "user1", "/path/to/song.mp3"], ) .unwrap(); let id: i64 = conn.last_insert_rowid(); // Transition: pending -> downloading conn.execute( "UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP WHERE id = ?1", params![id], ) .unwrap(); let status: String = conn .query_row("SELECT status FROM downloads WHERE id = ?1", params![id], |row| row.get(0)) .unwrap(); assert_eq!(status, "downloading"); // Transition: downloading -> completed conn.execute( "UPDATE downloads SET status = 'completed', progress = 1.0, completed_at = CURRENT_TIMESTAMP WHERE id = ?1", params![id], ) .unwrap(); let (status, progress): (String, f64) = conn .query_row( "SELECT status, progress FROM downloads WHERE id = ?1", params![id], |row| Ok((row.get(0)?, row.get(1)?)), ) .unwrap(); assert_eq!(status, "completed"); assert!((progress - 1.0).abs() < 0.001); } #[test] fn test_download_progress_updates() { let db = setup_test_db(); let conn = db.connection(); let conn = conn.lock_safe(); conn.execute( "INSERT INTO downloads (item_id, user_id, file_path, status, progress, bytes_downloaded, file_size) VALUES (?1, ?2, ?3, 'downloading', 0.0, 0, 10000000)", params!["item1", "user1", "/path/to/song.mp3"], ) .unwrap(); let id: i64 = conn.last_insert_rowid(); // Simulate progress updates for i in 1..=10 { let progress = i as f64 / 10.0; let bytes = i * 1000000; conn.execute( "UPDATE downloads SET progress = ?1, bytes_downloaded = ?2 WHERE id = ?3", params![progress, bytes, id], ) .unwrap(); let (actual_progress, actual_bytes): (f64, i64) = conn .query_row( "SELECT progress, bytes_downloaded FROM downloads WHERE id = ?1", params![id], |row| Ok((row.get(0)?, row.get(1)?)), ) .unwrap(); assert!((actual_progress - progress).abs() < 0.001); assert_eq!(actual_bytes, bytes); } } #[test] fn test_compute_download_stats_empty() { let downloads = vec![]; let stats = compute_download_stats(&downloads); assert_eq!(stats.total, 0); assert_eq!(stats.active_count, 0); assert_eq!(stats.queued_count, 0); assert_eq!(stats.completed_count, 0); assert_eq!(stats.failed_count, 0); assert_eq!(stats.paused_count, 0); } #[test] fn test_compute_download_stats_mixed() { let downloads = vec![ create_test_download(1, "downloading"), create_test_download(2, "pending"), create_test_download(3, "downloading"), create_test_download(4, "completed"), create_test_download(5, "failed"), create_test_download(6, "paused"), ]; let stats = compute_download_stats(&downloads); assert_eq!(stats.total, 6); assert_eq!(stats.active_count, 2); assert_eq!(stats.queued_count, 1); assert_eq!(stats.completed_count, 1); assert_eq!(stats.failed_count, 1); assert_eq!(stats.paused_count, 1); } #[test] fn test_compute_download_stats_all_same_status() { let downloads = vec![ create_test_download(1, "completed"), create_test_download(2, "completed"), create_test_download(3, "completed"), ]; let stats = compute_download_stats(&downloads); assert_eq!(stats.total, 3); assert_eq!(stats.active_count, 0); assert_eq!(stats.queued_count, 0); assert_eq!(stats.completed_count, 3); assert_eq!(stats.failed_count, 0); assert_eq!(stats.paused_count, 0); } /// Helper to create a test DownloadInfo for stats testing fn create_test_download(id: i64, status: &str) -> DownloadInfo { DownloadInfo { id, item_id: format!("item{}", id), user_id: "test_user".to_string(), file_path: format!("/tmp/download{}", id), file_size: Some(1000), mime_type: Some("audio/flac".to_string()), status: status.to_string(), progress: 0.0, bytes_downloaded: 0, queued_at: "2024-01-01T00:00:00Z".to_string(), started_at: None, completed_at: None, error_message: None, retry_count: 0, priority: 0, item_name: Some(format!("Track {}", id)), artist_name: None, album_name: None, series_name: None, season_name: None, episode_number: None, season_number: None, quality_preset: None, media_type: "audio".to_string(), download_source: "user".to_string(), } } }