From 9988729aabe1e3698943ca9a8837a101067c7eac Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 19 Jul 2026 00:56:15 +0200 Subject: [PATCH] feat(art): default to host-served cover art (scales to large libraries) Adds art.mode "host" (now the default): emit each cover's local file PATH and let the host serve the bytes via its art proxy, instead of inlining a data URL. The reconcile payload carries no image data, so a large Playnite library no longer exceeds the host's request-body limit. "dataurl" (inline, small libraries) and "off" remain. Requires a host with the provider-art proxy (punktfunk main: feat/library-local-art). Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 13 +++++++------ config.example.json | 2 +- src/art.ts | 20 +++++++++++++++----- src/config.ts | 14 +++++++++----- src/engine/reconcile.ts | 9 +++++---- test/art.test.ts | 38 ++++++++++++++++++++++++++++++++------ 6 files changed, 69 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 56b22f5..c80f623 100644 --- a/README.md +++ b/README.md @@ -63,17 +63,18 @@ maintain. ## Box art -Playnite stores cover art as **local files**, and the host only proxies Steam art — so this plugin -inlines each cover as a size-capped `data:` URL that clients render directly (no network fetch). +Playnite stores cover art as **local files** on the host. Rather than shipping image bytes in the +reconcile (which doesn't scale — a large library blows past the host's request-body limit), the plugin +sends the local file **path** and the host serves the cover through its art proxy +(`/library/art/…`, exactly like Steam art). The payload stays tiny at any library size. | `art.mode` | Behaviour | |--------------|-----------------------------------------------------------------| -| `dataurl` | Embed the cover (default). `maxBytes` caps a single image; oversized covers are skipped. | +| `host` | **Default.** Send the cover's local path; the host serves the bytes. Scales to thousands of titles. Requires a host with the provider-art proxy. | +| `dataurl` | Inline the cover as a size-capped `data:` URL (`maxBytes` caps one image). Self-contained, but for **small** libraries only — a big one exceeds the host's body limit. | | `off` | Sync titles with no art (lightest payload). | -`art.includeBackground` additionally embeds the Playnite background as `hero` art (large — off by -default). For a very large library, inlined covers make a heavy library payload — the console warns -above ~3000 titles; narrow it with a source filter or set `art.mode` to `off`. +`art.includeBackground` additionally serves the Playnite background as `hero` art (off by default). ## Filters diff --git a/config.example.json b/config.example.json index 7226cbb..e4941f1 100644 --- a/config.example.json +++ b/config.example.json @@ -6,7 +6,7 @@ "excludeSources": ["Xbox"] }, "art": { - "mode": "dataurl", + "mode": "host", "maxBytes": 1500000, "includeBackground": false }, diff --git a/src/art.ts b/src/art.ts index b16d370..74cec68 100644 --- a/src/art.ts +++ b/src/art.ts @@ -1,8 +1,8 @@ -// Cover art. Playnite stores art as local files on the host; the host's library only proxies Steam -// art, and provider entries are fetched by clients directly — so for a Playnite title we inline the -// cover as a `data:` URL the clients render without any network fetch. A `data:` URL bloats the -// library payload, so anything over `maxBytes` is dropped rather than embedded (design default: cover -// only; backgrounds are large and opt-in). +// Cover art. Playnite stores art as local files on the host. Two delivery modes (see ArtMode): +// - `host` (default): emit the local file PATH; the updated host serves the bytes via its art proxy +// (`/library/art/…`), so the reconcile payload carries no image data and scales to any library size. +// - `dataurl`: inline the cover as a size-capped `data:` URL (self-contained, but only fit for small +// libraries — a big one blows past the host's reconcile body limit). `maxBytes` caps a single image. import * as fs from "node:fs"; import * as path from "node:path"; @@ -48,6 +48,16 @@ export const buildArtwork = ( art: ArtOptions, ): Artwork | undefined => { if (art.mode === "off") return undefined; + // `host` mode: emit the local file paths verbatim (the exporter only sets a path when the file + // exists). The host serves the bytes via its art proxy, so the reconcile payload carries no image + // data and scales to any library size. The default. + if (art.mode === "host") { + const out: Artwork = {}; + if (game.cover) out.portrait = game.cover; + if (art.includeBackground && game.background) out.hero = game.background; + return out.portrait || out.hero ? out : undefined; + } + // `dataurl` mode: inline (size-capped) — self-contained but only fit for small libraries. const portrait = fileToDataUrl(game.cover, art.maxBytes); const hero = art.includeBackground ? fileToDataUrl(game.background, art.maxBytes) diff --git a/src/config.ts b/src/config.ts index c094fb6..8910447 100644 --- a/src/config.ts +++ b/src/config.ts @@ -25,10 +25,14 @@ export interface FilterOptions { excludeSources: string[]; } -/** How cover art reaches the clients. Playnite art is local files on the host, and the host only - * proxies Steam art — so for a provider entry we inline the cover as a size-capped `data:` URL that - * clients render directly. `off` syncs titles with no art. */ -export type ArtMode = "dataurl" | "off"; +/** How cover art reaches the clients. Playnite art is local files on the host: + * - `host` (default): send the local file PATH; the host serves the bytes through its art proxy + * (`/library/art/…`, like Steam art). The reconcile payload carries no image data, so this scales + * to libraries of any size — the recommended mode. + * - `dataurl`: inline each cover as a size-capped `data:` URL. Self-contained but does NOT scale — a + * large library blows past the host's reconcile body limit. Small libraries only. + * - `off`: sync titles with no art. */ +export type ArtMode = "host" | "dataurl" | "off"; export interface ArtOptions { mode: ArtMode; @@ -88,7 +92,7 @@ export const DEFAULT_FILTER: FilterOptions = { }; export const DEFAULT_ART: ArtOptions = { - mode: "dataurl", + mode: "host", maxBytes: 1_500_000, includeBackground: false, }; diff --git a/src/engine/reconcile.ts b/src/engine/reconcile.ts index dae353f..faf4b30 100644 --- a/src/engine/reconcile.ts +++ b/src/engine/reconcile.ts @@ -61,8 +61,9 @@ export interface ComputeResult { report: SyncReport; } -/** Warn above this many entries — every cover is an inlined `data:` URL, so a huge library makes a - * heavy library payload (see README "Box art"). */ +/** In `dataurl` art mode only: warn above this many entries, since each inlined cover adds to the + * reconcile payload and a large library blows past the host's body limit (see README "Box art"). + * `host` mode (the default) sends paths, not bytes, so it has no such ceiling. */ const WARN_ENTRIES = 3000; export const computeEntries = ({ @@ -128,9 +129,9 @@ export const computeEntries = ({ // Stable order (by title) so the reconcile fingerprint is deterministic across reads. entries.sort((a, b) => a.title.localeCompare(b.title)); - if (entries.length > WARN_ENTRIES) { + if (config.art.mode === "dataurl" && entries.length > WARN_ENTRIES) { warnings.push( - `${entries.length} entries — large library; inlined covers make a heavy library payload. Consider art.mode "off" or a tighter source filter.`, + `${entries.length} entries with art.mode "dataurl" — inlined covers will exceed the host's reconcile body limit. Switch to art.mode "host" (the default), which serves covers by path.`, ); } diff --git a/test/art.test.ts b/test/art.test.ts index 69a86b5..faf9180 100644 --- a/test/art.test.ts +++ b/test/art.test.ts @@ -58,25 +58,51 @@ describe("buildArtwork", () => { buildArtwork(game({ cover }), { ...DEFAULT_ART, mode: "off" }), ).toBeUndefined(); }); - test("dataurl mode embeds the cover as portrait", () => { + test("host mode (default) emits the raw cover path — no bytes", () => { const cover = write("c2.png", Buffer.from([1, 2])); const art = buildArtwork(game({ cover }), DEFAULT_ART); + expect(art?.portrait).toBe(cover); + expect(art?.hero).toBeUndefined(); + }); + test("host mode includeBackground adds the hero path", () => { + const cover = write("c2b.png", Buffer.from([1])); + const background = write("b2b.jpg", Buffer.from([2])); + const art = buildArtwork(game({ cover, background }), { + ...DEFAULT_ART, + includeBackground: true, + }); + expect(art?.portrait).toBe(cover); + expect(art?.hero).toBe(background); + }); + test("host mode with no cover yields undefined", () => { + expect(buildArtwork(game({ cover: null }), DEFAULT_ART)).toBeUndefined(); + }); + test("dataurl mode embeds the cover as a portrait data URL", () => { + const cover = write("c3.png", Buffer.from([1, 2])); + const art = buildArtwork(game({ cover }), { + ...DEFAULT_ART, + mode: "dataurl", + }); expect(art?.portrait?.startsWith("data:image/png;base64,")).toBe(true); expect(art?.hero).toBeUndefined(); }); - test("includeBackground adds hero", () => { - const cover = write("c3.png", Buffer.from([1])); - const background = write("b3.jpg", Buffer.from([2])); + test("dataurl includeBackground adds a hero data URL", () => { + const cover = write("c4.png", Buffer.from([1])); + const background = write("b4.jpg", Buffer.from([2])); const art = buildArtwork(game({ cover, background }), { ...DEFAULT_ART, + mode: "dataurl", includeBackground: true, }); expect(art?.portrait).toBeTruthy(); expect(art?.hero?.startsWith("data:image/jpeg;base64,")).toBe(true); }); - test("no readable art yields undefined", () => { + test("dataurl mode with no readable art yields undefined", () => { expect( - buildArtwork(game({ cover: "/does/not/exist.png" }), DEFAULT_ART), + buildArtwork(game({ cover: "/does/not/exist.png" }), { + ...DEFAULT_ART, + mode: "dataurl", + }), ).toBeUndefined(); }); });