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/// What a resolver hands back for one queued row: the URL to fetch and, for a
521/// video, the size predicted for it (see `download::estimate`).
522#[derive(Debug, Clone, PartialEq, Eq)]
523pub(crate) struct ResolvedDownloadUrl {
524    pub url: String,
525    pub expected_bytes: Option<u64>,
526}
527
528impl From<String> for ResolvedDownloadUrl {
529    /// An audio stream URL: served static, so the response states its own
530    /// length and nothing needs predicting.
531    fn from(url: String) -> Self {
532        Self {
533            url,
534            expected_bytes: None,
535        }
536    }
537}
538
539impl From<crate::repository::ResolvedVideoDownload> for ResolvedDownloadUrl {
540    fn from(r: crate::repository::ResolvedVideoDownload) -> Self {
541        Self {
542            url: r.url,
543            expected_bytes: r.expected_bytes,
544        }
545    }
546}
547
548/// Core of [`resume_queued_downloads`], factored out for testing: select every
549/// `pending`/`stream_url IS NULL` row, resolve each via `resolve` (returning
550/// `None` leaves the row pending), and heal the row so the pump can start it.
551/// The `resolve` closure receives `(item_id, media_type, quality_preset)`.
552///
553/// `only_ids` restricts the sweep to specific download rows. Reconnect passes
554/// `None` and heals everything; a bulk enqueue (an album, say) passes the rows
555/// it just created, so clicking download on one album cannot also start every
556/// unrelated row that has been sitting pending.
557pub(crate) async fn resolve_pending_download_urls<F, Fut>(
558    db_service: &Arc<crate::storage::db_service::RusqliteService>,
559    target_dir: &str,
560    only_ids: Option<&[i64]>,
561    resolve: F,
562) -> Result<ResumeQueuedResult, String>
563where
564    F: Fn(String, String, String) -> Fut,
565    Fut: std::future::Future<Output = Option<ResolvedDownloadUrl>>,
566{
567    if only_ids.is_some_and(|ids| ids.is_empty()) {
568        return Ok(ResumeQueuedResult {
569            resolved: 0,
570            failed: 0,
571        });
572    }
573    // A row's own media_type wins; otherwise the *item's* type decides. Rows
574    // queued from a media card never carry one (`download_item` does not record
575    // it), and defaulting that NULL to 'audio' resolved movies against
576    // `get_audio_stream_url` — the file on disk was an audio-only transcode, so
577    // offline video could never play. Falling back to 'audio' only when the item
578    // is unknown keeps the historical behaviour for uncached items.
579    // TRACES: UR-071, UR-052 | DR-135
580    let video_types = VIDEO_ITEM_TYPES
581        .iter()
582        .map(|t| format!("'{t}'"))
583        .collect::<Vec<_>>()
584        .join(", ");
585    let id_filter = match only_ids {
586        Some(ids) => format!(
587            " AND d.id IN ({})",
588            ids.iter()
589                .map(|id| id.to_string())
590                .collect::<Vec<_>>()
591                .join(", ")
592        ),
593        None => String::new(),
594    };
595    let rows_query = Query::new(format!(
596        "SELECT d.id, d.item_id,
597                COALESCE(
598                    d.media_type,
599                    CASE WHEN i.item_type IN ({video_types}) THEN 'video'
600                         WHEN i.item_type IS NOT NULL THEN 'audio'
601                    END,
602                    'audio'),
603                COALESCE(d.quality_preset, 'original')
604         FROM downloads d
605         LEFT JOIN items i ON i.id = d.item_id
606         WHERE d.status = 'pending' AND d.stream_url IS NULL{id_filter}"
607    ));
608    let rows: Vec<(i64, String, String, String)> = db_service
609        .query_many(rows_query, |row| {
610            Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
611        })
612        .await
613        .map_err(|e| e.to_string())?;
614
615    if rows.is_empty() {
616        return Ok(ResumeQueuedResult {
617            resolved: 0,
618            failed: 0,
619        });
620    }
621
622    info!(
623        "[Catalog] Resolving {} offline-queued downloads on reconnect",
624        rows.len()
625    );
626
627    let mut resolved = 0usize;
628    let mut failed = 0usize;
629
630    for (download_id, item_id, media_type, quality) in rows {
631        let target = match resolve(item_id.clone(), media_type, quality).await {
632            Some(target) => target,
633            None => {
634                failed += 1;
635                continue;
636            }
637        };
638
639        // Heal the row so the pump can start it. Guard on stream_url IS NULL so a
640        // concurrent resolver doesn't clobber an already-started row. The
641        // predicted size, when there is one, gives the worker a progress total
642        // for a response that carries none (DR-290).
643        let expected = target
644            .expected_bytes
645            .and_then(|n| i64::try_from(n).ok())
646            .map_or(QueryParam::Null, QueryParam::Int64);
647        let update = Query::with_params(
648            "UPDATE downloads SET stream_url = ?, target_dir = ?,
649                 file_size = COALESCE(?, file_size)
650             WHERE id = ? AND status = 'pending' AND stream_url IS NULL",
651            vec![
652                QueryParam::String(target.url),
653                QueryParam::String(target_dir.to_string()),
654                expected,
655                QueryParam::Int64(download_id),
656            ],
657        );
658        match db_service.execute(update).await {
659            Ok(n) if n > 0 => resolved += 1,
660            Ok(_) => {} // already resolved by someone else; not a failure
661            Err(e) => {
662                warn!(
663                    "[Catalog] Failed to persist URL for download {}: {}",
664                    download_id, e
665                );
666                failed += 1;
667            }
668        }
669    }
670
671    Ok(ResumeQueuedResult { resolved, failed })
672}
673
674/// Resolve the stream URL for every download row that was queued while offline
675/// (`status = 'pending' AND stream_url IS NULL`), then pump the queue so they
676/// start. Call this on reconnect.
677///
678/// Audio rows resolve via `get_audio_stream_url`; video rows (media_type =
679/// 'video') via the pure `get_video_download_url` builder using the row's stored
680/// `quality_preset` — mirroring `enqueue_video_downloads`. Rows whose URL can't
681/// be resolved are left pending (they retry on the next reconnect).
682#[tauri::command]
683#[specta::specta]
684pub async fn resume_queued_downloads(
685    repository: State<'_, RepositoryManagerWrapper>,
686    db: State<'_, DatabaseWrapper>,
687    download_manager: State<'_, DownloadManagerWrapper>,
688    app: tauri::AppHandle,
689    handle: String,
690) -> Result<ResumeQueuedResult, String> {
691    use crate::repository::MediaRepository;
692
693    let repo = repository.0.get(&handle).ok_or("Repository not found")?;
694
695    // The pump needs a target_dir; use the same storage root the other download
696    // paths use (the database's parent directory — see `storage_get_path`).
697    let (db_service, target_dir) = {
698        let database = db.0.lock().map_err(|e| e.to_string())?;
699        let target_dir = database
700            .path()
701            .parent()
702            .ok_or_else(|| "Database path has no parent directory".to_string())?
703            .to_string_lossy()
704            .to_string();
705        (Arc::new(database.service()), target_dir)
706    };
707
708    // Recover stale downloads: rows left in 'downloading' when the app was killed
709    // mid-transfer are orphaned — nothing ever restarts them, so they show as
710    // permanently "downloading". Reset them to 'pending' and clear the stale
711    // stream_url so they get re-resolved and restarted from scratch below.
712    let recover_query = Query::new(
713        "UPDATE downloads SET status = 'pending', stream_url = NULL, progress = 0, \
714         bytes_downloaded = 0, started_at = NULL \
715         WHERE status = 'downloading'",
716    );
717    match db_service.execute(recover_query).await {
718        Ok(n) if n > 0 => info!("[Catalog] Reset {} stale 'downloading' rows to pending", n),
719        Ok(_) => {}
720        Err(e) => warn!("[Catalog] Failed to reset stale downloads: {}", e),
721    }
722
723    // Repair rows that completed as audio because their media_type was missing;
724    // they hold an audio-only transcode where a video should be, so requeue them
725    // for the resolver below. TRACES: UR-071 | DR-136
726    if let Err(e) = requeue_mistyped_video_downloads(&db_service).await {
727        warn!(
728            "[Catalog] Failed to requeue mis-typed video downloads: {}",
729            e
730        );
731    }
732
733    // Resolve each row's URL against the (now reachable) repository.
734    let repo_for_resolve = Arc::clone(&repo);
735    let outcome = resolve_pending_download_urls(
736        &db_service,
737        &target_dir,
738        None,
739        move |item_id: String, media_type: String, quality: String| {
740            let repo = Arc::clone(&repo_for_resolve);
741            async move {
742                if media_type == "video" {
743                    Some(
744                        crate::repository::resolve_video_download(
745                            repo.as_ref(),
746                            &item_id,
747                            &quality,
748                            None,
749                        )
750                        .await
751                        .into(),
752                    )
753                } else {
754                    match repo.get_audio_stream_url(&item_id).await {
755                        Ok(url) => Some(url.into()),
756                        Err(e) => {
757                            warn!(
758                                "[Catalog] Failed to resolve audio URL for {}: {:?}",
759                                item_id, e
760                            );
761                            None
762                        }
763                    }
764                }
765            }
766        },
767    )
768    .await
769    .map_err(|e| e.to_string())?;
770
771    let ResumeQueuedResult { resolved, failed } = outcome;
772
773    // Kick the pump so the newly-resolved rows actually start.
774    if resolved > 0 {
775        let active_downloads = {
776            let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
777            manager.get_active_downloads()
778        };
779        pump_download_queue(app, db_service, active_downloads).await;
780    }
781
782    info!(
783        "[Catalog] Resume complete: {} resolved, {} failed",
784        resolved, failed
785    );
786
787    Ok(ResumeQueuedResult { resolved, failed })
788}
789
790#[cfg(test)]
791mod tests {
792    use super::*;
793    use crate::storage::db_service::RusqliteService;
794    use crate::utils::lock::MutexSafe;
795    use rusqlite::Connection;
796    use std::sync::Mutex;
797
798    /// The re-index policy. Pure, so it is testable without a clock, a server or
799    /// a database — which is the reason it was factored out of the scheduler.
800    ///
801    /// TRACES: UR-065 | DR-109 | UT-115
802    #[test]
803    fn test_index_is_due() {
804        let ttl = Duration::from_secs(6 * 60 * 60);
805        let now = chrono::DateTime::parse_from_rfc3339("2026-08-04T12:00:00+00:00")
806            .unwrap()
807            .with_timezone(&chrono::Utc);
808
809        // Never indexed => due. This is the first-run case.
810        assert!(index_is_due(None, now, ttl));
811
812        // Indexed 7 hours ago => past the 6h TTL => due.
813        assert!(index_is_due(Some("2026-08-04T05:00:00+00:00"), now, ttl));
814
815        // Indexed 1 hour ago => fresh => not due. This is what stops the
816        // scheduler re-crawling every tick.
817        assert!(!index_is_due(Some("2026-08-04T11:00:00+00:00"), now, ttl));
818
819        // Exactly at the TTL boundary counts as due.
820        assert!(index_is_due(Some("2026-08-04T06:00:00+00:00"), now, ttl));
821
822        // A corrupt stored value must trigger a re-index, not freeze the
823        // catalog forever behind an unparseable timestamp.
824        assert!(index_is_due(Some("not-a-timestamp"), now, ttl));
825        assert!(index_is_due(Some(""), now, ttl));
826
827        // A timestamp in the future (clock skew, or a restored backup) is not
828        // due — it ages into due-ness rather than causing a crawl every tick.
829        assert!(!index_is_due(Some("2026-08-05T00:00:00+00:00"), now, ttl));
830
831        // Offsets other than UTC are compared as instants, not as strings.
832        assert!(!index_is_due(Some("2026-08-04T13:30:00+02:00"), now, ttl));
833    }
834
835    fn test_db() -> Arc<RusqliteService> {
836        let conn = Connection::open_in_memory().unwrap();
837        conn.execute_batch(
838            r#"
839            CREATE TABLE downloads (
840                id INTEGER PRIMARY KEY AUTOINCREMENT,
841                item_id TEXT NOT NULL,
842                status TEXT NOT NULL,
843                stream_url TEXT,
844                target_dir TEXT,
845                media_type TEXT,
846                quality_preset TEXT,
847                file_size INTEGER,
848                progress REAL DEFAULT 0,
849                bytes_downloaded INTEGER DEFAULT 0,
850                started_at TEXT,
851                completed_at TEXT
852            );
853            CREATE TABLE items (
854                id TEXT PRIMARY KEY,
855                item_type TEXT
856            );
857            "#,
858        )
859        .unwrap();
860        Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))))
861    }
862
863    async fn insert_item(db: &Arc<RusqliteService>, item_id: &str, item_type: &str) {
864        db.execute(Query::with_params(
865            "INSERT INTO items (id, item_type) VALUES (?, ?)",
866            vec![
867                QueryParam::String(item_id.to_string()),
868                QueryParam::String(item_type.to_string()),
869            ],
870        ))
871        .await
872        .unwrap();
873    }
874
875    async fn insert_download(
876        db: &Arc<RusqliteService>,
877        item_id: &str,
878        status: &str,
879        stream_url: Option<&str>,
880        media_type: Option<&str>,
881    ) {
882        let q = Query::with_params(
883            "INSERT INTO downloads (item_id, status, stream_url, media_type) VALUES (?, ?, ?, ?)",
884            vec![
885                QueryParam::String(item_id.to_string()),
886                QueryParam::String(status.to_string()),
887                stream_url
888                    .map(|s| QueryParam::String(s.to_string()))
889                    .unwrap_or(QueryParam::Null),
890                media_type
891                    .map(|s| QueryParam::String(s.to_string()))
892                    .unwrap_or(QueryParam::Null),
893            ],
894        );
895        db.execute(q).await.unwrap();
896    }
897
898    async fn get_row(
899        db: &Arc<RusqliteService>,
900        item_id: &str,
901    ) -> (String, Option<String>, Option<String>) {
902        let q = Query::with_params(
903            "SELECT status, stream_url, target_dir FROM downloads WHERE item_id = ?",
904            vec![QueryParam::String(item_id.to_string())],
905        );
906        db.query_one(q, |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
907            .await
908            .unwrap()
909    }
910
911    /// IT-017: a download queued from a greyed-out offline catalog entry
912    /// (pending, `stream_url IS NULL`) persists, and on reconnect its URL is
913    /// resolved and the row is healed (URL + target dir) so the pump can start
914    /// it — while already-resolved rows are left untouched.
915    ///
916    /// TRACES: UR-052, UR-011 | IT-017
917    #[tokio::test]
918    async fn resolves_offline_queued_row_and_leaves_resolved_rows_untouched() {
919        let db = test_db();
920        // A row queued offline: pending with no URL yet.
921        insert_download(&db, "queued-1", "pending", None, None).await;
922        // An already-resolved pending row: must NOT be touched.
923        insert_download(&db, "already", "pending", Some("http://existing/url"), None).await;
924        // A completed row: irrelevant.
925        insert_download(&db, "done", "completed", Some("http://done/url"), None).await;
926
927        let out = resolve_pending_download_urls(
928            &db,
929            "/data/downloads",
930            None,
931            |item_id, _mt, _q| async move { Some(format!("http://resolved/{item_id}").into()) },
932        )
933        .await
934        .unwrap();
935
936        assert_eq!(out.resolved, 1);
937        assert_eq!(out.failed, 0);
938
939        // The offline-queued row now has a URL + target dir and stays pending.
940        let (status, url, target) = get_row(&db, "queued-1").await;
941        assert_eq!(status, "pending");
942        assert_eq!(url.as_deref(), Some("http://resolved/queued-1"));
943        assert_eq!(target.as_deref(), Some("/data/downloads"));
944
945        // The already-resolved row is unchanged (not re-resolved).
946        let (_s, url2, _t) = get_row(&db, "already").await;
947        assert_eq!(url2.as_deref(), Some("http://existing/url"));
948    }
949
950    /// A bulk enqueue resolves only the rows it just created. Downloading one
951    /// album must not also start every unrelated row that has been sitting
952    /// pending with no URL (the smart cache leaves plenty of those).
953    ///
954    /// TRACES: UR-018, UR-055 | DR-173 | UT-171
955    #[tokio::test]
956    async fn only_ids_restricts_the_sweep_to_the_given_rows() {
957        let db = test_db();
958        insert_download(&db, "mine", "pending", None, Some("audio")).await;
959        insert_download(&db, "someone-elses", "pending", None, Some("audio")).await;
960
961        let mine: i64 = db
962            .query_one(
963                Query::new("SELECT id FROM downloads WHERE item_id = 'mine'"),
964                |row| row.get(0),
965            )
966            .await
967            .unwrap();
968
969        let out = resolve_pending_download_urls(
970            &db,
971            "/data",
972            Some(&[mine]),
973            |item_id, _mt, _q| async move { Some(format!("http://resolved/{item_id}").into()) },
974        )
975        .await
976        .unwrap();
977
978        assert_eq!(out.resolved, 1);
979        assert_eq!(out.failed, 0);
980
981        let (_s, url, _t) = get_row(&db, "mine").await;
982        assert_eq!(url.as_deref(), Some("http://resolved/mine"));
983
984        let (status, other_url, _t) = get_row(&db, "someone-elses").await;
985        assert_eq!(status, "pending");
986        assert_eq!(
987            other_url, None,
988            "a scoped resolve must leave unrelated pending rows alone"
989        );
990    }
991
992    /// An empty id list resolves nothing — it must not fall through to "sweep
993    /// everything", which is what an unguarded `IN ()` would amount to.
994    ///
995    /// TRACES: UR-018, UR-055 | DR-173 | UT-171
996    #[tokio::test]
997    async fn an_empty_id_list_resolves_nothing() {
998        let db = test_db();
999        insert_download(&db, "untouched", "pending", None, Some("audio")).await;
1000
1001        let out =
1002            resolve_pending_download_urls(&db, "/data", Some(&[]), |item_id, _mt, _q| async move {
1003                Some(format!("http://resolved/{item_id}").into())
1004            })
1005            .await
1006            .unwrap();
1007
1008        assert_eq!(out.resolved, 0);
1009        let (_s, url, _t) = get_row(&db, "untouched").await;
1010        assert_eq!(url, None);
1011    }
1012
1013    #[tokio::test]
1014    async fn counts_unresolvable_rows_as_failed_and_leaves_them_pending() {
1015        let db = test_db();
1016        insert_download(&db, "bad", "pending", None, None).await;
1017
1018        // Resolver returns None (e.g. server lookup failed).
1019        let out =
1020            resolve_pending_download_urls(&db, "/data", None, |_id, _mt, _q| async move { None })
1021                .await
1022                .unwrap();
1023
1024        assert_eq!(out.resolved, 0);
1025        assert_eq!(out.failed, 1);
1026
1027        // Still pending with no URL, so a later reconnect can retry it.
1028        let (status, url, _t) = get_row(&db, "bad").await;
1029        assert_eq!(status, "pending");
1030        assert_eq!(url, None);
1031    }
1032
1033    /// A movie queued from a media card has no `media_type` — `download_item`
1034    /// never records one. Defaulting that NULL to 'audio' resolved the row
1035    /// against `get_audio_stream_url`, so the "downloaded movie" on disk was an
1036    /// audio-only transcode and offline video playback could never work. The
1037    /// item's own type is the authority.
1038    ///
1039    /// TRACES: UR-071, UR-052 | DR-135 | UT-125
1040    #[tokio::test]
1041    async fn null_media_type_resolves_from_the_item_type_not_audio() {
1042        let db = test_db();
1043        insert_item(&db, "movie-1", "Movie").await;
1044        insert_item(&db, "ep-1", "Episode").await;
1045        insert_item(&db, "track-1", "Audio").await;
1046        for id in ["movie-1", "ep-1", "track-1"] {
1047            insert_download(&db, id, "pending", None, None).await;
1048        }
1049
1050        let seen = Arc::new(Mutex::new(Vec::new()));
1051        let seen_c = Arc::clone(&seen);
1052        resolve_pending_download_urls(&db, "/data", None, move |item_id, media_type, _q| {
1053            let seen = Arc::clone(&seen_c);
1054            async move {
1055                seen.lock_safe().push((item_id.clone(), media_type));
1056                Some(format!("http://resolved/{item_id}").into())
1057            }
1058        })
1059        .await
1060        .unwrap();
1061
1062        let seen = seen.lock_safe().clone();
1063        let of = |id: &str| {
1064            seen.iter()
1065                .find(|(i, _)| i == id)
1066                .map(|(_, m)| m.clone())
1067                .unwrap()
1068        };
1069        assert_eq!(of("movie-1"), "video", "a Movie must download as video");
1070        assert_eq!(of("ep-1"), "video", "an Episode must download as video");
1071        assert_eq!(of("track-1"), "audio", "a track is still audio");
1072    }
1073
1074    /// An unknown item (never cached locally) has no type to derive from, so it
1075    /// keeps the historical audio default rather than failing the row.
1076    ///
1077    /// TRACES: UR-071 | DR-135 | UT-125
1078    #[tokio::test]
1079    async fn unknown_item_falls_back_to_audio() {
1080        let db = test_db();
1081        insert_download(&db, "ghost", "pending", None, None).await;
1082
1083        let seen = Arc::new(Mutex::new(String::new()));
1084        let seen_c = Arc::clone(&seen);
1085        resolve_pending_download_urls(&db, "/data", None, move |_id, media_type, _q| {
1086            let seen = Arc::clone(&seen_c);
1087            async move {
1088                *seen.lock_safe() = media_type;
1089                Some("http://x".to_string().into())
1090            }
1091        })
1092        .await
1093        .unwrap();
1094
1095        assert_eq!(*seen.lock_safe(), "audio");
1096    }
1097
1098    /// An explicit `media_type` on the row always wins over the item's type.
1099    ///
1100    /// TRACES: UR-071 | DR-135 | UT-125
1101    #[tokio::test]
1102    async fn explicit_media_type_beats_the_item_type() {
1103        let db = test_db();
1104        insert_item(&db, "odd", "Audio").await;
1105        insert_download(&db, "odd", "pending", None, Some("video")).await;
1106
1107        let seen = Arc::new(Mutex::new(String::new()));
1108        let seen_c = Arc::clone(&seen);
1109        resolve_pending_download_urls(&db, "/data", None, move |_id, media_type, _q| {
1110            let seen = Arc::clone(&seen_c);
1111            async move {
1112                *seen.lock_safe() = media_type;
1113                Some("http://x".to_string().into())
1114            }
1115        })
1116        .await
1117        .unwrap();
1118
1119        assert_eq!(*seen.lock_safe(), "video");
1120    }
1121
1122    /// Rows already downloaded under the audio default hold an audio-only
1123    /// transcode on disk, so they play as a broken video forever. They are
1124    /// identifiable — no `media_type` but a video item — and are requeued so the
1125    /// resolver fetches the real video. Correctly-typed rows and genuine audio
1126    /// downloads must be left alone.
1127    ///
1128    /// TRACES: UR-071 | DR-136 | UT-126
1129    #[tokio::test]
1130    async fn requeues_video_downloaded_under_the_audio_default() {
1131        let db = test_db();
1132        insert_item(&db, "movie-1", "Movie").await;
1133        insert_item(&db, "track-1", "Audio").await;
1134        insert_item(&db, "movie-ok", "Movie").await;
1135        // Mis-downloaded: completed, no media_type, video item.
1136        insert_download(&db, "movie-1", "completed", Some("http://audio/url"), None).await;
1137        // A real audio download: untouched.
1138        insert_download(&db, "track-1", "completed", Some("http://audio/ok"), None).await;
1139        // A correctly-typed video download: untouched.
1140        insert_download(
1141            &db,
1142            "movie-ok",
1143            "completed",
1144            Some("http://video/ok"),
1145            Some("video"),
1146        )
1147        .await;
1148
1149        let requeued = requeue_mistyped_video_downloads(&db).await.unwrap();
1150        assert_eq!(requeued, 1);
1151
1152        let (status, url, _t) = get_row(&db, "movie-1").await;
1153        assert_eq!(status, "pending", "the mis-typed row must download again");
1154        assert_eq!(url, None, "its audio URL must be cleared so it re-resolves");
1155
1156        let (status, url, _t) = get_row(&db, "track-1").await;
1157        assert_eq!(status, "completed", "a real audio download is untouched");
1158        assert_eq!(url.as_deref(), Some("http://audio/ok"));
1159
1160        let (status, _u, _t) = get_row(&db, "movie-ok").await;
1161        assert_eq!(status, "completed", "a correct video download is untouched");
1162    }
1163
1164    /// A transcode answers with no `Content-Length`, so the worker's only
1165    /// chance at a progress total is the size predicted at resolve time. That
1166    /// prediction has to reach the row, and only where there is one — an
1167    /// audio row's `None` must not null out a size the row already holds.
1168    ///
1169    /// TRACES: UR-071 | DR-290 | UT-253
1170    #[tokio::test]
1171    async fn resolving_persists_the_predicted_size_without_erasing_a_known_one() {
1172        let db = test_db();
1173        insert_download(&db, "film", "pending", None, Some("video")).await;
1174        insert_download(&db, "track", "pending", None, Some("audio")).await;
1175        db.execute(Query::with_params(
1176            "UPDATE downloads SET file_size = 777 WHERE item_id = ?",
1177            vec![QueryParam::String("track".to_string())],
1178        ))
1179        .await
1180        .unwrap();
1181
1182        resolve_pending_download_urls(&db, "/data", None, |item_id, media_type, _q| async move {
1183            Some(ResolvedDownloadUrl {
1184                url: format!("http://resolved/{item_id}"),
1185                expected_bytes: (media_type == "video").then_some(1_500_000_000),
1186            })
1187        })
1188        .await
1189        .unwrap();
1190
1191        let size = |item: &'static str| {
1192            let db = Arc::clone(&db);
1193            async move {
1194                db.query_one(
1195                    Query::with_params(
1196                        "SELECT file_size FROM downloads WHERE item_id = ?",
1197                        vec![QueryParam::String(item.to_string())],
1198                    ),
1199                    |row| row.get::<_, Option<i64>>(0),
1200                )
1201                .await
1202                .unwrap()
1203            }
1204        };
1205        assert_eq!(size("film").await, Some(1_500_000_000));
1206        assert_eq!(size("track").await, Some(777));
1207    }
1208
1209    #[tokio::test]
1210    async fn video_rows_use_media_type_in_resolver() {
1211        let db = test_db();
1212        insert_download(&db, "vid-1", "pending", None, Some("video")).await;
1213
1214        let out = resolve_pending_download_urls(
1215            &db,
1216            "/data",
1217            None,
1218            |item_id, media_type, _q| async move {
1219                assert_eq!(media_type, "video");
1220                Some(format!("http://transcode/{item_id}").into())
1221            },
1222        )
1223        .await
1224        .unwrap();
1225
1226        assert_eq!(out.resolved, 1);
1227        let (_s, url, _t) = get_row(&db, "vid-1").await;
1228        assert_eq!(url.as_deref(), Some("http://transcode/vid-1"));
1229    }
1230}