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
+113
View File
@@ -0,0 +1,113 @@
// The plugin's configuration model — the single source of truth for the sync engine, editable by hand
// (headless boxes) or by the web UI. Everything the engine needs is derivable from this file plus the
// exporter's `punktfunk-library.json`; `resolveConfig` fills defaults so the rest of the code works
// against a fully-populated shape.
/** When and how often we re-read the export and reconcile. */
export interface SyncOptions {
/** Interval poll in minutes — the backstop under the exporter-driven file watch. */
pollMinutes: number;
/** Watch the exporter's output directory and reconcile on change (the primary trigger). */
watch: boolean;
/** Debounce window for coalescing watch/poll-triggered re-reads (ms). */
debounceMs: number;
}
/** Which Playnite games become library entries. */
export interface FilterOptions {
/** Only sync games Playnite marks installed (default). Off = include not-yet-installed titles too. */
installedOnly: boolean;
/** Include games flagged Hidden in Playnite (default off). */
includeHidden: boolean;
/** If non-empty, keep ONLY games whose source is in this list ("Steam", "GOG", …). */
sources: string[];
/** Drop games whose source is in this list (applied after `sources`). */
excludeSources: string[];
}
/** How cover art reaches the clients. Playnite art is local files on the host, and the host only
* proxies Steam art — so for a provider entry we inline the cover as a size-capped `data:` URL that
* clients render directly. `off` syncs titles with no art. */
export type ArtMode = "dataurl" | "off";
export interface ArtOptions {
mode: ArtMode;
/** Skip an image whose file is larger than this (bytes) — a `data:` URL bloats the library payload,
* so covers over the cap are dropped rather than inlined. */
maxBytes: number;
/** Also inline the Playnite background as `hero` art (usually large — off by default). */
includeBackground: boolean;
}
export interface UiOptions {
/** Fallback standalone mode: serve the UI on a fixed port behind a password instead of via the
* console proxy. For host-only installs without the console. */
standalone: boolean;
/** Standalone bind port. */
port: number;
/** Standalone bind address (defaults to loopback; a non-loopback bind REQUIRES a password). */
bind: string;
/** scrypt password hash (`scrypt$<saltB64>$<hashB64>`), required for a non-loopback standalone bind. */
passwordHash?: string;
}
/** The on-disk config as authored (all optional — `resolveConfig` fills the rest). */
export interface RawConfig {
/** Override the auto-detected Playnite data directory (`%APPDATA%\Playnite`). Point it at a
* portable install, or at the interactive user's profile when the runner runs as a different user. */
playniteDir?: string;
sync?: Partial<SyncOptions>;
filter?: Partial<FilterOptions>;
art?: Partial<ArtOptions>;
ui?: Partial<UiOptions>;
/** Dev/acceptance flag: reconcile one hardcoded entry so the round-trip is observable. */
devEntry?: boolean;
}
/** The fully-resolved config the engine consumes. */
export interface Config {
playniteDir?: string;
sync: SyncOptions;
filter: FilterOptions;
art: ArtOptions;
ui: UiOptions;
devEntry: boolean;
}
export const DEFAULT_SYNC: SyncOptions = {
pollMinutes: 5,
watch: true,
debounceMs: 1500,
};
export const DEFAULT_FILTER: FilterOptions = {
installedOnly: true,
includeHidden: false,
sources: [],
excludeSources: [],
};
export const DEFAULT_ART: ArtOptions = {
mode: "dataurl",
maxBytes: 1_500_000,
includeBackground: false,
};
export const DEFAULT_UI: UiOptions = {
standalone: false,
port: 47994,
bind: "127.0.0.1",
};
/** Fill defaults over an authored config so the rest of the engine sees a total shape. */
export const resolveConfig = (raw: RawConfig): Config => ({
playniteDir: raw.playniteDir?.trim() || undefined,
sync: { ...DEFAULT_SYNC, ...raw.sync },
filter: { ...DEFAULT_FILTER, ...raw.filter },
art: { ...DEFAULT_ART, ...raw.art },
ui: { ...DEFAULT_UI, ...raw.ui },
devEntry: raw.devEntry ?? false,
});
/** The empty starting config for a fresh install. */
export const emptyConfig = (): Config => resolveConfig({});