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();
});
});