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
+21 -25
View File
@@ -1,11 +1,13 @@
<!-- TRACES: UR-037, UR-051, UR-052, UR-068 | DR-042, DR-068, DR-078, DR-119 -->
<!-- TRACES: UR-037, UR-051, UR-052, UR-068 | DR-042, DR-068, DR-078, DR-119, DR-292 -->
<script lang="ts">
import type { MediaItem, Library } from "$lib/api/types";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import { downloads } from "$lib/stores/downloads";
import { isConnected } from "$lib/stores/connectivity";
import { showServerCatalog } from "$lib/services/offlineCatalog";
import { auth } from "$lib/stores/auth";
import { deviceContentIds } from "$lib/services/downloadedCatalog";
import { isServerOnly as computeIsServerOnly } from "$lib/utils/serverOnly";
import { queueOfflineDownload } from "$lib/services/queueOfflineDownload";
import CachedImage from "$lib/components/common/CachedImage.svelte";
import FavoriteButton from "$lib/components/FavoriteButton.svelte";
import { favoriteOverrides, resolveIsFavorite } from "$lib/stores/favorites";
@@ -143,11 +145,21 @@
const isQueued = $derived(downloadInfo?.status === "pending");
// Actively transferring (as opposed to merely queued/pending for reconnect).
const isActivelyDownloading = $derived(downloadInfo?.status === "downloading");
// "Server only" = offline, reveal on, and not already downloaded or actively
// transferring. A `pending` (queued-for-reconnect) item stays server-only so
// it can show the Queued badge in place of the queue button.
// "Server only" = offline, reveal on, and nothing on the device behind this
// card — neither its own download nor, for a container, its children's (the
// album case: only tracks carry download rows). A `pending`
// (queued-for-reconnect) item stays server-only so it can show the Queued
// badge in place of the queue button. Shared with the list view; see
// $lib/utils/serverOnly.
const isServerOnly = $derived(
isMediaItem && !$isConnected && $showServerCatalog && !isDownloaded && !isActivelyDownloading,
computeIsServerOnly({
isMediaItem,
isConnected: $isConnected,
revealServerCatalog: $showServerCatalog,
isDownloaded,
isActivelyDownloading,
hasDeviceContent: $deviceContentIds.has(item.id),
}),
);
// The heart is about an item, so libraries never get one, and a greyed
@@ -165,29 +177,13 @@
async function queueForDownload(e: Event) {
e.stopPropagation();
if (!isMediaItem) return;
const media = item as MediaItem;
const userId = auth.getUserId();
if (!userId) {
queueError = "Not signed in";
return;
}
try {
queueError = null;
// Derive a sensible on-disk path; the backend heals stream_url on reconnect.
const filePath = `downloads/${media.id}`;
await downloads.downloadItem(
media.id,
userId,
filePath,
undefined,
undefined,
media.name,
media.artists?.join(", ") ?? undefined,
media.albumName ?? undefined,
);
await queueOfflineDownload(item as MediaItem);
} catch (err) {
log.error("Failed to queue download:", err);
queueError = "Failed to queue";
queueError =
err instanceof Error && err.message === "Not signed in" ? err.message : "Failed to queue";
}
}