feat(downloads): browsable downloaded library with on-disk usage
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
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
// 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);
|
||||
Reference in New Issue
Block a user