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