Skip to main content

#
jellytau_lib/commands/download/
mod.rs

    1//! Tauri commands for download operations
    2
    3#[cfg(test)]
    4use crate::utils::lock::MutexSafe;
    5use log::{debug, error, info, warn};
    6use std::path::{Component, Path, PathBuf};
    7use std::sync::{Arc, Mutex};
    8use tauri::{Manager, State};
    9
    10use super::{DatabaseWrapper, SmartCacheWrapper};
    11use crate::download::network::{NetworkState, NetworkStateHandle, NetworkType};
    12use crate::download::{DownloadInfo, DownloadManager};
    13use crate::storage::db_service::{DatabaseService, Query, QueryParam};
    14
    15// Cohesive command clusters in their own submodules, re-exported so the command
    16// names remain at `commands::download::*` (invoke_handler unchanged).
    17mod pinning;
    18mod smart_cache;
    19pub use pinning::*;
    20pub use smart_cache::*;
    21
    22/// One row of the series episode listing used when queueing a whole series:
    23/// `(id, name, season_name, index_number, parent_index_number)`.
    24type EpisodeRow = (String, String, Option<String>, Option<i32>, Option<i32>);
    25
    26/// Wrapper for DownloadManager to be used as Tauri state
    27pub struct DownloadManagerWrapper(pub Mutex<DownloadManager>);
    28
    29/// Wrapper for the current network transport, used by the WiFi-only gate.
    30///
    31/// TRACES: UR-053 | DR-074
    32pub struct NetworkStateWrapper(pub NetworkStateHandle);
    33
    34/// Report the device's current network transport (Android → Rust).
    35///
    36/// The frontend calls this on startup and whenever the native network callback
    37/// fires. Updating to an acceptable network re-pumps the download queue, so a
    38/// queue parked on "waiting for WiFi" drains itself without user action.
    39///
    40/// TRACES: UR-053 | DR-074
    41#[tauri::command]
    42#[specta::specta]
    43pub async fn set_network_state(
    44    app: tauri::AppHandle,
    45    network: NetworkStateWrapperArg,
    46    db: State<'_, DatabaseWrapper>,
    47    download_manager: State<'_, DownloadManagerWrapper>,
    48) -> Result<(), String> {
    49    let new_state = NetworkState {
    50        network_type: network.network_type,
    51        unmetered: network.unmetered,
    52    };
    53
    54    let handle = app.state::<NetworkStateWrapper>().0.clone();
    55    let previous = handle.get().await;
    56    handle.set(new_state).await;
    57
    58    if previous != new_state {
    59        info!(
    60            "[network] Transport changed: {:?} (unmetered={}) -> {:?} (unmetered={})",
    61            previous.network_type, previous.unmetered, new_state.network_type, new_state.unmetered
    62        );
    63    }
    64
    65    // If the new network unblocks the gate, drain whatever was waiting.
    66    if downloads_allowed_on_current_network(&app).await {
    67        let db_service = {
    68            let database = db.0.lock().map_err(|e| e.to_string())?;
    69            Arc::new(database.service())
    70        };
    71        let active = {
    72            let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
    73            manager.get_active_downloads()
    74        };
    75        pump_download_queue(app.clone(), db_service, active).await;
    76    }
    77
    78    Ok(())
    79}
    80
    81/// Argument struct for [`set_network_state`].
    82///
    83/// TRACES: UR-053 | DR-074
    84#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
    85#[serde(rename_all = "camelCase")]
    86pub struct NetworkStateWrapperArg {
    87    pub network_type: NetworkType,
    88    pub unmetered: bool,
    89}
    90
    91/// Whether downloads are currently permitted by the WiFi-only gate.
    92///
    93/// The downloads UI uses this to render "Waiting for WiFi" on pending rows
    94/// rather than leaving them looking silently stuck.
    95///
    96/// TRACES: UR-053 | DR-074
    97#[tauri::command]
    98#[specta::specta]
    99pub async fn get_downloads_allowed(app: tauri::AppHandle) -> Result<bool, String> {
    100    Ok(downloads_allowed_on_current_network(&app).await)
    101}
    102
    103/// Download statistics computed server-side
    104#[allow(dead_code)]
    105#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
    106#[serde(rename_all = "camelCase")]
    107pub struct DownloadStats {
    108    pub total: usize,
    109    pub active_count: usize,
    110    pub queued_count: usize,
    111    pub completed_count: usize,
    112    pub failed_count: usize,
    113    pub paused_count: usize,
    114}
    115
    116/// Enhanced response with pre-computed stats
    117#[allow(dead_code)]
    118#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
    119#[serde(rename_all = "camelCase")]
    120pub struct DownloadsResponse {
    121    pub downloads: Vec<DownloadInfo>,
    122    pub stats: DownloadStats,
    123}
    124
    125/// Sanitize filename by removing invalid characters
    126fn sanitize_filename(name: &str) -> String {
    127    name.chars()
    128        .map(|c| match c {
    129            '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
    130            _ => c,
    131        })
    132        .collect()
    133}
    134
    135/// The directory every download has to stay inside: the storage root
    136/// `storage_get_path` hands the frontend, which is the database's parent.
    137///
    138/// TRACES: DR-211 | UT-205
    139fn download_root(db: &DatabaseWrapper) -> Result<PathBuf, String> {
    140    let database = db.0.lock().map_err(|e| e.to_string())?;
    141    database
    142        .path()
    143        .parent()
    144        .map(|p| p.to_path_buf())
    145        .ok_or_else(|| "Database path has no parent directory".to_string())
    146}
    147
    148/// Fold `..` out of `candidate` and require what is left to sit inside `root`.
    149///
    150/// Lexical rather than `canonicalize`, the same way `media_server::resolve_path`
    151/// does it: the file usually does not exist yet, so canonicalising would fail on
    152/// the ordinary case. The check has to come *after* the caller's join, because
    153/// `Path::join` drops the base when the joined half is absolute — such a path is
    154/// not folded, it is obeyed, and only the `starts_with` below catches it.
    155///
    156/// TRACES: DR-211 | UT-205
    157fn confine_to_root(root: &Path, candidate: &Path) -> Result<PathBuf, String> {
    158    let mut resolved = PathBuf::new();
    159    for component in candidate.components() {
    160        match component {
    161            Component::ParentDir => {
    162                resolved.pop();
    163            }
    164            Component::CurDir => {}
    165            other => resolved.push(other),
    166        }
    167    }
    168
    169    if resolved.starts_with(root) {
    170        Ok(resolved)
    171    } else {
    172        Err(format!(
    173            "Refusing a download path outside the download directory: {}",
    174            candidate.display()
    175        ))
    176    }
    177}
    178
    179/// Sanitize a queued download's path and confine it to the download directory.
    180///
    181/// Every path the app builds for itself comes back unchanged — files on disk and
    182/// `downloads` rows point at these exact spellings — and [`sanitize_filename`]
    183/// is idempotent, so the already-safe name `download_item_and_start` passes in
    184/// is not sanitized into a second, different one.
    185///
    186/// TRACES: DR-211 | UT-205
    187fn confine_queued_path(root: &Path, file_path: &str) -> Result<String, String> {
    188    let mut sanitized = PathBuf::new();
    189    for component in Path::new(file_path).components() {
    190        match component {
    191            Component::Normal(part) => sanitized.push(sanitize_filename(&part.to_string_lossy())),
    192            // Kept as they are, so `confine_to_root` is the single thing
    193            // deciding whether what they add up to is still inside the root.
    194            other => sanitized.push(other),
    195        }
    196    }
    197
    198    confine_to_root(root, &root.join(&sanitized))?;
    199    Ok(sanitized.to_string_lossy().to_string())
    200}
    201
    202/// Request payload for download_item_and_start (bundled to stay within specta's
    203/// 10-argument command limit).
    204#[derive(Debug, specta::Type, serde::Deserialize)]
    205#[serde(rename_all = "camelCase")]
    206pub struct DownloadItemAndStartRequest {
    207    pub item_id: String,
    208    pub user_id: String,
    209    pub stream_url: String,
    210    pub target_dir: String,
    211    pub item_name: Option<String>,
    212    pub artist_name: Option<String>,
    213    pub album_name: Option<String>,
    214}
    215
    216/// Request payload for download_item.
    217#[derive(Debug, specta::Type, serde::Deserialize)]
    218#[serde(rename_all = "camelCase")]
    219pub struct DownloadItemRequest {
    220    pub item_id: String,
    221    pub user_id: String,
    222    pub file_path: String,
    223    pub mime_type: Option<String>,
    224    pub priority: Option<i32>,
    225    pub item_name: Option<String>,
    226    pub artist_name: Option<String>,
    227    pub album_name: Option<String>,
    228    pub expected_size: Option<i64>,
    229}
    230
    231/// Request payload for download_video.
    232#[derive(Debug, specta::Type, serde::Deserialize)]
    233#[serde(rename_all = "camelCase")]
    234pub struct DownloadVideoRequest {
    235    pub item_id: String,
    236    pub user_id: String,
    237    pub file_path: String,
    238    pub mime_type: Option<String>,
    239    pub priority: Option<i32>,
    240    pub item_name: Option<String>,
    241    pub quality_preset: Option<String>,
    242    pub series_name: Option<String>,
    243    pub season_name: Option<String>,
    244    pub episode_number: Option<i32>,
    245    pub season_number: Option<i32>,
    246}
    247
    248/// Queue and start a download in a single atomic operation
    249/// This simplifies the frontend flow by combining multiple steps
    250#[tauri::command]
    251#[specta::specta]
    252pub async fn download_item_and_start(
    253    db: State<'_, DatabaseWrapper>,
    254    smart_cache: State<'_, SmartCacheWrapper>,
    255    download_manager: State<'_, DownloadManagerWrapper>,
    256    app: tauri::AppHandle,
    257    request: DownloadItemAndStartRequest,
    258) -> Result<i64, String> {
    259    let DownloadItemAndStartRequest {
    260        item_id,
    261        user_id,
    262        stream_url,
    263        target_dir,
    264        item_name,
    265        artist_name,
    266        album_name,
    267    } = request;
    268    // Sanitize filename
    269    let safe_name = sanitize_filename(item_name.as_deref().unwrap_or(&item_id));
    270    let file_path = format!("downloads/{}.mp3", safe_name);
    271
    272    // Queue the download
    273    let download_id = download_item(
    274        db.clone(),
    275        smart_cache.clone(),
    276        DownloadItemRequest {
    277            item_id,
    278            user_id,
    279            file_path,
    280            mime_type: None,
    281            priority: None,
    282            item_name,
    283            artist_name,
    284            album_name,
    285            expected_size: None,
    286        },
    287    )
    288    .await?;
    289
    290    // Start the download immediately
    291    start_download(
    292        db,
    293        download_manager,
    294        app,
    295        download_id,
    296        stream_url,
    297        target_dir,
    298    )
    299    .await?;
    300
    301    Ok(download_id)
    302}
    303
    304/// Queue a media item for download
    305#[tauri::command]
    306#[specta::specta]
    307pub async fn download_item(
    308    db: State<'_, DatabaseWrapper>,
    309    smart_cache: State<'_, SmartCacheWrapper>,
    310    request: DownloadItemRequest,
    311) -> Result<i64, String> {
    312    let DownloadItemRequest {
    313        item_id,
    314        user_id,
    315        file_path,
    316        mime_type,
    317        priority,
    318        item_name,
    319        artist_name,
    320        album_name,
    321        expected_size,
    322    } = request;
    323
    324    // `start_download` joins this onto the target directory, and `Path::join`
    325    // drops the base when the second half is absolute, so the row itself has to
    326    // be confined — not only the place it is used. `download_item_and_start`
    327    // sanitizes the name it builds, but `download_item` is a command in its own
    328    // right, so that guard was simply routed around by calling this directly.
    329    // TRACES: DR-211 | UT-205
    330    let file_path = {
    331        let root = download_root(&db)?;
    332        confine_queued_path(&root, &file_path)?
    333    };
    334
    335    let db_service = {
    336        let database = db.0.lock().map_err(|e| e.to_string())?;
    337        Arc::new(database.service())
    338    };
    339
    340    // Check storage limit if size is known
    341    if let Some(size) = expected_size {
    342        // Clone Arc to avoid holding lock during async operations
    343        let cache_arc = {
    344            let guard = smart_cache.0.lock().map_err(|e| e.to_string())?;
    345            guard.clone()
    346        };
    347
    348        // Check if we have space
    349        let can_download = cache_arc
    350            .can_download_async(&db_service, &user_id, size as u64)
    351            .await;
    352
    353        if !can_download {
    354            warn!("Storage limit reached. Attempting to free space...");
    355
    356            // Reclaim expired temporary entries first: they are dead weight, so
    357            // freeing them may avoid evicting cache that is still within its
    358            // life. Best-effort — a failure here just means eviction does more.
    359            // TRACES: UR-071 | DR-127
    360            match cache_arc
    361                .reclaim_expired_async(&db_service, &user_id, &chrono::Utc::now().to_rfc3339())
    362                .await
    363            {
    364                Ok(n) if n > 0 => info!("Reclaimed {} expired cache entries", n),
    365                Ok(_) => {}
    366                Err(e) => warn!("Expired-entry reclaim failed: {}", e),
    367            }
    368
    369            // Try to evict LRU items to make space
    370            match cache_arc
    371                .evict_lru_async(&db_service, &user_id, size as u64)
    372                .await
    373            {
    374                Ok(freed) if freed > 0 => {
    375                    info!("Freed {} bytes, proceeding with download", freed);
    376                }
    377                Ok(_) => {
    378                    let storage_limit =
    379                        cache_arc.get_config().map(|c| c.storage_limit).unwrap_or(0);
    380                    return Err(format!(
    381                        "Storage limit reached ({} bytes). Unable to free enough space.",
    382                        storage_limit
    383                    ));
    384                }
    385                Err(e) => {
    386                    error!("Failed to evict items: {}", e);
    387                    return Err(format!("Storage limit reached. Eviction failed: {}", e));
    388                }
    389            }
    390        }
    391    }
    392
    393    // Insert or update download record with metadata
    394    let insert_query = Query::with_params(
    395        "INSERT INTO downloads (item_id, user_id, file_path, mime_type, status, priority, queued_at, item_name, artist_name, album_name)
    396         VALUES (?, ?, ?, ?, 'pending', ?, CURRENT_TIMESTAMP, ?, ?, ?)
    397         ON CONFLICT(item_id, user_id) DO UPDATE SET
    398           priority = excluded.priority,
    399           status = 'pending',
    400           queued_at = CURRENT_TIMESTAMP,
    401           item_name = COALESCE(excluded.item_name, downloads.item_name),
    402           artist_name = COALESCE(excluded.artist_name, downloads.artist_name),
    403           album_name = COALESCE(excluded.album_name, downloads.album_name)",
    404        vec![
    405            QueryParam::String(item_id.clone()),
    406            QueryParam::String(user_id.clone()),
    407            QueryParam::String(file_path),
    408            mime_type.map(QueryParam::String).unwrap_or(QueryParam::Null),
    409            QueryParam::Int(priority.unwrap_or(0)),
    410            item_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
    411            artist_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
    412            album_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
    413        ],
    414    );
    415
    416    db_service
    417        .execute(insert_query)
    418        .await
    419        .map_err(|e| e.to_string())?;
    420
    421    // Query for the download ID by unique constraint columns
    422    // NOTE: last_insert_rowid() doesn't work reliably with UPSERT - it only updates on INSERT, not UPDATE
    423    let id_query = Query::with_params(
    424        "SELECT id FROM downloads WHERE item_id = ? AND user_id = ?",
    425        vec![QueryParam::String(item_id), QueryParam::String(user_id)],
    426    );
    427
    428    let download_id: i64 = db_service
    429        .query_one(id_query, |row| row.get(0))
    430        .await
    431        .map_err(|e| e.to_string())?;
    432
    433    Ok(download_id)
    434}
    435
    436/// One track of an album, as the album-download path queues it.
    437///
    438/// `artist_name` carries whatever the catalog holds for the track's artists (a
    439/// JSON array, as stored on `items.artists`); it is display metadata for the
    440/// downloads list, not a lookup key.
    441///
    442/// TRACES: UR-018, UR-055 | DR-173
    443#[derive(Debug, Clone, PartialEq)]
    444pub(crate) struct AlbumTrack {
    445    pub id: String,
    446    pub name: String,
    447    pub artist_name: Option<String>,
    448    pub album_name: Option<String>,
    449    pub index_number: Option<i32>,
    450}
    451
    452impl From<&crate::repository::types::MediaItem> for AlbumTrack {
    453    fn from(item: &crate::repository::types::MediaItem) -> Self {
    454        Self {
    455            id: item.id.clone(),
    456            name: item.name.clone(),
    457            artist_name: item
    458                .artists
    459                .as_ref()
    460                .and_then(|a| serde_json::to_string(a).ok()),
    461            album_name: item.album_name.clone(),
    462            index_number: item.index_number,
    463        }
    464    }
    465}
    466
    467/// The album's tracks as the local catalog cache knows them.
    468///
    469/// Only a fallback for [`download_album`]: the cache links a track to its album
    470/// through `items.album_id`, which Jellyfin does not populate on every listing
    471/// endpoint, so this can legitimately return fewer tracks than the album has.
    472///
    473/// TRACES: UR-018, UR-055 | DR-173
    474pub(crate) async fn cached_album_tracks(
    475    db_service: &Arc<crate::storage::db_service::RusqliteService>,
    476    album_id: &str,
    477) -> Result<Vec<AlbumTrack>, String> {
    478    let tracks_query = Query::with_params(
    479        "SELECT id, name, artists, album_name, index_number FROM items
    480         WHERE (album_id = ? OR parent_id = ?) AND item_type = 'Audio'
    481         ORDER BY index_number",
    482        vec![
    483            QueryParam::String(album_id.to_string()),
    484            QueryParam::String(album_id.to_string()),
    485        ],
    486    );
    487
    488    db_service
    489        .query_many(tracks_query, |row| {
    490            Ok(AlbumTrack {
    491                id: row.get(0)?,
    492                name: row.get(1)?,
    493                artist_name: row.get(2)?,
    494                album_name: row.get(3)?,
    495                index_number: row.get(4)?,
    496            })
    497        })
    498        .await
    499        .map_err(|e| e.to_string())
    500}
    501
    502/// Queue one download row per track and link every track to its album.
    503///
    504/// The linkage is the half that is easy to miss: offline browsing joins a track
    505/// to its album on `items.album_id` (see `OfflineRepository::get_items`), so a
    506/// track whose cached row lacks it stays invisible under the album even after
    507/// its file is on disk. Queuing a track *is* the statement that it belongs to
    508/// this album, so the link is written here rather than hoped for from whichever
    509/// listing endpoint happened to cache the row.
    510///
    511/// Idempotent: re-queuing an album fills in what is missing and returns the same
    512/// row ids, in the order the tracks were given.
    513///
    514/// A file name per track, unique within the album.
    515///
    516/// A title is not a unique name inside its own album: a deluxe edition carries
    517/// the album version and a demo of the same song, and a two-disc set repeats
    518/// titles across discs. Naming files after the title alone gave those tracks one
    519/// path, and each download overwrote the previous one — an album that quietly
    520/// ends up short by however many titles it repeats. The track number
    521/// disambiguates the ordinary case; anything still colliding falls back to the
    522/// item id, which is unique by construction.
    523///
    524/// TRACES: UR-018, UR-055 | DR-173 | UT-172
    525pub(crate) fn album_file_names(tracks: &[AlbumTrack]) -> Vec<String> {
    526    let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
    527    for track in tracks {
    528        *counts.entry(track.name.to_lowercase()).or_default() += 1;
    529    }
    530
    531    tracks
    532        .iter()
    533        .map(|track| {
    534            let title = sanitize_filename(&track.name);
    535            if counts.get(&track.name.to_lowercase()).copied().unwrap_or(0) <= 1 {
    536                return format!("{}.mp3", title);
    537            }
    538            match track.index_number {
    539                Some(n) => format!("{:02} - {} [{}].mp3", n, title, track.id),
    540                None => format!("{} [{}].mp3", title, track.id),
    541            }
    542        })
    543        .collect()
    544}
    545
    546/// TRACES: UR-018, UR-055 | DR-173 | UT-170
    547pub(crate) async fn queue_album_tracks(
    548    db_service: &Arc<crate::storage::db_service::RusqliteService>,
    549    album_id: &str,
    550    tracks: &[AlbumTrack],
    551    user_id: &str,
    552    base_path: &str,
    553) -> Result<Vec<i64>, String> {
    554    let mut download_ids = Vec::with_capacity(tracks.len());
    555    let file_names = album_file_names(tracks);
    556
    557    for (track, file_name) in tracks.iter().zip(file_names) {
    558        // Cache a row for a track the catalog has never seen, borrowing the
    559        // album's server. Nothing is inserted when the album itself is unknown,
    560        // which also keeps the parent_id foreign key satisfiable.
    561        let cache_query = Query::with_params(
    562            "INSERT OR IGNORE INTO items
    563                (id, server_id, parent_id, name, item_type, album_id, album_name, artists, index_number)
    564             SELECT ?, a.server_id, a.id, ?, 'Audio', a.id, ?, ?, ?
    565             FROM items a WHERE a.id = ?",
    566            vec![
    567                QueryParam::String(track.id.clone()),
    568                QueryParam::String(track.name.clone()),
    569                track
    570                    .album_name
    571                    .clone()
    572                    .map(QueryParam::String)
    573                    .unwrap_or(QueryParam::Null),
    574                track
    575                    .artist_name
    576                    .clone()
    577                    .map(QueryParam::String)
    578                    .unwrap_or(QueryParam::Null),
    579                track
    580                    .index_number
    581                    .map(QueryParam::Int)
    582                    .unwrap_or(QueryParam::Null),
    583                QueryParam::String(album_id.to_string()),
    584            ],
    585        );
    586        db_service
    587            .execute(cache_query)
    588            .await
    589            .map_err(|e| e.to_string())?;
    590
    591        // Link an already-cached track to the album. The parent_id subquery
    592        // resolves to NULL when the album is not cached, so the foreign key
    593        // holds either way.
    594        let link_query = Query::with_params(
    595            "UPDATE items
    596                SET album_id = ?,
    597                    parent_id = COALESCE(parent_id, (SELECT id FROM items WHERE id = ?))
    598              WHERE id = ?",
    599            vec![
    600                QueryParam::String(album_id.to_string()),
    601                QueryParam::String(album_id.to_string()),
    602                QueryParam::String(track.id.clone()),
    603            ],
    604        );
    605        db_service
    606            .execute(link_query)
    607            .await
    608            .map_err(|e| e.to_string())?;
    609
    610        let file_path = format!("{}/{}", base_path, file_name);
    611
    612        // Queue at album priority (100). A track already downloaded stays
    613        // completed — re-queuing an album must fill the gaps, not re-fetch it.
    614        let insert_query = Query::with_params(
    615            "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at, item_name, artist_name, album_name, media_type)
    616             VALUES (?, ?, ?, 'pending', 100, CURRENT_TIMESTAMP, ?, ?, ?, 'audio')
    617             ON CONFLICT(item_id, user_id) DO UPDATE SET
    618               priority = 100,
    619               status = CASE WHEN downloads.status = 'completed' THEN 'completed' ELSE 'pending' END,
    620               media_type = 'audio',
    621               item_name = COALESCE(excluded.item_name, downloads.item_name),
    622               artist_name = COALESCE(excluded.artist_name, downloads.artist_name),
    623               album_name = COALESCE(excluded.album_name, downloads.album_name)",
    624            vec![
    625                QueryParam::String(track.id.clone()),
    626                QueryParam::String(user_id.to_string()),
    627                QueryParam::String(file_path),
    628                QueryParam::String(track.name.clone()),
    629                track
    630                    .artist_name
    631                    .clone()
    632                    .map(QueryParam::String)
    633                    .unwrap_or(QueryParam::Null),
    634                track
    635                    .album_name
    636                    .clone()
    637                    .map(QueryParam::String)
    638                    .unwrap_or(QueryParam::Null),
    639            ],
    640        );
    641
    642        db_service
    643            .execute(insert_query)
    644            .await
    645            .map_err(|e| e.to_string())?;
    646
    647        // Query for the actual download ID (last_insert_rowid doesn't work with UPSERT)
    648        let id_query = Query::with_params(
    649            "SELECT id FROM downloads WHERE item_id = ? AND user_id = ?",
    650            vec![
    651                QueryParam::String(track.id.clone()),
    652                QueryParam::String(user_id.to_string()),
    653            ],
    654        );
    655
    656        let download_id: i64 = db_service
    657            .query_one(id_query, |row| row.get(0))
    658            .await
    659            .map_err(|e| e.to_string())?;
    660        download_ids.push(download_id);
    661    }
    662
    663    Ok(download_ids)
    664}
    665
    666/// Queue an entire album for download.
    667///
    668/// Owns the whole operation: the album's track list comes from the server (the
    669/// only place that knows all of it), every track is queued and linked to its
    670/// album, each row's stream URL is resolved here, and the queue is pumped.
    671///
    672/// The frontend used to do the second half — resolve one URL per track and pair
    673/// it with the returned ids **by position**. That pairing had no basis: the ids
    674/// came back in the backend's own order over a different set of rows, so
    675/// whenever the two lists disagreed a row was handed another track's URL, and
    676/// any track past the end of the shorter list was never started at all. Nothing
    677/// crosses the boundary now except the album id.
    678///
    679/// TRACES: UR-018, UR-055 | DR-173 | UT-170
    680#[tauri::command]
    681#[specta::specta]
    682// Three of the eight arguments are Tauri `State<'_, _>` injections plus the
    683// `AppHandle`, not caller input. Folding the rest into a struct would change the
    684// IPC contract and the generated TypeScript for no readability gain.
    685#[allow(clippy::too_many_arguments)]
    686pub async fn download_album(
    687    db: State<'_, DatabaseWrapper>,
    688    repository: State<'_, crate::commands::repository::RepositoryManagerWrapper>,
    689    download_manager: State<'_, DownloadManagerWrapper>,
    690    app: tauri::AppHandle,
    691    handle: String,
    692    album_id: String,
    693    user_id: String,
    694    base_path: String,
    695) -> Result<Vec<i64>, String> {
    696    let db_service = {
    697        let database = db.0.lock().map_err(|e| e.to_string())?;
    698        Arc::new(database.service())
    699    };
    700
    701    let repo = repository.0.get(&handle);
    702
    703    // Ask the server what the album contains; the cache is only a fallback for
    704    // when it cannot answer.
    705    let tracks: Vec<AlbumTrack> = match &repo {
    706        Some(repo) => match repo.get_album_tracks(&album_id).await {
    707            Ok(items) if !items.is_empty() => items.iter().map(AlbumTrack::from).collect(),
    708            Ok(_) => cached_album_tracks(&db_service, &album_id).await?,
    709            Err(e) => {
    710                warn!(
    711                    "[download_album] Could not list album {} from the repository ({:?}); \
    712                     falling back to the cached track list",
    713                    album_id, e
    714                );
    715                cached_album_tracks(&db_service, &album_id).await?
    716            }
    717        },
    718        None => cached_album_tracks(&db_service, &album_id).await?,
    719    };
    720
    721    if tracks.is_empty() {
    722        warn!("[download_album] No tracks found for album {}", album_id);
    723        return Ok(Vec::new());
    724    }
    725
    726    let download_ids =
    727        queue_album_tracks(&db_service, &album_id, &tracks, &user_id, &base_path).await?;
    728
    729    info!(
    730        "[download_album] Queued {} track(s) for album {}",
    731        download_ids.len(),
    732        album_id
    733    );
    734
    735    // Resolve each queued row's stream URL here, then pump. Without a
    736    // repository (or while offline) the rows stay pending with no URL and
    737    // `resume_queued_downloads` picks them up on reconnect.
    738    let Some(repo) = repo else {
    739        return Ok(download_ids);
    740    };
    741
    742    let target_dir = {
    743        let database = db.0.lock().map_err(|e| e.to_string())?;
    744        database
    745            .path()
    746            .parent()
    747            .ok_or_else(|| "Database path has no parent directory".to_string())?
    748            .to_string_lossy()
    749            .to_string()
    750    };
    751
    752    let repo_for_resolve = Arc::clone(&repo);
    753    let outcome = crate::commands::catalog::resolve_pending_download_urls(
    754        &db_service,
    755        &target_dir,
    756        Some(&download_ids),
    757        move |item_id: String, _media_type: String, _quality: String| {
    758            let repo = Arc::clone(&repo_for_resolve);
    759            async move {
    760                use crate::repository::MediaRepository;
    761                match repo.get_audio_stream_url(&item_id).await {
    762                    Ok(url) => Some(url),
    763                    Err(e) => {
    764                        warn!(
    765                            "[download_album] Failed to resolve stream URL for {}: {:?}",
    766                            item_id, e
    767                        );
    768                        None
    769                    }
    770                }
    771            }
    772        },
    773    )
    774    .await?;
    775
    776    if outcome.failed > 0 {
    777        warn!(
    778            "[download_album] {} track(s) could not be resolved and stay queued for the next \
    779             reconnect",
    780            outcome.failed
    781        );
    782    }
    783
    784    let active_downloads = {
    785        let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
    786        manager.get_active_downloads()
    787    };
    788    pump_download_queue(app, db_service, active_downloads).await;
    789
    790    Ok(download_ids)
    791}
    792
    793/// Queue a video item (movie or episode) for download with quality preset
    794#[tauri::command]
    795#[specta::specta]
    796pub async fn download_video(
    797    db: State<'_, DatabaseWrapper>,
    798    request: DownloadVideoRequest,
    799) -> Result<i64, String> {
    800    let DownloadVideoRequest {
    801        item_id,
    802        user_id,
    803        file_path,
    804        mime_type,
    805        priority,
    806        item_name,
    807        quality_preset,
    808        series_name,
    809        season_name,
    810        episode_number,
    811        season_number,
    812    } = request;
    813    let db_service = {
    814        let database = db.0.lock().map_err(|e| e.to_string())?;
    815        Arc::new(database.service())
    816    };
    817
    818    let quality = quality_preset.unwrap_or_else(|| "original".to_string());
    819
    820    // Insert or update download record with video metadata
    821    let insert_query = Query::with_params(
    822        "INSERT INTO downloads (item_id, user_id, file_path, mime_type, status, priority, queued_at,
    823                                item_name, quality_preset, media_type, series_name, season_name,
    824                                episode_number, season_number)
    825         VALUES (?, ?, ?, ?, 'pending', ?, CURRENT_TIMESTAMP, ?, ?, 'video', ?, ?, ?, ?)
    826         ON CONFLICT(item_id, user_id) DO UPDATE SET
    827           priority = excluded.priority,
    828           status = 'pending',
    829           queued_at = CURRENT_TIMESTAMP,
    830           quality_preset = excluded.quality_preset,
    831           item_name = COALESCE(excluded.item_name, downloads.item_name),
    832           series_name = COALESCE(excluded.series_name, downloads.series_name),
    833           season_name = COALESCE(excluded.season_name, downloads.season_name),
    834           episode_number = COALESCE(excluded.episode_number, downloads.episode_number),
    835           season_number = COALESCE(excluded.season_number, downloads.season_number)",
    836        vec![
    837            QueryParam::String(item_id.clone()),
    838            QueryParam::String(user_id.clone()),
    839            QueryParam::String(file_path),
    840            mime_type.map(QueryParam::String).unwrap_or(QueryParam::Null),
    841            QueryParam::Int(priority.unwrap_or(0)),
    842            item_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
    843            QueryParam::String(quality),
    844            series_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
    845            season_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
    846            episode_number.map(QueryParam::Int).unwrap_or(QueryParam::Null),
    847            season_number.map(QueryParam::Int).unwrap_or(QueryParam::Null),
    848        ],
    849    );
    850
    851    db_service
    852        .execute(insert_query)
    853        .await
    854        .map_err(|e| e.to_string())?;
    855
    856    // Query for the download ID by unique constraint columns
    857    let id_query = Query::with_params(
    858        "SELECT id FROM downloads WHERE item_id = ? AND user_id = ?",
    859        vec![QueryParam::String(item_id), QueryParam::String(user_id)],
    860    );
    861
    862    let download_id: i64 = db_service
    863        .query_one(id_query, |row| row.get(0))
    864        .await
    865        .map_err(|e| e.to_string())?;
    866
    867    Ok(download_id)
    868}
    869
    870/// Queue all episodes of a series for download
    871#[tauri::command]
    872#[specta::specta]
    873pub async fn download_series(
    874    db: State<'_, DatabaseWrapper>,
    875    series_id: String,
    876    series_name: String,
    877    user_id: String,
    878    base_path: String,
    879    quality_preset: Option<String>,
    880) -> Result<Vec<i64>, String> {
    881    let db_service = {
    882        let database = db.0.lock().map_err(|e| e.to_string())?;
    883        Arc::new(database.service())
    884    };
    885
    886    let quality = quality_preset.unwrap_or_else(|| "original".to_string());
    887
    888    // Get all episodes for this series, ordered by season and episode number
    889    let episodes_query = Query::with_params(
    890        "SELECT id, name, season_name, index_number, parent_index_number
    891         FROM items
    892         WHERE series_id = ? AND item_type = 'Episode'
    893         ORDER BY parent_index_number, index_number",
    894        vec![QueryParam::String(series_id)],
    895    );
    896
    897    let episodes: Vec<EpisodeRow> = db_service
    898        .query_many(episodes_query, |row| {
    899            Ok((
    900                row.get(0)?,
    901                row.get(1)?,
    902                row.get(2)?,
    903                row.get(3)?,
    904                row.get(4)?,
    905            ))
    906        })
    907        .await
    908        .map_err(|e| e.to_string())?;
    909
    910    let mut download_ids = Vec::new();
    911
    912    // Queue each episode with descending priority (first episodes download first)
    913    // Priority starts high and decreases so earlier episodes finish first
    914    let total_episodes = episodes.len() as i32;
    915    for (idx, (episode_id, episode_name, season_name, episode_number, season_number)) in
    916        episodes.into_iter().enumerate()
    917    {
    918        let priority = 1000 - idx as i32; // High priority for first episodes
    919
    920        // Create path like: videos/SeriesName/S01E01_Title.mp4
    921        let season_num = season_number.unwrap_or(1);
    922        let episode_num = episode_number.unwrap_or(1);
    923        let file_name = format!(
    924            "S{:02}E{:02}_{}.mp4",
    925            season_num,
    926            episode_num,
    927            sanitize_filename(&episode_name)
    928        );
    929        let file_path = format!(
    930            "{}/{}/{}",
    931            base_path,
    932            sanitize_filename(&series_name),
    933            file_name
    934        );
    935
    936        let insert_query = Query::with_params(
    937            "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at,
    938                                    item_name, quality_preset, media_type, series_name, season_name,
    939                                    episode_number, season_number)
    940             VALUES (?, ?, ?, 'pending', ?, CURRENT_TIMESTAMP, ?, ?, 'video', ?, ?, ?, ?)
    941             ON CONFLICT(item_id, user_id) DO UPDATE SET
    942               priority = excluded.priority,
    943               status = 'pending',
    944               queued_at = CURRENT_TIMESTAMP,
    945               quality_preset = excluded.quality_preset,
    946               item_name = COALESCE(excluded.item_name, downloads.item_name),
    947               series_name = COALESCE(excluded.series_name, downloads.series_name),
    948               season_name = COALESCE(excluded.season_name, downloads.season_name),
    949               episode_number = COALESCE(excluded.episode_number, downloads.episode_number),
    950               season_number = COALESCE(excluded.season_number, downloads.season_number)",
    951            vec![
    952                QueryParam::String(episode_id.clone()),
    953                QueryParam::String(user_id.clone()),
    954                QueryParam::String(file_path),
    955                QueryParam::Int(priority),
    956                QueryParam::String(episode_name),
    957                QueryParam::String(quality.clone()),
    958                QueryParam::String(series_name.clone()),
    959                season_name
    960                    .map(QueryParam::String)
    961                    .unwrap_or(QueryParam::Null),
    962                episode_number
    963                    .map(QueryParam::Int)
    964                    .unwrap_or(QueryParam::Null),
    965                season_number
    966                    .map(QueryParam::Int)
    967                    .unwrap_or(QueryParam::Null),
    968            ],
    969        );
    970
    971        db_service
    972            .execute(insert_query)
    973            .await
    974            .map_err(|e| e.to_string())?;
    975
    976        let id_query = Query::with_params(
    977            "SELECT id FROM downloads WHERE item_id = ? AND user_id = ?",
    978            vec![
    979                QueryParam::String(episode_id),
    980                QueryParam::String(user_id.clone()),
    981            ],
    982        );
    983
    984        let download_id: i64 = db_service
    985            .query_one(id_query, |row| row.get(0))
    986            .await
    987            .map_err(|e| e.to_string())?;
    988
    989        download_ids.push(download_id);
    990    }
    991
    992    info!(
    993        "[download_series] Queued {} episodes for series '{}'",
    994        total_episodes, series_name
    995    );
    996    Ok(download_ids)
    997}
    998
    999/// Queue all episodes of a specific season for download
    1000#[tauri::command]
    1001#[specta::specta]
    1002// One of the eight arguments is a Tauri `State<'_, _>` injection; the rest are
    1003// the season's identifying fields. Folding them into a struct would change the
    1004// IPC contract and the generated TypeScript for no readability gain.
    1005#[allow(clippy::too_many_arguments)]
    1006pub async fn download_season(
    1007    db: State<'_, DatabaseWrapper>,
    1008    season_id: String,
    1009    series_name: String,
    1010    season_name: String,
    1011    season_number: i32,
    1012    user_id: String,
    1013    base_path: String,
    1014    quality_preset: Option<String>,
    1015) -> Result<Vec<i64>, String> {
    1016    let db_service = {
    1017        let database = db.0.lock().map_err(|e| e.to_string())?;
    1018        Arc::new(database.service())
    1019    };
    1020
    1021    let quality = quality_preset.unwrap_or_else(|| "original".to_string());
    1022
    1023    // Get all episodes for this season, ordered by episode number
    1024    let episodes_query = Query::with_params(
    1025        "SELECT id, name, index_number
    1026         FROM items
    1027         WHERE parent_id = ? AND item_type = 'Episode'
    1028         ORDER BY index_number",
    1029        vec![QueryParam::String(season_id)],
    1030    );
    1031
    1032    let episodes: Vec<(String, String, Option<i32>)> = db_service
    1033        .query_many(episodes_query, |row| {
    1034            Ok((row.get(0)?, row.get(1)?, row.get(2)?))
    1035        })
    1036        .await
    1037        .map_err(|e| e.to_string())?;
    1038
    1039    let mut download_ids = Vec::new();
    1040
    1041    // Queue each episode with priority based on episode number
    1042    for (idx, (episode_id, episode_name, episode_number)) in episodes.into_iter().enumerate() {
    1043        let priority = 1000 - idx as i32;
    1044        let episode_num = episode_number.unwrap_or(1);
    1045
    1046        let file_name = format!(
    1047            "S{:02}E{:02}_{}.mp4",
    1048            season_number,
    1049            episode_num,
    1050            sanitize_filename(&episode_name)
    1051        );
    1052        let file_path = format!(
    1053            "{}/{}/{}",
    1054            base_path,
    1055            sanitize_filename(&series_name),
    1056            file_name
    1057        );
    1058
    1059        let insert_query = Query::with_params(
    1060            "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at,
    1061                                    item_name, quality_preset, media_type, series_name, season_name,
    1062                                    episode_number, season_number)
    1063             VALUES (?, ?, ?, 'pending', ?, CURRENT_TIMESTAMP, ?, ?, 'video', ?, ?, ?, ?)
    1064             ON CONFLICT(item_id, user_id) DO UPDATE SET
    1065               priority = excluded.priority,
    1066               status = 'pending',
    1067               queued_at = CURRENT_TIMESTAMP,
    1068               quality_preset = excluded.quality_preset",
    1069            vec![
    1070                QueryParam::String(episode_id.clone()),
    1071                QueryParam::String(user_id.clone()),
    1072                QueryParam::String(file_path),
    1073                QueryParam::Int(priority),
    1074                QueryParam::String(episode_name),
    1075                QueryParam::String(quality.clone()),
    1076                QueryParam::String(series_name.clone()),
    1077                QueryParam::String(season_name.clone()),
    1078                QueryParam::Int(episode_num),
    1079                QueryParam::Int(season_number),
    1080            ],
    1081        );
    1082
    1083        db_service
    1084            .execute(insert_query)
    1085            .await
    1086            .map_err(|e| e.to_string())?;
    1087
    1088        let id_query = Query::with_params(
    1089            "SELECT id FROM downloads WHERE item_id = ? AND user_id = ?",
    1090            vec![
    1091                QueryParam::String(episode_id),
    1092                QueryParam::String(user_id.clone()),
    1093            ],
    1094        );
    1095
    1096        let download_id: i64 = db_service
    1097            .query_one(id_query, |row| row.get(0))
    1098            .await
    1099            .map_err(|e| e.to_string())?;
    1100
    1101        download_ids.push(download_id);
    1102    }
    1103
    1104    info!(
    1105        "[download_season] Queued {} episodes for {} - {}",
    1106        download_ids.len(),
    1107        series_name,
    1108        season_name
    1109    );
    1110    Ok(download_ids)
    1111}
    1112
    1113/// Helper to compute download statistics from a list of downloads
    1114#[allow(dead_code)]
    1115fn compute_download_stats(downloads: &[DownloadInfo]) -> DownloadStats {
    1116    let mut stats = DownloadStats {
    1117        total: downloads.len(),
    1118        active_count: 0,
    1119        queued_count: 0,
    1120        completed_count: 0,
    1121        failed_count: 0,
    1122        paused_count: 0,
    1123    };
    1124
    1125    for download in downloads {
    1126        match download.status.as_str() {
    1127            "downloading" => stats.active_count += 1,
    1128            "pending" => stats.queued_count += 1,
    1129            "completed" => stats.completed_count += 1,
    1130            "failed" => stats.failed_count += 1,
    1131            "paused" => stats.paused_count += 1,
    1132            _ => {}
    1133        }
    1134    }
    1135
    1136    stats
    1137}
    1138
    1139/// Get all downloads for a user, optionally filtered by status
    1140#[tauri::command]
    1141#[specta::specta]
    1142pub async fn get_downloads(
    1143    db: State<'_, DatabaseWrapper>,
    1144    user_id: String,
    1145    status_filter: Option<Vec<String>>,
    1146) -> Result<DownloadsResponse, String> {
    1147    let db_service = {
    1148        let database = db.0.lock().map_err(|e| e.to_string())?;
    1149        Arc::new(database.service())
    1150    };
    1151
    1152    // Use COALESCE to prefer stored metadata over LEFT JOIN results
    1153    // This ensures correct display even when items aren't synced locally
    1154    let (query_str, params) = if let Some(ref statuses) = status_filter {
    1155        let placeholders = statuses.iter().map(|_| "?").collect::<Vec<_>>().join(",");
    1156        let sql = format!(
    1157            "SELECT d.id, d.item_id, d.user_id, d.file_path, d.file_size, d.mime_type, d.status, d.progress,
    1158                    d.bytes_downloaded, d.queued_at, d.started_at, d.completed_at, d.error_message,
    1159                    d.retry_count, d.priority,
    1160                    COALESCE(d.item_name, i.name) as item_name,
    1161                    COALESCE(d.artist_name, i.artists) as artist_name,
    1162                    COALESCE(d.album_name, i.album_name) as album_name,
    1163                    COALESCE(d.series_name, i.series_name) as series_name,
    1164                    COALESCE(d.season_name, i.season_name) as season_name,
    1165                    COALESCE(d.episode_number, i.index_number) as episode_number,
    1166                    COALESCE(d.season_number, i.parent_index_number) as season_number,
    1167                    d.quality_preset,
    1168                    COALESCE(d.media_type, 'audio') as media_type,
    1169                    COALESCE(d.download_source, 'user') as download_source
    1170             FROM downloads d
    1171             LEFT JOIN items i ON d.item_id = i.id
    1172             WHERE d.user_id = ? AND d.status IN ({})
    1173             ORDER BY d.priority DESC, d.queued_at ASC",
    1174            placeholders
    1175        );
    1176        let mut p = vec![QueryParam::String(user_id.clone())];
    1177        p.extend(statuses.iter().map(|s| QueryParam::String(s.clone())));
    1178        (sql, p)
    1179    } else {
    1180        let sql = "SELECT d.id, d.item_id, d.user_id, d.file_path, d.file_size, d.mime_type, d.status, d.progress,
    1181                d.bytes_downloaded, d.queued_at, d.started_at, d.completed_at, d.error_message,
    1182                d.retry_count, d.priority,
    1183                COALESCE(d.item_name, i.name) as item_name,
    1184                COALESCE(d.artist_name, i.artists) as artist_name,
    1185                COALESCE(d.album_name, i.album_name) as album_name,
    1186                COALESCE(d.series_name, i.series_name) as series_name,
    1187                COALESCE(d.season_name, i.season_name) as season_name,
    1188                COALESCE(d.episode_number, i.index_number) as episode_number,
    1189                COALESCE(d.season_number, i.parent_index_number) as season_number,
    1190                d.quality_preset,
    1191                COALESCE(d.media_type, 'audio') as media_type,
    1192                COALESCE(d.download_source, 'user') as download_source
    1193         FROM downloads d
    1194         LEFT JOIN items i ON d.item_id = i.id
    1195         WHERE d.user_id = ?
    1196         ORDER BY d.priority DESC, d.queued_at ASC"
    1197            .to_string();
    1198        (sql, vec![QueryParam::String(user_id)])
    1199    };
    1200
    1201    let query = Query::with_params(query_str, params);
    1202
    1203    let downloads = db_service
    1204        .query_many(query, map_download_row)
    1205        .await
    1206        .map_err(|e| e.to_string())?;
    1207
    1208    // Compute stats in single pass
    1209    let stats = compute_download_stats(&downloads);
    1210
    1211    Ok(DownloadsResponse { downloads, stats })
    1212}
    1213
    1214/// Pause a download.
    1215///
    1216/// Writing `status = 'paused'` is only half of it, and used to be all of it: the
    1217/// streaming task knew nothing about the row and kept running, then overwrote it
    1218/// with `completed`/`failed` when it finished. The row flicked to "paused" and
    1219/// undid itself — the reported "pause does not work". Signalling the worker is
    1220/// what actually stops the bytes; it leaves the `.part` file in place so
    1221/// [`resume_download`] can continue from it.
    1222///
    1223/// A queued (not yet started) download has no worker to signal, and the status
    1224/// write alone is enough — the pump skips anything that is not `pending`.
    1225///
    1226/// TRACES: UR-055 | DR-168
    1227#[tauri::command]
    1228#[specta::specta]
    1229pub async fn pause_download(
    1230    db: State<'_, DatabaseWrapper>,
    1231    download_id: i64,
    1232) -> Result<(), String> {
    1233    let db_service = {
    1234        let database = db.0.lock().map_err(|e| e.to_string())?;
    1235        Arc::new(database.service())
    1236    };
    1237
    1238    let query = Query::with_params(
    1239        "UPDATE downloads SET status = 'paused' WHERE id = ? AND status IN ('downloading', 'pending')",
    1240        vec![QueryParam::Int64(download_id)],
    1241    );
    1242
    1243    db_service.execute(query).await.map_err(|e| e.to_string())?;
    1244
    1245    let was_running = crate::download::stop::signal(download_id);
    1246    info!(
    1247        "[pause] Download {} paused (in flight: {})",
    1248        download_id, was_running
    1249    );
    1250    Ok(())
    1251}
    1252
    1253/// Resume a paused download.
    1254///
    1255/// Flipping the row back to `pending` is likewise not enough on its own: the
    1256/// pump is not a poller, it runs when something calls it, so a resumed download
    1257/// sat untouched until some unrelated event happened to pump the queue. That is
    1258/// the other half of "resume does not work".
    1259///
    1260/// TRACES: UR-055 | DR-168
    1261#[tauri::command]
    1262#[specta::specta]
    1263pub async fn resume_download(
    1264    app: tauri::AppHandle,
    1265    db: State<'_, DatabaseWrapper>,
    1266    download_manager: State<'_, DownloadManagerWrapper>,
    1267    download_id: i64,
    1268) -> Result<(), String> {
    1269    let db_service = {
    1270        let database = db.0.lock().map_err(|e| e.to_string())?;
    1271        Arc::new(database.service())
    1272    };
    1273
    1274    let query = Query::with_params(
    1275        "UPDATE downloads SET status = 'pending', error_message = NULL WHERE id = ? AND status IN ('paused', 'failed')",
    1276        vec![QueryParam::Int64(download_id)],
    1277    );
    1278
    1279    db_service.execute(query).await.map_err(|e| e.to_string())?;
    1280
    1281    // Drop any stale stop flag before the pump can start this id again, or the
    1282    // resumed run would read the pause that stopped it and halt immediately.
    1283    crate::download::stop::clear(download_id);
    1284
    1285    let active_downloads = {
    1286        let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
    1287        manager.get_active_downloads()
    1288    };
    1289    pump_download_queue(app, db_service, active_downloads).await;
    1290
    1291    Ok(())
    1292}
    1293
    1294/// Cancel a download
    1295#[tauri::command]
    1296#[specta::specta]
    1297pub async fn cancel_download(
    1298    db: State<'_, DatabaseWrapper>,
    1299    download_manager: State<'_, DownloadManagerWrapper>,
    1300    download_id: i64,
    1301) -> Result<(), String> {
    1302    let db_service = {
    1303        let database = db.0.lock().map_err(|e| e.to_string())?;
    1304        Arc::new(database.service())
    1305    };
    1306
    1307    // Get file path before deleting
    1308    let file_query = Query::with_params(
    1309        "SELECT file_path FROM downloads WHERE id = ?",
    1310        vec![QueryParam::Int64(download_id)],
    1311    );
    1312
    1313    let file_path: Option<String> = db_service
    1314        .query_optional(file_query, |row| row.get(0))
    1315        .await
    1316        .ok()
    1317        .flatten();
    1318
    1319    // Delete from database
    1320    let delete_query = Query::with_params(
    1321        "DELETE FROM downloads WHERE id = ?",
    1322        vec![QueryParam::Int64(download_id)],
    1323    );
    1324
    1325    db_service
    1326        .execute(delete_query)
    1327        .await
    1328        .map_err(|e| e.to_string())?;
    1329
    1330    // Stop the worker if this download is actually running. Without this the
    1331    // task keeps streaming into a `.part` file whose `downloads` row has just
    1332    // been deleted — bytes with nothing pointing at them, and the file below is
    1333    // removed while still being written to. (DR-168)
    1334    crate::download::stop::signal(download_id);
    1335    crate::download::stop::clear(download_id);
    1336
    1337    // Unregister from download manager (in case it was active)
    1338    {
    1339        let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
    1340        manager.unregister_download(download_id);
    1341        info!(
    1342            "Cancelled download {}. Active downloads: {}",
    1343            download_id,
    1344            manager.active_count()
    1345        );
    1346    }
    1347
    1348    // Delete the partial file, and any completed file, if present. Both go
    1349    // through `partial_path` so this cannot drift from what the worker writes —
    1350    // it did, and every cancelled download leaked its partial. (DR-169)
    1351    if let Some(path) = file_path {
    1352        let target = std::path::PathBuf::from(&path);
    1353        let _ = std::fs::remove_file(crate::download::worker::partial_path(&target));
    1354    }
    1355
    1356    Ok(())
    1357}
    1358
    1359/// Mark a download as completed
    1360#[tauri::command]
    1361#[specta::specta]
    1362pub async fn mark_download_completed(
    1363    db: State<'_, DatabaseWrapper>,
    1364    download_id: i64,
    1365    bytes_downloaded: i64,
    1366    file_path: String,
    1367) -> Result<(), String> {
    1368    // Deleting a download reads this straight back into `std::fs::remove_file`,
    1369    // so a row must never come to name a file outside the download directory.
    1370    // The worker reports the absolute path it wrote, and joining an absolute
    1371    // path onto the root yields it unchanged, so that case is stored verbatim;
    1372    // the frontend's fallback to the row's own (relative) path resolves under
    1373    // the root, where the worker put it.
    1374    // TRACES: DR-211 | UT-205
    1375    let file_path = {
    1376        let root = download_root(&db)?;
    1377        confine_to_root(&root, &root.join(&file_path))?
    1378            .to_string_lossy()
    1379            .to_string()
    1380    };
    1381
    1382    let db_service = {
    1383        let database = db.0.lock().map_err(|e| e.to_string())?;
    1384        Arc::new(database.service())
    1385    };
    1386
    1387    let query = Query::with_params(
    1388        "UPDATE downloads SET status = 'completed', progress = 1.0, bytes_downloaded = ?,
    1389         file_size = ?, file_path = ?, completed_at = CURRENT_TIMESTAMP WHERE id = ?",
    1390        vec![
    1391            QueryParam::Int64(bytes_downloaded),
    1392            QueryParam::Int64(bytes_downloaded),
    1393            QueryParam::String(file_path),
    1394            QueryParam::Int64(download_id),
    1395        ],
    1396    );
    1397
    1398    db_service.execute(query).await.map_err(|e| e.to_string())?;
    1399    Ok(())
    1400}
    1401
    1402/// Mark a download as failed
    1403#[tauri::command]
    1404#[specta::specta]
    1405pub async fn mark_download_failed(
    1406    db: State<'_, DatabaseWrapper>,
    1407    download_id: i64,
    1408    error_message: String,
    1409) -> Result<(), String> {
    1410    let db_service = {
    1411        let database = db.0.lock().map_err(|e| e.to_string())?;
    1412        Arc::new(database.service())
    1413    };
    1414
    1415    let query = Query::with_params(
    1416        "UPDATE downloads SET status = 'failed', error_message = ? WHERE id = ?",
    1417        vec![
    1418            QueryParam::String(error_message),
    1419            QueryParam::Int64(download_id),
    1420        ],
    1421    );
    1422
    1423    db_service.execute(query).await.map_err(|e| e.to_string())?;
    1424    Ok(())
    1425}
    1426
    1427/// Start downloading a file immediately
    1428/// This command actually downloads the file using the worker
    1429#[tauri::command]
    1430#[specta::specta]
    1431pub async fn start_download(
    1432    db: State<'_, DatabaseWrapper>,
    1433    download_manager: State<'_, DownloadManagerWrapper>,
    1434    app: tauri::AppHandle,
    1435    download_id: i64,
    1436    stream_url: String,
    1437    target_dir: String,
    1438) -> Result<(), String> {
    1439    use crate::download::events::DownloadEvent;
    1440    use tauri::Emitter;
    1441
    1442    debug!("start_download called for download_id: {}", download_id);
    1443    debug!("   stream_url: {}", stream_url);
    1444    debug!("   target_dir: {}", target_dir);
    1445
    1446    // Check concurrent download limit and register this download
    1447    {
    1448        let manager = download_manager.0.lock().map_err(|e| {
    1449            error!("Failed to lock download manager: {}", e);
    1450            format!("Failed to lock download manager: {}", e)
    1451        })?;
    1452
    1453        if !manager.can_start_download() {
    1454            warn!(
    1455                "Cannot start download: maximum concurrent downloads ({}) reached",
    1456                manager.max_concurrent()
    1457            );
    1458            debug!("   Active downloads: {}", manager.active_count());
    1459            return Err(format!(
    1460                "Maximum concurrent downloads ({}) reached. Please wait for existing downloads to complete.",
    1461                manager.max_concurrent()
    1462            ));
    1463        }
    1464
    1465        // Register this download as active
    1466        let registered = manager.register_download(download_id);
    1467        if !registered {
    1468            warn!(
    1469                "Failed to register download {}: already registered or limit reached",
    1470                download_id
    1471            );
    1472            return Err("Download already in progress or limit reached".to_string());
    1473        }
    1474
    1475        info!(
    1476            "Download {} registered. Active downloads: {}/{}",
    1477            download_id,
    1478            manager.active_count(),
    1479            manager.max_concurrent()
    1480        );
    1481    }
    1482
    1483    // Get download info from DB
    1484    let db_service = {
    1485        let database = db.0.lock().map_err(|e| {
    1486            error!("Failed to lock database: {}", e);
    1487            e.to_string()
    1488        })?;
    1489        Arc::new(database.service())
    1490    };
    1491
    1492    let info_query = Query::with_params(
    1493        "SELECT item_id, file_path, file_size FROM downloads WHERE id = ?",
    1494        vec![QueryParam::Int64(download_id)],
    1495    );
    1496
    1497    let (item_id, file_path, file_size): (String, String, Option<i64>) = db_service
    1498        .query_one(info_query, |row| {
    1499            Ok((row.get(0)?, row.get(1)?, row.get(2)?))
    1500        })
    1501        .await
    1502        .map_err(|e| {
    1503            error!("Failed to query download info: {}", e);
    1504            e.to_string()
    1505        })?;
    1506
    1507    debug!(
    1508        "   Retrieved: item_id={}, file_path={}, file_size={:?}",
    1509        item_id, file_path, file_size
    1510    );
    1511
    1512    // Both halves of this join reached us from the frontend, so resolve them
    1513    // against the download directory before a single byte is written.
    1514    // TRACES: DR-211 | UT-205
    1515    let target_path = {
    1516        let root = download_root(&db)?;
    1517        confine_to_root(&root, &PathBuf::from(&target_dir).join(&file_path))?
    1518    };
    1519
    1520    // Make a HEAD request to get the file size from Content-Length header
    1521    debug!("Making HEAD request to get file size...");
    1522    let head_response = reqwest::Client::new().head(&stream_url).send().await;
    1523
    1524    let file_size_from_server = match head_response {
    1525        Ok(response) => {
    1526            let size = response
    1527                .headers()
    1528                .get(reqwest::header::CONTENT_LENGTH)
    1529                .and_then(|v| v.to_str().ok())
    1530                .and_then(|v| v.parse::<i64>().ok());
    1531
    1532            if let Some(size) = size {
    1533                debug!(
    1534                    "   Got file size from server: {} bytes ({} MB)",
    1535                    size,
    1536                    size / 1024 / 1024
    1537                );
    1538            } else {
    1539                warn!("   Server didn't provide Content-Length header");
    1540            }
    1541            size
    1542        }
    1543        Err(e) => {
    1544            warn!("   HEAD request failed: {}, continuing anyway...", e);
    1545            None
    1546        }
    1547    };
    1548
    1549    // Update status to downloading and save file_size if we got it.
    1550    // Also persist the resolved stream URL + target dir so the queue pump can
    1551    // restart/resume this download by itself if needed.
    1552    let update_query = if let Some(size) = file_size_from_server {
    1553        Query::with_params(
    1554            "UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP, file_size = ?, stream_url = ?, target_dir = ? WHERE id = ?",
    1555            vec![
    1556                QueryParam::Int64(size),
    1557                QueryParam::String(stream_url.clone()),
    1558                QueryParam::String(target_dir.clone()),
    1559                QueryParam::Int64(download_id),
    1560            ],
    1561        )
    1562    } else {
    1563        Query::with_params(
    1564            "UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP, stream_url = ?, target_dir = ? WHERE id = ?",
    1565            vec![
    1566                QueryParam::String(stream_url.clone()),
    1567                QueryParam::String(target_dir.clone()),
    1568                QueryParam::Int64(download_id),
    1569            ],
    1570        )
    1571    };
    1572
    1573    db_service
    1574        .execute(update_query)
    1575        .await
    1576        .map_err(|e| e.to_string())?;
    1577
    1578    // Emit started event
    1579    let started_event = DownloadEvent::Started {
    1580        download_id,
    1581        item_id: item_id.clone(),
    1582    };
    1583    debug!("Emitting download-event: {:?}", started_event);
    1584    debug!(
    1585        "   Serialized: {}",
    1586        serde_json::to_string(&started_event).unwrap_or_default()
    1587    );
    1588    match app.emit("download-event", started_event) {
    1589        Ok(_) => debug!("   Event emitted successfully"),
    1590        Err(e) => error!("   Event emit failed: {:?}", e),
    1591    }
    1592
    1593    // Get a clone of the active downloads Arc for unregistering later
    1594    let active_downloads = {
    1595        let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
    1596        manager.get_active_downloads()
    1597    };
    1598
    1599    // Run the worker in the background; on completion/failure it frees the slot
    1600    // and pumps the next pending download.
    1601    spawn_download_worker(
    1602        app.clone(),
    1603        download_id,
    1604        item_id,
    1605        stream_url,
    1606        target_path,
    1607        active_downloads,
    1608    );
    1609
    1610    Ok(())
    1611}
    1612
    1613/// Enqueue a download with its resolved stream URL, then let the queue pump
    1614/// start it (or a higher-priority pending item) when a slot is free.
    1615///
    1616/// Unlike [`start_download`], this never errors when the concurrency limit is
    1617/// reached: the URL is persisted on the row and the pump will pick it up once a
    1618/// slot frees. This is the path bulk operations (album/series/season) use so
    1619/// every queued item eventually downloads without the frontend re-issuing it.
    1620#[tauri::command]
    1621#[specta::specta]
    1622pub async fn enqueue_download(
    1623    db: State<'_, DatabaseWrapper>,
    1624    download_manager: State<'_, DownloadManagerWrapper>,
    1625    app: tauri::AppHandle,
    1626    download_id: i64,
    1627    stream_url: String,
    1628    target_dir: String,
    1629) -> Result<(), String> {
    1630    let db_service = {
    1631        let database = db.0.lock().map_err(|e| e.to_string())?;
    1632        Arc::new(database.service())
    1633    };
    1634
    1635    // Persist the resolved URL/dir and mark the row pending so the pump can
    1636    // start it. We don't flip to 'downloading' here — the pump owns that.
    1637    let update_query = Query::with_params(
    1638        "UPDATE downloads SET status = 'pending', stream_url = ?, target_dir = ? WHERE id = ?",
    1639        vec![
    1640            QueryParam::String(stream_url),
    1641            QueryParam::String(target_dir),
    1642            QueryParam::Int64(download_id),
    1643        ],
    1644    );
    1645    db_service
    1646        .execute(update_query)
    1647        .await
    1648        .map_err(|e| e.to_string())?;
    1649
    1650    // Kick the pump: it will start as many pending downloads as there are slots.
    1651    let active_downloads = {
    1652        let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
    1653        manager.get_active_downloads()
    1654    };
    1655    pump_download_queue(app, db_service, active_downloads).await;
    1656
    1657    Ok(())
    1658}
    1659
    1660/// Enqueue a batch of already-queued video downloads, resolving each one's
    1661/// transcode URL from the repository using the `quality_preset` stored on the
    1662/// row. Then let the pump start them subject to the concurrency limit.
    1663///
    1664/// This is the bulk video path (series/season): `download_series`/
    1665/// `download_season` insert the rows, then this resolves URLs and enqueues them
    1666/// so they actually start. Resolving server-side avoids round-tripping every
    1667/// episode URL through the frontend.
    1668#[tauri::command]
    1669#[specta::specta]
    1670pub async fn enqueue_video_downloads(
    1671    db: State<'_, DatabaseWrapper>,
    1672    download_manager: State<'_, DownloadManagerWrapper>,
    1673    repository: State<'_, crate::commands::repository::RepositoryManagerWrapper>,
    1674    app: tauri::AppHandle,
    1675    handle: String,
    1676    download_ids: Vec<i64>,
    1677    target_dir: String,
    1678) -> Result<(), String> {
    1679    let repo = repository.0.get(&handle).ok_or("Repository not found")?;
    1680
    1681    let db_service = {
    1682        let database = db.0.lock().map_err(|e| e.to_string())?;
    1683        Arc::new(database.service())
    1684    };
    1685
    1686    for download_id in download_ids {
    1687        // Read the item + quality preset for this queued download.
    1688        let info_query = Query::with_params(
    1689            "SELECT item_id, COALESCE(quality_preset, 'original') FROM downloads WHERE id = ?",
    1690            vec![QueryParam::Int64(download_id)],
    1691        );
    1692        let (item_id, quality): (String, String) = match db_service
    1693            .query_one(info_query, |row| Ok((row.get(0)?, row.get(1)?)))
    1694            .await
    1695        {
    1696            Ok(row) => row,
    1697            Err(e) => {
    1698                warn!("[enqueue_video] Skipping download {}: {}", download_id, e);
    1699                continue;
    1700            }
    1701        };
    1702
    1703        // Build the download URL, resolving the source's audio codec first so a
    1704        // track this device cannot decode is re-encoded on the way down rather
    1705        // than saved as a silent file (DR-167).
    1706        let stream_url =
    1707            crate::repository::resolve_video_download_url(repo.as_ref(), &item_id, &quality, None)
    1708                .await;
    1709
    1710        let update_query = Query::with_params(
    1711            "UPDATE downloads SET status = 'pending', stream_url = ?, target_dir = ? WHERE id = ?",
    1712            vec![
    1713                QueryParam::String(stream_url),
    1714                QueryParam::String(target_dir.clone()),
    1715                QueryParam::Int64(download_id),
    1716            ],
    1717        );
    1718        if let Err(e) = db_service.execute(update_query).await {
    1719            warn!(
    1720                "[enqueue_video] Failed to persist URL for download {}: {}",
    1721                download_id, e
    1722            );
    1723        }
    1724    }
    1725
    1726    // Pump once: starts up to max_concurrent, the rest drain as slots free.
    1727    let active_downloads = {
    1728        let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
    1729        manager.get_active_downloads()
    1730    };
    1731    pump_download_queue(app, db_service, active_downloads).await;
    1732
    1733    Ok(())
    1734}
    1735
    1736/// Whether the current network permits downloads, given the user's WiFi-only
    1737/// preference.
    1738///
    1739/// Reads `wifi_only` from the SmartCache config (the single home of the
    1740/// setting) and checks it against the transport reported by the platform. On
    1741/// desktop the transport defaults to unmetered ethernet, so this is always
    1742/// true there.
    1743///
    1744/// TRACES: UR-053 | DR-074
    1745pub(crate) async fn downloads_allowed_on_current_network(app: &tauri::AppHandle) -> bool {
    1746    let wifi_only = {
    1747        let smart_cache = app.state::<SmartCacheWrapper>();
    1748        let cache = match smart_cache.0.lock() {
    1749            Ok(c) => c,
    1750            Err(e) => {
    1751                error!("[pump] Failed to lock smart cache: {}", e);
    1752                // Fail open: a lock problem must not silently wedge downloads.
    1753                return true;
    1754            }
    1755        };
    1756        cache.get_config().map(|c| c.wifi_only).unwrap_or(false)
    1757    };
    1758
    1759    if !wifi_only {
    1760        return true;
    1761    }
    1762
    1763    let network = app.state::<NetworkStateWrapper>();
    1764    network.0.allows_download(true).await
    1765}
    1766
    1767/// Start as many pending downloads as there are free concurrency slots.
    1768///
    1769/// Picks the highest-priority `pending` rows that have a persisted `stream_url`
    1770/// (FIFO within a priority), registers each, flips it to `downloading`, and
    1771/// spawns a worker. Each spawned worker calls this again on completion/failure,
    1772/// so the queue drains itself without any frontend involvement.
    1773pub(crate) async fn pump_download_queue(
    1774    app: tauri::AppHandle,
    1775    db_service: Arc<crate::storage::db_service::RusqliteService>,
    1776    active_downloads: Arc<Mutex<std::collections::HashSet<i64>>>,
    1777) {
    1778    use crate::download::events::DownloadEvent;
    1779    use tauri::Emitter;
    1780
    1781    // WiFi-only gate (UR-053): when the user has restricted downloads to
    1782    // unmetered networks and we're on cellular (or can't tell), leave every
    1783    // pending row exactly as it is. They stay 'pending' and the Android
    1784    // network callback re-pumps us as soon as an acceptable network appears.
    1785    if !downloads_allowed_on_current_network(&app).await {
    1786        info!("[pump] Downloads paused: waiting for an unmetered network (WiFi-only enabled)");
    1787        let _ = app.emit("download-event", DownloadEvent::WaitingForNetwork);
    1788        return;
    1789    }
    1790
    1791    let max_concurrent = {
    1792        let manager = app.state::<DownloadManagerWrapper>();
    1793        let manager = match manager.0.lock() {
    1794            Ok(m) => m,
    1795            Err(e) => {
    1796                error!("[pump] Failed to lock download manager: {}", e);
    1797                return;
    1798            }
    1799        };
    1800        manager.max_concurrent()
    1801    };
    1802
    1803    loop {
    1804        // How many slots are free right now?
    1805        let free_slots = {
    1806            let active = match active_downloads.lock() {
    1807                Ok(a) => a,
    1808                Err(e) => {
    1809                    error!("[pump] Failed to lock active downloads: {}", e);
    1810                    return;
    1811                }
    1812            };
    1813            max_concurrent.saturating_sub(active.len())
    1814        };
    1815        if free_slots == 0 {
    1816            return;
    1817        }
    1818
    1819        // Find the next pending, startable download (has a stream URL). Exclude
    1820        // anything already registered as active to avoid double-starting.
    1821        let next_query = Query::with_params(
    1822            "SELECT id, item_id, file_path, stream_url, target_dir
    1823             FROM downloads
    1824             WHERE status = 'pending'
    1825               AND stream_url IS NOT NULL
    1826               AND target_dir IS NOT NULL
    1827             ORDER BY priority DESC, queued_at ASC",
    1828            vec![],
    1829        );
    1830
    1831        let candidates: Vec<(i64, String, String, String, String)> = match db_service
    1832            .query_many(next_query, |row| {
    1833                Ok((
    1834                    row.get(0)?,
    1835                    row.get(1)?,
    1836                    row.get(2)?,
    1837                    row.get(3)?,
    1838                    row.get(4)?,
    1839                ))
    1840            })
    1841            .await
    1842        {
    1843            Ok(rows) => rows,
    1844            Err(e) => {
    1845                error!("[pump] Failed to query pending downloads: {}", e);
    1846                return;
    1847            }
    1848        };
    1849
    1850        // Pick the first candidate not already active.
    1851        let next = candidates.into_iter().find(|(id, _, _, _, _)| {
    1852            active_downloads
    1853                .lock()
    1854                .map(|active| !active.contains(id))
    1855                .unwrap_or(false)
    1856        });
    1857
    1858        let (download_id, item_id, file_path, stream_url, target_dir) = match next {
    1859            Some(n) => n,
    1860            None => return, // Nothing pending to start
    1861        };
    1862
    1863        // Confine the row's path before it takes a slot. A row whose target
    1864        // escapes the download directory can never start, so it is failed here
    1865        // rather than picked again on the next pass — this loop re-queries, so
    1866        // merely skipping it would not terminate.
    1867        // TRACES: DR-211 | UT-205
    1868        let confined = {
    1869            let db_state = app.state::<DatabaseWrapper>();
    1870            download_root(&db_state).and_then(|root| {
    1871                confine_to_root(&root, &PathBuf::from(&target_dir).join(&file_path))
    1872            })
    1873        };
    1874        let target_path = match confined {
    1875            Ok(path) => path,
    1876            Err(e) => {
    1877                error!("[pump] Refusing download {}: {}", download_id, e);
    1878                let fail_query = Query::with_params(
    1879                    "UPDATE downloads SET status = 'failed', error_message = ? WHERE id = ?",
    1880                    vec![QueryParam::String(e), QueryParam::Int64(download_id)],
    1881                );
    1882                if let Err(db_err) = db_service.execute(fail_query).await {
    1883                    error!(
    1884                        "[pump] Failed to mark download {} failed: {}",
    1885                        download_id, db_err
    1886                    );
    1887                    return;
    1888                }
    1889                continue;
    1890            }
    1891        };
    1892
    1893        // Register the slot. If registration fails (race: another pump filled
    1894        // the last slot), stop — we'll be re-pumped when a slot frees.
    1895        {
    1896            let manager = app.state::<DownloadManagerWrapper>();
    1897            let manager = match manager.0.lock() {
    1898                Ok(m) => m,
    1899                Err(e) => {
    1900                    error!("[pump] Failed to lock download manager: {}", e);
    1901                    return;
    1902                }
    1903            };
    1904            if !manager.register_download(download_id) {
    1905                return;
    1906            }
    1907            info!(
    1908                "[pump] Download {} started. Active downloads: {}/{}",
    1909                download_id,
    1910                manager.active_count(),
    1911                manager.max_concurrent()
    1912            );
    1913        }
    1914
    1915        // Mark as downloading and stamp started_at.
    1916        let update_query = Query::with_params(
    1917            "UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP WHERE id = ?",
    1918            vec![QueryParam::Int64(download_id)],
    1919        );
    1920        if let Err(e) = db_service.execute(update_query).await {
    1921            error!(
    1922                "[pump] Failed to mark download {} downloading: {}",
    1923                download_id, e
    1924            );
    1925            if let Ok(mut a) = active_downloads.lock() {
    1926                a.remove(&download_id);
    1927            }
    1928            continue;
    1929        }
    1930
    1931        // Emit started event so the UI flips the row.
    1932        let _ = app.emit(
    1933            "download-event",
    1934            DownloadEvent::Started {
    1935                download_id,
    1936                item_id: item_id.clone(),
    1937            },
    1938        );
    1939
    1940        spawn_download_worker(
    1941            app.clone(),
    1942            download_id,
    1943            item_id,
    1944            stream_url,
    1945            target_path,
    1946            active_downloads.clone(),
    1947        );
    1948    }
    1949}
    1950
    1951/// Spawn the background worker for one download. On completion or failure it
    1952/// unregisters the slot, emits the terminal event, and pumps the queue so the
    1953/// next pending download starts automatically.
    1954fn spawn_download_worker(
    1955    app: tauri::AppHandle,
    1956    download_id: i64,
    1957    item_id: String,
    1958    stream_url: String,
    1959    target_path: std::path::PathBuf,
    1960    active_downloads: Arc<Mutex<std::collections::HashSet<i64>>>,
    1961) {
    1962    use crate::download::events::DownloadEvent;
    1963    use crate::download::{DownloadTask, DownloadWorker};
    1964    use tauri::Emitter;
    1965
    1966    let task = DownloadTask {
    1967        url: stream_url,
    1968        target_path: target_path.clone(),
    1969    };
    1970
    1971    tauri::async_runtime::spawn(async move {
    1972        debug!("Download task started for download_id: {}", download_id);
    1973        let worker = DownloadWorker::new();
    1974
    1975        // Progress callback that emits events to the frontend
    1976        let progress_app = app.clone();
    1977        let progress_item_id = item_id.clone();
    1978        let on_progress = move |bytes_downloaded: u64, total_bytes: Option<u64>| {
    1979            let progress = total_bytes
    1980                .filter(|&t| t > 0)
    1981                .map(|t| bytes_downloaded as f64 / t as f64)
    1982                .unwrap_or(0.0);
    1983
    1984            let event = DownloadEvent::Progress {
    1985                download_id,
    1986                item_id: progress_item_id.clone(),
    1987                bytes_downloaded: bytes_downloaded as i64,
    1988                total_bytes: total_bytes.map(|t| t as i64),
    1989                progress,
    1990            };
    1991            let _ = progress_app.emit("download-event", event);
    1992        };
    1993
    1994        // Registering returns a fresh flag, so a download resumed after a pause
    1995        // does not inherit the stop that ended its previous run. (DR-168)
    1996        let stop_flag = crate::download::stop::register(download_id);
    1997        let result = worker.download(&task, &stop_flag, on_progress).await;
    1998        crate::download::stop::clear(download_id);
    1999
    2000        // Free the slot before pumping so the next download can take it.
    2001        if let Ok(mut active) = active_downloads.lock() {
    2002            active.remove(&download_id);
    2003            debug!(
    2004                "   Unregistered download {}. Active downloads: {}",
    2005                download_id,
    2006                active.len()
    2007            );
    2008        }
    2009
    2010        // The pump runs downloads in the background, so the terminal status MUST
    2011        // be persisted to the DB here — the frontend event handler only writes it
    2012        // when that download happens to be loaded in its store, which is not the
    2013        // case for auto-pumped rows (or any completion while the downloads page is
    2014        // closed). `check_for_local_download` filters on status = 'completed', so a
    2015        // missed write leaves finished files unrecognized: albums never show as
    2016        // downloaded and playback never switches from the (expiring) stream to the
    2017        // local file, cutting tracks off mid-play.
    2018        let db_service = {
    2019            let db = app.state::<DatabaseWrapper>();
    2020            let database = match db.0.lock() {
    2021                Ok(d) => d,
    2022                Err(e) => {
    2023                    error!(
    2024                        "[pump] Failed to lock database after download {}: {}",
    2025                        download_id, e
    2026                    );
    2027                    return;
    2028                }
    2029            };
    2030            Arc::new(database.service())
    2031        };
    2032
    2033        match result {
    2034            Ok(res) => {
    2035                info!(
    2036                    "Download completed successfully: {} bytes",
    2037                    res.bytes_downloaded
    2038                );
    2039                let file_path = target_path.to_string_lossy().to_string();
    2040
    2041                let update = Query::with_params(
    2042                    "UPDATE downloads SET status = 'completed', progress = 1.0, \
    2043                     bytes_downloaded = ?, file_size = ?, file_path = ?, \
    2044                     completed_at = CURRENT_TIMESTAMP WHERE id = ?",
    2045                    vec![
    2046                        QueryParam::Int64(res.bytes_downloaded as i64),
    2047                        QueryParam::Int64(res.bytes_downloaded as i64),
    2048                        QueryParam::String(file_path.clone()),
    2049                        QueryParam::Int64(download_id),
    2050                    ],
    2051                );
    2052                if let Err(e) = db_service.execute(update).await {
    2053                    error!(
    2054                        "[pump] Failed to persist completed status for download {}: {}",
    2055                        download_id, e
    2056                    );
    2057                }
    2058
    2059                let completed_event = DownloadEvent::Completed {
    2060                    download_id,
    2061                    item_id,
    2062                    file_path,
    2063                };
    2064                match app.emit("download-event", completed_event) {
    2065                    Ok(_) => debug!("   Completed event emitted successfully"),
    2066                    Err(e) => error!("   Completed event emit failed: {:?}", e),
    2067                }
    2068            }
    2069            // A pause or cancel is not a failure. The row already says `paused`
    2070            // (or the row is gone, for a cancel), and overwriting that with
    2071            // `failed` is what made a pause look like an error and stranded the
    2072            // download outside the resumable set. The `.part` file is deliberately
    2073            // left alone — it is what the resume continues from. (DR-168)
    2074            Err(e) if e.is_stopped() => {
    2075                info!(
    2076                    "[pump] Download {} stopped by request; partial file kept for resume",
    2077                    download_id
    2078                );
    2079            }
    2080            Err(e) => {
    2081                error!("Download failed: {:?}", e);
    2082
    2083                let update = Query::with_params(
    2084                    "UPDATE downloads SET status = 'failed', error_message = ? WHERE id = ?",
    2085                    vec![
    2086                        QueryParam::String(e.to_string()),
    2087                        QueryParam::Int64(download_id),
    2088                    ],
    2089                );
    2090                if let Err(db_err) = db_service.execute(update).await {
    2091                    error!(
    2092                        "[pump] Failed to persist failed status for download {}: {}",
    2093                        download_id, db_err
    2094                    );
    2095                }
    2096
    2097                let failed_event = DownloadEvent::Failed {
    2098                    download_id,
    2099                    item_id,
    2100                    error: e.to_string(),
    2101                };
    2102                match app.emit("download-event", failed_event) {
    2103                    Ok(_) => debug!("   Failed event emitted successfully"),
    2104                    Err(e) => error!("   Failed event emit failed: {:?}", e),
    2105                }
    2106            }
    2107        }
    2108
    2109        // A slot just freed — start the next pending download (if any).
    2110        pump_download_queue(app.clone(), db_service, active_downloads).await;
    2111    });
    2112}
    2113
    2114/// Delete a completed download
    2115#[tauri::command]
    2116#[specta::specta]
    2117pub async fn delete_download(
    2118    db: State<'_, DatabaseWrapper>,
    2119    download_id: i64,
    2120) -> Result<(), String> {
    2121    let db_service = {
    2122        let database = db.0.lock().map_err(|e| e.to_string())?;
    2123        Arc::new(database.service())
    2124    };
    2125
    2126    // Get file path
    2127    let file_query = Query::with_params(
    2128        "SELECT file_path FROM downloads WHERE id = ?",
    2129        vec![QueryParam::Int64(download_id)],
    2130    );
    2131
    2132    let file_path: Option<String> = db_service
    2133        .query_optional(file_query, |row| row.get(0))
    2134        .await
    2135        .ok()
    2136        .flatten();
    2137
    2138    // Delete from database
    2139    let delete_query = Query::with_params(
    2140        "DELETE FROM downloads WHERE id = ?",
    2141        vec![QueryParam::Int64(download_id)],
    2142    );
    2143
    2144    db_service
    2145        .execute(delete_query)
    2146        .await
    2147        .map_err(|e| e.to_string())?;
    2148
    2149    // Delete actual file if exists
    2150    if let Some(path) = file_path {
    2151        let _ = std::fs::remove_file(&path); // Ignore errors
    2152    }
    2153
    2154    Ok(())
    2155}
    2156
    2157/// Helper function to map database row to DownloadInfo
    2158fn map_download_row(row: &rusqlite::Row) -> rusqlite::Result<DownloadInfo> {
    2159    Ok(DownloadInfo {
    2160        id: row.get(0)?,
    2161        item_id: row.get(1)?,
    2162        user_id: row.get(2)?,
    2163        file_path: row.get(3)?,
    2164        file_size: row.get(4)?,
    2165        mime_type: row.get(5)?,
    2166        status: row.get(6)?,
    2167        progress: row.get(7)?,
    2168        bytes_downloaded: row.get(8)?,
    2169        queued_at: row.get(9)?,
    2170        started_at: row.get(10)?,
    2171        completed_at: row.get(11)?,
    2172        error_message: row.get(12)?,
    2173        retry_count: row.get(13)?,
    2174        priority: row.get(14)?,
    2175        item_name: row.get(15)?,
    2176        artist_name: row.get(16)?,
    2177        album_name: row.get(17)?,
    2178        // Video-specific metadata
    2179        series_name: row.get(18)?,
    2180        season_name: row.get(19)?,
    2181        episode_number: row.get(20)?,
    2182        season_number: row.get(21)?,
    2183        quality_preset: row.get(22)?,
    2184        media_type: row
    2185            .get::<_, Option<String>>(23)?
    2186            .unwrap_or_else(|| "audio".to_string()),
    2187        download_source: row
    2188            .get::<_, Option<String>>(24)?
    2189            .unwrap_or_else(|| "user".to_string()),
    2190    })
    2191}
    2192
    2193/// Storage statistics for downloads
    2194#[derive(specta::Type, Debug, Clone, serde::Serialize)]
    2195pub struct StorageStats {
    2196    pub total_bytes: i64,
    2197    pub total_items: i64,
    2198    pub albums: Vec<AlbumStorageInfo>,
    2199}
    2200
    2201/// Storage info for a single album
    2202#[derive(specta::Type, Debug, Clone, serde::Serialize)]
    2203pub struct AlbumStorageInfo {
    2204    pub album_id: String,
    2205    pub album_name: String,
    2206    pub artist_name: Option<String>,
    2207    pub bytes_used: i64,
    2208    pub track_count: i64,
    2209}
    2210
    2211/// Get storage statistics for downloads
    2212#[tauri::command]
    2213#[specta::specta]
    2214pub async fn get_download_storage_stats(
    2215    db: State<'_, DatabaseWrapper>,
    2216    user_id: String,
    2217) -> Result<StorageStats, String> {
    2218    let db_service = {
    2219        let database = db.0.lock().map_err(|e| e.to_string())?;
    2220        Arc::new(database.service())
    2221    };
    2222
    2223    // Get total storage and item count
    2224    let total_query = Query::with_params(
    2225        "SELECT COALESCE(SUM(file_size), 0), COUNT(*)
    2226         FROM downloads
    2227         WHERE user_id = ? AND status = 'completed'",
    2228        vec![QueryParam::String(user_id.clone())],
    2229    );
    2230
    2231    let (total_bytes, total_items): (i64, i64) = db_service
    2232        .query_one(total_query, |row| Ok((row.get(0)?, row.get(1)?)))
    2233        .await
    2234        .map_err(|e| e.to_string())?;
    2235
    2236    // Get storage breakdown by album
    2237    let albums_query = Query::with_params(
    2238        "SELECT
    2239            COALESCE(i.album_id, 'unknown') as album_id,
    2240            COALESCE(i.album_name, 'Unknown Album') as album_name,
    2241            i.artists as artist_name,
    2242            COALESCE(SUM(d.file_size), 0) as bytes_used,
    2243            COUNT(*) as track_count
    2244         FROM downloads d
    2245         LEFT JOIN items i ON d.item_id = i.id
    2246         WHERE d.user_id = ? AND d.status = 'completed'
    2247         GROUP BY COALESCE(i.album_id, 'unknown')
    2248         ORDER BY bytes_used DESC",
    2249        vec![QueryParam::String(user_id)],
    2250    );
    2251
    2252    let albums: Vec<AlbumStorageInfo> = db_service
    2253        .query_many(albums_query, |row| {
    2254            Ok(AlbumStorageInfo {
    2255                album_id: row.get(0)?,
    2256                album_name: row.get(1)?,
    2257                artist_name: row.get(2)?,
    2258                bytes_used: row.get(3)?,
    2259                track_count: row.get(4)?,
    2260            })
    2261        })
    2262        .await
    2263        .map_err(|e| e.to_string())?;
    2264
    2265    Ok(StorageStats {
    2266        total_bytes,
    2267        total_items,
    2268        albums,
    2269    })
    2270}
    2271
    2272/// Delete all downloads for a user
    2273#[tauri::command]
    2274#[specta::specta]
    2275pub async fn delete_all_downloads(
    2276    db: State<'_, DatabaseWrapper>,
    2277    user_id: String,
    2278) -> Result<i64, String> {
    2279    let db_service = {
    2280        let database = db.0.lock().map_err(|e| e.to_string())?;
    2281        Arc::new(database.service())
    2282    };
    2283
    2284    // Get all file paths for completed downloads
    2285    let file_query = Query::with_params(
    2286        "SELECT file_path FROM downloads WHERE user_id = ? AND status = 'completed'",
    2287        vec![QueryParam::String(user_id.clone())],
    2288    );
    2289
    2290    let file_paths: Vec<String> = db_service
    2291        .query_many(file_query, |row| row.get(0))
    2292        .await
    2293        .map_err(|e| e.to_string())?;
    2294
    2295    // Delete all downloads for user
    2296    let delete_query = Query::with_params(
    2297        "DELETE FROM downloads WHERE user_id = ?",
    2298        vec![QueryParam::String(user_id)],
    2299    );
    2300
    2301    let deleted_count = db_service
    2302        .execute(delete_query)
    2303        .await
    2304        .map_err(|e| e.to_string())?;
    2305
    2306    // Delete actual files
    2307    for path in file_paths {
    2308        let _ = std::fs::remove_file(&path); // Ignore errors
    2309        let _ = std::fs::remove_file(format!("{}.part", path)); // Also clean up partials
    2310    }
    2311
    2312    Ok(deleted_count as i64)
    2313}
    2314
    2315/// Clear all stale pending/failed/paused downloads
    2316#[tauri::command]
    2317#[specta::specta]
    2318pub async fn clear_stale_downloads(
    2319    db: State<'_, DatabaseWrapper>,
    2320    user_id: String,
    2321) -> Result<i64, String> {
    2322    let db_service = {
    2323        let database = db.0.lock().map_err(|e| e.to_string())?;
    2324        Arc::new(database.service())
    2325    };
    2326
    2327    // Ids as well as paths: a stale row may still have a worker attached (a
    2328    // 'downloading' row that was paused mid-flight is 'paused' here), and
    2329    // deleting the row without stopping the task leaves it writing to a file we
    2330    // are about to remove. (DR-168)
    2331    let file_query = Query::with_params(
    2332        "SELECT id, file_path FROM downloads WHERE user_id = ? AND status IN ('pending', 'paused', 'failed')",
    2333        vec![QueryParam::String(user_id.clone())],
    2334    );
    2335
    2336    let stale: Vec<(i64, String)> = db_service
    2337        .query_many(file_query, |row| Ok((row.get(0)?, row.get(1)?)))
    2338        .await
    2339        .map_err(|e| e.to_string())?;
    2340
    2341    for (id, _) in &stale {
    2342        crate::download::stop::signal(*id);
    2343        crate::download::stop::clear(*id);
    2344    }
    2345    let file_paths: Vec<String> = stale.into_iter().map(|(_, path)| path).collect();
    2346
    2347    // Delete all pending, paused, and failed downloads (but keep completed ones)
    2348    let delete_query = Query::with_params(
    2349        "DELETE FROM downloads WHERE user_id = ? AND status IN ('pending', 'paused', 'failed')",
    2350        vec![QueryParam::String(user_id)],
    2351    );
    2352
    2353    let deleted_count = db_service
    2354        .execute(delete_query)
    2355        .await
    2356        .map_err(|e| e.to_string())?;
    2357
    2358    // Delete any partial files, via the shared helper so this cannot drift from
    2359    // what the worker actually writes. (DR-169)
    2360    for path in file_paths {
    2361        let target = std::path::PathBuf::from(&path);
    2362        let _ = std::fs::remove_file(&target);
    2363        let _ = std::fs::remove_file(crate::download::worker::partial_path(&target));
    2364    }
    2365
    2366    Ok(deleted_count as i64)
    2367}
    2368
    2369/// Delete all downloads for a specific album
    2370#[tauri::command]
    2371#[specta::specta]
    2372pub async fn delete_album_downloads(
    2373    db: State<'_, DatabaseWrapper>,
    2374    album_id: String,
    2375    user_id: String,
    2376) -> Result<i64, String> {
    2377    let db_service = {
    2378        let database = db.0.lock().map_err(|e| e.to_string())?;
    2379        Arc::new(database.service())
    2380    };
    2381
    2382    // Get file paths for this album's downloads
    2383    let file_query = Query::with_params(
    2384        "SELECT d.file_path FROM downloads d
    2385         JOIN items i ON d.item_id = i.id
    2386         WHERE d.user_id = ? AND i.album_id = ? AND d.status = 'completed'",
    2387        vec![
    2388            QueryParam::String(user_id.clone()),
    2389            QueryParam::String(album_id.clone()),
    2390        ],
    2391    );
    2392
    2393    let file_paths: Vec<String> = db_service
    2394        .query_many(file_query, |row| row.get(0))
    2395        .await
    2396        .map_err(|e| e.to_string())?;
    2397
    2398    // Delete downloads for this album
    2399    let delete_query = Query::with_params(
    2400        "DELETE FROM downloads WHERE user_id = ? AND item_id IN (SELECT id FROM items WHERE album_id = ?)",
    2401        vec![QueryParam::String(user_id), QueryParam::String(album_id)],
    2402    );
    2403
    2404    let deleted_count = db_service
    2405        .execute(delete_query)
    2406        .await
    2407        .map_err(|e| e.to_string())?;
    2408
    2409    // Delete actual files
    2410    for path in file_paths {
    2411        let _ = std::fs::remove_file(&path);
    2412        let _ = std::fs::remove_file(format!("{}.part", path));
    2413    }
    2414
    2415    Ok(deleted_count as i64)
    2416}
    2417
    2418/// Remove every completed download at or under a container item.
    2419///
    2420/// Works at any level of the Downloaded browse: a leaf (removes just that
    2421/// download), an album/season/series (removes all downloaded descendants linked
    2422/// via album_id/season_id/series_id/parent_id). Deletes the DB rows and the
    2423/// on-disk files. Returns the number of downloads removed. Idempotent.
    2424///
    2425/// TRACES: UR-055 | DR-083
    2426#[tauri::command]
    2427#[specta::specta]
    2428pub async fn delete_downloads_under(
    2429    db: State<'_, DatabaseWrapper>,
    2430    item_id: String,
    2431    user_id: String,
    2432) -> Result<i64, String> {
    2433    let db_service = {
    2434        let database = db.0.lock().map_err(|e| e.to_string())?;
    2435        Arc::new(database.service())
    2436    };
    2437
    2438    // The item itself, or any child linked to it by container id.
    2439    const SCOPE: &str = "d.user_id = ? AND d.status = 'completed'
    2440         AND (
    2441             d.item_id = ?
    2442             OR d.item_id IN (
    2443                 SELECT c.id FROM items c
    2444                 WHERE c.album_id = ? OR c.season_id = ? OR c.series_id = ? OR c.parent_id = ?
    2445             )
    2446         )";
    2447
    2448    let file_query = Query::with_params(
    2449        format!("SELECT d.file_path FROM downloads d WHERE {SCOPE}"),
    2450        vec![
    2451            QueryParam::String(user_id.clone()),
    2452            QueryParam::String(item_id.clone()),
    2453            QueryParam::String(item_id.clone()),
    2454            QueryParam::String(item_id.clone()),
    2455            QueryParam::String(item_id.clone()),
    2456            QueryParam::String(item_id.clone()),
    2457        ],
    2458    );
    2459    let file_paths: Vec<String> = db_service
    2460        .query_many(file_query, |row| row.get(0))
    2461        .await
    2462        .map_err(|e| e.to_string())?;
    2463
    2464    let delete_query = Query::with_params(
    2465        format!("DELETE FROM downloads WHERE id IN (SELECT d.id FROM downloads d WHERE {SCOPE})"),
    2466        vec![
    2467            QueryParam::String(user_id),
    2468            QueryParam::String(item_id.clone()),
    2469            QueryParam::String(item_id.clone()),
    2470            QueryParam::String(item_id.clone()),
    2471            QueryParam::String(item_id.clone()),
    2472            QueryParam::String(item_id),
    2473        ],
    2474    );
    2475    let deleted_count = db_service
    2476        .execute(delete_query)
    2477        .await
    2478        .map_err(|e| e.to_string())?;
    2479
    2480    for path in file_paths {
    2481        let _ = std::fs::remove_file(&path);
    2482        let _ = std::fs::remove_file(format!("{}.part", path));
    2483    }
    2484
    2485    Ok(deleted_count as i64)
    2486}
    2487
    2488/// Download manager statistics
    2489#[derive(specta::Type, Debug, Clone, serde::Serialize)]
    2490pub struct DownloadManagerStats {
    2491    pub max_concurrent: usize,
    2492    pub active_count: usize,
    2493    pub available_slots: usize,
    2494}
    2495
    2496/// Get download manager statistics
    2497#[tauri::command]
    2498#[specta::specta]
    2499pub async fn get_download_manager_stats(
    2500    download_manager: State<'_, DownloadManagerWrapper>,
    2501) -> Result<DownloadManagerStats, String> {
    2502    let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
    2503
    2504    let active_count = manager.active_count();
    2505    let max_concurrent = manager.max_concurrent();
    2506
    2507    Ok(DownloadManagerStats {
    2508        max_concurrent,
    2509        active_count,
    2510        available_slots: max_concurrent.saturating_sub(active_count),
    2511    })
    2512}
    2513
    2514/// Set the maximum concurrent downloads
    2515#[tauri::command]
    2516#[specta::specta]
    2517pub async fn set_max_concurrent_downloads(
    2518    download_manager: State<'_, DownloadManagerWrapper>,
    2519    max: usize,
    2520) -> Result<(), String> {
    2521    let mut manager = download_manager.0.lock().map_err(|e| e.to_string())?;
    2522    manager.set_max_concurrent(max);
    2523    info!("Set max concurrent downloads to: {}", max);
    2524    Ok(())
    2525}
    2526
    2527// TRACES: UR-011, UR-018 | DR-015, DR-018 | UT-042, UT-043
    2528#[cfg(test)]
    2529mod tests {
    2530    use super::*;
    2531    use crate::storage::Database;
    2532    use rusqlite::params;
    2533
    2534    #[test]
    2535    fn test_sanitize_filename() {
    2536        assert_eq!(sanitize_filename("normal.mp3"), "normal.mp3");
    2537        assert_eq!(
    2538            sanitize_filename("track/with\\invalid:chars"),
    2539            "track_with_invalid_chars"
    2540        );
    2541        assert_eq!(sanitize_filename("song?.mp3"), "song_.mp3");
    2542        assert_eq!(sanitize_filename("file<>|?.txt"), "file____.txt");
    2543    }
    2544
    2545    #[test]
    2546    fn test_sanitize_filename_preserves_extension() {
    2547        assert_eq!(sanitize_filename("my:song.mp3"), "my_song.mp3");
    2548        assert_eq!(sanitize_filename("track/1.flac"), "track_1.flac");
    2549    }
    2550
    2551    /// The download directory as it looks on a device, for the path tests.
    2552    const TEST_ROOT: &str = "/data/data/com.dtourolle.jellytau/files";
    2553
    2554    /// A queued `file_path` cannot walk out of the download directory.
    2555    ///
    2556    /// `download_item` is a command in its own right, so sanitizing in
    2557    /// `download_item_and_start` was routed around by invoking it directly, and
    2558    /// `start_download` then joined the raw string onto the target directory.
    2559    ///
    2560    /// TRACES: DR-211 | UT-205
    2561    #[test]
    2562    fn test_queued_download_paths_cannot_escape_the_download_directory() {
    2563        let root = Path::new(TEST_ROOT);
    2564
    2565        assert!(confine_queued_path(root, "downloads/../../../../etc/cron.d/pwn").is_err());
    2566        assert!(confine_queued_path(root, "../.bashrc").is_err());
    2567        assert!(confine_queued_path(root, "/etc/cron.d/pwn").is_err());
    2568
    2569        // Why the absolute case needs its own guard rather than folding: the
    2570        // join the download path performs discards the base entirely.
    2571        //
    2572        // clippy::join_absolute_paths flags exactly this shape, and is right to
    2573        // in production code — here the discarded base *is* the assertion, so
    2574        // the lint is allowed rather than the code changed. Note the lint would
    2575        // not have caught the original defect: the real join sites take a
    2576        // variable, and the lint only fires on a literal starting with `/`.
    2577        #[allow(clippy::join_absolute_paths)]
    2578        {
    2579            assert_eq!(
    2580                PathBuf::from(root).join("/etc/cron.d/pwn"),
    2581                PathBuf::from("/etc/cron.d/pwn")
    2582            );
    2583        }
    2584    }
    2585
    2586    /// The paths the app builds for itself have to survive unchanged: files are
    2587    /// already on disk and `downloads` rows point at these exact spellings.
    2588    ///
    2589    /// TRACES: DR-211 | UT-205
    2590    #[test]
    2591    fn test_queued_download_paths_are_otherwise_unchanged() {
    2592        let root = Path::new(TEST_ROOT);
    2593
    2594        for path in [
    2595            "downloads/9f8e7d6c",             // MediaCard's queue-for-reconnect
    2596            "videos/movies/Arrival.mp4",      // VideoDownloadButton
    2597            "albums/abc123/01 - Opening.mp3", // queue_album_tracks
    2598            // download_series/download_season build an absolute path, because
    2599            // their base_path is `${targetDir}/videos`.
    2600            "/data/data/com.dtourolle.jellytau/files/videos/Show/S01E02_Pilot.mp4",
    2601        ] {
    2602            assert_eq!(confine_queued_path(root, path).unwrap(), path);
    2603        }
    2604
    2605        // `download_item_and_start` sanitizes the name before calling
    2606        // `download_item`; sanitizing it again must not yield a second, different
    2607        // name, which would orphan the row and the file it names.
    2608        let already = format!("downloads/{}.mp3", sanitize_filename("AC/DC: Live?"));
    2609        assert_eq!(confine_queued_path(root, &already).unwrap(), already);
    2610    }
    2611
    2612    /// A completed row's `file_path` is read straight back into
    2613    /// `std::fs::remove_file` when the download is deleted, so `mark_download_completed`
    2614    /// must not be able to register a file outside the download directory.
    2615    ///
    2616    /// TRACES: DR-211 | UT-205
    2617    #[test]
    2618    fn test_a_completed_download_cannot_register_a_file_outside_the_root() {
    2619        let root = Path::new(TEST_ROOT);
    2620
    2621        // What the worker actually reports — the absolute path it wrote. Stored
    2622        // exactly as it arrives.
    2623        let written = "/data/data/com.dtourolle.jellytau/files/downloads/9f8e7d6c";
    2624        assert_eq!(
    2625            confine_to_root(root, Path::new(written)).unwrap(),
    2626            PathBuf::from(written)
    2627        );
    2628
    2629        // The row's own path, if the frontend falls back to it: relative, and it
    2630        // resolves to where the worker wrote the file.
    2631        assert_eq!(
    2632            confine_to_root(root, &root.join("downloads/9f8e7d6c")).unwrap(),
    2633            PathBuf::from(written)
    2634        );
    2635
    2636        assert!(confine_to_root(root, Path::new("/home/u/.ssh/id_ed25519")).is_err());
    2637        assert!(confine_to_root(
    2638            root,
    2639            Path::new("/data/data/com.dtourolle.jellytau/files/../../../../etc/passwd")
    2640        )
    2641        .is_err());
    2642    }
    2643
    2644    /// Helper to set up test database with required foreign key data
    2645    fn setup_test_db() -> Database {
    2646        let db = Database::open_in_memory().unwrap();
    2647        let conn = db.connection();
    2648        let conn = conn.lock_safe();
    2649
    2650        // Create server
    2651        conn.execute(
    2652            "INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
    2653            params!["server1", "Test Server", "http://localhost:8096"],
    2654        )
    2655        .unwrap();
    2656
    2657        // Create user
    2658        conn.execute(
    2659            "INSERT INTO users (id, server_id, username) VALUES (?1, ?2, ?3)",
    2660            params!["user1", "server1", "testuser"],
    2661        )
    2662        .unwrap();
    2663
    2664        // Create some items for download testing
    2665        conn.execute(
    2666            "INSERT INTO items (id, server_id, name, item_type, album_id, index_number) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
    2667            params!["item1", "server1", "Test Song 1", "Audio", "album1", 1],
    2668        )
    2669        .unwrap();
    2670
    2671        conn.execute(
    2672            "INSERT INTO items (id, server_id, name, item_type, album_id, index_number) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
    2673            params!["item2", "server1", "Test Song 2", "Audio", "album1", 2],
    2674        )
    2675        .unwrap();
    2676
    2677        conn.execute(
    2678            "INSERT INTO items (id, server_id, name, item_type, album_id, index_number) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
    2679            params!["item3", "server1", "Test Song 3", "Audio", "album1", 3],
    2680        )
    2681        .unwrap();
    2682
    2683        drop(conn);
    2684        db
    2685    }
    2686
    2687    #[test]
    2688    fn test_download_item_returns_correct_id_on_insert() {
    2689        let db = setup_test_db();
    2690        let conn = db.connection();
    2691        let conn = conn.lock_safe();
    2692
    2693        // Insert a new download
    2694        conn.execute(
    2695            "INSERT INTO downloads (item_id, user_id, file_path, mime_type, status, priority, queued_at)
    2696             VALUES (?1, ?2, ?3, ?4, 'pending', ?5, CURRENT_TIMESTAMP)",
    2697            params!["item1", "user1", "/path/to/file.mp3", "audio/mpeg", 0],
    2698        )
    2699        .unwrap();
    2700
    2701        // Query for the download ID (like our fixed code does)
    2702        let download_id: i64 = conn
    2703            .query_row(
    2704                "SELECT id FROM downloads WHERE item_id = ?1 AND user_id = ?2",
    2705                params!["item1", "user1"],
    2706                |row| row.get(0),
    2707            )
    2708            .unwrap();
    2709
    2710        assert!(download_id > 0, "Download ID should be positive");
    2711
    2712        // Verify the download record exists with correct data
    2713        let (status, file_path): (String, String) = conn
    2714            .query_row(
    2715                "SELECT status, file_path FROM downloads WHERE id = ?1",
    2716                params![download_id],
    2717                |row| Ok((row.get(0)?, row.get(1)?)),
    2718            )
    2719            .unwrap();
    2720
    2721        assert_eq!(status, "pending");
    2722        assert_eq!(file_path, "/path/to/file.mp3");
    2723    }
    2724
    2725    #[test]
    2726    fn test_download_item_upsert_returns_correct_id() {
    2727        let db = setup_test_db();
    2728        let conn = db.connection();
    2729        let conn = conn.lock_safe();
    2730
    2731        // First insert
    2732        conn.execute(
    2733            "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at)
    2734             VALUES (?1, ?2, ?3, 'pending', 0, CURRENT_TIMESTAMP)",
    2735            params!["item1", "user1", "/path/to/file.mp3"],
    2736        )
    2737        .unwrap();
    2738
    2739        let first_id: i64 = conn
    2740            .query_row(
    2741                "SELECT id FROM downloads WHERE item_id = ?1 AND user_id = ?2",
    2742                params!["item1", "user1"],
    2743                |row| row.get(0),
    2744            )
    2745            .unwrap();
    2746
    2747        // Simulate failure - mark as failed
    2748        conn.execute(
    2749            "UPDATE downloads SET status = 'failed', error_message = 'Network error' WHERE id = ?1",
    2750            params![first_id],
    2751        )
    2752        .unwrap();
    2753
    2754        // Now re-download (UPSERT) - this is the scenario that was broken
    2755        conn.execute(
    2756            "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at)
    2757             VALUES (?1, ?2, ?3, 'pending', 0, CURRENT_TIMESTAMP)
    2758             ON CONFLICT(item_id, user_id) DO UPDATE SET
    2759               priority = excluded.priority,
    2760               status = 'pending',
    2761               queued_at = CURRENT_TIMESTAMP",
    2762            params!["item1", "user1", "/path/to/file.mp3"],
    2763        )
    2764        .unwrap();
    2765
    2766        // The old buggy code used last_insert_rowid() which would return 0 or wrong value
    2767        // Our fixed code queries by unique constraint
    2768        let second_id: i64 = conn
    2769            .query_row(
    2770                "SELECT id FROM downloads WHERE item_id = ?1 AND user_id = ?2",
    2771                params!["item1", "user1"],
    2772                |row| row.get(0),
    2773            )
    2774            .unwrap();
    2775
    2776        // The ID should be the same as the first insert (UPSERT updated the existing row)
    2777        assert_eq!(first_id, second_id, "UPSERT should return the same row ID");
    2778
    2779        // Verify the status was reset to pending
    2780        let status: String = conn
    2781            .query_row(
    2782                "SELECT status FROM downloads WHERE id = ?1",
    2783                params![second_id],
    2784                |row| row.get(0),
    2785            )
    2786            .unwrap();
    2787
    2788        assert_eq!(
    2789            status, "pending",
    2790            "Status should be reset to pending after UPSERT"
    2791        );
    2792    }
    2793
    2794    #[test]
    2795    fn test_query_by_unique_constraint_is_reliable() {
    2796        // This test demonstrates that querying by unique constraint is always reliable,
    2797        // unlike last_insert_rowid() which has undefined behavior with UPSERT.
    2798        //
    2799        // SQLite documentation states that last_insert_rowid() behavior is undefined
    2800        // when ON CONFLICT triggers an UPDATE instead of INSERT. Some versions return 0,
    2801        // others return the existing row ID - it's not consistent.
    2802        //
    2803        // Our fix: always query by the unique constraint columns (item_id, user_id)
    2804        // to get the correct download ID, regardless of whether INSERT or UPDATE occurred.
    2805        let db = setup_test_db();
    2806        let conn = db.connection();
    2807        let conn = conn.lock_safe();
    2808
    2809        // First insert
    2810        conn.execute(
    2811            "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at)
    2812             VALUES (?1, ?2, ?3, 'pending', 0, CURRENT_TIMESTAMP)",
    2813            params!["item1", "user1", "/path/to/file.mp3"],
    2814        )
    2815        .unwrap();
    2816
    2817        let first_id: i64 = conn
    2818            .query_row(
    2819                "SELECT id FROM downloads WHERE item_id = ?1 AND user_id = ?2",
    2820                params!["item1", "user1"],
    2821                |row| row.get(0),
    2822            )
    2823            .unwrap();
    2824
    2825        // UPSERT (triggers UPDATE)
    2826        conn.execute(
    2827            "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at)
    2828             VALUES (?1, ?2, ?3, 'pending', 0, CURRENT_TIMESTAMP)
    2829             ON CONFLICT(item_id, user_id) DO UPDATE SET
    2830               status = 'pending'",
    2831            params!["item1", "user1", "/path/to/file.mp3"],
    2832        )
    2833        .unwrap();
    2834
    2835        // Our approach: query by unique constraint - always works!
    2836        let id_after_upsert: i64 = conn
    2837            .query_row(
    2838                "SELECT id FROM downloads WHERE item_id = ?1 AND user_id = ?2",
    2839                params!["item1", "user1"],
    2840                |row| row.get(0),
    2841            )
    2842            .unwrap();
    2843
    2844        // This should ALWAYS be the same ID - our approach is reliable
    2845        assert_eq!(
    2846            first_id, id_after_upsert,
    2847            "Query by unique constraint should always return correct ID"
    2848        );
    2849    }
    2850
    2851    #[test]
    2852    fn test_download_album_returns_correct_ids() {
    2853        let db = setup_test_db();
    2854        let conn = db.connection();
    2855        let conn = conn.lock_safe();
    2856
    2857        // Insert downloads for multiple items (simulating album download)
    2858        let track_ids = vec!["item1", "item2", "item3"];
    2859        let mut download_ids = Vec::new();
    2860
    2861        for track_id in &track_ids {
    2862            conn.execute(
    2863                "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at)
    2864                 VALUES (?1, ?2, ?3, 'pending', 100, CURRENT_TIMESTAMP)
    2865                 ON CONFLICT(item_id, user_id) DO UPDATE SET
    2866                   priority = 100,
    2867                   status = 'pending'",
    2868                params![track_id, "user1", format!("/path/{}.mp3", track_id)],
    2869            )
    2870            .unwrap();
    2871
    2872            // Use our fixed approach - query by unique constraint
    2873            let download_id: i64 = conn
    2874                .query_row(
    2875                    "SELECT id FROM downloads WHERE item_id = ?1 AND user_id = ?2",
    2876                    params![track_id, "user1"],
    2877                    |row| row.get(0),
    2878                )
    2879                .unwrap();
    2880
    2881            download_ids.push(download_id);
    2882        }
    2883
    2884        // All IDs should be unique and positive
    2885        assert_eq!(download_ids.len(), 3);
    2886        for id in &download_ids {
    2887            assert!(*id > 0, "Download ID should be positive");
    2888        }
    2889
    2890        // IDs should be unique
    2891        let mut sorted_ids = download_ids.clone();
    2892        sorted_ids.sort();
    2893        sorted_ids.dedup();
    2894        assert_eq!(sorted_ids.len(), 3, "All download IDs should be unique");
    2895    }
    2896
    2897    #[test]
    2898    fn test_download_album_upsert_returns_correct_ids() {
    2899        let db = setup_test_db();
    2900        let conn = db.connection();
    2901        let conn = conn.lock_safe();
    2902
    2903        // First download attempt - insert all
    2904        let track_ids = vec!["item1", "item2", "item3"];
    2905        let mut first_ids = Vec::new();
    2906
    2907        for track_id in &track_ids {
    2908            conn.execute(
    2909                "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at)
    2910                 VALUES (?1, ?2, ?3, 'pending', 100, CURRENT_TIMESTAMP)",
    2911                params![track_id, "user1", format!("/path/{}.mp3", track_id)],
    2912            )
    2913            .unwrap();
    2914
    2915            let id: i64 = conn
    2916                .query_row(
    2917                    "SELECT id FROM downloads WHERE item_id = ?1 AND user_id = ?2",
    2918                    params![track_id, "user1"],
    2919                    |row| row.get(0),
    2920                )
    2921                .unwrap();
    2922            first_ids.push(id);
    2923        }
    2924
    2925        // Mark all as failed
    2926        conn.execute("UPDATE downloads SET status = 'failed'", [])
    2927            .unwrap();
    2928
    2929        // Re-download (UPSERT all)
    2930        let mut second_ids = Vec::new();
    2931
    2932        for track_id in &track_ids {
    2933            conn.execute(
    2934                "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at)
    2935                 VALUES (?1, ?2, ?3, 'pending', 100, CURRENT_TIMESTAMP)
    2936                 ON CONFLICT(item_id, user_id) DO UPDATE SET
    2937                   priority = 100,
    2938                   status = 'pending'",
    2939                params![track_id, "user1", format!("/path/{}.mp3", track_id)],
    2940            )
    2941            .unwrap();
    2942
    2943            let id: i64 = conn
    2944                .query_row(
    2945                    "SELECT id FROM downloads WHERE item_id = ?1 AND user_id = ?2",
    2946                    params![track_id, "user1"],
    2947                    |row| row.get(0),
    2948                )
    2949                .unwrap();
    2950            second_ids.push(id);
    2951        }
    2952
    2953        // IDs should be the same (UPSERT updates existing rows)
    2954        assert_eq!(first_ids, second_ids, "UPSERT should preserve original IDs");
    2955    }
    2956
    2957    #[test]
    2958    fn test_get_downloads_returns_correct_metadata() {
    2959        let db = setup_test_db();
    2960        let conn = db.connection();
    2961        let conn = conn.lock_safe();
    2962
    2963        // Insert a download
    2964        conn.execute(
    2965            "INSERT INTO downloads (item_id, user_id, file_path, status, progress, priority)
    2966             VALUES (?1, ?2, ?3, 'downloading', 0.5, 10)",
    2967            params!["item1", "user1", "/path/to/song.mp3"],
    2968        )
    2969        .unwrap();
    2970
    2971        // Query downloads with item metadata
    2972        let download: DownloadInfo = conn
    2973            .query_row(
    2974                "SELECT d.id, d.item_id, d.user_id, d.file_path, d.file_size, d.mime_type, d.status, d.progress,
    2975                        d.bytes_downloaded, d.queued_at, d.started_at, d.completed_at, d.error_message,
    2976                        d.retry_count, d.priority,
    2977                        COALESCE(d.item_name, i.name) as item_name,
    2978                        COALESCE(d.artist_name, i.artists) as artist_name,
    2979                        COALESCE(d.album_name, i.album_name) as album_name,
    2980                        COALESCE(d.series_name, i.series_name) as series_name,
    2981                        COALESCE(d.season_name, i.season_name) as season_name,
    2982                        COALESCE(d.episode_number, i.index_number) as episode_number,
    2983                        COALESCE(d.season_number, i.parent_index_number) as season_number,
    2984                        d.quality_preset,
    2985                        COALESCE(d.media_type, 'audio') as media_type,
    2986                        COALESCE(d.download_source, 'user') as download_source
    2987                 FROM downloads d
    2988                 LEFT JOIN items i ON d.item_id = i.id
    2989                 WHERE d.user_id = ?1",
    2990                params!["user1"],
    2991                map_download_row,
    2992            )
    2993            .unwrap();
    2994
    2995        assert_eq!(download.item_id, "item1");
    2996        assert_eq!(download.status, "downloading");
    2997        assert!((download.progress - 0.5).abs() < 0.001);
    2998        assert_eq!(download.priority, 10);
    2999        assert_eq!(download.item_name, Some("Test Song 1".to_string()));
    3000    }
    3001
    3002    #[test]
    3003    fn test_download_status_transitions() {
    3004        let db = setup_test_db();
    3005        let conn = db.connection();
    3006        let conn = conn.lock_safe();
    3007
    3008        // Insert pending download
    3009        conn.execute(
    3010            "INSERT INTO downloads (item_id, user_id, file_path, status)
    3011             VALUES (?1, ?2, ?3, 'pending')",
    3012            params!["item1", "user1", "/path/to/song.mp3"],
    3013        )
    3014        .unwrap();
    3015
    3016        let id: i64 = conn.last_insert_rowid();
    3017
    3018        // Transition: pending -> downloading
    3019        conn.execute(
    3020            "UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP WHERE id = ?1",
    3021            params![id],
    3022        )
    3023        .unwrap();
    3024
    3025        let status: String = conn
    3026            .query_row(
    3027                "SELECT status FROM downloads WHERE id = ?1",
    3028                params![id],
    3029                |row| row.get(0),
    3030            )
    3031            .unwrap();
    3032        assert_eq!(status, "downloading");
    3033
    3034        // Transition: downloading -> completed
    3035        conn.execute(
    3036            "UPDATE downloads SET status = 'completed', progress = 1.0, completed_at = CURRENT_TIMESTAMP WHERE id = ?1",
    3037            params![id],
    3038        )
    3039        .unwrap();
    3040
    3041        let (status, progress): (String, f64) = conn
    3042            .query_row(
    3043                "SELECT status, progress FROM downloads WHERE id = ?1",
    3044                params![id],
    3045                |row| Ok((row.get(0)?, row.get(1)?)),
    3046            )
    3047            .unwrap();
    3048        assert_eq!(status, "completed");
    3049        assert!((progress - 1.0).abs() < 0.001);
    3050    }
    3051
    3052    #[test]
    3053    fn test_download_progress_updates() {
    3054        let db = setup_test_db();
    3055        let conn = db.connection();
    3056        let conn = conn.lock_safe();
    3057
    3058        conn.execute(
    3059            "INSERT INTO downloads (item_id, user_id, file_path, status, progress, bytes_downloaded, file_size)
    3060             VALUES (?1, ?2, ?3, 'downloading', 0.0, 0, 10000000)",
    3061            params!["item1", "user1", "/path/to/song.mp3"],
    3062        )
    3063        .unwrap();
    3064
    3065        let id: i64 = conn.last_insert_rowid();
    3066
    3067        // Simulate progress updates
    3068        for i in 1..=10 {
    3069            let progress = i as f64 / 10.0;
    3070            let bytes = i * 1000000;
    3071
    3072            conn.execute(
    3073                "UPDATE downloads SET progress = ?1, bytes_downloaded = ?2 WHERE id = ?3",
    3074                params![progress, bytes, id],
    3075            )
    3076            .unwrap();
    3077
    3078            let (actual_progress, actual_bytes): (f64, i64) = conn
    3079                .query_row(
    3080                    "SELECT progress, bytes_downloaded FROM downloads WHERE id = ?1",
    3081                    params![id],
    3082                    |row| Ok((row.get(0)?, row.get(1)?)),
    3083                )
    3084                .unwrap();
    3085
    3086            assert!((actual_progress - progress).abs() < 0.001);
    3087            assert_eq!(actual_bytes, bytes);
    3088        }
    3089    }
    3090
    3091    #[test]
    3092    fn test_compute_download_stats_empty() {
    3093        let downloads = vec![];
    3094        let stats = compute_download_stats(&downloads);
    3095        assert_eq!(stats.total, 0);
    3096        assert_eq!(stats.active_count, 0);
    3097        assert_eq!(stats.queued_count, 0);
    3098        assert_eq!(stats.completed_count, 0);
    3099        assert_eq!(stats.failed_count, 0);
    3100        assert_eq!(stats.paused_count, 0);
    3101    }
    3102
    3103    #[test]
    3104    fn test_compute_download_stats_mixed() {
    3105        let downloads = vec![
    3106            create_test_download(1, "downloading"),
    3107            create_test_download(2, "pending"),
    3108            create_test_download(3, "downloading"),
    3109            create_test_download(4, "completed"),
    3110            create_test_download(5, "failed"),
    3111            create_test_download(6, "paused"),
    3112        ];
    3113        let stats = compute_download_stats(&downloads);
    3114        assert_eq!(stats.total, 6);
    3115        assert_eq!(stats.active_count, 2);
    3116        assert_eq!(stats.queued_count, 1);
    3117        assert_eq!(stats.completed_count, 1);
    3118        assert_eq!(stats.failed_count, 1);
    3119        assert_eq!(stats.paused_count, 1);
    3120    }
    3121
    3122    #[test]
    3123    fn test_compute_download_stats_all_same_status() {
    3124        let downloads = vec![
    3125            create_test_download(1, "completed"),
    3126            create_test_download(2, "completed"),
    3127            create_test_download(3, "completed"),
    3128        ];
    3129        let stats = compute_download_stats(&downloads);
    3130        assert_eq!(stats.total, 3);
    3131        assert_eq!(stats.active_count, 0);
    3132        assert_eq!(stats.queued_count, 0);
    3133        assert_eq!(stats.completed_count, 3);
    3134        assert_eq!(stats.failed_count, 0);
    3135        assert_eq!(stats.paused_count, 0);
    3136    }
    3137
    3138    /// Helper to create a test DownloadInfo for stats testing
    3139    fn create_test_download(id: i64, status: &str) -> DownloadInfo {
    3140        DownloadInfo {
    3141            id,
    3142            item_id: format!("item{}", id),
    3143            user_id: "test_user".to_string(),
    3144            file_path: format!("/tmp/download{}", id),
    3145            file_size: Some(1000),
    3146            mime_type: Some("audio/flac".to_string()),
    3147            status: status.to_string(),
    3148            progress: 0.0,
    3149            bytes_downloaded: 0,
    3150            queued_at: "2024-01-01T00:00:00Z".to_string(),
    3151            started_at: None,
    3152            completed_at: None,
    3153            error_message: None,
    3154            retry_count: 0,
    3155            priority: 0,
    3156            item_name: Some(format!("Track {}", id)),
    3157            artist_name: None,
    3158            album_name: None,
    3159            series_name: None,
    3160            season_name: None,
    3161            episode_number: None,
    3162            season_number: None,
    3163            quality_preset: None,
    3164            media_type: "audio".to_string(),
    3165            download_source: "user".to_string(),
    3166        }
    3167    }
    3168
    3169    // ===== Album download: track sourcing and album linkage =====
    3170
    3171    /// A database with just the tables the album-download path touches.
    3172    fn album_test_db() -> Arc<crate::storage::db_service::RusqliteService> {
    3173        let conn = rusqlite::Connection::open_in_memory().unwrap();
    3174        conn.execute_batch(
    3175            r#"
    3176            CREATE TABLE items (
    3177                id TEXT PRIMARY KEY,
    3178                server_id TEXT NOT NULL,
    3179                parent_id TEXT,
    3180                name TEXT NOT NULL,
    3181                item_type TEXT NOT NULL,
    3182                album_id TEXT,
    3183                album_name TEXT,
    3184                album_artist TEXT,
    3185                artists TEXT,
    3186                index_number INTEGER
    3187            );
    3188            CREATE TABLE downloads (
    3189                id INTEGER PRIMARY KEY AUTOINCREMENT,
    3190                item_id TEXT NOT NULL,
    3191                user_id TEXT NOT NULL,
    3192                file_path TEXT NOT NULL,
    3193                status TEXT DEFAULT 'pending',
    3194                priority INTEGER DEFAULT 0,
    3195                progress REAL DEFAULT 0,
    3196                queued_at TEXT,
    3197                item_name TEXT,
    3198                artist_name TEXT,
    3199                album_name TEXT,
    3200                media_type TEXT,
    3201                stream_url TEXT,
    3202                target_dir TEXT,
    3203                UNIQUE(item_id, user_id)
    3204            );
    3205            INSERT INTO items (id, server_id, name, item_type)
    3206                VALUES ('album1', 'server1', 'The Golden Age', 'MusicAlbum');
    3207            "#,
    3208        )
    3209        .unwrap();
    3210        Arc::new(crate::storage::db_service::RusqliteService::new(Arc::new(
    3211            Mutex::new(conn),
    3212        )))
    3213    }
    3214
    3215    fn album_track(id: &str, name: &str, index: i32) -> AlbumTrack {
    3216        AlbumTrack {
    3217            id: id.to_string(),
    3218            name: name.to_string(),
    3219            artist_name: Some("Woodkid".to_string()),
    3220            album_name: Some("The Golden Age".to_string()),
    3221            index_number: Some(index),
    3222        }
    3223    }
    3224
    3225    /// The album-download regression: every track the album actually has must be
    3226    /// queued, and each queued track must be linked to its album.
    3227    ///
    3228    /// `download_album` used to take its track list from
    3229    /// `items WHERE album_id = ?`. Jellyfin does not return `AlbumId` on every
    3230    /// listing endpoint, so tracks cached from those endpoints sit in `items`
    3231    /// with a NULL `album_id` — invisible to that query. "Download album" then
    3232    /// silently queued only the subset that happened to carry the link, which is
    3233    /// the reported "only 4-5 songs downloaded". The same column is what offline
    3234    /// browsing joins tracks to their album on (`i.album_id = ?` in
    3235    /// `OfflineRepository::get_items`), so even a track that did download stayed
    3236    /// invisible under its album offline.
    3237    ///
    3238    /// TRACES: UR-018, UR-055 | DR-173 | UT-170
    3239    #[tokio::test]
    3240    async fn test_queue_album_tracks_queues_every_track_and_links_it_to_the_album() {
    3241        let db = album_test_db();
    3242
    3243        // The cache holds all three tracks, but only one carries `album_id` —
    3244        // exactly the state the bug report's database is in.
    3245        for sql in [
    3246            "INSERT INTO items (id, server_id, name, item_type, album_id) \
    3247             VALUES ('t1', 'server1', 'Run Boy Run', 'Audio', 'album1')",
    3248            "INSERT INTO items (id, server_id, name, item_type, album_id) \
    3249             VALUES ('t2', 'server1', 'The Great Escape', 'Audio', NULL)",
    3250            "INSERT INTO items (id, server_id, name, item_type, album_id) \
    3251             VALUES ('t3', 'server1', 'Boat Song', 'Audio', NULL)",
    3252        ] {
    3253            db.execute(Query::new(sql)).await.unwrap();
    3254        }
    3255
    3256        let tracks = vec![
    3257            album_track("t1", "Run Boy Run", 1),
    3258            album_track("t2", "The Great Escape", 2),
    3259            album_track("t3", "Boat Song", 3),
    3260        ];
    3261
    3262        let ids = queue_album_tracks(&db, "album1", &tracks, "user1", "albums/album1")
    3263            .await
    3264            .unwrap();
    3265
    3266        assert_eq!(
    3267            ids.len(),
    3268            3,
    3269            "every track of the album must get a download row"
    3270        );
    3271
    3272        let queued: i64 = db
    3273            .query_one(
    3274                Query::new("SELECT COUNT(*) FROM downloads WHERE status = 'pending'"),
    3275                |row| row.get(0),
    3276            )
    3277            .await
    3278            .unwrap();
    3279        assert_eq!(queued, 3);
    3280
    3281        // Each track is now linked to its album, so the offline album page can
    3282        // find it once the download completes.
    3283        let linked: i64 = db
    3284            .query_one(
    3285                Query::new("SELECT COUNT(*) FROM items WHERE album_id = 'album1'"),
    3286                |row| row.get(0),
    3287            )
    3288            .await
    3289            .unwrap();
    3290        assert_eq!(
    3291            linked, 3,
    3292            "queued tracks must be linked to their album; offline browsing joins on album_id"
    3293        );
    3294    }
    3295
    3296    /// The returned ids must line up with the tracks that were passed in. The
    3297    /// frontend used to pair `downloadIds[i]` with its own `tracks[i]`, which is
    3298    /// only sound if both lists agree — they did not, because the backend
    3299    /// ordered by `index_number` over a different set of rows. Resolving URLs in
    3300    /// Rust removes the pairing entirely, but the order is still the contract
    3301    /// for anything that reads the ids back.
    3302    ///
    3303    /// TRACES: UR-018, UR-055 | DR-173 | UT-170
    3304    #[tokio::test]
    3305    async fn test_queue_album_tracks_returns_ids_in_track_order() {
    3306        let db = album_test_db();
    3307        let tracks = vec![
    3308            album_track("t1", "Run Boy Run", 1),
    3309            album_track("t2", "The Great Escape", 2),
    3310        ];
    3311
    3312        let ids = queue_album_tracks(&db, "album1", &tracks, "user1", "albums/album1")
    3313            .await
    3314            .unwrap();
    3315
    3316        for (id, track) in ids.iter().zip(tracks.iter()) {
    3317            let item_id: String = db
    3318                .query_one(
    3319                    Query::with_params(
    3320                        "SELECT item_id FROM downloads WHERE id = ?",
    3321                        vec![QueryParam::Int64(*id)],
    3322                    ),
    3323                    |row| row.get(0),
    3324                )
    3325                .await
    3326                .unwrap();
    3327            assert_eq!(&item_id, &track.id, "id {} must be {}'s row", id, track.id);
    3328        }
    3329    }
    3330
    3331    /// Re-queueing an album already partly downloaded must not duplicate rows or
    3332    /// reset a completed track — it fills in what is missing.
    3333    ///
    3334    /// TRACES: UR-018, UR-055 | DR-173 | UT-170
    3335    #[tokio::test]
    3336    async fn test_queue_album_tracks_is_idempotent() {
    3337        let db = album_test_db();
    3338        let tracks = vec![
    3339            album_track("t1", "Run Boy Run", 1),
    3340            album_track("t2", "The Great Escape", 2),
    3341        ];
    3342
    3343        let first = queue_album_tracks(&db, "album1", &tracks, "user1", "albums/album1")
    3344            .await
    3345            .unwrap();
    3346        let second = queue_album_tracks(&db, "album1", &tracks, "user1", "albums/album1")
    3347            .await
    3348            .unwrap();
    3349
    3350        assert_eq!(first, second, "the same tracks must map to the same rows");
    3351
    3352        let rows: i64 = db
    3353            .query_one(Query::new("SELECT COUNT(*) FROM downloads"), |row| {
    3354                row.get(0)
    3355            })
    3356            .await
    3357            .unwrap();
    3358        assert_eq!(rows, 2, "re-queueing must not duplicate download rows");
    3359    }
    3360
    3361    /// Two tracks of one album can share a title — a deluxe edition carrying the
    3362    /// album version and a demo of the same song, or the same song on two discs.
    3363    /// Naming the file after the title alone gave them one path, so the second
    3364    /// download overwrote the first and the album ended up short however many
    3365    /// duplicates it had.
    3366    ///
    3367    /// TRACES: UR-018, UR-055 | DR-173 | UT-172
    3368    #[test]
    3369    fn test_album_file_names_are_unique_within_the_album() {
    3370        let tracks = vec![
    3371            album_track("t1", "Crucified Again", 5),
    3372            album_track("t2", "Crucified Again", 5),
    3373            album_track("t3", "Get Right", 7),
    3374        ];
    3375
    3376        let names = album_file_names(&tracks);
    3377
    3378        assert_eq!(names.len(), 3);
    3379        let unique: std::collections::HashSet<_> = names.iter().collect();
    3380        assert_eq!(
    3381            unique.len(),
    3382            3,
    3383            "every track of an album needs its own file: {:?}",
    3384            names
    3385        );
    3386        assert!(names.iter().all(|n| n.ends_with(".mp3")), "{:?}", names);
    3387        assert!(
    3388            names[2].contains("Get Right"),
    3389            "an unambiguous title keeps its name: {}",
    3390            names[2]
    3391        );
    3392    }
    3393
    3394    /// Path separators in a track title must not escape the album directory.
    3395    ///
    3396    /// TRACES: UR-018, UR-055 | DR-173 | UT-172
    3397    #[test]
    3398    fn test_album_file_names_sanitize_the_title() {
    3399        let names = album_file_names(&[album_track("t1", "AC/DC: Live?", 1)]);
    3400        assert!(!names[0].contains('/'), "{}", names[0]);
    3401        assert!(!names[0].contains(':'), "{}", names[0]);
    3402    }
    3403
    3404    /// The offline fallback reads the catalog directly, not through the
    3405    /// availability-gated offline listing: queueing an album while the server is
    3406    /// unreachable is a supported flow (the rows resolve on reconnect), and
    3407    /// gating it on what is already downloaded would queue only the tracks the
    3408    /// device already has.
    3409    ///
    3410    /// TRACES: UR-018, UR-055 | DR-173 | UT-170
    3411    #[tokio::test]
    3412    async fn test_cached_album_tracks_finds_tracks_by_either_album_link() {
    3413        let db = album_test_db();
    3414        for sql in [
    3415            "INSERT INTO items (id, server_id, name, item_type, album_id, index_number) \
    3416             VALUES ('t1', 'server1', 'Run Boy Run', 'Audio', 'album1', 1)",
    3417            // Linked by parent_id only — how a track cached from a folder
    3418            // listing lands in the catalog.
    3419            "INSERT INTO items (id, server_id, name, item_type, parent_id, index_number) \
    3420             VALUES ('t2', 'server1', 'The Great Escape', 'Audio', 'album1', 2)",
    3421            // A different album's track must not be swept in.
    3422            "INSERT INTO items (id, server_id, name, item_type, album_id) \
    3423             VALUES ('other', 'server1', 'Iron', 'Audio', 'album2')",
    3424        ] {
    3425            db.execute(Query::new(sql)).await.unwrap();
    3426        }
    3427
    3428        let tracks = cached_album_tracks(&db, "album1").await.unwrap();
    3429        let ids: Vec<_> = tracks.iter().map(|t| t.id.as_str()).collect();
    3430        assert_eq!(ids, vec!["t1", "t2"]);
    3431    }
    3432
    3433    /// Tracks the cache has never seen still get queued: the row is created and
    3434    /// an `items` row is written for it, so the download is both startable and
    3435    /// visible offline afterwards.
    3436    ///
    3437    /// TRACES: UR-018, UR-055 | DR-173 | UT-170
    3438    #[tokio::test]
    3439    async fn test_queue_album_tracks_handles_tracks_absent_from_the_cache() {
    3440        let db = album_test_db();
    3441        let tracks = vec![album_track("never-cached", "Iron", 1)];
    3442
    3443        let ids = queue_album_tracks(&db, "album1", &tracks, "user1", "albums/album1")
    3444            .await
    3445            .unwrap();
    3446        assert_eq!(ids.len(), 1);
    3447
    3448        let (item_type, album_id): (String, Option<String>) = db
    3449            .query_one(
    3450                Query::new("SELECT item_type, album_id FROM items WHERE id = 'never-cached'"),
    3451                |row| Ok((row.get(0)?, row.get(1)?)),
    3452            )
    3453            .await
    3454            .unwrap();
    3455        assert_eq!(item_type, "Audio");
    3456        assert_eq!(album_id.as_deref(), Some("album1"));
    3457    }
    3458}