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
+135
View File
@@ -0,0 +1,135 @@
// Locating and reading the exporter's output. The companion Playnite extension (`exporter/`) writes
// `punktfunk-library.json` into its own Playnite plugin-data directory, i.e.
// `<PlayniteData>/ExtensionsData/<pluginId-guid>/punktfunk-library.json`. We don't hardcode that guid
// — we glob `ExtensionsData/*/punktfunk-library.json` and take the newest, so the reader is decoupled
// from the exporter's id.
//
// The plugin runs on the same box as Playnite (it's the streaming host), so these are plain local
// reads. The one wrinkle is *which user's* `%APPDATA%`: if the runner runs as the interactive user we
// find it directly; if it runs as SYSTEM/another user we fall back to scanning `C:\Users\*`. An
// explicit `playniteDir` in the config always wins.
import * as fs from "node:fs";
import * as path from "node:path";
import type { Config } from "./config.js";
import {
EXPORT_FILE,
isLibraryExport,
type LibraryExport,
SCHEMA,
} from "./export-schema.js";
const exists = (p: string): boolean => {
try {
fs.accessSync(p);
return true;
} catch {
return false;
}
};
/** Candidate Playnite data directories, most-specific first (config override handled by the caller). */
export const candidatePlayniteDirs = (): string[] => {
const out: string[] = [];
if (process.platform === "win32") {
const appdata = process.env.APPDATA;
if (appdata) out.push(path.join(appdata, "Playnite"));
// SYSTEM/other-user runner: scan every local profile's roaming AppData.
const usersRoot = path.join(process.env.SystemDrive ?? "C:", "\\", "Users");
try {
for (const user of fs.readdirSync(usersRoot)) {
out.push(path.join(usersRoot, user, "AppData", "Roaming", "Playnite"));
}
} catch {
// no C:\Users (or not Windows-like) — fine
}
} else {
// Playnite is Windows-only, but a Wine/portable layout may set these — best effort.
const home = process.env.HOME;
if (home) {
out.push(path.join(home, ".local", "share", "Playnite"));
}
}
// De-dupe, preserve order.
return [...new Set(out)];
};
export interface Location {
/** The resolved Playnite data dir, or null if none found. */
dir: string | null;
/** The candidate dirs we considered (for the UI's diagnostics view). */
candidates: string[];
/** The resolved export file, or null if the exporter hasn't written one yet. */
file: string | null;
/** mtime (epoch ms) of the export file, or null. */
mtime: number | null;
}
/** Find the newest `punktfunk-library.json` under a Playnite dir's `ExtensionsData`. */
const findExportUnder = (
dir: string,
): { file: string; mtime: number } | null => {
const base = path.join(dir, "ExtensionsData");
let best: { file: string; mtime: number } | null = null;
let subdirs: string[];
try {
subdirs = fs.readdirSync(base);
} catch {
return null;
}
for (const sub of subdirs) {
const file = path.join(base, sub, EXPORT_FILE);
try {
const st = fs.statSync(file);
if (!best || st.mtimeMs > best.mtime) best = { file, mtime: st.mtimeMs };
} catch {
// no export in this extension's dir
}
}
return best;
};
/** Resolve where the export lives. `config.playniteDir` wins; otherwise probe the candidates. */
export const locateExport = (config: Config): Location => {
const override = config.playniteDir;
const candidates = override
? [override, ...candidatePlayniteDirs()]
: candidatePlayniteDirs();
// First, a dir that actually holds an export file.
for (const dir of candidates) {
const hit = findExportUnder(dir);
if (hit) return { dir, candidates, file: hit.file, mtime: hit.mtime };
}
// Else, the first dir that at least exists (so the UI can say "Playnite found, exporter not
// installed yet" vs "Playnite not found").
const dir = candidates.find(exists) ?? null;
return { dir, candidates, file: null, mtime: null };
};
export class ExportError extends Error {}
/** Read + validate the export file. Throws `ExportError` on a missing/corrupt/too-new file. */
export const readExport = (file: string): LibraryExport => {
let raw: string;
try {
raw = fs.readFileSync(file, "utf8");
} catch (e) {
throw new ExportError(`cannot read ${file}: ${e}`);
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (e) {
throw new ExportError(`${file} is not valid JSON: ${e}`);
}
if (!isLibraryExport(parsed)) {
throw new ExportError(`${file} is not a punktfunk library export`);
}
if (Math.floor(parsed.schema) > SCHEMA) {
throw new ExportError(
`${file} is schema ${parsed.schema}; this plugin understands up to ${SCHEMA} — update the plugin`,
);
}
return parsed;
};