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:
@@ -0,0 +1,53 @@
|
||||
// Where the plugin's own files live. The host config-dir resolution mirrors the SDK's `configDir`
|
||||
// (`@punktfunk/host` does not re-export it) so we always land in the same place the host and runner
|
||||
// use; the plugin owns a `playnite/` subtree under it, created 0700.
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/** The host's config dir — the same resolution the host and SDK use. */
|
||||
export const hostConfigDir = (): string => {
|
||||
const explicit = process.env.PUNKTFUNK_CONFIG_DIR;
|
||||
if (explicit) return explicit;
|
||||
if (process.platform === "win32") {
|
||||
const base = process.env.ProgramData ?? process.env.APPDATA ?? ".";
|
||||
return path.join(base, "punktfunk");
|
||||
}
|
||||
const base =
|
||||
process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config");
|
||||
return path.join(base, "punktfunk");
|
||||
};
|
||||
|
||||
/** This plugin's private directory: `<config_dir>/playnite`. */
|
||||
export const pluginDir = (): string => path.join(hostConfigDir(), "playnite");
|
||||
|
||||
/** `<config_dir>/playnite/config.json` — operator-editable, atomic-written. */
|
||||
export const configPath = (): string => path.join(pluginDir(), "config.json");
|
||||
|
||||
/** `<config_dir>/playnite/cache.json` — disposable derived state (last reconcile fingerprint). */
|
||||
export const cachePath = (): string => path.join(pluginDir(), "cache.json");
|
||||
|
||||
/**
|
||||
* Locate the built SPA directory (`dist/ui`) at runtime. The package ships as ONE self-contained
|
||||
* bundle so it installs from the Gitea registry with no external deps — which means `import.meta.url`
|
||||
* can be `dist/index.js` (bundled) or `dist/server/index.js` (unbundled dev). We walk up from the
|
||||
* calling module looking for a `ui/index.html`, so both layouts resolve. `fromUrl` is the caller's
|
||||
* `import.meta.url`.
|
||||
*/
|
||||
export const resolveUiDir = (fromUrl: string): string => {
|
||||
let dir = path.dirname(fileURLToPath(fromUrl));
|
||||
for (let i = 0; i < 6; i++) {
|
||||
for (const cand of [path.join(dir, "ui"), path.join(dir, "dist", "ui")]) {
|
||||
try {
|
||||
if (fs.existsSync(path.join(cand, "index.html"))) return cand;
|
||||
} catch {
|
||||
// keep walking
|
||||
}
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return path.join(path.dirname(fileURLToPath(fromUrl)), "ui");
|
||||
};
|
||||
Reference in New Issue
Block a user