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
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env bun
// Dev/debug CLI — reuses the pure core and the engine. `where`/`preview` are offline (no host needed);
// `sync`/`uninstall` connect to the host and reconcile. Handy for configuring a headless box or
// checking what a `config.json` will produce before pushing it to the library.
//
// bunx punktfunk-plugin-playnite where # where the exporter output was found
// bunx punktfunk-plugin-playnite preview # the desired library (no host write)
// bunx punktfunk-plugin-playnite sync # reconcile into the live library
// bunx punktfunk-plugin-playnite uninstall # remove all this plugin's entries
// bunx punktfunk-plugin-playnite set-password <pw> # standalone-UI password
import { connect } from "@punktfunk/host";
import { Engine } from "./engine/index.js";
import { computeEntries } from "./engine/reconcile.js";
import { locateExport, readExport } from "./playnite.js";
import { hashPassword } from "./server/password.js";
import { loadConfig, saveConfig } from "./state.js";
const cmdWhere = (): void => {
const config = loadConfig();
const loc = locateExport(config);
console.log(`Playnite data dir: ${loc.dir ?? "(not found)"}`);
console.log(`Export file: ${loc.file ?? "(not found)"}`);
if (loc.mtime)
console.log(`Last written: ${new Date(loc.mtime).toISOString()}`);
console.log("Candidates probed:");
for (const c of loc.candidates) console.log(` ${c}`);
if (!loc.file) {
console.log(
"\nNo export found. Install the Punktfunk Sync extension in Playnite (see exporter/README.md),",
);
console.log("or set `playniteDir` in the plugin config.json.");
}
};
const cmdPreview = (): void => {
const config = loadConfig();
const loc = locateExport(config);
if (!loc.file) {
console.log("No exporter output found — run `where` for details.");
process.exitCode = 1;
return;
}
const exp = readExport(loc.file);
const { entries, report } = computeEntries({
exp,
config,
artFor: () => undefined,
});
console.log(
`Preview: ${report.included}/${report.considered} game(s) would sync (${report.skipped.length} skipped).`,
);
const bySource = Object.entries(report.perSource).sort((a, b) => b[1] - a[1]);
for (const [s, n] of bySource) console.log(` ${s}: ${n}`);
console.log();
for (const e of entries.slice(0, 40))
console.log(` ${e.title}${e.launch?.value ?? "(no launch)"}`);
if (entries.length > 40) console.log(` … and ${entries.length - 40} more`);
if (report.skipped.length) {
console.log(`\n${report.skipped.length} skipped (first 20):`);
for (const s of report.skipped.slice(0, 20))
console.log(` ${s.title}: ${s.reason}`);
}
for (const w of report.warnings) console.log(`! ${w}`);
};
const cmdSync = async (): Promise<void> => {
const pf = await connect();
try {
const report = await new Engine({ pf }).sync("cli");
if (report) {
console.log(
`Synced: ${report.included} title(s), ${report.skipped.length} skipped.`,
);
} else {
console.log("Sync did not reconcile (no change, or see log).");
}
} finally {
pf.close();
}
};
const cmdUninstall = async (): Promise<void> => {
const pf = await connect();
try {
await new Engine({ pf }).uninstall();
console.log("Removed all playnite entries from the library.");
} finally {
pf.close();
}
};
const cmdSetPassword = (password: string | undefined): void => {
if (!password) {
console.error("usage: set-password <password>");
process.exitCode = 2;
return;
}
const config = loadConfig();
config.ui.passwordHash = hashPassword(password);
saveConfig(config);
console.log("Standalone-UI password set (config.ui.passwordHash).");
};
const main = async (): Promise<void> => {
const [cmd, arg] = process.argv.slice(2);
switch (cmd) {
case "where":
return cmdWhere();
case "preview":
return cmdPreview();
case "sync":
return cmdSync();
case "uninstall":
return cmdUninstall();
case "set-password":
return cmdSetPassword(arg);
default:
console.log(
"usage: punktfunk-plugin-playnite <where|preview|sync|uninstall|set-password>",
);
process.exitCode = cmd ? 2 : 0;
}
};
await main();