feat: Playnite library sync plugin + exporter
A two-part Punktfunk plugin that syncs the Playnite library into the host
game library as the `playnite` provider:
- exporter/ — a minimal C# Playnite GenericPlugin ("Punktfunk Sync") that
writes punktfunk-library.json on every library change. Reading Playnite's
live-locked LiteDB from outside isn't robust, so this adapter runs inside
Playnite. Builds net462 via reference assemblies; packaged as a .pext.
- src/ — the TS plugin (on @punktfunk/host, rom-manager shape): watches the
export, embeds covers as data URLs, reconciles via
PUT /library/provider/playnite, with a console-hosted web UI + standalone
fallback + CLI. Launch = playnite://playnite/start/<id>, run by the host
in the interactive session so Playnite performs the real launch.
Verified locally: backend tsc + 23 tests + biome clean, SPA build, C#
exporter build + .pext pack.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+60
@@ -0,0 +1,60 @@
|
||||
// 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).
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import type { ArtOptions } from "./config.js";
|
||||
import type { ExportedGame } from "./export-schema.js";
|
||||
import type { Artwork } from "./wire.js";
|
||||
|
||||
const MIME: Record<string, string> = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".webp": "image/webp",
|
||||
".gif": "image/gif",
|
||||
".bmp": "image/bmp",
|
||||
".ico": "image/x-icon",
|
||||
".tga": "image/x-tga",
|
||||
};
|
||||
|
||||
const mimeFor = (file: string): string | null =>
|
||||
MIME[path.extname(file).toLowerCase()] ?? null;
|
||||
|
||||
/** Read a local image file into a `data:` URL, or null if missing, oversized, or an unknown type. */
|
||||
export const fileToDataUrl = (
|
||||
file: string | null,
|
||||
maxBytes: number,
|
||||
): string | null => {
|
||||
if (!file) return null;
|
||||
const mime = mimeFor(file);
|
||||
if (!mime) return null;
|
||||
try {
|
||||
const st = fs.statSync(file);
|
||||
if (!st.isFile() || st.size === 0 || st.size > maxBytes) return null;
|
||||
const b64 = fs.readFileSync(file).toString("base64");
|
||||
return `data:${mime};base64,${b64}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/** Build the artwork block for a game per the art config. Returns undefined when nothing was embedded. */
|
||||
export const buildArtwork = (
|
||||
game: ExportedGame,
|
||||
art: ArtOptions,
|
||||
): Artwork | undefined => {
|
||||
if (art.mode === "off") return undefined;
|
||||
const portrait = fileToDataUrl(game.cover, art.maxBytes);
|
||||
const hero = art.includeBackground
|
||||
? fileToDataUrl(game.background, art.maxBytes)
|
||||
: null;
|
||||
if (!portrait && !hero) return undefined;
|
||||
const out: Artwork = {};
|
||||
if (portrait) out.portrait = portrait;
|
||||
if (hero) out.hero = hero;
|
||||
return out;
|
||||
};
|
||||
Reference in New Issue
Block a user