diff --git a/src/lib/api/bindings.ts b/src/lib/api/bindings.ts index 89c7b914f..925b4dd54 100644 --- a/src/lib/api/bindings.ts +++ b/src/lib/api/bindings.ts @@ -2135,7 +2135,18 @@ export type AuthServerInfo = { name: string; version: string; id: string; /** * Normalized server URL with protocol and no trailing slash */ -normalizedUrl: string } +normalizedUrl: string; +/** + * Whether this build can talk to this server, as an **opaque state**. + * + * The version string above is informational — for display and for the log. + * This is the judgement, made in Rust, because deciding whether an API + * version is usable is domain reasoning: the frontend must never compare a + * version number, for the same reason it never receives an item-type list. + * + * TRACES: UR-085 | DR-286 + */ +compatibility: ServerCompatibility } /** * Autoplay settings (controls next episode behavior) */ @@ -3428,6 +3439,37 @@ export type SecurityStatus = { usingKeyring: boolean; storageType: string } * Audio track preference for a series */ export type SeriesAudioPreference = { seriesId: string; audioTrackDisplayTitle: string | null; audioTrackLanguage: string | null; audioTrackIndex: number | null } +/** + * The verdict on a server's version. + * + * Deliberately three states rather than a boolean. "Unrecognised" is not a + * failure: a server newer than this build resolves forward and works, and + * refusing it would make every JellyTau release expire the moment the server + * upgrades. Only a server below the supported floor is refused, where failure + * is certain rather than merely likely. + * + * TRACES: UR-085 | DR-286 + */ +export type ServerCompatibility = +/** + * A generation this build knows and was tested against. + */ +{ type: "supported" } | +/** + * Parsed, but newer than anything this build knows. Treated as the newest + * known generation; everything works, and this exists so the UI *may* + * mention it rather than so it must. + */ +{ type: "newerThanKnown" } | +/** + * The version string could not be parsed. Treated as supported — we do not + * refuse a server on the strength of not understanding its version string. + */ +{ type: "unknownVersion" } | +/** + * Below the supported floor. This one is a refusal. + */ +{ type: "tooOld"; minimum: string } /** * Server info returned to frontend */ diff --git a/src/lib/services/imageCache.test.ts b/src/lib/services/imageCache.test.ts index b30c6210d..905205d3e 100644 --- a/src/lib/services/imageCache.test.ts +++ b/src/lib/services/imageCache.test.ts @@ -6,7 +6,6 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import { - getCachedImageUrl, getCacheStats, setCacheLimit, clearCache, @@ -50,43 +49,6 @@ describe("image cache service", () => { vi.clearAllMocks(); }); - describe("getCachedImageUrl", () => { - it("should build server URL with default image type", async () => { - const url = await getCachedImageUrl("http://server.local:8096", "item-123"); - expect(url).toContain("http://server.local:8096/Items/item-123/Images/Primary"); - }); - - it("should build server URL with custom image type", async () => { - const url = await getCachedImageUrl("http://server.local:8096", "item-123", "Backdrop"); - expect(url).toContain("Backdrop"); - }); - - it("should include image options in URL", async () => { - const url = await getCachedImageUrl("http://server.local:8096", "item-123", "Primary", { - maxWidth: 300, - maxHeight: 400, - quality: 90, - tag: "abc123", - }); - expect(url).toContain("maxWidth=300"); - expect(url).toContain("maxHeight=400"); - expect(url).toContain("quality=90"); - expect(url).toContain("tag=abc123"); - }); - - it("should trigger background caching", async () => { - const { invoke } = await import("@tauri-apps/api/core"); - const invokeSpy = vi.mocked(invoke); - - await getCachedImageUrl("http://server.local:8096", "item-123"); - - const saveCall = invokeSpy.mock.calls.find((call) => call[0] === "thumbnail_save"); - expect(saveCall).toBeDefined(); - expect(saveCall![1]).toHaveProperty("itemId", "item-123"); - expect(saveCall![1]).toHaveProperty("imageType", "Primary"); - }); - }); - describe("cache statistics", () => { it("should get cache statistics", async () => { const stats = await getCacheStats(); diff --git a/src/lib/services/imageCache.ts b/src/lib/services/imageCache.ts index 381f888a3..bf2bd325e 100644 --- a/src/lib/services/imageCache.ts +++ b/src/lib/services/imageCache.ts @@ -1,11 +1,23 @@ -// Image cache service - Handles lazy caching of thumbnails with LRU eviction -// TRACES: UR-007 | DR-016 +// Image cache service — cache statistics, limits and eviction. +// +// This module used to also export `getCachedImageUrl`, which built +// `${serverUrl}/Items/${itemId}/Images/${imageType}` in the frontend. That was a +// Jellyfin route in the presentation layer — domain logic by this project's own +// litmus test (would it change if Jellyfin changed its API?) — and it was +// **dead**: nothing outside this file and its test ever called it. The live path +// is CachedImage.svelte -> commands.imageGetUrl -> Rust, which was already +// correct. It was deleted rather than migrated (DR-285). +// +// Note for whoever touches the CSP next: that function was the last +// `convertFileSrc` caller, so the asset-protocol grant narrowed to +// `$APPDATA/thumbnails/**` under DR-198 now has no caller at all and is a +// candidate for removal. Left in place here deliberately — dropping a capability +// grant is a security change that deserves its own commit and its own testing on +// Android, not a side effect of deleting dead code. +// +// TRACES: UR-007, UR-085 | DR-016, DR-285 -import { convertFileSrc } from "@tauri-apps/api/core"; import { commands } from "$lib/api/bindings"; -import { createLogger } from "$lib/utils/logger"; - -const log = createLogger("ImageCache"); /** * Statistics about the thumbnail cache @@ -16,63 +28,6 @@ export interface ImageCacheStats { limitBytes: number; } -/** - * Get an image URL, checking cache first then falling back to server. - * Triggers background caching if not cached. - * - * @param serverUrl - The Jellyfin server base URL - * @param itemId - The Jellyfin item ID - * @param imageType - The image type (Primary, Backdrop, etc.) - * @param options - Image options (maxWidth, maxHeight, quality, tag) - * @returns The image URL (local asset URL if cached, server URL otherwise) - */ -export async function getCachedImageUrl( - serverUrl: string, - itemId: string, - imageType: string = "Primary", - options: { - maxWidth?: number; - maxHeight?: number; - quality?: number; - tag?: string; - } = {}, -): Promise { - const tag = options.tag || "default"; - - // Try to get cached version - try { - const cachedPath = await commands.thumbnailGetCached(itemId, imageType, tag); - - if (cachedPath) { - // Convert file path to asset URL for Tauri. This is the only remaining - // convertFileSrc caller, which is why the asset-protocol scope is narrowed - // to $APPDATA/thumbnails/** — a path outside it resolves to nothing. - // TRACES: UR-012 | DR-134, DR-198 - return convertFileSrc(cachedPath); - } - } catch (e) { - log.debug("Failed to check thumbnail cache:", e); - } - - // Build server URL - const params = new URLSearchParams(); - if (options.maxWidth) params.set("maxWidth", options.maxWidth.toString()); - if (options.maxHeight) params.set("maxHeight", options.maxHeight.toString()); - if (options.quality) params.set("quality", options.quality.toString()); - if (options.tag) params.set("tag", options.tag); - - const serverImageUrl = `${serverUrl}/Items/${itemId}/Images/${imageType}?${params.toString()}`; - - // Trigger background caching (fire and forget) - commands.thumbnailSave(itemId, imageType, tag, serverImageUrl).catch((e) => { - // Silently fail - caching is best-effort - log.debug("Background thumbnail cache failed:", e); - }); - - // Return server URL for immediate display - return serverImageUrl; -} - /** * Get thumbnail cache statistics */ diff --git a/src/lib/utils/serverCompatibility.test.ts b/src/lib/utils/serverCompatibility.test.ts new file mode 100644 index 000000000..c213522e8 --- /dev/null +++ b/src/lib/utils/serverCompatibility.test.ts @@ -0,0 +1,34 @@ +/** + * TRACES: UR-085 | DR-286 + */ +import { describe, it, expect } from "vitest"; +import { compatibilityNotice } from "./serverCompatibility"; + +describe("server compatibility notice", () => { + it("says nothing about a supported server", () => { + expect(compatibilityNotice({ type: "supported" }, "12.0.0")).toBeNull(); + }); + + it("does not interrupt anyone over a version it could not parse", () => { + // Refusing, or even warning, on an unreadable version string would punish + // the user for a parsing limitation of ours. + expect(compatibilityNotice({ type: "unknownVersion" }, "weird-build")).toBeNull(); + }); + + it("mentions a newer-than-known server without blocking it", () => { + const notice = compatibilityNotice({ type: "newerThanKnown" }, "13.0.0"); + expect(notice).not.toBeNull(); + expect(notice!.blocking).toBe(false); + expect(notice!.tone).toBe("warning"); + expect(notice!.message).toContain("13.0.0"); + }); + + it("blocks a server below the floor and names the floor", () => { + const notice = compatibilityNotice({ type: "tooOld", minimum: "10.10" }, "10.9.11"); + expect(notice).not.toBeNull(); + expect(notice!.blocking).toBe(true); + expect(notice!.tone).toBe("error"); + expect(notice!.message).toContain("10.9.11"); + expect(notice!.message).toContain("10.10"); + }); +}); diff --git a/src/lib/utils/serverCompatibility.ts b/src/lib/utils/serverCompatibility.ts new file mode 100644 index 000000000..ac0817858 --- /dev/null +++ b/src/lib/utils/serverCompatibility.ts @@ -0,0 +1,54 @@ +// Presentation of the backend's server-compatibility verdict. +// +// The decision is Rust's — see `ServerCompatibility` in `auth/mod.rs`. This file +// decides only how it *reads*, which is presentation and changes only if the UI +// is redesigned. Nothing here compares a version number, and nothing here may +// start to: the backend sends an opaque state precisely so the frontend cannot. +// +// TRACES: UR-085 | DR-286 + +import type { ServerCompatibility } from "$lib/api/bindings"; + +export interface CompatibilityNotice { + /** Blocks going on to the login step. Only a server below the floor does. */ + blocking: boolean; + tone: "error" | "warning"; + message: string; +} + +/** + * What to show the user about a server's version, or `null` when there is + * nothing worth saying — which is the common case. + */ +export function compatibilityNotice( + compatibility: ServerCompatibility, + serverVersion: string, +): CompatibilityNotice | null { + switch (compatibility.type) { + case "supported": + return null; + + case "unknownVersion": + // Not worth interrupting anyone over: the server almost certainly works, + // and we simply could not read what it called itself. + return null; + + case "newerThanKnown": + return { + blocking: false, + tone: "warning", + message: + `This server (${serverVersion}) is newer than this version of JellyTau. ` + + `It should work normally — update the app if anything looks wrong.`, + }; + + case "tooOld": + return { + blocking: true, + tone: "error", + message: + `This server runs Jellyfin ${serverVersion}. JellyTau needs ` + + `${compatibility.minimum} or newer.`, + }; + } +} diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte index b559e1abb..c2cd75e2b 100644 --- a/src/routes/login/+page.svelte +++ b/src/routes/login/+page.svelte @@ -1,6 +1,7 @@