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) <noreply@anthropic.com>
This commit is contained in:
@@ -63,17 +63,18 @@ maintain.
|
|||||||
|
|
||||||
## Box art
|
## Box art
|
||||||
|
|
||||||
Playnite stores cover art as **local files**, and the host only proxies Steam art — so this plugin
|
Playnite stores cover art as **local files** on the host. Rather than shipping image bytes in the
|
||||||
inlines each cover as a size-capped `data:` URL that clients render directly (no network fetch).
|
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 |
|
| `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). |
|
| `off` | Sync titles with no art (lightest payload). |
|
||||||
|
|
||||||
`art.includeBackground` additionally embeds the Playnite background as `hero` art (large — off by
|
`art.includeBackground` additionally serves the Playnite background as `hero` art (off by default).
|
||||||
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`.
|
|
||||||
|
|
||||||
## Filters
|
## Filters
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
"excludeSources": ["Xbox"]
|
"excludeSources": ["Xbox"]
|
||||||
},
|
},
|
||||||
"art": {
|
"art": {
|
||||||
"mode": "dataurl",
|
"mode": "host",
|
||||||
"maxBytes": 1500000,
|
"maxBytes": 1500000,
|
||||||
"includeBackground": false
|
"includeBackground": false
|
||||||
},
|
},
|
||||||
|
|||||||
+15
-5
@@ -1,8 +1,8 @@
|
|||||||
// Cover art. Playnite stores art as local files on the host; the host's library only proxies Steam
|
// Cover art. Playnite stores art as local files on the host. Two delivery modes (see ArtMode):
|
||||||
// art, and provider entries are fetched by clients directly — so for a Playnite title we inline the
|
// - `host` (default): emit the local file PATH; the updated host serves the bytes via its art proxy
|
||||||
// cover as a `data:` URL the clients render without any network fetch. A `data:` URL bloats the
|
// (`/library/art/…`), so the reconcile payload carries no image data and scales to any library size.
|
||||||
// library payload, so anything over `maxBytes` is dropped rather than embedded (design default: cover
|
// - `dataurl`: inline the cover as a size-capped `data:` URL (self-contained, but only fit for small
|
||||||
// only; backgrounds are large and opt-in).
|
// 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 fs from "node:fs";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
@@ -48,6 +48,16 @@ export const buildArtwork = (
|
|||||||
art: ArtOptions,
|
art: ArtOptions,
|
||||||
): Artwork | undefined => {
|
): Artwork | undefined => {
|
||||||
if (art.mode === "off") return 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 portrait = fileToDataUrl(game.cover, art.maxBytes);
|
||||||
const hero = art.includeBackground
|
const hero = art.includeBackground
|
||||||
? fileToDataUrl(game.background, art.maxBytes)
|
? fileToDataUrl(game.background, art.maxBytes)
|
||||||
|
|||||||
+9
-5
@@ -25,10 +25,14 @@ export interface FilterOptions {
|
|||||||
excludeSources: string[];
|
excludeSources: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** How cover art reaches the clients. Playnite art is local files on the host, and the host only
|
/** How cover art reaches the clients. Playnite art is local files on the host:
|
||||||
* proxies Steam art — so for a provider entry we inline the cover as a size-capped `data:` URL that
|
* - `host` (default): send the local file PATH; the host serves the bytes through its art proxy
|
||||||
* clients render directly. `off` syncs titles with no art. */
|
* (`/library/art/…`, like Steam art). The reconcile payload carries no image data, so this scales
|
||||||
export type ArtMode = "dataurl" | "off";
|
* 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 {
|
export interface ArtOptions {
|
||||||
mode: ArtMode;
|
mode: ArtMode;
|
||||||
@@ -88,7 +92,7 @@ export const DEFAULT_FILTER: FilterOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_ART: ArtOptions = {
|
export const DEFAULT_ART: ArtOptions = {
|
||||||
mode: "dataurl",
|
mode: "host",
|
||||||
maxBytes: 1_500_000,
|
maxBytes: 1_500_000,
|
||||||
includeBackground: false,
|
includeBackground: false,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -61,8 +61,9 @@ export interface ComputeResult {
|
|||||||
report: SyncReport;
|
report: SyncReport;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Warn above this many entries — every cover is an inlined `data:` URL, so a huge library makes a
|
/** In `dataurl` art mode only: warn above this many entries, since each inlined cover adds to the
|
||||||
* heavy library payload (see README "Box art"). */
|
* 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;
|
const WARN_ENTRIES = 3000;
|
||||||
|
|
||||||
export const computeEntries = ({
|
export const computeEntries = ({
|
||||||
@@ -128,9 +129,9 @@ export const computeEntries = ({
|
|||||||
// Stable order (by title) so the reconcile fingerprint is deterministic across reads.
|
// Stable order (by title) so the reconcile fingerprint is deterministic across reads.
|
||||||
entries.sort((a, b) => a.title.localeCompare(b.title));
|
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(
|
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.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+32
-6
@@ -58,25 +58,51 @@ describe("buildArtwork", () => {
|
|||||||
buildArtwork(game({ cover }), { ...DEFAULT_ART, mode: "off" }),
|
buildArtwork(game({ cover }), { ...DEFAULT_ART, mode: "off" }),
|
||||||
).toBeUndefined();
|
).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 cover = write("c2.png", Buffer.from([1, 2]));
|
||||||
const art = buildArtwork(game({ cover }), DEFAULT_ART);
|
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?.portrait?.startsWith("data:image/png;base64,")).toBe(true);
|
||||||
expect(art?.hero).toBeUndefined();
|
expect(art?.hero).toBeUndefined();
|
||||||
});
|
});
|
||||||
test("includeBackground adds hero", () => {
|
test("dataurl includeBackground adds a hero data URL", () => {
|
||||||
const cover = write("c3.png", Buffer.from([1]));
|
const cover = write("c4.png", Buffer.from([1]));
|
||||||
const background = write("b3.jpg", Buffer.from([2]));
|
const background = write("b4.jpg", Buffer.from([2]));
|
||||||
const art = buildArtwork(game({ cover, background }), {
|
const art = buildArtwork(game({ cover, background }), {
|
||||||
...DEFAULT_ART,
|
...DEFAULT_ART,
|
||||||
|
mode: "dataurl",
|
||||||
includeBackground: true,
|
includeBackground: true,
|
||||||
});
|
});
|
||||||
expect(art?.portrait).toBeTruthy();
|
expect(art?.portrait).toBeTruthy();
|
||||||
expect(art?.hero?.startsWith("data:image/jpeg;base64,")).toBe(true);
|
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(
|
expect(
|
||||||
buildArtwork(game({ cover: "/does/not/exist.png" }), DEFAULT_ART),
|
buildArtwork(game({ cover: "/does/not/exist.png" }), {
|
||||||
|
...DEFAULT_ART,
|
||||||
|
mode: "dataurl",
|
||||||
|
}),
|
||||||
).toBeUndefined();
|
).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user