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();
});
});
+71
View File
@@ -0,0 +1,71 @@
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 { resolveConfig } from "../src/config.js";
import { EXPORT_FILE } from "../src/export-schema.js";
import { ExportError, locateExport, readExport } from "../src/playnite.js";
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pf-playnite-loc-"));
afterAll(() => fs.rmSync(root, { recursive: true, force: true }));
/** Lay out a fake Playnite data dir with an export under ExtensionsData/<guid>/. */
const layout = (name: string, doc: unknown): string => {
const dir = path.join(root, name);
const ext = path.join(dir, "ExtensionsData", "abc-guid");
fs.mkdirSync(ext, { recursive: true });
if (doc !== undefined)
fs.writeFileSync(path.join(ext, EXPORT_FILE), JSON.stringify(doc));
return dir;
};
const validDoc = {
schema: 1,
generatedAt: "2026-01-01T00:00:00Z",
playnite: { version: "10", mode: "Desktop" },
games: [],
};
describe("locateExport", () => {
test("finds the export under an overridden Playnite dir", () => {
const dir = layout("found", validDoc);
const loc = locateExport(resolveConfig({ playniteDir: dir }));
expect(loc.dir).toBe(dir);
expect(loc.file).toBe(
path.join(dir, "ExtensionsData", "abc-guid", EXPORT_FILE),
);
expect(loc.mtime).toBeGreaterThan(0);
});
test("reports the dir but no file when the exporter hasn't written yet", () => {
const dir = path.join(root, "empty");
fs.mkdirSync(dir, { recursive: true });
const loc = locateExport(resolveConfig({ playniteDir: dir }));
expect(loc.dir).toBe(dir);
expect(loc.file).toBeNull();
});
});
describe("readExport", () => {
test("reads a valid export", () => {
const dir = layout("valid", validDoc);
const file = path.join(dir, "ExtensionsData", "abc-guid", EXPORT_FILE);
expect(readExport(file).schema).toBe(1);
});
test("rejects a future schema", () => {
const dir = layout("future", { ...validDoc, schema: 99 });
const file = path.join(dir, "ExtensionsData", "abc-guid", EXPORT_FILE);
expect(() => readExport(file)).toThrow(ExportError);
});
test("rejects non-JSON and foreign shapes", () => {
const bad = path.join(root, "bad.json");
fs.writeFileSync(bad, "{not json");
expect(() => readExport(bad)).toThrow(ExportError);
const foreign = path.join(root, "foreign.json");
fs.writeFileSync(foreign, JSON.stringify({ hello: "world" }));
expect(() => readExport(foreign)).toThrow(ExportError);
});
});
+149
View File
@@ -0,0 +1,149 @@
import { describe, expect, test } from "bun:test";
import { resolveConfig } from "../src/config.js";
import {
computeEntries,
isGuid,
launchCommand,
} from "../src/engine/reconcile.js";
import type { ExportedGame, LibraryExport } from "../src/export-schema.js";
const G1 = "11111111-1111-1111-1111-111111111111";
const G2 = "22222222-2222-2222-2222-222222222222";
const game = (over: Partial<ExportedGame>): ExportedGame => ({
id: G1,
name: "Game",
installed: true,
hidden: false,
source: "Steam",
platforms: ["PC (Windows)"],
cover: null,
background: null,
icon: null,
...over,
});
const exp = (games: ExportedGame[]): LibraryExport => ({
schema: 1,
generatedAt: "2026-01-01T00:00:00Z",
playnite: { version: "10", mode: "Desktop" },
games,
});
const noArt = () => undefined;
describe("isGuid", () => {
test("accepts a real Playnite guid", () => {
expect(isGuid(G1)).toBe(true);
});
test("rejects junk", () => {
expect(isGuid("not-a-guid")).toBe(false);
expect(isGuid('570" & calc')).toBe(false);
});
});
describe("launchCommand", () => {
test("Windows uses start + playnite:// uri", () => {
expect(launchCommand(G1, "win32")).toEqual({
kind: "command",
value: `start "" "playnite://playnite/start/${G1}"`,
});
});
test("non-Windows falls back to xdg-open", () => {
expect(launchCommand(G1, "linux")?.value).toContain("xdg-open");
});
});
describe("computeEntries filtering", () => {
test("installedOnly drops uninstalled games", () => {
const config = resolveConfig({});
const { entries, report } = computeEntries({
exp: exp([game({ id: G1 }), game({ id: G2, installed: false })]),
config,
platform: "win32",
artFor: noArt,
});
expect(entries.map((e) => e.external_id)).toEqual([G1]);
expect(report.skipped[0]?.reason).toBe("not installed");
});
test("includeHidden lets hidden games through", () => {
const hidden = exp([game({ id: G1, hidden: true })]);
expect(
computeEntries({
exp: hidden,
config: resolveConfig({}),
platform: "win32",
artFor: noArt,
}).entries.length,
).toBe(0);
expect(
computeEntries({
exp: hidden,
config: resolveConfig({ filter: { includeHidden: true } }),
platform: "win32",
artFor: noArt,
}).entries.length,
).toBe(1);
});
test("source include/exclude is case-insensitive", () => {
const games = exp([
game({ id: G1, source: "Steam" }),
game({ id: G2, source: "GOG" }),
]);
const only = computeEntries({
exp: games,
config: resolveConfig({ filter: { sources: ["steam"] } }),
platform: "win32",
artFor: noArt,
});
expect(only.entries.map((e) => e.external_id)).toEqual([G1]);
const without = computeEntries({
exp: games,
config: resolveConfig({ filter: { excludeSources: ["GOG"] } }),
platform: "win32",
artFor: noArt,
});
expect(without.entries.map((e) => e.external_id)).toEqual([G1]);
});
test("invalid ids and empty titles are skipped", () => {
const { entries, report } = computeEntries({
exp: exp([game({ id: "bogus" }), game({ id: G2, name: " " })]),
config: resolveConfig({}),
platform: "win32",
artFor: noArt,
});
expect(entries.length).toBe(0);
expect(report.skipped.map((s) => s.reason).sort()).toEqual([
"invalid game id",
"no title",
]);
});
test("entries are title-sorted and per-source counted", () => {
const { entries, report } = computeEntries({
exp: exp([
game({ id: G1, name: "Zelda", source: "Nintendo" }),
game({ id: G2, name: "Antichamber", source: "Steam" }),
]),
config: resolveConfig({}),
platform: "win32",
artFor: noArt,
});
expect(entries.map((e) => e.title)).toEqual(["Antichamber", "Zelda"]);
expect(report.perSource).toEqual({ Nintendo: 1, Steam: 1 });
});
test("artFor result is attached", () => {
const { entries } = computeEntries({
exp: exp([game({ id: G1 })]),
config: resolveConfig({}),
platform: "win32",
artFor: () => ({ portrait: "data:image/png;base64,AAAA" }),
});
expect(entries[0]?.art?.portrait).toBe("data:image/png;base64,AAAA");
});
});