fix(offline): one server-only rule for both library views

Two defects with one cause: "server only" was a private $derived inside
MediaCard.

The list view (what LibraryGrid renders when the stored view preference
is list) had no notion of it at all, so a library browsed as a list
offline showed every revealed item as an ordinary tappable row that plays
nothing, with no way to queue it.

And the rule asked the downloads store whether *this item id* was
downloaded — but only a playable leaf (Audio, Movie, Episode) ever has a
download row. An album's tracks carry them, the album does not, so a
fully downloaded album greyed itself out and offered to queue what was
already on the device.

The rule moves to the pure $lib/utils/serverOnly and both views call it.
The container half is answered by the backend rather than guessed at:
get_download_disk_usage().sizes already carries container subtotals
beside leaf sizes (DR-085), so deviceContentIds is membership in a
Rust-computed map, not a frontend list of which item types are
containers. That map was loaded only by the Downloads page, so the shell
primes it at startup and re-reads it whenever the offline gate settles.
Queueing is shared too, since the list view had no copy to diverge from.

TRACES: UR-052, UR-055 | DR-292 | UT-257, UT-258
This commit is contained in:
2026-09-22 21:29:38 -04:00
parent a90de67c54
commit bb7d5dc01a
10 changed files with 468 additions and 36 deletions
+16
View File
@@ -112,6 +112,22 @@ function createDownloadedCatalogStore() {
export const downloadedCatalog = createDownloadedCatalogStore();
/**
* Every item id the device holds bytes for — playable leaves *and* the
* containers above them, as the backend's disk-usage map reports them.
*
* This is what stops the offline browse greying out a fully downloaded album:
* only a leaf (Audio, Movie, Episode) ever has a download row of its own, so
* asking the downloads store about an album id always answered "no". Which ids
* are containers, and which children roll up into them, stays a Rust question
* (`get_download_disk_usage`); the frontend only reads membership.
*
* Refreshed with the rest of the catalog — see `downloadedCatalog.refresh()`.
*
* TRACES: UR-052, UR-056 | DR-292
*/
export const deviceContentIds = derived(downloadedCatalog, ($c) => new Set(Object.keys($c.sizes)));
export const downloadedLibraries = derived(downloadedCatalog, ($c) => $c.libraries);
export const downloadedDeviceTotal = derived(downloadedCatalog, ($c) => $c.deviceTotalBytes);
export const downloadedItemCount = derived(downloadedCatalog, ($c) => $c.itemCount);
+33
View File
@@ -0,0 +1,33 @@
/**
* Queue a server-only item for download on the next reconnect.
*
* Offline this only persists a `pending` downloads row with no `stream_url`;
* the reconnect handler resolves the URL and the pump starts it (see
* `offlineCatalog`). Shared by the grid card and the list row so the two
* surfaces queue identically — the list view previously had no way to queue at
* all.
*
* TRACES: UR-052 | DR-292
*/
import type { MediaItem } from "$lib/api/types";
import { downloads } from "$lib/stores/downloads";
import { auth } from "$lib/stores/auth";
export async function queueOfflineDownload(item: MediaItem): Promise<void> {
const userId = auth.getUserId();
if (!userId) throw new Error("Not signed in");
// A sensible on-disk path; the backend heals `stream_url` on reconnect.
const filePath = `downloads/${item.id}`;
await downloads.downloadItem(
item.id,
userId,
filePath,
undefined,
undefined,
item.name,
item.artists?.join(", ") ?? undefined,
item.albumName ?? undefined,
);
}