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:
2026-07-23 20:02:55 +02:00
parent 8f4f651bac
commit f25deba824
14 changed files with 1418 additions and 224 deletions
+130
View File
@@ -784,6 +784,19 @@ async deleteAllDownloads(userId: string) : Promise<number> {
async deleteAlbumDownloads(albumId: string, userId: string) : Promise<number> {
return await TAURI_INVOKE("delete_album_downloads", { albumId, userId });
},
/**
* Remove every completed download at or under a container item.
*
* Works at any level of the Downloaded browse: a leaf (removes just that
* download), an album/season/series (removes all downloaded descendants linked
* via album_id/season_id/series_id/parent_id). Deletes the DB rows and the
* on-disk files. Returns the number of downloads removed. Idempotent.
*
* TRACES: UR-055 | DR-083
*/
async deleteDownloadsUnder(itemId: string, userId: string) : Promise<number> {
return await TAURI_INVOKE("delete_downloads_under", { itemId, userId });
},
/**
* Clear all stale pending/failed/paused downloads
*/
@@ -915,6 +928,29 @@ async updateSmartCacheConfig(config: CacheConfig) : Promise<null> {
async getSmartCacheConfig() : Promise<CacheConfig> {
return await TAURI_INVOKE("get_smart_cache_config");
},
/**
* Report the device's current network transport (Android → Rust).
*
* The frontend calls this on startup and whenever the native network callback
* fires. Updating to an acceptable network re-pumps the download queue, so a
* queue parked on "waiting for WiFi" drains itself without user action.
*
* TRACES: UR-053 | DR-074
*/
async setNetworkState(network: NetworkStateWrapperArg) : Promise<null> {
return await TAURI_INVOKE("set_network_state", { network });
},
/**
* Whether downloads are currently permitted by the WiFi-only gate.
*
* The downloads UI uses this to render "Waiting for WiFi" on pending rows
* rather than leaving them looking silently stuck.
*
* TRACES: UR-053 | DR-074
*/
async getDownloadsAllowed() : Promise<boolean> {
return await TAURI_INVOKE("get_downloads_allowed");
},
/**
* Get album recommendations based on play history
*/
@@ -1166,6 +1202,33 @@ async repositoryGetItems(handle: string, parentId: string, options: GetItemsOpti
async repositoryGetItem(handle: string, itemId: string) : Promise<MediaItem> {
return await TAURI_INVOKE("repository_get_item", { handle, itemId });
},
/**
* Downloaded-only browse: libraries that contain downloaded content.
*
* Backs the Downloads "Downloaded" surface. Never merges server results and is
* authoritative — an empty list means nothing is downloaded.
*
* TRACES: UR-055 | DR-082
*/
async repositoryGetDownloadedLibraries(handle: string) : Promise<Library[]> {
return await TAURI_INVOKE("repository_get_downloaded_libraries", { handle });
},
/**
* Downloaded-only browse: items under a container that are on the device.
*
* TRACES: UR-055 | DR-082, DR-083
*/
async repositoryGetDownloadedItems(handle: string, parentId: string, options: GetItemsOptions | null) : Promise<SearchResult> {
return await TAURI_INVOKE("repository_get_downloaded_items", { handle, parentId, options });
},
/**
* On-disk usage of downloaded content (device total, per-item/container bytes).
*
* TRACES: UR-056 | DR-085
*/
async repositoryGetDownloadDiskUsage(handle: string) : Promise<DownloadDiskUsage> {
return await TAURI_INVOKE("repository_get_download_disk_usage", { handle });
},
/**
* Query the optional JRay plugin for the actors on screen at time `t`
* (seconds) in an item. Returns an empty list when JRay isn't installed or
@@ -1626,6 +1689,34 @@ connectionError: string | null;
* Whether we're currently checking connectivity
*/
isChecking: boolean }
/**
* On-disk usage of downloaded content, for the Downloads surface.
*
* `sizes` maps an item id (leaf *or* container) to its bytes on disk: a leaf's
* own file size, a container's summed downloaded descendants. `device_total_bytes`
* and `item_count` are the headline figures for the Downloaded surface top bar.
*
* TRACES: UR-056 | DR-085
*/
export type DownloadDiskUsage = {
/**
* item id → bytes on disk (leaf's own size, or a container's subtotal).
*/
sizes: Partial<{ [key in string]: number }>;
/**
* Container id → true when it is only *partially* downloaded (has cached
* children that are not downloaded). Absent/false ⇒ fully downloaded. Lets
* the Downloaded surface badge partial vs. full containers.
*/
partialContainers: Partial<{ [key in string]: boolean }>;
/**
* Sum of all downloaded leaf sizes — the device total.
*/
deviceTotalBytes: number;
/**
* Number of downloaded leaf items (not containers).
*/
itemCount: number }
/**
* Information about a download
*/
@@ -1757,6 +1848,45 @@ export type MediaType = "audio" | "video"
* Converts from both local MediaItem and remote NowPlayingItem
*/
export type MergedMediaItem = { id: string; title: string; artist: string | null; album: string | null; albumId: string | null; duration: number | null; primaryImageTag: string | null; mediaType: string }
/**
* Argument struct for [`set_network_state`].
*
* TRACES: UR-053 | DR-074
*/
export type NetworkStateWrapperArg = { networkType: NetworkType; unmetered: boolean }
/**
* Kind of network transport currently active.
*
* Mirrors the string constants in `NetworkTypeMonitor.kt`; the two must stay
* in sync (the serde rename below is what the frontend sends).
*
* TRACES: UR-053 | DR-074
*/
export type NetworkType =
/**
* No active network.
*/
"none" |
/**
* WiFi (may still be metered — check `unmetered`).
*/
"wifi" |
/**
* Wired ethernet, typical on Android TV and desktop.
*/
"ethernet" |
/**
* Mobile data — never acceptable when wifi-only is enabled.
*/
"cellular" |
/**
* Some other transport (VPN over unknown carrier, Bluetooth tethering, …).
*/
"other" |
/**
* Could not determine the transport.
*/
"unknown"
export type NowPlayingItem = { id: string | null; name: string | null; runTimeTicks: number | null; album: string | null; albumId: string | null; albumArtist: string | null; artists: string[] | null; imageTags: Partial<{ [key in string]: string }> | null; primaryImageTag: string | null; albumPrimaryImageTag: string | null; Type: string | null }
export type OfflineItem = { id: string; name: string; itemType: string; albumId: string | null; albumName: string | null; artists: string | null; runtimeTicks: number | null; primaryImageTag: string | null }
/**
+40
View File
@@ -337,6 +337,46 @@ describe("RepositoryClient", () => {
requestId: 0,
});
});
// Downloaded-only browse path (UR-055 | DR-082) — verifies command names and
// camelCase params per the Tauri v2 rule (CLAUDE.md).
it("should get downloaded libraries from backend", async () => {
const mockLibraries = [{ id: "lib1", name: "Music", collectionType: "music" }];
(invoke as any).mockResolvedValueOnce(mockLibraries);
const libraries = await client.getDownloadedLibraries();
expect(libraries).toEqual(mockLibraries);
expect(invoke).toHaveBeenCalledWith("repository_get_downloaded_libraries", {
handle: "test-handle-123",
});
});
it("should get downloaded items with camelCase params", async () => {
const mockResult = { items: [{ id: "t1", name: "Track", type: "Audio" }], totalRecordCount: 1 };
(invoke as any).mockResolvedValueOnce(mockResult);
const result = await client.getDownloadedItems("album1", { limit: 50 });
expect(result).toEqual(mockResult);
expect(invoke).toHaveBeenCalledWith("repository_get_downloaded_items", {
handle: "test-handle-123",
parentId: "album1",
options: { limit: 50 },
});
});
it("should get download disk usage from backend", async () => {
const mockUsage = { sizes: { t1: 1000 }, partialContainers: {}, deviceTotalBytes: 1000, itemCount: 1 };
(invoke as any).mockResolvedValueOnce(mockUsage);
const usage = await client.getDownloadDiskUsage();
expect(usage).toEqual(mockUsage);
expect(invoke).toHaveBeenCalledWith("repository_get_download_disk_usage", {
handle: "test-handle-123",
});
});
});
describe("Playback Methods", () => {
+26 -1
View File
@@ -3,7 +3,7 @@
// NO direct HTTP calls - everything routes through Rust backend
import { commands } from "./bindings";
import type { JRayActor } from "./bindings";
import type { JRayActor, DownloadDiskUsage } from "./bindings";
import type { QualityPreset } from "./quality-presets";
import type {
Library,
@@ -91,6 +91,31 @@ export class RepositoryClient {
return commands.repositoryGetItem(this.ensureHandle(), itemId);
}
/**
* Downloaded-only browse: libraries that contain downloaded content.
* Never merges server results; an empty list is authoritative.
* TRACES: UR-055 | DR-082
*/
async getDownloadedLibraries(): Promise<Library[]> {
return commands.repositoryGetDownloadedLibraries(this.ensureHandle());
}
/**
* Downloaded-only browse: items under a container that are on the device.
* TRACES: UR-055 | DR-082, DR-083
*/
async getDownloadedItems(parentId: string, options?: GetItemsOptions): Promise<SearchResult> {
return commands.repositoryGetDownloadedItems(this.ensureHandle(), parentId, options ?? null);
}
/**
* On-disk usage of downloaded content (device total + per-item/container bytes).
* TRACES: UR-056 | DR-085
*/
async getDownloadDiskUsage(): Promise<DownloadDiskUsage> {
return commands.repositoryGetDownloadDiskUsage(this.ensureHandle());
}
/**
* Query the optional JRay plugin for the actors on screen at time `t`
* (seconds) in an item. Resolves to an empty array when JRay isn't installed
@@ -0,0 +1,189 @@
<!--
Downloaded browse surface: the library, filtered to what's on the device.
Reuses the library's own grid/cards. The top level lists only libraries with
downloaded content; drilling into a library shows its downloaded items in the
same grid used online. Clicking a leaf/detail item navigates to the shared
`/library/[id]` detail page, where Play uses the local file. Per-item and
device disk usage ride along via the size labels and the top bar.
TRACES: UR-055, UR-056 | DR-081, DR-082, DR-083, DR-085
-->
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import type { Library, MediaItem } from "$lib/api/types";
import LibraryGrid from "$lib/components/library/LibraryGrid.svelte";
import { formatBytes } from "$lib/utils/formatBytes";
import {
downloadedCatalog,
downloadedLibraries,
downloadedDeviceTotal,
downloadedItemCount,
} from "$lib/services/downloadedCatalog";
// Drill state: null = library list; otherwise the library we're inside.
let currentLibrary = $state<Library | null>(null);
let items = $state<MediaItem[]>([]);
let loadingItems = $state(false);
let loadError = $state<string | null>(null);
const loading = $derived($downloadedCatalog.loading);
onMount(() => {
void downloadedCatalog.refresh();
});
async function openLibrary(library: Library) {
currentLibrary = library;
loadingItems = true;
loadError = null;
try {
items = await downloadedCatalog.loadItems(library.id);
} catch (err) {
loadError = err instanceof Error ? err.message : "Failed to load downloads";
items = [];
} finally {
loadingItems = false;
}
}
function backToLibraries() {
currentLibrary = null;
items = [];
loadError = null;
}
// Containers (album/season/series/box set) drill via the shared detail page,
// which is offline-aware; leaves open their detail/play surface there too.
function onItemClick(item: MediaItem | Library) {
if ("collectionType" in item) {
// A Library (top level) — drill in place.
void openLibrary(item as Library);
return;
}
goto(`/library/${item.id}`);
}
// A size label for a card, if we have a byte figure for it.
function sizeLabelFor(item: MediaItem | Library): string | undefined {
const bytes = $downloadedCatalog.sizes[item.id];
return bytes && bytes > 0 ? formatBytes(bytes) : undefined;
}
// Remove a downloaded item/container, stating the reclaim amount first.
async function removeItem(item: MediaItem | Library) {
if (!("type" in item)) return;
const bytes = $downloadedCatalog.sizes[item.id] ?? 0;
const freed = bytes > 0 ? ` This frees ${formatBytes(bytes)}.` : "";
if (!confirm(`Remove “${item.name}” from this device?${freed}`)) return;
try {
await downloadedCatalog.remove(item.id);
// Reload the current library so removed items (and now-empty containers)
// drop out of the browse.
if (currentLibrary) {
items = await downloadedCatalog.loadItems(currentLibrary.id);
}
} catch (err) {
loadError = err instanceof Error ? err.message : "Failed to remove download";
}
}
// Full vs partial container badge (leaves get no container badge here).
function downloadedBadgeFor(item: MediaItem | Library): "full" | "partial" | undefined {
if (!("type" in item)) return undefined;
const isContainer = ["MusicAlbum", "Series", "Season", "BoxSet"].includes(item.type);
if (!isContainer) return undefined;
return $downloadedCatalog.partialContainers[item.id] ? "partial" : "full";
}
</script>
<div class="space-y-5">
<!-- Device total: the headline figure, reconciles with the listed sum. -->
<div
class="flex items-center justify-between rounded-lg border border-gray-700 bg-[var(--color-surface)] px-4 py-3"
>
<div class="flex items-center gap-3">
<svg class="h-5 w-5 text-[var(--color-jellyfin)]" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.8">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 7v10a2 2 0 002 2h12a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H6a2 2 0 00-2 2z" />
</svg>
<p class="text-sm text-gray-200">
<span class="font-semibold text-white">{formatBytes($downloadedDeviceTotal)}</span>
on device
<span class="text-gray-500">·</span>
{$downloadedItemCount}
{$downloadedItemCount === 1 ? "item" : "items"}
</p>
</div>
</div>
{#if currentLibrary}
<!-- Inside a library: breadcrumb back to the library list. -->
<div class="flex items-center gap-2 text-sm">
<button
onclick={backToLibraries}
class="text-gray-400 hover:text-white transition-colors"
>
Downloaded
</button>
<span class="text-gray-600">/</span>
<span class="text-white font-medium">{currentLibrary.name}</span>
</div>
{#if loadError}
<p class="text-sm text-red-400">{loadError}</p>
{/if}
<LibraryGrid
items={items.map((i) => i)}
loading={loadingItems}
showViewToggle={true}
musicContent={currentLibrary.collectionType === "music"}
{sizeLabelFor}
{downloadedBadgeFor}
onItemRemove={removeItem}
{onItemClick}
/>
{#if !loadingItems && items.length === 0 && !loadError}
<p class="text-center py-8 text-gray-500 text-sm">Nothing downloaded in this library.</p>
{/if}
{:else if loading}
<p class="text-center py-12 text-gray-400">Loading your downloads…</p>
{:else if $downloadedLibraries.length === 0}
<!-- Empty Downloaded state: authoritative "nothing downloaded", not a server miss. -->
<div class="rounded-lg border border-gray-700 bg-[var(--color-surface)] p-10 text-center">
<svg class="mx-auto mb-4 h-14 w-14 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.4">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4m-9 7h10" />
</svg>
<p class="text-lg font-medium text-gray-300">Nothing downloaded yet</p>
<p class="mt-2 text-sm text-gray-500">
Browse your library and tap download to save media for offline.
</p>
<button
onclick={() => goto("/library")}
class="mt-5 rounded-lg bg-[var(--color-jellyfin)] px-4 py-2 text-sm font-medium text-white hover:opacity-90 transition"
>
Go to library
</button>
</div>
{:else}
<!-- Library list — only libraries with downloaded content. -->
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{#each $downloadedLibraries as lib (lib.id)}
<button
onclick={() => openLibrary(lib)}
class="group/card flex flex-col text-left transition-transform duration-200 hover:scale-105"
>
<div class="relative aspect-video w-full overflow-hidden rounded-lg bg-[var(--color-surface)] shadow-md flex items-center justify-center">
<svg class="h-10 w-10 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.4">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-7l-2-2H5a2 2 0 00-2 2z" />
</svg>
</div>
<p class="mt-2 truncate text-sm font-medium text-white group-hover/card:text-[var(--color-jellyfin)] transition-colors">
{lib.name}
</p>
</button>
{/each}
</div>
{/if}
</div>
+15 -3
View File
@@ -1,3 +1,4 @@
<!-- TRACES: UR-029, UR-051 | DR-069, DR-070 -->
<script lang="ts">
import type { MediaItem, Library } from "$lib/api/types";
import MediaCard from "./MediaCard.svelte";
@@ -9,12 +10,20 @@
title?: string;
loading?: boolean;
showViewToggle?: boolean;
forceGrid?: boolean;
musicContent?: boolean;
onItemClick?: (item: MediaItem | Library) => void;
/**
* Optional per-item secondary label (e.g. on-disk size for the Downloaded
* surface), forwarded to each card. TRACES: UR-056 | DR-085
*/
sizeLabelFor?: (item: MediaItem | Library) => string | undefined;
/** Optional per-item container download badge for the Downloaded surface. */
downloadedBadgeFor?: (item: MediaItem | Library) => "full" | "partial" | undefined;
/** Optional per-item remove-from-device handler for the Downloaded surface. */
onItemRemove?: (item: MediaItem | Library) => void;
}
let { items, title, loading = false, showViewToggle = true, forceGrid = false, musicContent = false, onItemClick }: Props = $props();
let { items, title, loading = false, showViewToggle = true, musicContent = false, onItemClick, sizeLabelFor, downloadedBadgeFor, onItemRemove }: Props = $props();
</script>
<div class="space-y-4">
@@ -65,7 +74,7 @@
<div class="text-center py-12 text-gray-400">
<p>No items found</p>
</div>
{:else if !forceGrid && $viewMode === "list"}
{:else if $viewMode === "list"}
<LibraryListView {items} showProgress={true} onItemClick={onItemClick} />
{:else}
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
@@ -74,6 +83,9 @@
<MediaCard
{item}
showProgress={true}
sizeLabel={sizeLabelFor?.(item)}
downloadedBadge={downloadedBadgeFor?.(item)}
onRemove={onItemRemove ? () => onItemRemove(item) : undefined}
onclick={() => onItemClick?.(item)}
/>
</div>
+58 -1
View File
@@ -1,3 +1,4 @@
<!-- TRACES: UR-051, UR-052 | DR-068, DR-078 -->
<script lang="ts">
import type { MediaItem, Library } from "$lib/api/types";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
@@ -12,10 +13,26 @@
size?: "small" | "medium" | "large";
showProgress?: boolean;
showDownloadStatus?: boolean;
/**
* Secondary on-disk size label (e.g. "1.2 GB"), shown under the subtitle.
* Used by the Downloaded browse surface. TRACES: UR-056 | DR-085
*/
sizeLabel?: string;
/**
* "full" | "partial" — badges a downloaded container on the artwork so a
* fully-downloaded item reads differently from a partially-downloaded one.
* TRACES: UR-055 | DR-083
*/
downloadedBadge?: "full" | "partial";
/**
* When set, a hover/focus "remove from device" control appears on the card
* (Downloaded surface only). TRACES: UR-055, UR-056 | DR-083
*/
onRemove?: () => void;
onclick?: () => void;
}
let { item, size = "medium", showProgress = false, showDownloadStatus = true, onclick }: Props = $props();
let { item, size = "medium", showProgress = false, showDownloadStatus = true, sizeLabel, downloadedBadge, onRemove, onclick }: Props = $props();
// Check if this item is downloaded
const downloadInfo = $derived(
@@ -219,6 +236,43 @@
</div>
{/if}
<!-- Remove-from-device control (Downloaded surface), shown on hover/focus -->
{#if onRemove}
<button
type="button"
onclick={(e) => { e.stopPropagation(); onRemove?.(); }}
class="absolute top-2 left-2 w-7 h-7 rounded-full bg-black/70 hover:bg-red-600 text-white flex items-center justify-center opacity-0 group-hover/card:opacity-100 focus:opacity-100 transition-opacity shadow-lg"
title="Remove from device"
aria-label="Remove {item.name} from device"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 7h12M9 7V5a1 1 0 011-1h4a1 1 0 011 1v2m-7 0v12a1 1 0 001 1h6a1 1 0 001-1V7" />
</svg>
</button>
{/if}
<!-- Container downloaded badge (Downloaded surface): full vs partial -->
{#if downloadedBadge}
<div
class="absolute bottom-2 right-2"
title={downloadedBadge === "full" ? "Fully downloaded" : "Partially downloaded"}
>
{#if downloadedBadge === "full"}
<div class="w-6 h-6 rounded-full bg-green-600 flex items-center justify-center shadow-lg">
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
{:else}
<div class="w-6 h-6 rounded-full bg-amber-500 flex items-center justify-center shadow-lg" aria-label="Partially downloaded">
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4" />
</svg>
</div>
{/if}
</div>
{/if}
<!-- Server-only: queue-for-download control (kept at full opacity over the
greyed artwork). Queued items show a "queued" badge instead. -->
{#if isServerOnly}
@@ -261,5 +315,8 @@
{#if subtitle()}
<p class="text-xs text-gray-400 truncate">{subtitle()}</p>
{/if}
{#if sizeLabel}
<p class="text-xs text-gray-500 truncate">{sizeLabel}</p>
{/if}
</div>
</svelte:element>
+116
View File
@@ -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.27.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);
+43
View File
@@ -0,0 +1,43 @@
import { describe, it, expect } from "vitest";
import { formatBytes } from "./formatBytes";
// TRACES: UR-056 | DR-085 | UT-050
describe("formatBytes", () => {
it("renders zero and non-positive as '0 B'", () => {
expect(formatBytes(0)).toBe("0 B");
expect(formatBytes(-1)).toBe("0 B");
expect(formatBytes(NaN)).toBe("0 B");
expect(formatBytes(Infinity)).toBe("0 B");
});
it("renders whole bytes below 1 KB", () => {
expect(formatBytes(1)).toBe("1 B");
expect(formatBytes(999)).toBe("999 B");
});
it("crosses to KB at 1000 bytes (decimal units)", () => {
expect(formatBytes(1000)).toBe("1 KB");
expect(formatBytes(1500)).toBe("1.5 KB");
});
it("shows 2-3 significant figures", () => {
expect(formatBytes(340_000_000)).toBe("340 MB");
expect(formatBytes(1_200_000_000)).toBe("1.2 GB");
expect(formatBytes(48_000_000)).toBe("48 MB");
});
it("trims trailing zeros", () => {
expect(formatBytes(2_000_000_000)).toBe("2 GB");
expect(formatBytes(10_000_000)).toBe("10 MB");
});
it("scales into large units", () => {
expect(formatBytes(3_400_000_000)).toBe("3.4 GB");
expect(formatBytes(1_000_000_000_000)).toBe("1 TB");
});
it("uses no decimals at or above 100 of a unit", () => {
// 123.4 MB → "123 MB" (2-3 sig figs, whole number band)
expect(formatBytes(123_400_000)).toBe("123 MB");
});
});
+50
View File
@@ -0,0 +1,50 @@
// Shared byte-size formatter for the Downloads surface.
//
// One formatter, used everywhere disk usage is shown (cards, detail pages, the
// device total, and the remove-reclaim prompt) so units are consistent. We use
// DECIMAL units (1 GB = 1000 MB), matching how phone storage screens and file
// browsers present sizes, and show 23 significant figures.
//
// TRACES: UR-056 | DR-085
const UNITS = ["B", "KB", "MB", "GB", "TB", "PB"] as const;
/**
* Format a byte count as a human-readable size string (decimal units).
*
* Examples: 0 → "0 B", 340_000_000 → "340 MB", 1_200_000_000 → "1.2 GB".
*
* - Bytes render as whole numbers (no "0.5 B").
* - KB and above show enough decimals for 23 significant figures: values
* ≥ 100 render with no decimals, ≥ 10 with one, otherwise two.
* - Negative / non-finite inputs are treated as 0 (sizes are never negative).
*/
export function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
let value = bytes;
let unitIndex = 0;
while (value >= 1000 && unitIndex < UNITS.length - 1) {
value /= 1000;
unitIndex += 1;
}
// Bytes are always whole; larger units get 23 significant figures.
let formatted: string;
if (unitIndex === 0) {
formatted = Math.round(value).toString();
} else if (value >= 100) {
formatted = Math.round(value).toString();
} else if (value >= 10) {
formatted = value.toFixed(1);
} else {
formatted = value.toFixed(2);
}
// Trim trailing zeros ("1.20" → "1.2", "1.00" → "1") for a cleaner label.
if (formatted.includes(".")) {
formatted = formatted.replace(/\.?0+$/, "");
}
return `${formatted} ${UNITS[unitIndex]}`;
}