feat: Playnite library sync plugin + exporter
CI / exporter (push) Failing after 24s
CI / build (push) Failing after 25s
CI / publish (push) Has been skipped

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:
2026-07-18 23:35:35 +02:00
commit 6694be9848
48 changed files with 3885 additions and 0 deletions
+82
View File
@@ -0,0 +1,82 @@
import { afterAll, describe, expect, test } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { buildArtwork, fileToDataUrl } from "../src/art.js";
import { DEFAULT_ART } from "../src/config.js";
import type { ExportedGame } from "../src/export-schema.js";
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pf-playnite-art-"));
const write = (name: string, bytes: Buffer): string => {
const p = path.join(dir, name);
fs.writeFileSync(p, bytes);
return p;
};
afterAll(() => fs.rmSync(dir, { recursive: true, force: true }));
const game = (over: Partial<ExportedGame>): ExportedGame => ({
id: "11111111-1111-1111-1111-111111111111",
name: "Game",
installed: true,
hidden: false,
source: "Steam",
platforms: [],
cover: null,
background: null,
icon: null,
...over,
});
describe("fileToDataUrl", () => {
test("encodes a small png as a data URL", () => {
const p = write("cover.png", Buffer.from([1, 2, 3, 4]));
expect(fileToDataUrl(p, 1000)).toBe(
`data:image/png;base64,${Buffer.from([1, 2, 3, 4]).toString("base64")}`,
);
});
test("drops files over the size cap", () => {
const p = write("big.jpg", Buffer.alloc(2000, 7));
expect(fileToDataUrl(p, 1000)).toBeNull();
});
test("rejects unknown extensions and null", () => {
const p = write("weird.xyz", Buffer.from([1]));
expect(fileToDataUrl(p, 1000)).toBeNull();
expect(fileToDataUrl(null, 1000)).toBeNull();
});
test("mime follows the extension", () => {
const p = write("c.jpeg", Buffer.from([9]));
expect(fileToDataUrl(p, 1000)?.startsWith("data:image/jpeg;base64,")).toBe(
true,
);
});
});
describe("buildArtwork", () => {
test("off mode yields no art", () => {
const cover = write("c1.png", Buffer.from([1]));
expect(
buildArtwork(game({ cover }), { ...DEFAULT_ART, mode: "off" }),
).toBeUndefined();
});
test("dataurl mode embeds the cover as portrait", () => {
const cover = write("c2.png", Buffer.from([1, 2]));
const art = buildArtwork(game({ cover }), DEFAULT_ART);
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]));
const art = buildArtwork(game({ cover, background }), {
...DEFAULT_ART,
includeBackground: true,
});
expect(art?.portrait).toBeTruthy();
expect(art?.hero?.startsWith("data:image/jpeg;base64,")).toBe(true);
});
test("no readable art yields undefined", () => {
expect(
buildArtwork(game({ cover: "/does/not/exist.png" }), DEFAULT_ART),
).toBeUndefined();
});
});