Skip to main content

jellytau_lib/commands/download/
mod.rs

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