//! Tauri commands for download operations #[cfg(test)] use crate::utils::lock::MutexSafe; use std::sync::{Arc, Mutex}; use tauri::State; use log::{debug, error, info, warn}; use crate::download::{DownloadInfo, DownloadManager}; use crate::storage::db_service::{DatabaseService, Query, QueryParam}; use super::{DatabaseWrapper, SmartCacheWrapper}; /// Wrapper for DownloadManager to be used as Tauri state pub struct DownloadManagerWrapper(pub Mutex); /// Download statistics computed server-side #[allow(dead_code)] #[derive(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(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() } /// Queue and start a download in a single atomic operation /// This simplifies the frontend flow by combining multiple steps #[tauri::command] pub async fn download_item_and_start( db: State<'_, DatabaseWrapper>, smart_cache: State<'_, SmartCacheWrapper>, download_manager: State<'_, DownloadManagerWrapper>, app: tauri::AppHandle, item_id: String, user_id: String, stream_url: String, target_dir: String, item_name: Option, artist_name: Option, album_name: Option, ) -> Result { // 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(), item_id, user_id, file_path, None, // mime_type None, // priority item_name, artist_name, album_name, None, // expected_size ).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] pub async fn download_item( db: State<'_, DatabaseWrapper>, smart_cache: State<'_, SmartCacheWrapper>, item_id: String, user_id: String, file_path: String, mime_type: Option, priority: Option, item_name: Option, artist_name: Option, album_name: Option, expected_size: Option, ) -> Result { 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] 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] pub async fn download_video( db: State<'_, DatabaseWrapper>, item_id: String, user_id: String, file_path: String, mime_type: Option, priority: Option, item_name: Option, quality_preset: Option, // Video-specific metadata series_name: Option, season_name: Option, episode_number: Option, season_number: Option, ) -> Result { 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] 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] 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) } /// Pin an item's metadata (protects from cache clear) #[tauri::command] pub async fn pin_item(db: State<'_, DatabaseWrapper>, item_id: 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 items SET is_pinned = 1 WHERE id = ?", vec![QueryParam::String(item_id)], ); db_service.execute(query).await.map_err(|e| e.to_string())?; Ok(()) } /// Unpin an item's metadata #[tauri::command] pub async fn unpin_item(db: State<'_, DatabaseWrapper>, item_id: 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 items SET is_pinned = 0 WHERE id = ?", vec![QueryParam::String(item_id)], ); db_service.execute(query).await.map_err(|e| e.to_string())?; Ok(()) } /// Check if an item is pinned #[tauri::command] pub async fn is_item_pinned(db: State<'_, DatabaseWrapper>, item_id: String) -> Result { let db_service = { let database = db.0.lock().map_err(|e| e.to_string())?; Arc::new(database.service()) }; let query = Query::with_params( "SELECT COALESCE(is_pinned, 0) FROM items WHERE id = ?", vec![QueryParam::String(item_id)], ); let is_pinned: i32 = db_service .query_optional(query, |row| row.get(0)) .await .map_err(|e| e.to_string())? .unwrap_or(0); Ok(is_pinned == 1) } /// 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] 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] 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] 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] 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] 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] 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] 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::{DownloadTask, DownloadWorker}; use crate::download::events::DownloadEvent; use std::path::PathBuf; 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 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 = ? WHERE id = ?", vec![QueryParam::Int64(size), QueryParam::Int64(download_id)], ) } else { Query::with_params( "UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP WHERE id = ?", vec![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); // Create download task let task = DownloadTask { url: stream_url, target_path: target_path.clone(), }; // Start download in background let app_clone = app.clone(); let item_id_clone = item_id.clone(); // 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() }; 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.clone(); let progress_item_id = item_id_clone.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); }; match worker.download(&task, on_progress).await { Ok(result) => { info!("Download completed successfully: {} bytes", result.bytes_downloaded); // Unregister from download manager if let Ok(mut active) = active_downloads.lock() { active.remove(&download_id); debug!(" Unregistered download {}. Active downloads: {}", download_id, active.len()); } // Emit completed event - the frontend will handle state updates let completed_event = DownloadEvent::Completed { download_id, item_id: item_id_clone, file_path: target_path.to_string_lossy().to_string(), }; debug!("Emitting completed event: {:?}", completed_event); debug!(" Serialized: {}", serde_json::to_string(&completed_event).unwrap_or_default()); match app_clone.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); // Unregister from download manager if let Ok(mut active) = active_downloads.lock() { active.remove(&download_id); debug!(" Unregistered failed download {}. Active downloads: {}", download_id, active.len()); } // Emit failed event - the frontend will handle state updates let failed_event = DownloadEvent::Failed { download_id, item_id: item_id_clone.clone(), error: e.to_string(), }; debug!("Emitting failed event: {:?}", failed_event); match app_clone.emit("download-event", failed_event) { Ok(_) => debug!(" Failed event emitted successfully"), Err(e) => error!(" Failed event emit failed: {:?}", e), } } } }); Ok(()) } /// Delete a completed download #[tauri::command] 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(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(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] 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] 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] 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] 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(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] 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] 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(()) } /// SmartCache statistics #[derive(Debug, Clone, serde::Serialize)] pub struct SmartCacheStats { pub total_size: u64, pub storage_limit: u64, pub available_space: u64, pub items_count: i64, pub config: crate::download::cache::CacheConfig, } /// Get SmartCache statistics #[tauri::command] pub async fn get_smart_cache_stats( db: State<'_, DatabaseWrapper>, smart_cache: State<'_, SmartCacheWrapper>, user_id: String, ) -> Result { let db_service = { let database = db.0.lock().map_err(|e| e.to_string())?; Arc::new(database.service()) }; // Clone cache to avoid holding lock across async operations let cache = { let guard = smart_cache.0.lock().map_err(|e| e.to_string())?; guard.clone() }; let total_size = cache .get_total_download_size_async(&db_service, &user_id) .await?; let config = cache .get_config() .ok_or_else(|| "Failed to get cache config".to_string())?; let storage_limit = config.storage_limit; let available_space = storage_limit.saturating_sub(total_size); // Get item count let count_query = Query::with_params( "SELECT COUNT(*) FROM downloads WHERE user_id = ? AND status = 'completed'", vec![QueryParam::String(user_id)], ); let items_count: i64 = db_service .query_one(count_query, |row| row.get(0)) .await .unwrap_or(0); Ok(SmartCacheStats { total_size, storage_limit, available_space, items_count, config, }) } /// Update SmartCache configuration #[tauri::command] pub async fn update_smart_cache_config( smart_cache: State<'_, SmartCacheWrapper>, config: crate::download::cache::CacheConfig, ) -> Result<(), String> { let cache = smart_cache.0.lock().map_err(|e| e.to_string())?; cache.update_config(config); info!("Updated SmartCache configuration"); Ok(()) } /// Get SmartCache configuration #[tauri::command] pub async fn get_smart_cache_config( smart_cache: State<'_, SmartCacheWrapper>, ) -> Result { let cache = smart_cache.0.lock().map_err(|e| e.to_string())?; cache .get_config() .ok_or_else(|| "Failed to get cache config".to_string()) } /// Album recommendation info #[derive(Debug, Clone, serde::Serialize)] pub struct AlbumRecommendation { pub album_id: String, pub album_name: String, pub tracks_played: usize, pub total_tracks: usize, pub should_download: bool, } /// Album affinity status info #[derive(Debug, Clone, serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct AlbumAffinityStatus { pub album_id: String, pub unique_tracks_played: usize, pub threshold: usize, pub threshold_reached: bool, } /// Get album recommendations based on play history #[tauri::command] pub async fn get_album_recommendations( db: State<'_, DatabaseWrapper>, smart_cache: State<'_, SmartCacheWrapper>, user_id: String, ) -> Result, String> { let db_service = { let database = db.0.lock().map_err(|e| e.to_string())?; Arc::new(database.service()) }; // Clone cache to avoid holding lock across async operations let cache = { let guard = smart_cache.0.lock().map_err(|e| e.to_string())?; guard.clone() }; // Get all albums that user has played tracks from let query = Query::with_params( "SELECT DISTINCT i.album_id, a.name FROM user_data ud JOIN items i ON ud.item_id = i.id JOIN items a ON i.album_id = a.id WHERE ud.user_id = ? AND ud.play_count > 0 AND i.item_type = 'Audio' AND i.album_id IS NOT NULL", vec![QueryParam::String(user_id.clone())], ); let albums: Vec<(String, String)> = db_service .query_many(query, |row| Ok((row.get(0)?, row.get(1)?))) .await .unwrap_or_default(); let mut recommendations = Vec::new(); for (album_id, album_name) in albums { // Check if should cache let should_download = cache.should_cache_album(&album_id).unwrap_or(false); // Get track counts let tracks_query = Query::with_params( "SELECT COUNT(*) as total, COUNT(ud.id) as played FROM items i LEFT JOIN user_data ud ON i.id = ud.item_id AND ud.user_id = ? WHERE i.album_id = ? AND i.item_type = 'Audio'", vec![ QueryParam::String(user_id.clone()), QueryParam::String(album_id.clone()), ], ); let (total_tracks, tracks_played): (i64, i64) = db_service .query_one(tracks_query, |row| Ok((row.get(0)?, row.get(1)?))) .await .unwrap_or((0, 0)); if tracks_played > 0 { recommendations.push(AlbumRecommendation { album_id, album_name, tracks_played: tracks_played as usize, total_tracks: total_tracks as usize, should_download, }); } } // Sort by tracks played (descending) recommendations.sort_by(|a, b| b.tracks_played.cmp(&a.tracks_played)); Ok(recommendations) } /// Get album affinity status for all tracked albums /// This shows the SmartCache's internal play history and threshold status #[tauri::command] pub fn get_album_affinity_status( smart_cache: State<'_, SmartCacheWrapper>, ) -> Result, String> { let cache = smart_cache.0.lock().map_err(|e| e.to_string())?; // Get the threshold from config let threshold = cache .get_config() .map(|c| c.album_affinity_threshold) .unwrap_or(3); // Get all tracked albums with their play counts let play_history = cache.get_album_play_history(); let mut statuses: Vec = play_history .into_iter() .map(|(album_id, unique_tracks_played)| { let threshold_reached = unique_tracks_played >= threshold; AlbumAffinityStatus { album_id, unique_tracks_played, threshold, threshold_reached, } }) .collect(); // Sort by play count (descending) statuses.sort_by(|a, b| b.unique_tracks_played.cmp(&a.unique_tracks_played)); Ok(statuses) } // 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(), } } }