Files
jellytau/src/lib/services/offlineCatalog.ts
T
dtourolle ad48d89dfe chore(format): run prettier over src/ and scripts/
Formatting was configured but never enforced: `bun run format:check`
reported 199 unformatted files and ran in no workflow and in no git hook,
so .prettierrc (printWidth 100, trailing commas) described an intention
rather than the tree.

This is the one-time sweep that makes the check gateable. Whitespace and
token-reflow only -- no behavioural change: `bun run check` reports 0
errors and all 1053 frontend tests pass before and after.

Kept out of every other commit on purpose. A 199-file diff mixed with
real changes is unreviewable, and the next commit turns format:check
into a hard CI gate so this cannot silently accumulate again.
2026-08-21 17:41:44 +02:00

168 lines
6.3 KiB
TypeScript

// 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<boolean> = 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<number> = 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<void> {
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<string | null> = 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<void> {
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<void> {
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<void> {
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<void> {
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.
}