// Offline catalog service - "browse & queue" for offline mode. // // Two responsibilities: // 1. Full-catalog pre-sync: while online, walk every library and persist all // items to the offline cache so the whole catalog is browsable (greyed out) // offline. Backed by the `syncFullCatalog` Rust command. // 2. Resume-on-reconnect: when the server becomes reachable again, resolve the // stream URLs of downloads that were queued while offline and let the pump // start them. Backed by `resumeQueuedDownloads`. // // It also owns the `showServerCatalog` UI flag (the offline banner toggle that // reveals greyed-out, non-downloaded server media). // // TRACES: UR-002, UR-052 | DR-078 import { writable, type Writable } from "svelte/store"; import { commands } from "$lib/api/bindings"; import { auth } from "$lib/stores/auth"; import { isConnected } from "$lib/stores/connectivity"; import { createLogger } from "$lib/utils/logger"; const log = createLogger("OfflineCatalog"); /** * When true (and offline), library grids reveal greyed-out versions of media * that exists on the server but isn't downloaded, each with a button to queue * a download for the next reconnect. Toggled from the offline banner. */ export const showServerCatalog: Writable = writable(false); /** * Bumped every time the backend's downloads-only gate *settles* into a new * state. Library pages watch it and re-query. * * The gate lives in Rust and is only consulted when a query runs, so flipping * it changes nothing already on screen. Without this signal the toggle merely * greyed cards — `MediaCard.isServerOnly` is a pure frontend derivation and * updates instantly — while the item list stayed as first loaded. That is the * "shows everything until I filter" behaviour: the listing had never been * re-queried under the closed gate. Going offline had the same problem, since * nothing reloads on the online → offline transition either. * * It bumps *after* the command resolves, never before: a reload racing the push * would re-query under the old gate and undo itself. * * TRACES: UR-052 | DR-143 */ export const catalogFilterVersion: Writable = writable(0); // Keep the backend's offline library queries in sync with the UI toggle. The // offline cache holds the whole synced catalog, so `get_items` would otherwise // return every server item even offline with the toggle off. Include the // non-downloaded catalog only when online (fast browsing reads the same cache) // or when the "Show all server media" toggle is on. let lastIncludeCatalog: boolean | null = null; async function pushCatalogVisibility(connected: boolean, showCatalog: boolean): Promise { const include = connected || showCatalog; if (include === lastIncludeCatalog) return; lastIncludeCatalog = include; try { await commands.setShowServerCatalog(include); } catch (err) { log.warn("Failed to set catalog visibility:", err); // The backend is still on the old gate, so forget that we sent this — // otherwise the next identical transition is skipped as a no-op and the // frontend and backend disagree about the filter for the rest of the // session. No version bump: there is nothing new to re-query under. lastIncludeCatalog = null; return; } catalogFilterVersion.update((n) => n + 1); } let connectedNow = true; let showCatalogNow = false; // Fire-and-forget on purpose: the push handles its own failure, and subscribers // must not block. Consumers wait on `catalogFilterVersion` instead. isConnected.subscribe((v) => { connectedNow = v; void pushCatalogVisibility(connectedNow, showCatalogNow); }); showServerCatalog.subscribe((v) => { showCatalogNow = v; void pushCatalogVisibility(connectedNow, showCatalogNow); }); /** Last time a full catalog sync completed, for a UI hint. */ export const lastCatalogSync: Writable = writable(null); // Guard against overlapping syncs (they can be slow on large libraries). let syncInProgress = false; function currentHandle(): string | null { try { return auth.getRepository().getHandle(); } catch { return null; // not connected yet } } /** * Force a full re-index now, ignoring freshness. * * Routine scheduling is the Rust indexer's job (DR-109) — this is the manual * override, for a "re-index now" affordance. It is deliberately *not* called on * startup or reconnect any more: doing so forced a full crawl on every launch * regardless of how fresh the index was. * * The backend refuses overlapping passes, so this is safe to call at any time. */ export async function syncCatalog(): Promise { if (syncInProgress) return; const handle = currentHandle(); if (!handle) return; syncInProgress = true; try { const result = await commands.syncFullCatalog(handle); log.info( `Synced ${result.itemsCached} items (${result.librariesFailed} libraries failed)` ); await refreshSyncStatus(); } catch (err) { log.warn("Full catalog sync failed:", err); } finally { syncInProgress = false; } } /** * Resolve URLs for downloads queued while offline and pump them. Call on * reconnect. No-ops if not connected. */ export async function resumeQueued(): Promise { const handle = currentHandle(); if (!handle) return; try { const result = await commands.resumeQueuedDownloads(handle); if (result.resolved > 0 || result.failed > 0) { log.info( `Resumed queued downloads: ${result.resolved} resolved, ${result.failed} failed` ); } } catch (err) { log.warn("Failed to resume queued downloads:", err); } } /** Refresh the last-synced timestamp from the backend. */ export async function refreshSyncStatus(): Promise { try { const status = await commands.catalogSyncStatus(); lastCatalogSync.set(status.lastSyncedAt ?? null); } catch (err) { log.debug("Failed to fetch sync status:", err); } } /** * Called when the server becomes reachable again: resume queued downloads first * (fast, user-visible), then refresh the catalog in the background. */ export async function onReconnected(): Promise { await resumeQueued(); // Re-indexing on reconnect is the Rust indexer's job (DR-109) — it re-checks // staleness every tick, so it picks this up without a nudge from here. Queued // downloads still need resolving from the frontend, which is why this // function remains. }