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:
@@ -493,6 +493,7 @@ Internal architecture, components, and application logic.
|
||||
| DR-289 | The download worker's HTTP client carries a **read** timeout and a connect timeout, never a total request timeout. reqwest's `Client::timeout` is a deadline that runs until the body has finished, and it was set to five minutes: every transfer longer than that was cut off mid-body as "error decoding response body" and retried. A transcode ignores `Range`, so each retry restarted from byte zero, met the same deadline, and after three attempts the download failed — no feature film at transcode speed ever completed on a device whose audio must be re-encoded, and a large direct copy limped through in five-minute slices with a backoff between each. A read timeout resets on every chunk, so it still catches a dead connection without capping how long a healthy transfer may run | Downloads | UR-071 | Done |
|
||||
| DR-290 | A download whose response states no length still reports progress against a predicted total. A transcode is produced as it is sent — chunked, no `Content-Length` — and the worker reported `progress: 0.0` for its whole duration: an empty bar reading "0%" while the byte count climbed for an hour, which is the case every film whose audio must be re-encoded lands in. The backend already fetches the item to decide the audio policy, and that item carries what a prediction needs: the source's size (an `original` download copies the picture, so the output is the source give or take the audio track — and exactly the source when nothing is re-encoded) and its runtime (a preset re-encodes at fixed rates, so the size is rate × runtime, from the same preset table the URL is built from so the two cannot drift). The prediction is made where the URL is resolved and persisted as the row's `file_size`; the worker uses it **only** when the response has no length, the server's figure always wins, an estimated bar is capped at 99% so a low prediction never shows a finished download still running, and the `Completed` event carries the bytes actually written so neither side persists the prediction as the real size. With no prediction the bar is indeterminate, which is honest and was the status quo. The single-video button joins the series/season buttons on the enqueue path so all three resolve — and predict — in one place | Downloads | UR-071 | Done |
|
||||
| DR-291 | The offline banner stays off the full-screen player. Every other shell rule in `layoutShell.ts` already treats `/player/*` as immersive; the amber "You're offline" strip was the one piece of chrome still rendered above it. On the native Android video path that is not cosmetic: VideoPlayer makes itself transparent so the ExoPlayer SurfaceView behind the WebView is visible (DR-185), so a shell child that still paints shows *through* the picture as a stripe across the top of the film. Offline is also precisely when a downloaded video plays, so the banner appeared when it was most in the way, and it offers the viewer nothing to act on — local playback needs no server. The rule moves into the pure module as `showOfflineBanner({ pathname, isAuthenticated, isConnected })` rather than staying an inline `{#if}` in the shell, so the immersive-route contract is stated in one tested place | UI | UR-003, UR-043 | Done |
|
||||
| DR-292 | The offline catalog reveal is one rule, applied by both library views. Two defects, one cause — "server only" was a private `$derived` inside `MediaCard`. (1) The list view (`LibraryListView`, 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. (2) 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, which is what "my downloaded music is greyed out" was. The rule moves to the pure `$lib/utils/serverOnly`, both views call it, and the container half is answered by the backend: `get_download_disk_usage().sizes` already carries container subtotals beside leaf sizes (DR-085), so `deviceContentIds` is membership in a Rust-computed map rather than a frontend guess at which item types are containers. That map was loaded only by the Downloads page, so the shell now primes it at startup and re-reads it whenever the offline gate settles (the DR-143 signal). Queueing is shared too (`queueOfflineDownload`), since the list view had no copy to diverge from | UI | UR-052, UR-055 | Done |
|
||||
|
||||
---
|
||||
|
||||
@@ -553,10 +554,10 @@ Internal architecture, components, and application logic.
|
||||
| UR-049 | IR-010 | DR-063, DR-064, DR-065, DR-147 |
|
||||
| UR-050 | - | DR-066, DR-067 |
|
||||
| UR-051 | - | DR-068, DR-069, DR-070 |
|
||||
| UR-052 | IR-027 | DR-078, DR-079, DR-080, DR-143 |
|
||||
| UR-052 | IR-027 | DR-078, DR-079, DR-080, DR-143, DR-292 |
|
||||
| UR-053 | IR-029 | DR-074 |
|
||||
| UR-054 | - | DR-075, DR-076, DR-077, DR-147 |
|
||||
| UR-055 | - | DR-081, DR-082, DR-083, DR-084, DR-167, DR-168, DR-169, DR-173 |
|
||||
| UR-055 | - | DR-081, DR-082, DR-083, DR-084, DR-167, DR-168, DR-169, DR-173, DR-292 |
|
||||
| UR-056 | - | DR-085 |
|
||||
| UR-057 | - | DR-086 |
|
||||
| UR-058 | - | DR-087, DR-142 |
|
||||
@@ -844,6 +845,8 @@ Internal architecture, components, and application logic.
|
||||
| UT-253 | Resolving a queued video row persists its predicted size, and resolving an audio row (no prediction) leaves a size the row already holds untouched | DR-290 | Done |
|
||||
| UT-254 | The progress row renders an unknown total as indeterminate rather than "0%", an estimated total as "~N%", an exact one plainly; the store carries the estimate flag through progress and persists the worker's byte count, never the prediction, on completion | DR-290 | Done |
|
||||
| UT-255 | The offline banner shows while offline on ordinary routes and never on `/player/*`, and stays off while connected or signed out | DR-291 | Done |
|
||||
| UT-257 | The server-only rule: true only offline with the reveal on and nothing on the device; never for a library tile; and not for a container whose children are downloaded (the greyed-album regression) | DR-292 | Done |
|
||||
| UT-258 | The list view greys a server-only row, makes it inert to tap, offers the queue button (and the Queued badge once pending), and leaves downloaded rows and containers with device content alone | DR-292 | Done |
|
||||
### Integration Tests
|
||||
|
||||
| Test ID | Test Description | Traces To | Status |
|
||||
|
||||
@@ -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}
|
||||
|
||||
<!-- 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;
|
||||
}
|
||||
@@ -19,6 +19,8 @@
|
||||
showServerCatalog,
|
||||
lastCatalogSync,
|
||||
} from "$lib/services/offlineCatalog";
|
||||
import { downloadedCatalog } from "$lib/services/downloadedCatalog";
|
||||
import { useOfflineFilterReload } from "$lib/composables/useOfflineFilterReload";
|
||||
import { playbackMode } from "$lib/stores/playbackMode";
|
||||
import { sessions } from "$lib/stores/sessions";
|
||||
import ReauthModal from "$lib/components/auth/ReauthModal.svelte";
|
||||
@@ -109,6 +111,11 @@
|
||||
shellReservesBottomInset({ pathname, isAuthenticated: $isAuthenticated }),
|
||||
);
|
||||
|
||||
// The device-content map decides which revealed cards are greyed, so it has
|
||||
// to be current whenever the offline gate settles — going offline, or
|
||||
// flipping "Show all server media" — not just at startup. (DR-292)
|
||||
useOfflineFilterReload(() => downloadedCatalog.refresh());
|
||||
|
||||
// The offline banner is shell chrome like the rest, so it stays off the
|
||||
// full-screen player — where on the native video path it would paint a
|
||||
// stripe straight through the film (DR-291).
|
||||
@@ -176,6 +183,14 @@
|
||||
const userId = get(auth).user?.id;
|
||||
if (userId) {
|
||||
downloads.refresh(userId).catch((err) => log.error("Initial downloads refresh failed:", err));
|
||||
// Which containers hold downloaded children is a backend question
|
||||
// (`get_download_disk_usage`), and the offline browse needs the answer to
|
||||
// avoid greying out a fully downloaded album — its *tracks* carry the
|
||||
// download rows, never the album. Primed here because the map used to be
|
||||
// loaded only by the Downloads page (DR-292).
|
||||
downloadedCatalog
|
||||
.refresh()
|
||||
.catch((err) => log.error("Initial downloaded-catalog refresh failed:", err));
|
||||
}
|
||||
|
||||
// Start sync service for offline mutation queue
|
||||
|
||||
Reference in New Issue
Block a user