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