6694be9848
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>
114 lines
4.1 KiB
TypeScript
114 lines
4.1 KiB
TypeScript
// 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({});
|