Skip to main content

jellytau_lib/commands/download/
mod.rs

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