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
@@ -0,0 +1,154 @@
/**
* The list view must grey and offer to queue server-only media exactly as the
* grid does. It did neither: switching a library to list view offline turned
* every non-downloaded item back into a normal, tappable row that plays
* nothing.
*
* TRACES: UR-052 | DR-292 | UT-258
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/svelte";
const h = vi.hoisted(() => {
function shim<T>(initial: T) {
let value = initial;
const subs = new Set<(v: T) => void>();
return {
set(v: T) {
value = v;
subs.forEach((fn) => fn(value));
},
subscribe(fn: (v: T) => void) {
subs.add(fn);
fn(value);
return () => subs.delete(fn);
},
};
}
return {
isConnectedStore: shim(true),
showServerCatalogStore: shim(false),
downloadsStore: shim({ downloads: {} as Record<string, unknown> }),
deviceContentIdsStore: shim(new Set<string>()),
downloadItem: vi.fn(async () => 1),
getUserId: vi.fn(() => "user-1"),
};
});
vi.mock("$lib/stores/connectivity", () => ({
isConnected: { subscribe: h.isConnectedStore.subscribe },
}));
vi.mock("$lib/services/offlineCatalog", () => ({
showServerCatalog: { subscribe: h.showServerCatalogStore.subscribe },
}));
vi.mock("$lib/services/downloadedCatalog", () => ({
deviceContentIds: { subscribe: h.deviceContentIdsStore.subscribe },
}));
vi.mock("$lib/stores/downloads", () => ({
downloads: { subscribe: h.downloadsStore.subscribe, downloadItem: h.downloadItem },
}));
vi.mock("$lib/stores/auth", () => ({
auth: { getUserId: h.getUserId },
}));
vi.mock("$lib/components/common/CachedImage.svelte", async () => ({
default: (await import("./__mocks__/StubImage.svelte")).default,
}));
import LibraryListView from "./LibraryListView.svelte";
const movie = {
id: "movie-1",
name: "Some Film",
type: "Movie" as const,
serverId: "server-1",
productionYear: 1999,
};
const album = {
id: "album-1",
name: "Some Album",
type: "MusicAlbum" as const,
serverId: "server-1",
};
describe("LibraryListView server-only rows", () => {
beforeEach(() => {
vi.clearAllMocks();
h.isConnectedStore.set(true);
h.showServerCatalogStore.set(false);
h.downloadsStore.set({ downloads: {} });
h.deviceContentIdsStore.set(new Set());
});
it("shows no queue control while online", () => {
render(LibraryListView, { props: { items: [movie] } });
expect(screen.queryByLabelText(/Queue download/i)).toBeNull();
});
it("offers to queue a server-only row when offline with the reveal on", () => {
h.isConnectedStore.set(false);
h.showServerCatalogStore.set(true);
render(LibraryListView, { props: { items: [movie] } });
expect(screen.getByLabelText(/Queue download for Some Film/i)).toBeTruthy();
});
it("queues the row's item on click", async () => {
h.isConnectedStore.set(false);
h.showServerCatalogStore.set(true);
render(LibraryListView, { props: { items: [movie] } });
await fireEvent.click(screen.getByLabelText(/Queue download for Some Film/i));
expect(h.downloadItem).toHaveBeenCalledTimes(1);
const args = h.downloadItem.mock.calls[0] as unknown as unknown[];
expect(args[0]).toBe("movie-1");
expect(args[1]).toBe("user-1");
});
it("makes a server-only row inert: tapping it cannot start playback", async () => {
h.isConnectedStore.set(false);
h.showServerCatalogStore.set(true);
const onItemClick = vi.fn();
render(LibraryListView, { props: { items: [movie], onItemClick } });
await fireEvent.click(screen.getByText("Some Film"));
expect(onItemClick).not.toHaveBeenCalled();
});
it("leaves a downloaded row alone", () => {
h.isConnectedStore.set(false);
h.showServerCatalogStore.set(true);
h.downloadsStore.set({
downloads: { "movie-1": { itemId: "movie-1", status: "completed", progress: 1 } },
});
render(LibraryListView, { props: { items: [movie] } });
expect(screen.queryByLabelText(/Queue download/i)).toBeNull();
});
it("leaves a container alone when the device holds its children", () => {
h.isConnectedStore.set(false);
h.showServerCatalogStore.set(true);
h.deviceContentIdsStore.set(new Set(["album-1"]));
render(LibraryListView, { props: { items: [album] } });
expect(screen.queryByLabelText(/Queue download/i)).toBeNull();
});
it("shows a Queued badge instead of the button for a pending row", () => {
h.isConnectedStore.set(false);
h.showServerCatalogStore.set(true);
h.downloadsStore.set({
downloads: { "movie-1": { itemId: "movie-1", status: "pending", progress: 0 } },
});
render(LibraryListView, { props: { items: [movie] } });
expect(screen.getByText(/Queued/i)).toBeTruthy();
expect(screen.queryByLabelText(/Queue download/i)).toBeNull();
});
});
@@ -1,10 +1,26 @@
<!--
List counterpart of the grid's MediaCard. The two must agree about what a row
offline means: before DR-292 this view knew nothing about the offline catalog
reveal, so a library switched to list view showed every server-only item as a
normal, tappable row that plays nothing.
TRACES: UR-052 | 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 { deviceContentIds } from "$lib/services/downloadedCatalog";
import { isServerOnly } from "$lib/utils/serverOnly";
import { queueOfflineDownload } from "$lib/services/queueOfflineDownload";
import { formatDuration } from "$lib/utils/duration";
import { createLogger } from "$lib/utils/logger";
import CachedImage from "$lib/components/common/CachedImage.svelte";
const log = createLogger("LibraryListView");
interface Props {
items: (MediaItem | Library)[];
showProgress?: boolean;
@@ -18,6 +34,33 @@
return Object.values($downloads.downloads).find((d) => d.itemId === itemId);
}
/** Same rule the grid card applies — see $lib/utils/serverOnly. */
function serverOnly(item: MediaItem | Library): boolean {
const info = getDownloadInfo(item.id);
return isServerOnly({
isMediaItem: "type" in item,
isConnected: $isConnected,
revealServerCatalog: $showServerCatalog,
isDownloaded: info?.status === "completed",
isActivelyDownloading: info?.status === "downloading",
hasDeviceContent: $deviceContentIds.has(item.id),
});
}
let queueError = $state<string | null>(null);
async function queueForDownload(e: Event, item: MediaItem | Library) {
e.stopPropagation();
if (!("type" in item)) return;
try {
queueError = null;
await queueOfflineDownload(item);
} catch (err) {
log.error("Failed to queue download:", err);
queueError = err instanceof Error ? err.message : "Failed to queue";
}
}
function getImageTag(item: MediaItem | Library): string | undefined {
return "imageId" in item
? (item.imageId ?? undefined)
@@ -78,12 +121,20 @@
{@const isDownloaded = downloadInfo?.status === "completed"}
{@const isDownloading =
downloadInfo?.status === "downloading" || downloadInfo?.status === "pending"}
{@const isQueued = downloadInfo?.status === "pending"}
{@const rowServerOnly = serverOnly(item)}
<button
type="button"
<!-- A server-only row is not a button: there is nothing to open offline,
and the queue control it carries cannot live inside one. -->
<svelte:element
this={rowServerOnly ? "div" : "button"}
type={rowServerOnly ? undefined : "button"}
role={rowServerOnly ? "group" : undefined}
data-grid-index={index}
onclick={() => onItemClick?.(item)}
class="w-full flex items-center gap-3 p-2 rounded-lg hover:bg-[var(--color-surface)] transition-colors group"
onclick={rowServerOnly ? undefined : () => onItemClick?.(item)}
class="w-full flex items-center gap-3 p-2 rounded-lg transition-colors group {rowServerOnly
? 'opacity-60'
: 'hover:bg-[var(--color-surface)]'}"
>
<!-- Track number or index -->
<span class="text-gray-500 w-6 text-right text-sm flex-shrink-0">
@@ -103,9 +154,11 @@
class="w-full h-full object-cover"
/>
<!-- Play overlay on hover -->
<!-- Play overlay on hover (never on an inert server-only row) -->
<div
class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
class="absolute inset-0 bg-black/50 opacity-0 transition-opacity flex items-center justify-center {rowServerOnly
? ''
: 'group-hover:opacity-100'}"
>
<svg class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
@@ -123,7 +176,9 @@
<!-- Title & Subtitle -->
<div class="flex-1 min-w-0 text-left">
<p
class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors"
class="text-sm font-medium text-white truncate transition-colors {rowServerOnly
? ''
: 'group-hover:text-[var(--color-jellyfin)]'}"
>
{truncateMiddle(item.name, 56)}
</p>
@@ -192,6 +247,39 @@
{#if duration}
<span class="text-xs text-gray-400 flex-shrink-0">{duration}</span>
{/if}
</button>
<!-- Server-only: queue for the next reconnect, mirroring the grid card.
A row already queued shows the badge instead of the button. -->
{#if rowServerOnly}
{#if isQueued}
<span
class="text-[10px] font-medium bg-white/10 text-gray-300 px-2 py-0.5 rounded-full flex-shrink-0"
title="Queued — will download on reconnect">Queued</span
>
{:else}
<button
type="button"
onclick={(e) => queueForDownload(e, item)}
class="w-8 h-8 rounded-full bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin)]/90 flex items-center justify-center flex-shrink-0 transition-colors"
title="Queue download for next connection"
aria-label="Queue download for {item.name}"
>
<svg
class="w-4 h-4 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4" />
</svg>
</button>
{/if}
{/if}
</svelte:element>
{/each}
{#if queueError}
<p class="text-xs text-red-400 px-2">{queueError}</p>
{/if}
</div>
@@ -24,12 +24,20 @@ const h = vi.hoisted(() => {
isConnectedStore: shim(true),
showServerCatalogStore: shim(false),
downloadsStore: shim({ downloads: {} as Record<string, any> }),
deviceContentIdsStore: shim(new Set<string>()),
downloadItem: vi.fn(async () => 1),
getUserId: vi.fn(() => "user-1"),
};
});
const { isConnectedStore, showServerCatalogStore, downloadsStore, downloadItem, getUserId } = h;
const {
isConnectedStore,
showServerCatalogStore,
downloadsStore,
deviceContentIdsStore,
downloadItem,
getUserId,
} = h;
vi.mock("$lib/stores/connectivity", () => ({
isConnected: { subscribe: h.isConnectedStore.subscribe },
@@ -39,6 +47,10 @@ vi.mock("$lib/services/offlineCatalog", () => ({
showServerCatalog: { subscribe: h.showServerCatalogStore.subscribe },
}));
vi.mock("$lib/services/downloadedCatalog", () => ({
deviceContentIds: { subscribe: h.deviceContentIdsStore.subscribe },
}));
vi.mock("$lib/stores/downloads", () => ({
downloads: { subscribe: h.downloadsStore.subscribe, downloadItem: h.downloadItem },
}));
@@ -69,6 +81,7 @@ describe("MediaCard server-only (offline browse & queue)", () => {
isConnectedStore.set(true);
showServerCatalogStore.set(false);
downloadsStore.set({ downloads: {} });
deviceContentIdsStore.set(new Set());
});
it("shows no queue button while online", () => {
@@ -117,6 +130,23 @@ describe("MediaCard server-only (offline browse & queue)", () => {
expect(screen.queryByLabelText(/Queue download/i)).toBeNull();
});
// An album carries no download row of its own — its tracks do — so the card
// greyed out a fully downloaded album and offered to queue what was already
// on the device. The backend's disk-usage map answers for containers too.
// TRACES: UR-052 | DR-292 | UT-257
it("does not grey a container whose children are on the device", () => {
isConnectedStore.set(false);
showServerCatalogStore.set(true);
deviceContentIdsStore.set(new Set(["album-1"]));
render(MediaCard, {
props: {
item: { id: "album-1", name: "Album X", type: "MusicAlbum", serverId: "server-1" },
},
});
expect(screen.queryByLabelText(/Queue download/i)).toBeNull();
});
it("does not grey out a completed download", () => {
isConnectedStore.set(false);
showServerCatalogStore.set(true);
+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";
}
}
+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,
);
}
+45
View File
@@ -0,0 +1,45 @@
/**
* Tests for the "server only" card rule shared by the grid and the list view.
*
* TRACES: UR-052 | DR-292 | UT-257
*/
import { describe, it, expect } from "vitest";
import { isServerOnly, type ServerOnlyInput } from "./serverOnly";
const offlineReveal: ServerOnlyInput = {
isMediaItem: true,
isConnected: false,
revealServerCatalog: true,
isDownloaded: false,
isActivelyDownloading: false,
hasDeviceContent: false,
};
describe("isServerOnly", () => {
it("is true only offline, with the reveal on, for an item with nothing on the device", () => {
expect(isServerOnly(offlineReveal)).toBe(true);
expect(isServerOnly({ ...offlineReveal, isConnected: true })).toBe(false);
expect(isServerOnly({ ...offlineReveal, revealServerCatalog: false })).toBe(false);
});
it("never greys a library tile: there is nothing to queue", () => {
expect(isServerOnly({ ...offlineReveal, isMediaItem: false })).toBe(false);
});
it("does not grey the item's own completed or in-flight download", () => {
expect(isServerOnly({ ...offlineReveal, isDownloaded: true })).toBe(false);
expect(isServerOnly({ ...offlineReveal, isActivelyDownloading: true })).toBe(false);
});
it("does not grey a container whose children are on the device", () => {
// The regression: an album has no download row of its own — its *tracks*
// do — so a fully downloaded album greyed itself out and offered to queue
// what was already there.
expect(isServerOnly({ ...offlineReveal, hasDeviceContent: true })).toBe(false);
});
it("still greys a container with nothing downloaded under it", () => {
expect(isServerOnly({ ...offlineReveal, hasDeviceContent: false })).toBe(true);
});
});
+52
View File
@@ -0,0 +1,52 @@
/**
* Is a card "server only" — revealed by the offline "Show all server media"
* toggle, but with nothing on the device behind it?
*
* Such cards render greyed out, are inert to tap (there is nothing to play),
* and offer to queue a download for the next reconnect instead.
*
* This lives in its own module because two surfaces answer the question — the
* grid (`MediaCard`) and the list (`LibraryListView`) — and they disagreed:
* the list had no notion of server-only at all, so switching a library to list
* view offline turned every non-downloaded item back into a normal, tappable
* row that plays nothing.
*
* `hasDeviceContent` is the fix for the second half of that defect. An item's
* *own* download row only ever exists for a playable leaf (Audio, Movie,
* Episode); an album or a season never has one, so a fully downloaded album
* greyed itself out and offered to queue what was already on the device. The
* caller passes the backend's answer — `get_download_disk_usage().sizes`
* carries container subtotals as well as leaf sizes — rather than the frontend
* deciding which item types are containers, which is taxonomy that belongs in
* Rust.
*
* TRACES: UR-052 | DR-292 | UT-257
*/
export interface ServerOnlyInput {
/** False for a `Library` tile — a library is never queued or greyed. */
isMediaItem: boolean;
/** Server reachability (`$isConnected`). */
isConnected: boolean;
/** The offline banner's "Show all server media" toggle. */
revealServerCatalog: boolean;
/** This item's own download row is `completed`. */
isDownloaded: boolean;
/** This item's own download row is actively transferring. */
isActivelyDownloading: boolean;
/** The device holds bytes at or under this item (leaf file or container). */
hasDeviceContent: boolean;
}
export function isServerOnly({
isMediaItem,
isConnected,
revealServerCatalog,
isDownloaded,
isActivelyDownloading,
hasDeviceContent,
}: ServerOnlyInput): boolean {
if (!isMediaItem) return false;
if (isConnected || !revealServerCatalog) return false;
return !isDownloaded && !isActivelyDownloading && !hasDeviceContent;
}