Replace the flat download list with a Downloaded browse surface that reuses the online grids/cards/detail pages, filtered to on-device media, plus a demoted Transfers tab. Add repository browse commands (getDownloadedLibraries/Items, disk usage) with offline/hybrid implementations, a downloadedCatalog service, formatBytes helper, and per-item/device disk-usage labels on cards and grids. Regenerated bindings. Also carries the inseparable UR-052 offline-filter hunks in offline.rs/hybrid.rs. TRACES: UR-055 | DR-081, DR-082, DR-083, DR-084; UR-056 | DR-085
117 lines
4.2 KiB
TypeScript
117 lines
4.2 KiB
TypeScript
// 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/specs/downloads-as-offline-library.md 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<string, number>;
|
||
/** container id → true when only partially downloaded. */
|
||
partialContainers: Record<string, boolean>;
|
||
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<DownloadedCatalogState>(initial);
|
||
|
||
function repo() {
|
||
return auth.getRepository();
|
||
}
|
||
|
||
/** Load the downloaded-library list and disk-usage totals for the top bar. */
|
||
async function refresh(): Promise<void> {
|
||
update((s) => ({ ...s, loading: true, error: null }));
|
||
try {
|
||
const [libraries, usage] = await Promise.all([
|
||
repo().getDownloadedLibraries(),
|
||
repo().getDownloadDiskUsage() as Promise<DownloadDiskUsage>,
|
||
]);
|
||
// The wire type is Partial<{ [k]: number }>; normalise to a dense record.
|
||
const sizes: Record<string, number> = {};
|
||
for (const [k, v] of Object.entries(usage.sizes)) {
|
||
if (typeof v === "number") sizes[k] = v;
|
||
}
|
||
const partialContainers: Record<string, boolean> = {};
|
||
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<MediaItem[]> {
|
||
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<number> {
|
||
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);
|