// Downloaded catalog service — the "Downloaded" browse surface's data source. // // This is the offline library, filtered to what's on the device. It reads the // dedicated offline-only browse path on the repository (never the hybrid merge), // so results are authoritative regardless of connectivity: an empty result means // "nothing downloaded here", never "server unreachable". See the spec // docs/architecture/02-svelte-frontend.md ("Downloaded Browse") and // ux-flows §7.2–7.3. // // It also owns disk-usage: a per-item/container byte map plus the device total, // aggregated from `downloads.file_size` by the backend. // // TRACES: UR-055, UR-056 | DR-082, DR-083, DR-085 import { writable, derived, get } from "svelte/store"; import type { Library, MediaItem, GetItemsOptions } from "$lib/api/types"; import type { DownloadDiskUsage } from "$lib/api/bindings"; import { commands } from "$lib/api/bindings"; import { auth } from "$lib/stores/auth"; interface DownloadedCatalogState { libraries: Library[]; /** item id → bytes on disk (leaf's own size, or a container subtotal). */ sizes: Record; /** container id → true when only partially downloaded. */ partialContainers: Record; deviceTotalBytes: number; itemCount: number; loading: boolean; error: string | null; } const initial: DownloadedCatalogState = { libraries: [], sizes: {}, partialContainers: {}, deviceTotalBytes: 0, itemCount: 0, loading: false, error: null, }; function createDownloadedCatalogStore() { const { subscribe, update, set } = writable(initial); function repo() { return auth.getRepository(); } /** Load the downloaded-library list and disk-usage totals for the top bar. */ async function refresh(): Promise { update((s) => ({ ...s, loading: true, error: null })); try { const [libraries, usage] = await Promise.all([ repo().getDownloadedLibraries(), repo().getDownloadDiskUsage() as Promise, ]); // The wire type is Partial<{ [k]: number }>; normalise to a dense record. const sizes: Record = {}; for (const [k, v] of Object.entries(usage.sizes)) { if (typeof v === "number") sizes[k] = v; } const partialContainers: Record = {}; for (const [k, v] of Object.entries(usage.partialContainers)) { if (v) partialContainers[k] = true; } update((s) => ({ ...s, libraries, sizes, partialContainers, deviceTotalBytes: usage.deviceTotalBytes, itemCount: usage.itemCount, loading: false, })); } catch (error) { const message = error instanceof Error ? error.message : "Failed to load downloads"; update((s) => ({ ...s, loading: false, error: message })); } } /** Downloaded-only items under a container (library, album, season, series). */ async function loadItems(parentId: string, options?: GetItemsOptions): Promise { const result = await repo().getDownloadedItems(parentId, options); return result.items; } /** Bytes on disk for an item id (leaf's own, or a container subtotal), or 0. */ function sizeOf(itemId: string): number { return get({ subscribe }).sizes[itemId] ?? 0; } /** * Remove every completed download at or under a container/leaf, then refresh * so the browse and totals update. Returns the number of downloads removed. * TRACES: UR-055, UR-056 | DR-083 */ async function remove(itemId: string): Promise { const userId = auth.getUserId(); if (!userId) throw new Error("Not signed in"); const removed = await commands.deleteDownloadsUnder(itemId, userId); await refresh(); return removed; } function reset() { set(initial); } return { subscribe, refresh, loadItems, sizeOf, remove, reset }; } export const downloadedCatalog = createDownloadedCatalogStore(); export const downloadedLibraries = derived(downloadedCatalog, ($c) => $c.libraries); export const downloadedDeviceTotal = derived(downloadedCatalog, ($c) => $c.deviceTotalBytes); export const downloadedItemCount = derived(downloadedCatalog, ($c) => $c.itemCount);