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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user