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:
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user