//! Tauri commands for download operations #[cfg(test)] use crate::utils::lock::MutexSafe; use log::{debug, error, info, warn}; use std::path::{Component, Path, PathBuf}; use std::sync::{Arc, Mutex}; use tauri::{Manager, State}; use super::{DatabaseWrapper, SmartCacheWrapper}; use crate::download::network::{NetworkState, NetworkStateHandle, NetworkType}; use crate::download::{DownloadInfo, DownloadManager}; use crate::storage::db_service::{DatabaseService, Query, QueryParam}; // 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::*; /// One row of the series episode listing used when queueing a whole series: /// `(id, name, season_name, index_number, parent_index_number)`. type EpisodeRow = (String, String, Option, Option, Option); /// Wrapper for DownloadManager to be used as Tauri state pub struct DownloadManagerWrapper(pub Mutex); /// Wrapper for the current network transport, used by the WiFi-only gate. /// /// TRACES: UR-053 | DR-074 pub struct NetworkStateWrapper(pub NetworkStateHandle); /// Report the device's current network transport (Android → Rust). /// /// The frontend calls this on startup and whenever the native network callback /// fires. Updating to an acceptable network re-pumps the download queue, so a /// queue parked on "waiting for WiFi" drains itself without user action. /// /// TRACES: UR-053 | DR-074 #[tauri::command] #[specta::specta] pub async fn set_network_state( app: tauri::AppHandle, network: NetworkStateWrapperArg, db: State<'_, DatabaseWrapper>, download_manager: State<'_, DownloadManagerWrapper>, ) -> Result<(), String> { let new_state = NetworkState { network_type: network.network_type, unmetered: network.unmetered, }; let handle = app.state::().0.clone(); let previous = handle.get().await; handle.set(new_state).await; if previous != new_state { info!( "[network] Transport changed: {:?} (unmetered={}) -> {:?} (unmetered={})", previous.network_type, previous.unmetered, new_state.network_type, new_state.unmetered ); } // If the new network unblocks the gate, drain whatever was waiting. if downloads_allowed_on_current_network(&app).await { let db_service = { let database = db.0.lock().map_err(|e| e.to_string())?; Arc::new(database.service()) }; let active = { let manager = download_manager.0.lock().map_err(|e| e.to_string())?; manager.get_active_downloads() }; pump_download_queue(app.clone(), db_service, active).await; } Ok(()) } /// Argument struct for [`set_network_state`]. /// /// TRACES: UR-053 | DR-074 #[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct NetworkStateWrapperArg { pub network_type: NetworkType, pub unmetered: bool, } /// Whether downloads are currently permitted by the WiFi-only gate. /// /// The downloads UI uses this to render "Waiting for WiFi" on pending rows /// rather than leaving them looking silently stuck. /// /// TRACES: UR-053 | DR-074 #[tauri::command] #[specta::specta] pub async fn get_downloads_allowed(app: tauri::AppHandle) -> Result { Ok(downloads_allowed_on_current_network(&app).await) } /// 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() } /// The directory every download has to stay inside: the storage root /// `storage_get_path` hands the frontend, which is the database's parent. /// /// TRACES: DR-211 | UT-205 fn download_root(db: &DatabaseWrapper) -> Result { let database = db.0.lock().map_err(|e| e.to_string())?; database .path() .parent() .map(|p| p.to_path_buf()) .ok_or_else(|| "Database path has no parent directory".to_string()) } /// Fold `..` out of `candidate` and require what is left to sit inside `root`. /// /// Lexical rather than `canonicalize`, the same way `media_server::resolve_path` /// does it: the file usually does not exist yet, so canonicalising would fail on /// the ordinary case. The check has to come *after* the caller's join, because /// `Path::join` drops the base when the joined half is absolute — such a path is /// not folded, it is obeyed, and only the `starts_with` below catches it. /// /// TRACES: DR-211 | UT-205 fn confine_to_root(root: &Path, candidate: &Path) -> Result { let mut resolved = PathBuf::new(); for component in candidate.components() { match component { Component::ParentDir => { resolved.pop(); } Component::CurDir => {} other => resolved.push(other), } } if resolved.starts_with(root) { Ok(resolved) } else { Err(format!( "Refusing a download path outside the download directory: {}", candidate.display() )) } } /// Sanitize a queued download's path and confine it to the download directory. /// /// Every path the app builds for itself comes back unchanged — files on disk and /// `downloads` rows point at these exact spellings — and [`sanitize_filename`] /// is idempotent, so the already-safe name `download_item_and_start` passes in /// is not sanitized into a second, different one. /// /// TRACES: DR-211 | UT-205 fn confine_queued_path(root: &Path, file_path: &str) -> Result { let mut sanitized = PathBuf::new(); for component in Path::new(file_path).components() { match component { Component::Normal(part) => sanitized.push(sanitize_filename(&part.to_string_lossy())), // Kept as they are, so `confine_to_root` is the single thing // deciding whether what they add up to is still inside the root. other => sanitized.push(other), } } confine_to_root(root, &root.join(&sanitized))?; Ok(sanitized.to_string_lossy().to_string()) } /// 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; // `start_download` joins this onto the target directory, and `Path::join` // drops the base when the second half is absolute, so the row itself has to // be confined — not only the place it is used. `download_item_and_start` // sanitizes the name it builds, but `download_item` is a command in its own // right, so that guard was simply routed around by calling this directly. // TRACES: DR-211 | UT-205 let file_path = { let root = download_root(&db)?; confine_queued_path(&root, &file_path)? }; 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..."); // Reclaim expired temporary entries first: they are dead weight, so // freeing them may avoid evicting cache that is still within its // life. Best-effort — a failure here just means eviction does more. // TRACES: UR-071 | DR-127 match cache_arc .reclaim_expired_async(&db_service, &user_id, &chrono::Utc::now().to_rfc3339()) .await { Ok(n) if n > 0 => info!("Reclaimed {} expired cache entries", n), Ok(_) => {} Err(e) => warn!("Expired-entry reclaim failed: {}", e), } // 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) } /// One track of an album, as the album-download path queues it. /// /// `artist_name` carries whatever the catalog holds for the track's artists (a /// JSON array, as stored on `items.artists`); it is display metadata for the /// downloads list, not a lookup key. /// /// TRACES: UR-018, UR-055 | DR-173 #[derive(Debug, Clone, PartialEq)] pub(crate) struct AlbumTrack { pub id: String, pub name: String, pub artist_name: Option, pub album_name: Option, pub index_number: Option, } impl From<&crate::repository::types::MediaItem> for AlbumTrack { fn from(item: &crate::repository::types::MediaItem) -> Self { Self { id: item.id.clone(), name: item.name.clone(), artist_name: item .artists .as_ref() .and_then(|a| serde_json::to_string(a).ok()), album_name: item.album_name.clone(), index_number: item.index_number, } } } /// The album's tracks as the local catalog cache knows them. /// /// Only a fallback for [`download_album`]: the cache links a track to its album /// through `items.album_id`, which Jellyfin does not populate on every listing /// endpoint, so this can legitimately return fewer tracks than the album has. /// /// TRACES: UR-018, UR-055 | DR-173 pub(crate) async fn cached_album_tracks( db_service: &Arc, album_id: &str, ) -> Result, String> { let tracks_query = Query::with_params( "SELECT id, name, artists, album_name, index_number FROM items WHERE (album_id = ? OR parent_id = ?) AND item_type = 'Audio' ORDER BY index_number", vec![ QueryParam::String(album_id.to_string()), QueryParam::String(album_id.to_string()), ], ); db_service .query_many(tracks_query, |row| { Ok(AlbumTrack { id: row.get(0)?, name: row.get(1)?, artist_name: row.get(2)?, album_name: row.get(3)?, index_number: row.get(4)?, }) }) .await .map_err(|e| e.to_string()) } /// Queue one download row per track and link every track to its album. /// /// The linkage is the half that is easy to miss: offline browsing joins a track /// to its album on `items.album_id` (see `OfflineRepository::get_items`), so a /// track whose cached row lacks it stays invisible under the album even after /// its file is on disk. Queuing a track *is* the statement that it belongs to /// this album, so the link is written here rather than hoped for from whichever /// listing endpoint happened to cache the row. /// /// Idempotent: re-queuing an album fills in what is missing and returns the same /// row ids, in the order the tracks were given. /// /// A file name per track, unique within the album. /// /// A title is not a unique name inside its own album: a deluxe edition carries /// the album version and a demo of the same song, and a two-disc set repeats /// titles across discs. Naming files after the title alone gave those tracks one /// path, and each download overwrote the previous one — an album that quietly /// ends up short by however many titles it repeats. The track number /// disambiguates the ordinary case; anything still colliding falls back to the /// item id, which is unique by construction. /// /// TRACES: UR-018, UR-055 | DR-173 | UT-172 pub(crate) fn album_file_names(tracks: &[AlbumTrack]) -> Vec { let mut counts: std::collections::HashMap = std::collections::HashMap::new(); for track in tracks { *counts.entry(track.name.to_lowercase()).or_default() += 1; } tracks .iter() .map(|track| { let title = sanitize_filename(&track.name); if counts.get(&track.name.to_lowercase()).copied().unwrap_or(0) <= 1 { return format!("{}.mp3", title); } match track.index_number { Some(n) => format!("{:02} - {} [{}].mp3", n, title, track.id), None => format!("{} [{}].mp3", title, track.id), } }) .collect() } /// TRACES: UR-018, UR-055 | DR-173 | UT-170 pub(crate) async fn queue_album_tracks( db_service: &Arc, album_id: &str, tracks: &[AlbumTrack], user_id: &str, base_path: &str, ) -> Result, String> { let mut download_ids = Vec::with_capacity(tracks.len()); let file_names = album_file_names(tracks); for (track, file_name) in tracks.iter().zip(file_names) { // Cache a row for a track the catalog has never seen, borrowing the // album's server. Nothing is inserted when the album itself is unknown, // which also keeps the parent_id foreign key satisfiable. let cache_query = Query::with_params( "INSERT OR IGNORE INTO items (id, server_id, parent_id, name, item_type, album_id, album_name, artists, index_number) SELECT ?, a.server_id, a.id, ?, 'Audio', a.id, ?, ?, ? FROM items a WHERE a.id = ?", vec![ QueryParam::String(track.id.clone()), QueryParam::String(track.name.clone()), track .album_name .clone() .map(QueryParam::String) .unwrap_or(QueryParam::Null), track .artist_name .clone() .map(QueryParam::String) .unwrap_or(QueryParam::Null), track .index_number .map(QueryParam::Int) .unwrap_or(QueryParam::Null), QueryParam::String(album_id.to_string()), ], ); db_service .execute(cache_query) .await .map_err(|e| e.to_string())?; // Link an already-cached track to the album. The parent_id subquery // resolves to NULL when the album is not cached, so the foreign key // holds either way. let link_query = Query::with_params( "UPDATE items SET album_id = ?, parent_id = COALESCE(parent_id, (SELECT id FROM items WHERE id = ?)) WHERE id = ?", vec![ QueryParam::String(album_id.to_string()), QueryParam::String(album_id.to_string()), QueryParam::String(track.id.clone()), ], ); db_service .execute(link_query) .await .map_err(|e| e.to_string())?; let file_path = format!("{}/{}", base_path, file_name); // Queue at album priority (100). A track already downloaded stays // completed — re-queuing an album must fill the gaps, not re-fetch it. 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, media_type) VALUES (?, ?, ?, 'pending', 100, CURRENT_TIMESTAMP, ?, ?, ?, 'audio') ON CONFLICT(item_id, user_id) DO UPDATE SET priority = 100, status = CASE WHEN downloads.status = 'completed' THEN 'completed' ELSE 'pending' END, media_type = 'audio', 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.to_string()), QueryParam::String(file_path), QueryParam::String(track.name.clone()), track .artist_name .clone() .map(QueryParam::String) .unwrap_or(QueryParam::Null), track .album_name .clone() .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.clone()), QueryParam::String(user_id.to_string()), ], ); 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 an entire album for download. /// /// Owns the whole operation: the album's track list comes from the server (the /// only place that knows all of it), every track is queued and linked to its /// album, each row's stream URL is resolved here, and the queue is pumped. /// /// The frontend used to do the second half — resolve one URL per track and pair /// it with the returned ids **by position**. That pairing had no basis: the ids /// came back in the backend's own order over a different set of rows, so /// whenever the two lists disagreed a row was handed another track's URL, and /// any track past the end of the shorter list was never started at all. Nothing /// crosses the boundary now except the album id. /// /// TRACES: UR-018, UR-055 | DR-173 | UT-170 #[tauri::command] #[specta::specta] // Three of the eight arguments are Tauri `State<'_, _>` injections plus the // `AppHandle`, not caller input. Folding the rest into a struct would change the // IPC contract and the generated TypeScript for no readability gain. #[allow(clippy::too_many_arguments)] pub async fn download_album( db: State<'_, DatabaseWrapper>, repository: State<'_, crate::commands::repository::RepositoryManagerWrapper>, download_manager: State<'_, DownloadManagerWrapper>, app: tauri::AppHandle, handle: String, 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()) }; let repo = repository.0.get(&handle); // Ask the server what the album contains; the cache is only a fallback for // when it cannot answer. let tracks: Vec = match &repo { Some(repo) => match repo.get_album_tracks(&album_id).await { Ok(items) if !items.is_empty() => items.iter().map(AlbumTrack::from).collect(), Ok(_) => cached_album_tracks(&db_service, &album_id).await?, Err(e) => { warn!( "[download_album] Could not list album {} from the repository ({:?}); \ falling back to the cached track list", album_id, e ); cached_album_tracks(&db_service, &album_id).await? } }, None => cached_album_tracks(&db_service, &album_id).await?, }; if tracks.is_empty() { warn!("[download_album] No tracks found for album {}", album_id); return Ok(Vec::new()); } let download_ids = queue_album_tracks(&db_service, &album_id, &tracks, &user_id, &base_path).await?; info!( "[download_album] Queued {} track(s) for album {}", download_ids.len(), album_id ); // Resolve each queued row's stream URL here, then pump. Without a // repository (or while offline) the rows stay pending with no URL and // `resume_queued_downloads` picks them up on reconnect. let Some(repo) = repo else { return Ok(download_ids); }; let target_dir = { let database = db.0.lock().map_err(|e| e.to_string())?; database .path() .parent() .ok_or_else(|| "Database path has no parent directory".to_string())? .to_string_lossy() .to_string() }; let repo_for_resolve = Arc::clone(&repo); let outcome = crate::commands::catalog::resolve_pending_download_urls( &db_service, &target_dir, Some(&download_ids), move |item_id: String, _media_type: String, _quality: String| { let repo = Arc::clone(&repo_for_resolve); async move { use crate::repository::MediaRepository; match repo.get_audio_stream_url(&item_id).await { Ok(url) => Some(url), Err(e) => { warn!( "[download_album] Failed to resolve stream URL for {}: {:?}", item_id, e ); None } } } }, ) .await?; if outcome.failed > 0 { warn!( "[download_album] {} track(s) could not be resolved and stay queued for the next \ reconnect", outcome.failed ); } 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(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 = 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] // One of the eight arguments is a Tauri `State<'_, _>` injection; the rest are // the season's identifying fields. Folding them into a struct would change the // IPC contract and the generated TypeScript for no readability gain. #[allow(clippy::too_many_arguments)] 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. /// /// Writing `status = 'paused'` is only half of it, and used to be all of it: the /// streaming task knew nothing about the row and kept running, then overwrote it /// with `completed`/`failed` when it finished. The row flicked to "paused" and /// undid itself — the reported "pause does not work". Signalling the worker is /// what actually stops the bytes; it leaves the `.part` file in place so /// [`resume_download`] can continue from it. /// /// A queued (not yet started) download has no worker to signal, and the status /// write alone is enough — the pump skips anything that is not `pending`. /// /// TRACES: UR-055 | DR-168 #[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 IN ('downloading', 'pending')", vec![QueryParam::Int64(download_id)], ); db_service.execute(query).await.map_err(|e| e.to_string())?; let was_running = crate::download::stop::signal(download_id); info!( "[pause] Download {} paused (in flight: {})", download_id, was_running ); Ok(()) } /// Resume a paused download. /// /// Flipping the row back to `pending` is likewise not enough on its own: the /// pump is not a poller, it runs when something calls it, so a resumed download /// sat untouched until some unrelated event happened to pump the queue. That is /// the other half of "resume does not work". /// /// TRACES: UR-055 | DR-168 #[tauri::command] #[specta::specta] pub async fn resume_download( app: tauri::AppHandle, 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()) }; let query = Query::with_params( "UPDATE downloads SET status = 'pending', error_message = NULL WHERE id = ? AND status IN ('paused', 'failed')", vec![QueryParam::Int64(download_id)], ); db_service.execute(query).await.map_err(|e| e.to_string())?; // Drop any stale stop flag before the pump can start this id again, or the // resumed run would read the pause that stopped it and halt immediately. crate::download::stop::clear(download_id); 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(()) } /// 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())?; // Stop the worker if this download is actually running. Without this the // task keeps streaming into a `.part` file whose `downloads` row has just // been deleted — bytes with nothing pointing at them, and the file below is // removed while still being written to. (DR-168) crate::download::stop::signal(download_id); crate::download::stop::clear(download_id); // 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 the partial file, and any completed file, if present. Both go // through `partial_path` so this cannot drift from what the worker writes — // it did, and every cancelled download leaked its partial. (DR-169) if let Some(path) = file_path { let target = std::path::PathBuf::from(&path); let _ = std::fs::remove_file(crate::download::worker::partial_path(&target)); } 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> { // Deleting a download reads this straight back into `std::fs::remove_file`, // so a row must never come to name a file outside the download directory. // The worker reports the absolute path it wrote, and joining an absolute // path onto the root yields it unchanged, so that case is stored verbatim; // the frontend's fallback to the row's own (relative) path resolves under // the root, where the worker put it. // TRACES: DR-211 | UT-205 let file_path = { let root = download_root(&db)?; confine_to_root(&root, &root.join(&file_path))? .to_string_lossy() .to_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 ); // Both halves of this join reached us from the frontend, so resolve them // against the download directory before a single byte is written. // TRACES: DR-211 | UT-205 let target_path = { let root = download_root(&db)?; confine_to_root(&root, &PathBuf::from(&target_dir).join(&file_path))? }; // 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), } // 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> { 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 download URL, resolving the source's audio codec first so a // track this device cannot decode is re-encoded on the way down rather // than saved as a silent file (DR-167). let stream_url = crate::repository::resolve_video_download_url(repo.as_ref(), &item_id, &quality, None) .await; 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(()) } /// Whether the current network permits downloads, given the user's WiFi-only /// preference. /// /// Reads `wifi_only` from the SmartCache config (the single home of the /// setting) and checks it against the transport reported by the platform. On /// desktop the transport defaults to unmetered ethernet, so this is always /// true there. /// /// TRACES: UR-053 | DR-074 pub(crate) async fn downloads_allowed_on_current_network(app: &tauri::AppHandle) -> bool { let wifi_only = { let smart_cache = app.state::(); let cache = match smart_cache.0.lock() { Ok(c) => c, Err(e) => { error!("[pump] Failed to lock smart cache: {}", e); // Fail open: a lock problem must not silently wedge downloads. return true; } }; cache.get_config().map(|c| c.wifi_only).unwrap_or(false) }; if !wifi_only { return true; } let network = app.state::(); network.0.allows_download(true).await } /// 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. pub(crate) async fn pump_download_queue( app: tauri::AppHandle, db_service: Arc, active_downloads: Arc>>, ) { use crate::download::events::DownloadEvent; use tauri::Emitter; // WiFi-only gate (UR-053): when the user has restricted downloads to // unmetered networks and we're on cellular (or can't tell), leave every // pending row exactly as it is. They stay 'pending' and the Android // network callback re-pumps us as soon as an acceptable network appears. if !downloads_allowed_on_current_network(&app).await { info!("[pump] Downloads paused: waiting for an unmetered network (WiFi-only enabled)"); let _ = app.emit("download-event", DownloadEvent::WaitingForNetwork); return; } 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 }; // Confine the row's path before it takes a slot. A row whose target // escapes the download directory can never start, so it is failed here // rather than picked again on the next pass — this loop re-queries, so // merely skipping it would not terminate. // TRACES: DR-211 | UT-205 let confined = { let db_state = app.state::(); download_root(&db_state).and_then(|root| { confine_to_root(&root, &PathBuf::from(&target_dir).join(&file_path)) }) }; let target_path = match confined { Ok(path) => path, Err(e) => { error!("[pump] Refusing download {}: {}", download_id, e); let fail_query = Query::with_params( "UPDATE downloads SET status = 'failed', error_message = ? WHERE id = ?", vec![QueryParam::String(e), QueryParam::Int64(download_id)], ); if let Err(db_err) = db_service.execute(fail_query).await { error!( "[pump] Failed to mark download {} failed: {}", download_id, db_err ); return; } continue; } }; // 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(), }, ); 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::events::DownloadEvent; use crate::download::{DownloadTask, DownloadWorker}; 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); }; // Registering returns a fresh flag, so a download resumed after a pause // does not inherit the stop that ended its previous run. (DR-168) let stop_flag = crate::download::stop::register(download_id); let result = worker.download(&task, &stop_flag, on_progress).await; crate::download::stop::clear(download_id); // 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() ); } // The pump runs downloads in the background, so the terminal status MUST // be persisted to the DB here — the frontend event handler only writes it // when that download happens to be loaded in its store, which is not the // case for auto-pumped rows (or any completion while the downloads page is // closed). `check_for_local_download` filters on status = 'completed', so a // missed write leaves finished files unrecognized: albums never show as // downloaded and playback never switches from the (expiring) stream to the // local file, cutting tracks off mid-play. 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()) }; match result { Ok(res) => { info!( "Download completed successfully: {} bytes", res.bytes_downloaded ); let file_path = target_path.to_string_lossy().to_string(); let update = 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(res.bytes_downloaded as i64), QueryParam::Int64(res.bytes_downloaded as i64), QueryParam::String(file_path.clone()), QueryParam::Int64(download_id), ], ); if let Err(e) = db_service.execute(update).await { error!( "[pump] Failed to persist completed status for download {}: {}", download_id, e ); } let completed_event = DownloadEvent::Completed { download_id, item_id, file_path, }; match app.emit("download-event", completed_event) { Ok(_) => debug!(" Completed event emitted successfully"), Err(e) => error!(" Completed event emit failed: {:?}", e), } } // A pause or cancel is not a failure. The row already says `paused` // (or the row is gone, for a cancel), and overwriting that with // `failed` is what made a pause look like an error and stranded the // download outside the resumable set. The `.part` file is deliberately // left alone — it is what the resume continues from. (DR-168) Err(e) if e.is_stopped() => { info!( "[pump] Download {} stopped by request; partial file kept for resume", download_id ); } Err(e) => { error!("Download failed: {:?}", e); let update = Query::with_params( "UPDATE downloads SET status = 'failed', error_message = ? WHERE id = ?", vec![ QueryParam::String(e.to_string()), QueryParam::Int64(download_id), ], ); if let Err(db_err) = db_service.execute(update).await { error!( "[pump] Failed to persist failed status for download {}: {}", download_id, db_err ); } 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). 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()) }; // Ids as well as paths: a stale row may still have a worker attached (a // 'downloading' row that was paused mid-flight is 'paused' here), and // deleting the row without stopping the task leaves it writing to a file we // are about to remove. (DR-168) let file_query = Query::with_params( "SELECT id, file_path FROM downloads WHERE user_id = ? AND status IN ('pending', 'paused', 'failed')", vec![QueryParam::String(user_id.clone())], ); let stale: Vec<(i64, String)> = db_service .query_many(file_query, |row| Ok((row.get(0)?, row.get(1)?))) .await .map_err(|e| e.to_string())?; for (id, _) in &stale { crate::download::stop::signal(*id); crate::download::stop::clear(*id); } let file_paths: Vec = stale.into_iter().map(|(_, path)| path).collect(); // 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, via the shared helper so this cannot drift from // what the worker actually writes. (DR-169) for path in file_paths { let target = std::path::PathBuf::from(&path); let _ = std::fs::remove_file(&target); let _ = std::fs::remove_file(crate::download::worker::partial_path(&target)); } 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) } /// Remove every completed download at or under a container item. /// /// Works at any level of the Downloaded browse: a leaf (removes just that /// download), an album/season/series (removes all downloaded descendants linked /// via album_id/season_id/series_id/parent_id). Deletes the DB rows and the /// on-disk files. Returns the number of downloads removed. Idempotent. /// /// TRACES: UR-055 | DR-083 #[tauri::command] #[specta::specta] pub async fn delete_downloads_under( db: State<'_, DatabaseWrapper>, item_id: String, user_id: String, ) -> Result { let db_service = { let database = db.0.lock().map_err(|e| e.to_string())?; Arc::new(database.service()) }; // The item itself, or any child linked to it by container id. const SCOPE: &str = "d.user_id = ? AND d.status = 'completed' AND ( d.item_id = ? OR d.item_id IN ( SELECT c.id FROM items c WHERE c.album_id = ? OR c.season_id = ? OR c.series_id = ? OR c.parent_id = ? ) )"; let file_query = Query::with_params( format!("SELECT d.file_path FROM downloads d WHERE {SCOPE}"), vec![ QueryParam::String(user_id.clone()), QueryParam::String(item_id.clone()), QueryParam::String(item_id.clone()), QueryParam::String(item_id.clone()), QueryParam::String(item_id.clone()), QueryParam::String(item_id.clone()), ], ); let file_paths: Vec = db_service .query_many(file_query, |row| row.get(0)) .await .map_err(|e| e.to_string())?; let delete_query = Query::with_params( format!("DELETE FROM downloads WHERE id IN (SELECT d.id FROM downloads d WHERE {SCOPE})"), vec![ QueryParam::String(user_id), QueryParam::String(item_id.clone()), QueryParam::String(item_id.clone()), QueryParam::String(item_id.clone()), QueryParam::String(item_id.clone()), QueryParam::String(item_id), ], ); let deleted_count = db_service .execute(delete_query) .await .map_err(|e| e.to_string())?; 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"); } /// The download directory as it looks on a device, for the path tests. const TEST_ROOT: &str = "/data/data/com.dtourolle.jellytau/files"; /// A queued `file_path` cannot walk out of the download directory. /// /// `download_item` is a command in its own right, so sanitizing in /// `download_item_and_start` was routed around by invoking it directly, and /// `start_download` then joined the raw string onto the target directory. /// /// TRACES: DR-211 | UT-205 #[test] fn test_queued_download_paths_cannot_escape_the_download_directory() { let root = Path::new(TEST_ROOT); assert!(confine_queued_path(root, "downloads/../../../../etc/cron.d/pwn").is_err()); assert!(confine_queued_path(root, "../.bashrc").is_err()); assert!(confine_queued_path(root, "/etc/cron.d/pwn").is_err()); // Why the absolute case needs its own guard rather than folding: the // join the download path performs discards the base entirely. assert_eq!( PathBuf::from(root).join("/etc/cron.d/pwn"), PathBuf::from("/etc/cron.d/pwn") ); } /// The paths the app builds for itself have to survive unchanged: files are /// already on disk and `downloads` rows point at these exact spellings. /// /// TRACES: DR-211 | UT-205 #[test] fn test_queued_download_paths_are_otherwise_unchanged() { let root = Path::new(TEST_ROOT); for path in [ "downloads/9f8e7d6c", // MediaCard's queue-for-reconnect "videos/movies/Arrival.mp4", // VideoDownloadButton "albums/abc123/01 - Opening.mp3", // queue_album_tracks // download_series/download_season build an absolute path, because // their base_path is `${targetDir}/videos`. "/data/data/com.dtourolle.jellytau/files/videos/Show/S01E02_Pilot.mp4", ] { assert_eq!(confine_queued_path(root, path).unwrap(), path); } // `download_item_and_start` sanitizes the name before calling // `download_item`; sanitizing it again must not yield a second, different // name, which would orphan the row and the file it names. let already = format!("downloads/{}.mp3", sanitize_filename("AC/DC: Live?")); assert_eq!(confine_queued_path(root, &already).unwrap(), already); } /// A completed row's `file_path` is read straight back into /// `std::fs::remove_file` when the download is deleted, so `mark_download_completed` /// must not be able to register a file outside the download directory. /// /// TRACES: DR-211 | UT-205 #[test] fn test_a_completed_download_cannot_register_a_file_outside_the_root() { let root = Path::new(TEST_ROOT); // What the worker actually reports — the absolute path it wrote. Stored // exactly as it arrives. let written = "/data/data/com.dtourolle.jellytau/files/downloads/9f8e7d6c"; assert_eq!( confine_to_root(root, Path::new(written)).unwrap(), PathBuf::from(written) ); // The row's own path, if the frontend falls back to it: relative, and it // resolves to where the worker wrote the file. assert_eq!( confine_to_root(root, &root.join("downloads/9f8e7d6c")).unwrap(), PathBuf::from(written) ); assert!(confine_to_root(root, Path::new("/home/u/.ssh/id_ed25519")).is_err()); assert!(confine_to_root( root, Path::new("/data/data/com.dtourolle.jellytau/files/../../../../etc/passwd") ) .is_err()); } /// 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(), } } // ===== Album download: track sourcing and album linkage ===== /// A database with just the tables the album-download path touches. fn album_test_db() -> Arc { let conn = rusqlite::Connection::open_in_memory().unwrap(); conn.execute_batch( r#" CREATE TABLE items ( id TEXT PRIMARY KEY, server_id TEXT NOT NULL, parent_id TEXT, name TEXT NOT NULL, item_type TEXT NOT NULL, album_id TEXT, album_name TEXT, album_artist TEXT, artists TEXT, index_number INTEGER ); CREATE TABLE downloads ( id INTEGER PRIMARY KEY AUTOINCREMENT, item_id TEXT NOT NULL, user_id TEXT NOT NULL, file_path TEXT NOT NULL, status TEXT DEFAULT 'pending', priority INTEGER DEFAULT 0, progress REAL DEFAULT 0, queued_at TEXT, item_name TEXT, artist_name TEXT, album_name TEXT, media_type TEXT, stream_url TEXT, target_dir TEXT, UNIQUE(item_id, user_id) ); INSERT INTO items (id, server_id, name, item_type) VALUES ('album1', 'server1', 'The Golden Age', 'MusicAlbum'); "#, ) .unwrap(); Arc::new(crate::storage::db_service::RusqliteService::new(Arc::new( Mutex::new(conn), ))) } fn album_track(id: &str, name: &str, index: i32) -> AlbumTrack { AlbumTrack { id: id.to_string(), name: name.to_string(), artist_name: Some("Woodkid".to_string()), album_name: Some("The Golden Age".to_string()), index_number: Some(index), } } /// The album-download regression: every track the album actually has must be /// queued, and each queued track must be linked to its album. /// /// `download_album` used to take its track list from /// `items WHERE album_id = ?`. Jellyfin does not return `AlbumId` on every /// listing endpoint, so tracks cached from those endpoints sit in `items` /// with a NULL `album_id` — invisible to that query. "Download album" then /// silently queued only the subset that happened to carry the link, which is /// the reported "only 4-5 songs downloaded". The same column is what offline /// browsing joins tracks to their album on (`i.album_id = ?` in /// `OfflineRepository::get_items`), so even a track that did download stayed /// invisible under its album offline. /// /// TRACES: UR-018, UR-055 | DR-173 | UT-170 #[tokio::test] async fn test_queue_album_tracks_queues_every_track_and_links_it_to_the_album() { let db = album_test_db(); // The cache holds all three tracks, but only one carries `album_id` — // exactly the state the bug report's database is in. for sql in [ "INSERT INTO items (id, server_id, name, item_type, album_id) \ VALUES ('t1', 'server1', 'Run Boy Run', 'Audio', 'album1')", "INSERT INTO items (id, server_id, name, item_type, album_id) \ VALUES ('t2', 'server1', 'The Great Escape', 'Audio', NULL)", "INSERT INTO items (id, server_id, name, item_type, album_id) \ VALUES ('t3', 'server1', 'Boat Song', 'Audio', NULL)", ] { db.execute(Query::new(sql)).await.unwrap(); } let tracks = vec![ album_track("t1", "Run Boy Run", 1), album_track("t2", "The Great Escape", 2), album_track("t3", "Boat Song", 3), ]; let ids = queue_album_tracks(&db, "album1", &tracks, "user1", "albums/album1") .await .unwrap(); assert_eq!( ids.len(), 3, "every track of the album must get a download row" ); let queued: i64 = db .query_one( Query::new("SELECT COUNT(*) FROM downloads WHERE status = 'pending'"), |row| row.get(0), ) .await .unwrap(); assert_eq!(queued, 3); // Each track is now linked to its album, so the offline album page can // find it once the download completes. let linked: i64 = db .query_one( Query::new("SELECT COUNT(*) FROM items WHERE album_id = 'album1'"), |row| row.get(0), ) .await .unwrap(); assert_eq!( linked, 3, "queued tracks must be linked to their album; offline browsing joins on album_id" ); } /// The returned ids must line up with the tracks that were passed in. The /// frontend used to pair `downloadIds[i]` with its own `tracks[i]`, which is /// only sound if both lists agree — they did not, because the backend /// ordered by `index_number` over a different set of rows. Resolving URLs in /// Rust removes the pairing entirely, but the order is still the contract /// for anything that reads the ids back. /// /// TRACES: UR-018, UR-055 | DR-173 | UT-170 #[tokio::test] async fn test_queue_album_tracks_returns_ids_in_track_order() { let db = album_test_db(); let tracks = vec![ album_track("t1", "Run Boy Run", 1), album_track("t2", "The Great Escape", 2), ]; let ids = queue_album_tracks(&db, "album1", &tracks, "user1", "albums/album1") .await .unwrap(); for (id, track) in ids.iter().zip(tracks.iter()) { let item_id: String = db .query_one( Query::with_params( "SELECT item_id FROM downloads WHERE id = ?", vec![QueryParam::Int64(*id)], ), |row| row.get(0), ) .await .unwrap(); assert_eq!(&item_id, &track.id, "id {} must be {}'s row", id, track.id); } } /// Re-queueing an album already partly downloaded must not duplicate rows or /// reset a completed track — it fills in what is missing. /// /// TRACES: UR-018, UR-055 | DR-173 | UT-170 #[tokio::test] async fn test_queue_album_tracks_is_idempotent() { let db = album_test_db(); let tracks = vec![ album_track("t1", "Run Boy Run", 1), album_track("t2", "The Great Escape", 2), ]; let first = queue_album_tracks(&db, "album1", &tracks, "user1", "albums/album1") .await .unwrap(); let second = queue_album_tracks(&db, "album1", &tracks, "user1", "albums/album1") .await .unwrap(); assert_eq!(first, second, "the same tracks must map to the same rows"); let rows: i64 = db .query_one(Query::new("SELECT COUNT(*) FROM downloads"), |row| { row.get(0) }) .await .unwrap(); assert_eq!(rows, 2, "re-queueing must not duplicate download rows"); } /// Two tracks of one album can share a title — a deluxe edition carrying the /// album version and a demo of the same song, or the same song on two discs. /// Naming the file after the title alone gave them one path, so the second /// download overwrote the first and the album ended up short however many /// duplicates it had. /// /// TRACES: UR-018, UR-055 | DR-173 | UT-172 #[test] fn test_album_file_names_are_unique_within_the_album() { let tracks = vec![ album_track("t1", "Crucified Again", 5), album_track("t2", "Crucified Again", 5), album_track("t3", "Get Right", 7), ]; let names = album_file_names(&tracks); assert_eq!(names.len(), 3); let unique: std::collections::HashSet<_> = names.iter().collect(); assert_eq!( unique.len(), 3, "every track of an album needs its own file: {:?}", names ); assert!(names.iter().all(|n| n.ends_with(".mp3")), "{:?}", names); assert!( names[2].contains("Get Right"), "an unambiguous title keeps its name: {}", names[2] ); } /// Path separators in a track title must not escape the album directory. /// /// TRACES: UR-018, UR-055 | DR-173 | UT-172 #[test] fn test_album_file_names_sanitize_the_title() { let names = album_file_names(&[album_track("t1", "AC/DC: Live?", 1)]); assert!(!names[0].contains('/'), "{}", names[0]); assert!(!names[0].contains(':'), "{}", names[0]); } /// The offline fallback reads the catalog directly, not through the /// availability-gated offline listing: queueing an album while the server is /// unreachable is a supported flow (the rows resolve on reconnect), and /// gating it on what is already downloaded would queue only the tracks the /// device already has. /// /// TRACES: UR-018, UR-055 | DR-173 | UT-170 #[tokio::test] async fn test_cached_album_tracks_finds_tracks_by_either_album_link() { let db = album_test_db(); for sql in [ "INSERT INTO items (id, server_id, name, item_type, album_id, index_number) \ VALUES ('t1', 'server1', 'Run Boy Run', 'Audio', 'album1', 1)", // Linked by parent_id only — how a track cached from a folder // listing lands in the catalog. "INSERT INTO items (id, server_id, name, item_type, parent_id, index_number) \ VALUES ('t2', 'server1', 'The Great Escape', 'Audio', 'album1', 2)", // A different album's track must not be swept in. "INSERT INTO items (id, server_id, name, item_type, album_id) \ VALUES ('other', 'server1', 'Iron', 'Audio', 'album2')", ] { db.execute(Query::new(sql)).await.unwrap(); } let tracks = cached_album_tracks(&db, "album1").await.unwrap(); let ids: Vec<_> = tracks.iter().map(|t| t.id.as_str()).collect(); assert_eq!(ids, vec!["t1", "t2"]); } /// Tracks the cache has never seen still get queued: the row is created and /// an `items` row is written for it, so the download is both startable and /// visible offline afterwards. /// /// TRACES: UR-018, UR-055 | DR-173 | UT-170 #[tokio::test] async fn test_queue_album_tracks_handles_tracks_absent_from_the_cache() { let db = album_test_db(); let tracks = vec![album_track("never-cached", "Iron", 1)]; let ids = queue_album_tracks(&db, "album1", &tracks, "user1", "albums/album1") .await .unwrap(); assert_eq!(ids.len(), 1); let (item_type, album_id): (String, Option) = db .query_one( Query::new("SELECT item_type, album_id FROM items WHERE id = 'never-cached'"), |row| Ok((row.get(0)?, row.get(1)?)), ) .await .unwrap(); assert_eq!(item_type, "Audio"); assert_eq!(album_id.as_deref(), Some("album1")); } }