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";
}
}