Skip to main content

#
jellytau_lib/commands/
catalog.rs

    1//! Tauri commands for the offline "browse & queue" feature.
    2//!
    3//! TRACES: UR-002, UR-007, UR-024 | JA-004, JA-016 | DR-012, DR-027
    4//!
    5//! Two backend pieces support browsing the full server catalog while offline
    6//! and queueing downloads that fire on reconnect:
    7//!
    8//! - [`sync_full_catalog`] walks every library while online and persists all
    9//!   items to the offline cache so the whole catalog is browsable (greyed out)
    10//!   offline. It reuses [`HybridRepository::cache_items_from_server`], which in
    11//!   turn reuses `OfflineRepository::save_to_cache` (sets `synced_at`, which is
    12//!   what `get_items` branch 3 serves offline).
    13//! - [`resume_queued_downloads`] resolves and pumps the `pending` download rows
    14//!   that were queued offline (they have `stream_url IS NULL`), mirroring the
    15//!   heal-and-pump pattern in `player_preload_upcoming`.
    16
    17use std::sync::atomic::{AtomicBool, Ordering};
    18use std::sync::Arc;
    19use std::time::Duration;
    20
    21use log::{info, warn};
    22use tauri::{Emitter, Manager, State};
    23
    24use crate::commands::download::{pump_download_queue, DownloadManagerWrapper};
    25use crate::commands::repository::RepositoryManagerWrapper;
    26use crate::commands::storage::DatabaseWrapper;
    27use crate::repository::types::GetItemsOptions;
    28use crate::storage::db_service::{DatabaseService, Query, QueryParam};
    29
    30/// app_settings key holding the RFC-3339 timestamp of the last successful
    31/// full-catalog sync.
    32const LAST_CATALOG_SYNC_KEY: &str = "last_catalog_sync";
    33
    34/// How long an index stays fresh before a re-index is due.
    35///
    36/// This lives in Rust rather than being a frontend constant because it decides
    37/// *whether the local cache is authoritative* — the same class of decision as
    38/// `include_catalog_browse`, and squarely the "sync policy" the spec review
    39/// checklist keeps out of the presentation layer. If it later becomes
    40/// user-configurable it stays a Rust-owned setting edited through a command.
    41const CATALOG_INDEX_TTL: Duration = Duration::from_secs(6 * 60 * 60);
    42
    43/// How often the scheduler wakes to *check* staleness. Far shorter than the TTL
    44/// because a tick is nearly free — one indexed `app_settings` lookup — and it is
    45/// what makes the indexer responsive to events it cannot subscribe to: signing
    46/// in, and coming back online. The TTL, not the tick, decides whether a crawl
    47/// actually happens.
    48const CATALOG_INDEX_TICK: Duration = Duration::from_secs(5 * 60);
    49
    50/// Delay before the first staleness check, to let sign-in complete and the
    51/// repository be registered. Without it the first check runs against an empty
    52/// repository manager and a fresh install would sit unindexed until the next
    53/// tick.
    54const CATALOG_INDEX_FIRST_CHECK: Duration = Duration::from_secs(15);
    55
    56/// Kebab-case, per the project's event convention.
    57pub const CATALOG_INDEX_EVENT: &str = "catalog-index-event";
    58
    59/// Guards against two passes running at once. Replaces the frontend's
    60/// `syncInProgress` boolean in `offlineCatalog.ts`, which could not see a pass
    61/// started by the scheduler.
    62static INDEX_IN_PROGRESS: AtomicBool = AtomicBool::new(false);
    63
    64/// Clears [`INDEX_IN_PROGRESS`] however the pass leaves — including on the `?`
    65/// early return when `get_libraries` fails, which a plain store at the end of
    66/// the function would leak.
    67struct IndexPassGuard;
    68
    69impl Drop for IndexPassGuard {
    70    fn drop(&mut self) {
    71        INDEX_IN_PROGRESS.store(false, Ordering::SeqCst);
    72    }
    73}
    74
    75/// Progress of a background index pass, for the staleness hint in the UI.
    76#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
    77#[serde(rename_all = "camelCase")]
    78pub struct CatalogIndexEvent {
    79    /// `started` | `finished` | `failed`
    80    pub state: String,
    81    pub items_cached: usize,
    82    pub items_pruned: usize,
    83    pub libraries_failed: usize,
    84    /// Present on `failed`.
    85    pub error: Option<String>,
    86}
    87
    88/// Item types worth caching for offline browsing: containers the library
    89/// landing pages render plus the playable leaves users queue for download.
    90/// `MusicArtist` and `Playlist` are here because search groups results by them
    91/// (UR-060's Artists group). Without them in the crawl, the local index can
    92/// never answer an artist query and those groups can only ever be filled by the
    93/// server leg. Keep this in step with what `prune_stale_catalog` is allowed to
    94/// sweep — the crawl is only authoritative for the types it asks for.
    95///
    96/// TRACES: UR-065, UR-060 | DR-111
    97const CATALOG_ITEM_TYPES: &[&str] = &[
    98    "MusicAlbum",
    99    "MusicArtist",
    100    "Movie",
    101    "Series",
    102    "Season",
    103    "Episode",
    104    "Audio",
    105    "BoxSet",
    106    "Playlist",
    107];
    108
    109/// Jellyfin item types whose download is a *video* stream rather than an audio
    110/// one. The download queue stores an opaque `media_type` ('audio'/'video'); this
    111/// is where the taxonomy that produces it lives, so the frontend never has to
    112/// know which item types are video.
    113///
    114/// TRACES: UR-071 | DR-135
    115const VIDEO_ITEM_TYPES: &[&str] = &["Movie", "Episode", "Video", "MusicVideo"];
    116
    117#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
    118#[serde(rename_all = "camelCase")]
    119pub struct CatalogSyncResult {
    120    /// Total items persisted to the offline cache across all libraries.
    121    pub items_cached: usize,
    122    /// Libraries that failed to sync (e.g. server hiccup); best-effort.
    123    pub libraries_failed: usize,
    124    /// Entries removed because the server no longer has them. Always 0 when any
    125    /// library failed, since a partial crawl cannot prove an item is gone.
    126    pub items_pruned: usize,
    127}
    128
    129#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
    130#[serde(rename_all = "camelCase")]
    131pub struct CatalogSyncStatus {
    132    /// RFC-3339 timestamp of the last successful sync, if any.
    133    pub last_synced_at: Option<String>,
    134}
    135
    136/// Walk every library on the server and persist all items to the offline cache
    137/// so the full catalog is browsable offline (greyed out when not downloaded).
    138///
    139/// Best-effort: a library that fails to fetch is counted and skipped rather than
    140/// aborting the whole sync. Runs libraries sequentially to avoid hammering the
    141/// server. Uses `Recursive=true` so a single request per library returns the
    142/// containers and their playable children.
    143#[tauri::command]
    144#[specta::specta]
    145pub async fn sync_full_catalog(
    146    repository: State<'_, RepositoryManagerWrapper>,
    147    db: State<'_, DatabaseWrapper>,
    148    handle: String,
    149) -> Result<CatalogSyncResult, String> {
    150    let repo = repository.0.get(&handle).ok_or("Repository not found")?;
    151
    152    let db_service = {
    153        let database = db.0.lock().map_err(|e| e.to_string())?;
    154        Arc::new(database.service())
    155    };
    156
    157    run_index_pass(repo, db_service).await
    158}
    159
    160/// One full-catalog indexing pass, shared by the [`sync_full_catalog`] command
    161/// and the background scheduler (DR-109) so there is exactly one implementation
    162/// and one concurrency guard.
    163///
    164/// TRACES: UR-065 | DR-109, DR-110
    165pub(crate) async fn run_index_pass(
    166    repo: Arc<crate::repository::HybridRepository>,
    167    db_service: Arc<crate::storage::db_service::RusqliteService>,
    168) -> Result<CatalogSyncResult, String> {
    169    use crate::repository::MediaRepository;
    170
    171    // One pass at a time. The command and the scheduler can both land here, and
    172    // two concurrent crawls would double the server load and race on the sweep.
    173    if INDEX_IN_PROGRESS.swap(true, Ordering::SeqCst) {
    174        return Err("A catalog index pass is already running".to_string());
    175    }
    176    let _guard = IndexPassGuard;
    177
    178    let libraries = repo.get_libraries().await.map_err(|e| e.to_string())?;
    179    info!(
    180        "[Catalog] Full sync starting across {} libraries",
    181        libraries.len()
    182    );
    183
    184    let include_types: Vec<String> = CATALOG_ITEM_TYPES.iter().map(|s| s.to_string()).collect();
    185
    186    // Taken before the crawl: every row the crawl writes gets a `synced_at`
    187    // newer than this, so anything still older afterwards is gone server-side.
    188    let pass_started_at = chrono::Utc::now().to_rfc3339();
    189
    190    let mut items_cached = 0usize;
    191    let mut libraries_failed = 0usize;
    192
    193    for library in &libraries {
    194        let opts = GetItemsOptions {
    195            recursive: Some(true),
    196            include_item_types: Some(include_types.clone()),
    197            limit: Some(100_000),
    198            ..Default::default()
    199        };
    200
    201        match repo.cache_items_from_server(&library.id, Some(opts)).await {
    202            Ok(items) => {
    203                info!(
    204                    "[Catalog] Cached {} items from library '{}'",
    205                    items.len(),
    206                    library.name
    207                );
    208                items_cached += items.len();
    209            }
    210            Err(e) => {
    211                warn!(
    212                    "[Catalog] Failed to sync library '{}': {:?}",
    213                    library.name, e
    214                );
    215                libraries_failed += 1;
    216            }
    217        }
    218    }
    219
    220    // Propagate server-side deletions — but only after a *complete* crawl.
    221    // `sync_full_catalog` is best-effort per library, and `items.parent_id` is
    222    // ON DELETE CASCADE, so sweeping when a library failed to fetch could
    223    // cascade a whole series away because one request timed out.
    224    let mut items_pruned = 0usize;
    225    if libraries_failed == 0 && !libraries.is_empty() {
    226        match repo
    227            .prune_stale_catalog(&pass_started_at, &include_types)
    228            .await
    229        {
    230            Ok(removed) => {
    231                items_pruned = removed;
    232                if removed > 0 {
    233                    info!(
    234                        "[Catalog] Pruned {} entries no longer on the server",
    235                        removed
    236                    );
    237                }
    238            }
    239            Err(e) => warn!("[Catalog] Prune of stale catalog entries failed: {:?}", e),
    240        }
    241    } else if libraries_failed > 0 {
    242        info!(
    243            "[Catalog] Skipping stale-entry prune: {} librar{} failed to sync, so the crawl is not authoritative",
    244            libraries_failed,
    245            if libraries_failed == 1 { "y" } else { "ies" }
    246        );
    247    }
    248
    249    // Record the sync time so callers can skip re-syncing too eagerly.
    250    let now = chrono::Utc::now().to_rfc3339();
    251    let upsert = Query::with_params(
    252        "INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
    253         ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP",
    254        vec![
    255            QueryParam::String(LAST_CATALOG_SYNC_KEY.to_string()),
    256            QueryParam::String(now),
    257        ],
    258    );
    259    if let Err(e) = db_service.execute(upsert).await {
    260        warn!("[Catalog] Failed to persist last-sync timestamp: {}", e);
    261    }
    262
    263    info!(
    264        "[Catalog] Full sync complete: {} items cached, {} pruned, {} libraries failed",
    265        items_cached, items_pruned, libraries_failed
    266    );
    267
    268    Ok(CatalogSyncResult {
    269        items_cached,
    270        libraries_failed,
    271        items_pruned,
    272    })
    273}
    274
    275/// Whether an index pass is due, given when one last completed.
    276///
    277/// Pure so the policy is unit-testable without a clock, a server, or a database.
    278/// `None` (never indexed) and an unparseable stored value both mean "due" — a
    279/// corrupt timestamp should trigger a re-index, not silently freeze the catalog.
    280///
    281/// TRACES: UR-065 | DR-109 | UT-115
    282pub(crate) fn index_is_due(
    283    last_synced_at: Option<&str>,
    284    now: chrono::DateTime<chrono::Utc>,
    285    ttl: Duration,
    286) -> bool {
    287    let Some(raw) = last_synced_at else {
    288        return true;
    289    };
    290    let Ok(last) = chrono::DateTime::parse_from_rfc3339(raw) else {
    291        return true;
    292    };
    293    now.signed_duration_since(last.with_timezone(&chrono::Utc))
    294        .to_std()
    295        .map(|elapsed| elapsed >= ttl)
    296        // Negative elapsed => the stored stamp is in the future (clock skew).
    297        // Not due; a future stamp will age into due-ness on its own.
    298        .unwrap_or(false)
    299}
    300
    301/// Read the last-sync timestamp straight from `app_settings`.
    302async fn read_last_sync(
    303    db_service: &Arc<crate::storage::db_service::RusqliteService>,
    304) -> Option<String> {
    305    db_service
    306        .query_optional(
    307            Query::with_params(
    308                "SELECT value FROM app_settings WHERE key = ?",
    309                vec![QueryParam::String(LAST_CATALOG_SYNC_KEY.to_string())],
    310            ),
    311            |row| row.get(0),
    312        )
    313        .await
    314        .ok()
    315        .flatten()
    316}
    317
    318/// Start the background catalog indexer.
    319///
    320/// Replaces the frontend's startup-only `syncCatalog()` call: index freshness is
    321/// sync policy and belongs in Rust (see the layer assignment in
    322/// docs/architecture/03-data-flow.md, "Search Flow"). Ticks every
    323/// [`CATALOG_INDEX_TICK`] and
    324/// runs a pass when a repository exists, the server is reachable, and the index
    325/// is older than [`CATALOG_INDEX_TTL`].
    326///
    327/// TRACES: UR-065 | DR-109, IR-030
    328pub fn spawn_catalog_indexer(app: tauri::AppHandle) {
    329    tauri::async_runtime::spawn(async move {
    330        // Check shortly after launch, then on every tick — not tick-then-check,
    331        // which would leave a fresh install unindexed for a full tick.
    332        tokio::time::sleep(CATALOG_INDEX_FIRST_CHECK).await;
    333
    334        loop {
    335            if let Err(e) = maybe_run_scheduled_pass(&app).await {
    336                // Never fatal — a failed pass leaves the existing index in place
    337                // and we retry on the next tick.
    338                warn!("[Catalog] Scheduled index pass skipped: {}", e);
    339            }
    340
    341            tokio::time::sleep(CATALOG_INDEX_TICK).await;
    342        }
    343    });
    344}
    345
    346/// One scheduler tick: check the preconditions, then index if due.
    347async fn maybe_run_scheduled_pass(app: &tauri::AppHandle) -> Result<(), String> {
    348    if INDEX_IN_PROGRESS.load(Ordering::SeqCst) {
    349        return Ok(());
    350    }
    351
    352    let db_service = {
    353        let db = app.state::<DatabaseWrapper>();
    354        let database = db.0.lock().map_err(|e| e.to_string())?;
    355        Arc::new(database.service())
    356    };
    357
    358    if !index_is_due(
    359        read_last_sync(&db_service).await.as_deref(),
    360        chrono::Utc::now(),
    361        CATALOG_INDEX_TTL,
    362    ) {
    363        return Ok(());
    364    }
    365
    366    // Offline: leave the index alone. The crawl would fail every library and,
    367    // more importantly, a partial crawl must never reach the deletion sweep.
    368    {
    369        let monitor = app.state::<crate::commands::connectivity::ConnectivityMonitorWrapper>();
    370        let monitor = monitor.0.lock().await;
    371        if !monitor.get_status().await.is_server_reachable {
    372            return Ok(());
    373        }
    374    }
    375
    376    let repo = {
    377        let manager = app.state::<RepositoryManagerWrapper>();
    378        let handles = manager.0.handles();
    379        let Some(handle) = handles.first() else {
    380            // Not signed in yet.
    381            return Ok(());
    382        };
    383        manager.0.get(handle).ok_or("Repository not found")?
    384    };
    385
    386    info!("[Catalog] Index is stale; starting a scheduled pass");
    387    let _ = app.emit(
    388        CATALOG_INDEX_EVENT,
    389        CatalogIndexEvent {
    390            state: "started".to_string(),
    391            items_cached: 0,
    392            items_pruned: 0,
    393            libraries_failed: 0,
    394            error: None,
    395        },
    396    );
    397
    398    match run_index_pass(repo, db_service).await {
    399        Ok(result) => {
    400            let _ = app.emit(
    401                CATALOG_INDEX_EVENT,
    402                CatalogIndexEvent {
    403                    state: "finished".to_string(),
    404                    items_cached: result.items_cached,
    405                    items_pruned: result.items_pruned,
    406                    libraries_failed: result.libraries_failed,
    407                    error: None,
    408                },
    409            );
    410            Ok(())
    411        }
    412        Err(e) => {
    413            let _ = app.emit(
    414                CATALOG_INDEX_EVENT,
    415                CatalogIndexEvent {
    416                    state: "failed".to_string(),
    417                    items_cached: 0,
    418                    items_pruned: 0,
    419                    libraries_failed: 0,
    420                    error: Some(e.clone()),
    421                },
    422            );
    423            Err(e)
    424        }
    425    }
    426}
    427
    428/// Report the last-synced timestamp so the UI can show a hint / decide whether
    429/// to trigger a fresh sync.
    430#[tauri::command]
    431#[specta::specta]
    432pub async fn catalog_sync_status(
    433    db: State<'_, DatabaseWrapper>,
    434) -> Result<CatalogSyncStatus, String> {
    435    let db_service = {
    436        let database = db.0.lock().map_err(|e| e.to_string())?;
    437        Arc::new(database.service())
    438    };
    439
    440    let query = Query::with_params(
    441        "SELECT value FROM app_settings WHERE key = ?",
    442        vec![QueryParam::String(LAST_CATALOG_SYNC_KEY.to_string())],
    443    );
    444    let last_synced_at: Option<String> = db_service
    445        .query_optional(query, |row| row.get(0))
    446        .await
    447        .map_err(|e| e.to_string())?;
    448
    449    Ok(CatalogSyncStatus { last_synced_at })
    450}
    451
    452/// Control whether offline library queries reveal the full synced catalog
    453/// (greyed-out, non-downloaded media) or only downloaded/local media.
    454///
    455/// The frontend calls this from the "Show all server media" toggle: pass `true`
    456/// when online, or when offline with the toggle on; pass `false` when offline
    457/// with the toggle off so library pages show downloaded media only. Fixes the
    458/// bug where offline library pages showed every server item regardless of the
    459/// toggle.
    460#[tauri::command]
    461#[specta::specta]
    462pub fn set_show_server_catalog(show: bool) {
    463    crate::repository::offline::set_include_catalog_browse(show);
    464}
    465
    466#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
    467#[serde(rename_all = "camelCase")]
    468pub struct ResumeQueuedResult {
    469    /// Rows whose stream URL was resolved and are now pump-eligible.
    470    pub resolved: usize,
    471    /// Rows that couldn't be resolved (item metadata / URL lookup failed).
    472    pub failed: usize,
    473}
    474
    475/// Requeue video downloads that were fetched as audio.
    476///
    477/// Before [`resolve_pending_download_urls`] consulted the item's type, a row
    478/// with no `media_type` — which is every row queued from a media card, since
    479/// `download_item` does not record one — resolved against
    480/// `get_audio_stream_url`. A movie queued that way completed with an audio-only
    481/// transcode on disk, so playing it offline could only ever fail. Those rows are
    482/// identifiable after the fact (no `media_type`, but a video item), so reset them
    483/// to pending with no URL and let the resolver fetch the real video.
    484///
    485/// Rows carrying an explicit `media_type` were resolved correctly and are left
    486/// alone, as are genuine audio downloads.
    487///
    488/// Returns the number of rows requeued.
    489///
    490/// TRACES: UR-071 | DR-136 | UT-126
    491pub(crate) async fn requeue_mistyped_video_downloads(
    492    db_service: &Arc<crate::storage::db_service::RusqliteService>,
    493) -> Result<usize, String> {
    494    let video_types = VIDEO_ITEM_TYPES
    495        .iter()
    496        .map(|t| format!("'{t}'"))
    497        .collect::<Vec<_>>()
    498        .join(", ");
    499
    500    let query = Query::new(format!(
    501        "UPDATE downloads
    502         SET status = 'pending', stream_url = NULL, progress = 0,
    503             bytes_downloaded = 0, started_at = NULL, completed_at = NULL
    504         WHERE media_type IS NULL
    505           AND status = 'completed'
    506           AND item_id IN (SELECT id FROM items WHERE item_type IN ({video_types}))"
    507    ));
    508
    509    let n = db_service.execute(query).await.map_err(|e| e.to_string())? as usize;
    510
    511    if n > 0 {
    512        info!(
    513            "[Catalog] Requeued {} video download(s) that were fetched as audio",
    514            n
    515        );
    516    }
    517    Ok(n)
    518}
    519
    520/// Core of [`resume_queued_downloads`], factored out for testing: select every
    521/// `pending`/`stream_url IS NULL` row, resolve each via `resolve` (returning
    522/// `None` leaves the row pending), and heal the row so the pump can start it.
    523/// The `resolve` closure receives `(item_id, media_type, quality_preset)`.
    524///
    525/// `only_ids` restricts the sweep to specific download rows. Reconnect passes
    526/// `None` and heals everything; a bulk enqueue (an album, say) passes the rows
    527/// it just created, so clicking download on one album cannot also start every
    528/// unrelated row that has been sitting pending.
    529pub(crate) async fn resolve_pending_download_urls<F, Fut>(
    530    db_service: &Arc<crate::storage::db_service::RusqliteService>,
    531    target_dir: &str,
    532    only_ids: Option<&[i64]>,
    533    resolve: F,
    534) -> Result<ResumeQueuedResult, String>
    535where
    536    F: Fn(String, String, String) -> Fut,
    537    Fut: std::future::Future<Output = Option<String>>,
    538{
    539    if only_ids.is_some_and(|ids| ids.is_empty()) {
    540        return Ok(ResumeQueuedResult {
    541            resolved: 0,
    542            failed: 0,
    543        });
    544    }
    545    // A row's own media_type wins; otherwise the *item's* type decides. Rows
    546    // queued from a media card never carry one (`download_item` does not record
    547    // it), and defaulting that NULL to 'audio' resolved movies against
    548    // `get_audio_stream_url` — the file on disk was an audio-only transcode, so
    549    // offline video could never play. Falling back to 'audio' only when the item
    550    // is unknown keeps the historical behaviour for uncached items.
    551    // TRACES: UR-071, UR-052 | DR-135
    552    let video_types = VIDEO_ITEM_TYPES
    553        .iter()
    554        .map(|t| format!("'{t}'"))
    555        .collect::<Vec<_>>()
    556        .join(", ");
    557    let id_filter = match only_ids {
    558        Some(ids) => format!(
    559            " AND d.id IN ({})",
    560            ids.iter()
    561                .map(|id| id.to_string())
    562                .collect::<Vec<_>>()
    563                .join(", ")
    564        ),
    565        None => String::new(),
    566    };
    567    let rows_query = Query::new(format!(
    568        "SELECT d.id, d.item_id,
    569                COALESCE(
    570                    d.media_type,
    571                    CASE WHEN i.item_type IN ({video_types}) THEN 'video'
    572                         WHEN i.item_type IS NOT NULL THEN 'audio'
    573                    END,
    574                    'audio'),
    575                COALESCE(d.quality_preset, 'original')
    576         FROM downloads d
    577         LEFT JOIN items i ON i.id = d.item_id
    578         WHERE d.status = 'pending' AND d.stream_url IS NULL{id_filter}"
    579    ));
    580    let rows: Vec<(i64, String, String, String)> = db_service
    581        .query_many(rows_query, |row| {
    582            Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
    583        })
    584        .await
    585        .map_err(|e| e.to_string())?;
    586
    587    if rows.is_empty() {
    588        return Ok(ResumeQueuedResult {
    589            resolved: 0,
    590            failed: 0,
    591        });
    592    }
    593
    594    info!(
    595        "[Catalog] Resolving {} offline-queued downloads on reconnect",
    596        rows.len()
    597    );
    598
    599    let mut resolved = 0usize;
    600    let mut failed = 0usize;
    601
    602    for (download_id, item_id, media_type, quality) in rows {
    603        let stream_url = match resolve(item_id.clone(), media_type, quality).await {
    604            Some(url) => url,
    605            None => {
    606                failed += 1;
    607                continue;
    608            }
    609        };
    610
    611        // Heal the row so the pump can start it. Guard on stream_url IS NULL so a
    612        // concurrent resolver doesn't clobber an already-started row.
    613        let update = Query::with_params(
    614            "UPDATE downloads SET stream_url = ?, target_dir = ?
    615             WHERE id = ? AND status = 'pending' AND stream_url IS NULL",
    616            vec![
    617                QueryParam::String(stream_url),
    618                QueryParam::String(target_dir.to_string()),
    619                QueryParam::Int64(download_id),
    620            ],
    621        );
    622        match db_service.execute(update).await {
    623            Ok(n) if n > 0 => resolved += 1,
    624            Ok(_) => {} // already resolved by someone else; not a failure
    625            Err(e) => {
    626                warn!(
    627                    "[Catalog] Failed to persist URL for download {}: {}",
    628                    download_id, e
    629                );
    630                failed += 1;
    631            }
    632        }
    633    }
    634
    635    Ok(ResumeQueuedResult { resolved, failed })
    636}
    637
    638/// Resolve the stream URL for every download row that was queued while offline
    639/// (`status = 'pending' AND stream_url IS NULL`), then pump the queue so they
    640/// start. Call this on reconnect.
    641///
    642/// Audio rows resolve via `get_audio_stream_url`; video rows (media_type =
    643/// 'video') via the pure `get_video_download_url` builder using the row's stored
    644/// `quality_preset` — mirroring `enqueue_video_downloads`. Rows whose URL can't
    645/// be resolved are left pending (they retry on the next reconnect).
    646#[tauri::command]
    647#[specta::specta]
    648pub async fn resume_queued_downloads(
    649    repository: State<'_, RepositoryManagerWrapper>,
    650    db: State<'_, DatabaseWrapper>,
    651    download_manager: State<'_, DownloadManagerWrapper>,
    652    app: tauri::AppHandle,
    653    handle: String,
    654) -> Result<ResumeQueuedResult, String> {
    655    use crate::repository::MediaRepository;
    656
    657    let repo = repository.0.get(&handle).ok_or("Repository not found")?;
    658
    659    // The pump needs a target_dir; use the same storage root the other download
    660    // paths use (the database's parent directory — see `storage_get_path`).
    661    let (db_service, target_dir) = {
    662        let database = db.0.lock().map_err(|e| e.to_string())?;
    663        let target_dir = database
    664            .path()
    665            .parent()
    666            .ok_or_else(|| "Database path has no parent directory".to_string())?
    667            .to_string_lossy()
    668            .to_string();
    669        (Arc::new(database.service()), target_dir)
    670    };
    671
    672    // Recover stale downloads: rows left in 'downloading' when the app was killed
    673    // mid-transfer are orphaned — nothing ever restarts them, so they show as
    674    // permanently "downloading". Reset them to 'pending' and clear the stale
    675    // stream_url so they get re-resolved and restarted from scratch below.
    676    let recover_query = Query::new(
    677        "UPDATE downloads SET status = 'pending', stream_url = NULL, progress = 0, \
    678         bytes_downloaded = 0, started_at = NULL \
    679         WHERE status = 'downloading'",
    680    );
    681    match db_service.execute(recover_query).await {
    682        Ok(n) if n > 0 => info!("[Catalog] Reset {} stale 'downloading' rows to pending", n),
    683        Ok(_) => {}
    684        Err(e) => warn!("[Catalog] Failed to reset stale downloads: {}", e),
    685    }
    686
    687    // Repair rows that completed as audio because their media_type was missing;
    688    // they hold an audio-only transcode where a video should be, so requeue them
    689    // for the resolver below. TRACES: UR-071 | DR-136
    690    if let Err(e) = requeue_mistyped_video_downloads(&db_service).await {
    691        warn!(
    692            "[Catalog] Failed to requeue mis-typed video downloads: {}",
    693            e
    694        );
    695    }
    696
    697    // Resolve each row's URL against the (now reachable) repository.
    698    let repo_for_resolve = Arc::clone(&repo);
    699    let outcome = resolve_pending_download_urls(
    700        &db_service,
    701        &target_dir,
    702        None,
    703        move |item_id: String, media_type: String, quality: String| {
    704            let repo = Arc::clone(&repo_for_resolve);
    705            async move {
    706                if media_type == "video" {
    707                    Some(
    708                        crate::repository::resolve_video_download_url(
    709                            repo.as_ref(),
    710                            &item_id,
    711                            &quality,
    712                            None,
    713                        )
    714                        .await,
    715                    )
    716                } else {
    717                    match repo.get_audio_stream_url(&item_id).await {
    718                        Ok(url) => Some(url),
    719                        Err(e) => {
    720                            warn!(
    721                                "[Catalog] Failed to resolve audio URL for {}: {:?}",
    722                                item_id, e
    723                            );
    724                            None
    725                        }
    726                    }
    727                }
    728            }
    729        },
    730    )
    731    .await
    732    .map_err(|e| e.to_string())?;
    733
    734    let ResumeQueuedResult { resolved, failed } = outcome;
    735
    736    // Kick the pump so the newly-resolved rows actually start.
    737    if resolved > 0 {
    738        let active_downloads = {
    739            let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
    740            manager.get_active_downloads()
    741        };
    742        pump_download_queue(app, db_service, active_downloads).await;
    743    }
    744
    745    info!(
    746        "[Catalog] Resume complete: {} resolved, {} failed",
    747        resolved, failed
    748    );
    749
    750    Ok(ResumeQueuedResult { resolved, failed })
    751}
    752
    753#[cfg(test)]
    754mod tests {
    755    use super::*;
    756    use crate::storage::db_service::RusqliteService;
    757    use crate::utils::lock::MutexSafe;
    758    use rusqlite::Connection;
    759    use std::sync::Mutex;
    760
    761    /// The re-index policy. Pure, so it is testable without a clock, a server or
    762    /// a database — which is the reason it was factored out of the scheduler.
    763    ///
    764    /// TRACES: UR-065 | DR-109 | UT-115
    765    #[test]
    766    fn test_index_is_due() {
    767        let ttl = Duration::from_secs(6 * 60 * 60);
    768        let now = chrono::DateTime::parse_from_rfc3339("2026-08-04T12:00:00+00:00")
    769            .unwrap()
    770            .with_timezone(&chrono::Utc);
    771
    772        // Never indexed => due. This is the first-run case.
    773        assert!(index_is_due(None, now, ttl));
    774
    775        // Indexed 7 hours ago => past the 6h TTL => due.
    776        assert!(index_is_due(Some("2026-08-04T05:00:00+00:00"), now, ttl));
    777
    778        // Indexed 1 hour ago => fresh => not due. This is what stops the
    779        // scheduler re-crawling every tick.
    780        assert!(!index_is_due(Some("2026-08-04T11:00:00+00:00"), now, ttl));
    781
    782        // Exactly at the TTL boundary counts as due.
    783        assert!(index_is_due(Some("2026-08-04T06:00:00+00:00"), now, ttl));
    784
    785        // A corrupt stored value must trigger a re-index, not freeze the
    786        // catalog forever behind an unparseable timestamp.
    787        assert!(index_is_due(Some("not-a-timestamp"), now, ttl));
    788        assert!(index_is_due(Some(""), now, ttl));
    789
    790        // A timestamp in the future (clock skew, or a restored backup) is not
    791        // due — it ages into due-ness rather than causing a crawl every tick.
    792        assert!(!index_is_due(Some("2026-08-05T00:00:00+00:00"), now, ttl));
    793
    794        // Offsets other than UTC are compared as instants, not as strings.
    795        assert!(!index_is_due(Some("2026-08-04T13:30:00+02:00"), now, ttl));
    796    }
    797
    798    fn test_db() -> Arc<RusqliteService> {
    799        let conn = Connection::open_in_memory().unwrap();
    800        conn.execute_batch(
    801            r#"
    802            CREATE TABLE downloads (
    803                id INTEGER PRIMARY KEY AUTOINCREMENT,
    804                item_id TEXT NOT NULL,
    805                status TEXT NOT NULL,
    806                stream_url TEXT,
    807                target_dir TEXT,
    808                media_type TEXT,
    809                quality_preset TEXT,
    810                progress REAL DEFAULT 0,
    811                bytes_downloaded INTEGER DEFAULT 0,
    812                started_at TEXT,
    813                completed_at TEXT
    814            );
    815            CREATE TABLE items (
    816                id TEXT PRIMARY KEY,
    817                item_type TEXT
    818            );
    819            "#,
    820        )
    821        .unwrap();
    822        Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))))
    823    }
    824
    825    async fn insert_item(db: &Arc<RusqliteService>, item_id: &str, item_type: &str) {
    826        db.execute(Query::with_params(
    827            "INSERT INTO items (id, item_type) VALUES (?, ?)",
    828            vec![
    829                QueryParam::String(item_id.to_string()),
    830                QueryParam::String(item_type.to_string()),
    831            ],
    832        ))
    833        .await
    834        .unwrap();
    835    }
    836
    837    async fn insert_download(
    838        db: &Arc<RusqliteService>,
    839        item_id: &str,
    840        status: &str,
    841        stream_url: Option<&str>,
    842        media_type: Option<&str>,
    843    ) {
    844        let q = Query::with_params(
    845            "INSERT INTO downloads (item_id, status, stream_url, media_type) VALUES (?, ?, ?, ?)",
    846            vec![
    847                QueryParam::String(item_id.to_string()),
    848                QueryParam::String(status.to_string()),
    849                stream_url
    850                    .map(|s| QueryParam::String(s.to_string()))
    851                    .unwrap_or(QueryParam::Null),
    852                media_type
    853                    .map(|s| QueryParam::String(s.to_string()))
    854                    .unwrap_or(QueryParam::Null),
    855            ],
    856        );
    857        db.execute(q).await.unwrap();
    858    }
    859
    860    async fn get_row(
    861        db: &Arc<RusqliteService>,
    862        item_id: &str,
    863    ) -> (String, Option<String>, Option<String>) {
    864        let q = Query::with_params(
    865            "SELECT status, stream_url, target_dir FROM downloads WHERE item_id = ?",
    866            vec![QueryParam::String(item_id.to_string())],
    867        );
    868        db.query_one(q, |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
    869            .await
    870            .unwrap()
    871    }
    872
    873    /// IT-017: a download queued from a greyed-out offline catalog entry
    874    /// (pending, `stream_url IS NULL`) persists, and on reconnect its URL is
    875    /// resolved and the row is healed (URL + target dir) so the pump can start
    876    /// it — while already-resolved rows are left untouched.
    877    ///
    878    /// TRACES: UR-052, UR-011 | IT-017
    879    #[tokio::test]
    880    async fn resolves_offline_queued_row_and_leaves_resolved_rows_untouched() {
    881        let db = test_db();
    882        // A row queued offline: pending with no URL yet.
    883        insert_download(&db, "queued-1", "pending", None, None).await;
    884        // An already-resolved pending row: must NOT be touched.
    885        insert_download(&db, "already", "pending", Some("http://existing/url"), None).await;
    886        // A completed row: irrelevant.
    887        insert_download(&db, "done", "completed", Some("http://done/url"), None).await;
    888
    889        let out = resolve_pending_download_urls(
    890            &db,
    891            "/data/downloads",
    892            None,
    893            |item_id, _mt, _q| async move { Some(format!("http://resolved/{item_id}")) },
    894        )
    895        .await
    896        .unwrap();
    897
    898        assert_eq!(out.resolved, 1);
    899        assert_eq!(out.failed, 0);
    900
    901        // The offline-queued row now has a URL + target dir and stays pending.
    902        let (status, url, target) = get_row(&db, "queued-1").await;
    903        assert_eq!(status, "pending");
    904        assert_eq!(url.as_deref(), Some("http://resolved/queued-1"));
    905        assert_eq!(target.as_deref(), Some("/data/downloads"));
    906
    907        // The already-resolved row is unchanged (not re-resolved).
    908        let (_s, url2, _t) = get_row(&db, "already").await;
    909        assert_eq!(url2.as_deref(), Some("http://existing/url"));
    910    }
    911
    912    /// A bulk enqueue resolves only the rows it just created. Downloading one
    913    /// album must not also start every unrelated row that has been sitting
    914    /// pending with no URL (the smart cache leaves plenty of those).
    915    ///
    916    /// TRACES: UR-018, UR-055 | DR-173 | UT-171
    917    #[tokio::test]
    918    async fn only_ids_restricts_the_sweep_to_the_given_rows() {
    919        let db = test_db();
    920        insert_download(&db, "mine", "pending", None, Some("audio")).await;
    921        insert_download(&db, "someone-elses", "pending", None, Some("audio")).await;
    922
    923        let mine: i64 = db
    924            .query_one(
    925                Query::new("SELECT id FROM downloads WHERE item_id = 'mine'"),
    926                |row| row.get(0),
    927            )
    928            .await
    929            .unwrap();
    930
    931        let out = resolve_pending_download_urls(
    932            &db,
    933            "/data",
    934            Some(&[mine]),
    935            |item_id, _mt, _q| async move { Some(format!("http://resolved/{item_id}")) },
    936        )
    937        .await
    938        .unwrap();
    939
    940        assert_eq!(out.resolved, 1);
    941        assert_eq!(out.failed, 0);
    942
    943        let (_s, url, _t) = get_row(&db, "mine").await;
    944        assert_eq!(url.as_deref(), Some("http://resolved/mine"));
    945
    946        let (status, other_url, _t) = get_row(&db, "someone-elses").await;
    947        assert_eq!(status, "pending");
    948        assert_eq!(
    949            other_url, None,
    950            "a scoped resolve must leave unrelated pending rows alone"
    951        );
    952    }
    953
    954    /// An empty id list resolves nothing — it must not fall through to "sweep
    955    /// everything", which is what an unguarded `IN ()` would amount to.
    956    ///
    957    /// TRACES: UR-018, UR-055 | DR-173 | UT-171
    958    #[tokio::test]
    959    async fn an_empty_id_list_resolves_nothing() {
    960        let db = test_db();
    961        insert_download(&db, "untouched", "pending", None, Some("audio")).await;
    962
    963        let out =
    964            resolve_pending_download_urls(&db, "/data", Some(&[]), |item_id, _mt, _q| async move {
    965                Some(format!("http://resolved/{item_id}"))
    966            })
    967            .await
    968            .unwrap();
    969
    970        assert_eq!(out.resolved, 0);
    971        let (_s, url, _t) = get_row(&db, "untouched").await;
    972        assert_eq!(url, None);
    973    }
    974
    975    #[tokio::test]
    976    async fn counts_unresolvable_rows_as_failed_and_leaves_them_pending() {
    977        let db = test_db();
    978        insert_download(&db, "bad", "pending", None, None).await;
    979
    980        // Resolver returns None (e.g. server lookup failed).
    981        let out =
    982            resolve_pending_download_urls(&db, "/data", None, |_id, _mt, _q| async move { None })
    983                .await
    984                .unwrap();
    985
    986        assert_eq!(out.resolved, 0);
    987        assert_eq!(out.failed, 1);
    988
    989        // Still pending with no URL, so a later reconnect can retry it.
    990        let (status, url, _t) = get_row(&db, "bad").await;
    991        assert_eq!(status, "pending");
    992        assert_eq!(url, None);
    993    }
    994
    995    /// A movie queued from a media card has no `media_type` — `download_item`
    996    /// never records one. Defaulting that NULL to 'audio' resolved the row
    997    /// against `get_audio_stream_url`, so the "downloaded movie" on disk was an
    998    /// audio-only transcode and offline video playback could never work. The
    999    /// item's own type is the authority.
    1000    ///
    1001    /// TRACES: UR-071, UR-052 | DR-135 | UT-125
    1002    #[tokio::test]
    1003    async fn null_media_type_resolves_from_the_item_type_not_audio() {
    1004        let db = test_db();
    1005        insert_item(&db, "movie-1", "Movie").await;
    1006        insert_item(&db, "ep-1", "Episode").await;
    1007        insert_item(&db, "track-1", "Audio").await;
    1008        for id in ["movie-1", "ep-1", "track-1"] {
    1009            insert_download(&db, id, "pending", None, None).await;
    1010        }
    1011
    1012        let seen = Arc::new(Mutex::new(Vec::new()));
    1013        let seen_c = Arc::clone(&seen);
    1014        resolve_pending_download_urls(&db, "/data", None, move |item_id, media_type, _q| {
    1015            let seen = Arc::clone(&seen_c);
    1016            async move {
    1017                seen.lock_safe().push((item_id.clone(), media_type));
    1018                Some(format!("http://resolved/{item_id}"))
    1019            }
    1020        })
    1021        .await
    1022        .unwrap();
    1023
    1024        let seen = seen.lock_safe().clone();
    1025        let of = |id: &str| {
    1026            seen.iter()
    1027                .find(|(i, _)| i == id)
    1028                .map(|(_, m)| m.clone())
    1029                .unwrap()
    1030        };
    1031        assert_eq!(of("movie-1"), "video", "a Movie must download as video");
    1032        assert_eq!(of("ep-1"), "video", "an Episode must download as video");
    1033        assert_eq!(of("track-1"), "audio", "a track is still audio");
    1034    }
    1035
    1036    /// An unknown item (never cached locally) has no type to derive from, so it
    1037    /// keeps the historical audio default rather than failing the row.
    1038    ///
    1039    /// TRACES: UR-071 | DR-135 | UT-125
    1040    #[tokio::test]
    1041    async fn unknown_item_falls_back_to_audio() {
    1042        let db = test_db();
    1043        insert_download(&db, "ghost", "pending", None, None).await;
    1044
    1045        let seen = Arc::new(Mutex::new(String::new()));
    1046        let seen_c = Arc::clone(&seen);
    1047        resolve_pending_download_urls(&db, "/data", None, move |_id, media_type, _q| {
    1048            let seen = Arc::clone(&seen_c);
    1049            async move {
    1050                *seen.lock_safe() = media_type;
    1051                Some("http://x".to_string())
    1052            }
    1053        })
    1054        .await
    1055        .unwrap();
    1056
    1057        assert_eq!(*seen.lock_safe(), "audio");
    1058    }
    1059
    1060    /// An explicit `media_type` on the row always wins over the item's type.
    1061    ///
    1062    /// TRACES: UR-071 | DR-135 | UT-125
    1063    #[tokio::test]
    1064    async fn explicit_media_type_beats_the_item_type() {
    1065        let db = test_db();
    1066        insert_item(&db, "odd", "Audio").await;
    1067        insert_download(&db, "odd", "pending", None, Some("video")).await;
    1068
    1069        let seen = Arc::new(Mutex::new(String::new()));
    1070        let seen_c = Arc::clone(&seen);
    1071        resolve_pending_download_urls(&db, "/data", None, move |_id, media_type, _q| {
    1072            let seen = Arc::clone(&seen_c);
    1073            async move {
    1074                *seen.lock_safe() = media_type;
    1075                Some("http://x".to_string())
    1076            }
    1077        })
    1078        .await
    1079        .unwrap();
    1080
    1081        assert_eq!(*seen.lock_safe(), "video");
    1082    }
    1083
    1084    /// Rows already downloaded under the audio default hold an audio-only
    1085    /// transcode on disk, so they play as a broken video forever. They are
    1086    /// identifiable — no `media_type` but a video item — and are requeued so the
    1087    /// resolver fetches the real video. Correctly-typed rows and genuine audio
    1088    /// downloads must be left alone.
    1089    ///
    1090    /// TRACES: UR-071 | DR-136 | UT-126
    1091    #[tokio::test]
    1092    async fn requeues_video_downloaded_under_the_audio_default() {
    1093        let db = test_db();
    1094        insert_item(&db, "movie-1", "Movie").await;
    1095        insert_item(&db, "track-1", "Audio").await;
    1096        insert_item(&db, "movie-ok", "Movie").await;
    1097        // Mis-downloaded: completed, no media_type, video item.
    1098        insert_download(&db, "movie-1", "completed", Some("http://audio/url"), None).await;
    1099        // A real audio download: untouched.
    1100        insert_download(&db, "track-1", "completed", Some("http://audio/ok"), None).await;
    1101        // A correctly-typed video download: untouched.
    1102        insert_download(
    1103            &db,
    1104            "movie-ok",
    1105            "completed",
    1106            Some("http://video/ok"),
    1107            Some("video"),
    1108        )
    1109        .await;
    1110
    1111        let requeued = requeue_mistyped_video_downloads(&db).await.unwrap();
    1112        assert_eq!(requeued, 1);
    1113
    1114        let (status, url, _t) = get_row(&db, "movie-1").await;
    1115        assert_eq!(status, "pending", "the mis-typed row must download again");
    1116        assert_eq!(url, None, "its audio URL must be cleared so it re-resolves");
    1117
    1118        let (status, url, _t) = get_row(&db, "track-1").await;
    1119        assert_eq!(status, "completed", "a real audio download is untouched");
    1120        assert_eq!(url.as_deref(), Some("http://audio/ok"));
    1121
    1122        let (status, _u, _t) = get_row(&db, "movie-ok").await;
    1123        assert_eq!(status, "completed", "a correct video download is untouched");
    1124    }
    1125
    1126    #[tokio::test]
    1127    async fn video_rows_use_media_type_in_resolver() {
    1128        let db = test_db();
    1129        insert_download(&db, "vid-1", "pending", None, Some("video")).await;
    1130
    1131        let out = resolve_pending_download_urls(
    1132            &db,
    1133            "/data",
    1134            None,
    1135            |item_id, media_type, _q| async move {
    1136                assert_eq!(media_type, "video");
    1137                Some(format!("http://transcode/{item_id}"))
    1138            },
    1139        )
    1140        .await
    1141        .unwrap();
    1142
    1143        assert_eq!(out.resolved, 1);
    1144        let (_s, url, _t) = get_row(&db, "vid-1").await;
    1145        assert_eq!(url.as_deref(), Some("http://transcode/vid-1"));
    1146    }
    1147}