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:
@@ -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 }
|
||||
/**
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user