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