// 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. // `/ExtensionsData//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 wrinkle is *which account* can see the file. The managed runner is de-privileged // (`NT AUTHORITY\LocalService`) and cannot read the interactive user's `%APPDATA%`, so the reliable // source is the **ingest inbox** (`/ingest/playnite/punktfunk-library.json`) the // exporter drops the file into — checked FIRST. The `%APPDATA%`/`C:\Users\*` ExtensionsData scan // still works for a same-user/SYSTEM runner or on Linux, and an explicit `playniteDir` override // joins the candidate list. 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"; import { ingestDir } from "./paths.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(); // The ingest inbox first: the exporter drops the library here (a flat file, not an // `ExtensionsData/` tree) so the de-privileged LocalService runner can read it — it can't // reach the interactive user's `%APPDATA%`. This is the reliable path on a managed Windows host. const inbox = ingestDir(); const inboxFile = path.join(inbox, EXPORT_FILE); try { const st = fs.statSync(inboxFile); return { dir: inbox, candidates: [inbox, ...candidates], file: inboxFile, mtime: st.mtimeMs, }; } catch { // no ingest drop yet — fall back to scanning Playnite's own data dirs } // Then, a Playnite data dir that actually holds an export file (same-user/SYSTEM runner, Linux). for (const dir of candidates) { const hit = findExportUnder(dir); if (hit) return { dir, candidates: [inbox, ...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: [inbox, ...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; };