refactor: rewrite on @punktfunk/plugin-kit — contract + plugin workspaces
The framework half of this plugin (engine lifecycle, config/state, host
reconcile, UI server, CLI) was ~80% identical to rom-manager's. That code is
now @punktfunk/plugin-kit; this replaces the local copy with it and adopts the
rom-manager blueprint layout.
contract/ Effect Schemas: the cross-process export contract (the C#
exporter's other half, with the schema-version guard as a decode
check), the config schema (one schema, Encoded = authored file
shape, defaults only via withDecodingDefaultKey), domain DTOs, the
HttpApi contract, API errors.
plugin/ src/domain = the pure core (locate/read, art, reconcile), ported
tests green before any Effect wiring; src/services = the kit's
ConfigService/CacheStore/SyncEngine/ProviderClient; definePluginKit
entry, kit CLI, auth-free dev API.
Preserved, because they are what makes playnite work at all: the ingest inbox
is still read FIRST (the de-privileged LocalService runner cannot reach the
interactive user's %APPDATA%), the host/dataurl/off art modes, and the
playnite:// launch hand-back. The C# exporter is untouched.
New: per-game include/exclude overrides, and an allow-listed /api/art proxy so
the SPA can render local Playnite covers — it serves only paths the currently
ingested export references.
Dropped: the standalone password UI server (console surface + CLI only, as in
rom-manager 0.3.0) and the devEntry flag.
Trap found and fixed here: the HttpApi encoder serialises `undefined` as
`null`, so a `Schema.optional` field the server omits cannot be decoded by the
client — silently in the SSE feed, loudly in the typed client. Every optional
EngineStatus field is `Schema.NullOr` with an explicit value on the wire.
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env bun
|
||||
// Ops CLI on the kit's dispatcher — the same layer graph as the plugin entry, so every
|
||||
// command sees the exact services the runner runs. where/preview work offline;
|
||||
// sync/uninstall talk to the host.
|
||||
import {
|
||||
type CliCommand,
|
||||
ProviderClient,
|
||||
runPluginCli,
|
||||
} from "@punktfunk/plugin-kit";
|
||||
import { Effect } from "effect";
|
||||
import pkg from "../package.json" with { type: "json" };
|
||||
import { PLUGIN_NAME, PROVIDER_ID } from "./const.js";
|
||||
import { type PlayniteServices, playniteLayer } from "./layer.js";
|
||||
import { PlayniteSync } from "./services/engine.js";
|
||||
|
||||
const print = (line: string) => Effect.sync(() => console.log(line));
|
||||
|
||||
const commands: Record<string, CliCommand<PlayniteServices>> = {
|
||||
where: {
|
||||
summary: "Show where the Playnite exporter's output was found",
|
||||
offline: true,
|
||||
run: () =>
|
||||
Effect.gen(function* () {
|
||||
const sync = yield* PlayniteSync;
|
||||
const loc = yield* sync.locate;
|
||||
yield* print(`Source dir: ${loc.dir ?? "(not found)"}`);
|
||||
yield* print(`Export file: ${loc.file ?? "(not found)"}`);
|
||||
yield* print(`Via ingest: ${loc.viaIngest ? "yes" : "no"}`);
|
||||
if (loc.mtime !== null) {
|
||||
yield* print(`Last written: ${new Date(loc.mtime).toISOString()}`);
|
||||
}
|
||||
yield* print("Candidates probed:");
|
||||
for (const c of loc.candidates) yield* print(` ${c}`);
|
||||
if (loc.file === null) {
|
||||
yield* print(
|
||||
"\nNo export found. Install the Punktfunk Sync extension in Playnite (see exporter/README.md),",
|
||||
);
|
||||
yield* print("or set `playniteDir` in the plugin's config.json.");
|
||||
}
|
||||
}),
|
||||
},
|
||||
preview: {
|
||||
summary: "Dry-run: show the entries a sync would reconcile",
|
||||
offline: true,
|
||||
run: () =>
|
||||
Effect.gen(function* () {
|
||||
const sync = yield* PlayniteSync;
|
||||
const { entries, report } = yield* sync.preview;
|
||||
yield* print(
|
||||
`${report.included}/${report.considered} game(s) would sync (${report.skipped.length} skipped, ${report.excluded.length} excluded by you).`,
|
||||
);
|
||||
for (const [source, n] of Object.entries(report.perSource).sort(
|
||||
(a, b) => b[1] - a[1],
|
||||
)) {
|
||||
yield* print(` ${source}: ${n}`);
|
||||
}
|
||||
for (const e of entries.slice(0, 40)) {
|
||||
yield* print(` ${e.title} → ${e.launch?.value ?? "(no launch)"}`);
|
||||
}
|
||||
if (entries.length > 40) {
|
||||
yield* print(` … and ${entries.length - 40} more`);
|
||||
}
|
||||
for (const s of report.skipped.slice(0, 20)) {
|
||||
yield* print(`SKIP ${s.title}: ${s.reason}`);
|
||||
}
|
||||
for (const w of report.warnings) yield* print(`! ${w}`);
|
||||
}),
|
||||
},
|
||||
sync: {
|
||||
summary: "Reconcile the library provider now",
|
||||
run: () =>
|
||||
Effect.gen(function* () {
|
||||
const sync = yield* PlayniteSync;
|
||||
const outcome = yield* sync.engine.sync("manual");
|
||||
yield* print(
|
||||
outcome._tag === "AlreadyRunning"
|
||||
? "a sync is already running"
|
||||
: `${outcome._tag}: ${outcome.report.included} entries`,
|
||||
);
|
||||
}),
|
||||
},
|
||||
uninstall: {
|
||||
summary: "Remove every playnite entry from the host library",
|
||||
run: () =>
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* ProviderClient;
|
||||
yield* provider.remove(PROVIDER_ID);
|
||||
yield* print("provider entries removed");
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
await runPluginCli({
|
||||
def: {
|
||||
name: PLUGIN_NAME,
|
||||
version: pkg.version,
|
||||
layer: playniteLayer,
|
||||
main: Effect.void,
|
||||
},
|
||||
commands,
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
// Plugin identity. The package name is `@punktfunk/plugin-playnite` (the runner's scoped
|
||||
// glob), but the `definePluginKit` name, the library provider id, the state/ingest dir
|
||||
// names, and the console-nav id are all the short `playnite`.
|
||||
export const PLUGIN_NAME = "playnite";
|
||||
export const PROVIDER_ID = "playnite";
|
||||
export const UI_TITLE = "Playnite";
|
||||
/** A lucide icon name for the console nav entry. */
|
||||
export const UI_ICON = "library-big";
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bun
|
||||
// Dev-only API server: the plugin's HttpApi on a plain loopback port (:5886), no auth, no
|
||||
// static files — the `bun run dev:live` proxy target for the Vite UI. Connects to a running
|
||||
// host when one is up (full sync path); otherwise runs host-less (locate, preview, config
|
||||
// and the art proxy all work; reconcile fails softly in the engine's safeSync).
|
||||
// This is NOT part of the shipped plugin (never bundled into dist/index.js or cli.js).
|
||||
import { connect, type Punktfunk } from "@punktfunk/host";
|
||||
import {
|
||||
hostClientFromFacade,
|
||||
loggingLayer,
|
||||
pluginInfoLayer,
|
||||
} from "@punktfunk/plugin-kit";
|
||||
import { Effect, Layer, ManagedRuntime } from "effect";
|
||||
import { HttpRouter } from "effect/unstable/http";
|
||||
import pkg from "../package.json" with { type: "json" };
|
||||
import { PLUGIN_NAME } from "./const.js";
|
||||
import { playniteLayer } from "./layer.js";
|
||||
import { makeApi } from "./services/api.js";
|
||||
import { PlayniteConfig } from "./services/config.js";
|
||||
import { PlayniteSync } from "./services/engine.js";
|
||||
|
||||
const PORT = Number(process.env.PLAYNITE_DEV_PORT ?? 5886);
|
||||
|
||||
const offlineFacade = (): Punktfunk =>
|
||||
({
|
||||
request: async (method: string, path: string) => {
|
||||
throw new Error(`dev: no host running (tried ${method} ${path})`);
|
||||
},
|
||||
close: () => {},
|
||||
}) as unknown as Punktfunk;
|
||||
|
||||
const pf = await connect().catch(() => {
|
||||
console.log("dev: no punktfunk host reachable — running host-less");
|
||||
return offlineFacade();
|
||||
});
|
||||
|
||||
const base = Layer.mergeAll(
|
||||
hostClientFromFacade(pf),
|
||||
pluginInfoLayer({ name: PLUGIN_NAME, version: pkg.version }),
|
||||
loggingLayer(PLUGIN_NAME),
|
||||
);
|
||||
const rt = ManagedRuntime.make(Layer.provideMerge(playniteLayer, base));
|
||||
|
||||
// No Effect.scoped here: the engine's loops belong to the runtime's layer scope and must
|
||||
// outlive this acquisition effect (they close on rt.dispose()).
|
||||
const { handler, dispose } = await rt.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const sync = yield* PlayniteSync;
|
||||
const config = yield* PlayniteConfig;
|
||||
yield* sync.engine.start;
|
||||
return HttpRouter.toWebHandler(makeApi({ sync, config }));
|
||||
}),
|
||||
);
|
||||
|
||||
const server = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: PORT,
|
||||
idleTimeout: 0, // SSE
|
||||
fetch: (req) => handler(req),
|
||||
});
|
||||
console.log(`playnite dev API on http://127.0.0.1:${server.port}/api/status`);
|
||||
|
||||
process.once("SIGINT", () => {
|
||||
void dispose()
|
||||
.then(() => rt.dispose())
|
||||
.finally(() => process.exit(0));
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
// Cover art. Playnite stores art as local files on the host (it passes a remote `http`
|
||||
// reference through verbatim). Three delivery modes — see `ArtOptions` in the contract:
|
||||
// - `host` (default): emit the reference AS-IS; the host serves local paths through its
|
||||
// art proxy (`/library/art/…`), so the reconcile payload carries no image bytes and
|
||||
// scales to any library size.
|
||||
// - `dataurl`: inline the cover as a size-capped `data:` URL (self-contained, but only fit
|
||||
// for small libraries — a big one blows past the host's reconcile body limit).
|
||||
// - `off`: no art at all.
|
||||
//
|
||||
// This module also owns the ALLOW-LIST for the plugin's own `/api/art` proxy: the SPA can't
|
||||
// load `C:\…\cover.jpg`, so it asks the plugin for those bytes — and the plugin only ever
|
||||
// serves a path that the current export actually references (`artPaths`).
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import type {
|
||||
ArtOptions,
|
||||
ExportedGame,
|
||||
LibraryExport,
|
||||
} from "@playnite/contract";
|
||||
import type { Artwork } from "@punktfunk/plugin-kit/wire";
|
||||
|
||||
const MIME: Record<string, string> = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".webp": "image/webp",
|
||||
".gif": "image/gif",
|
||||
".bmp": "image/bmp",
|
||||
".ico": "image/x-icon",
|
||||
".tga": "image/x-tga",
|
||||
};
|
||||
|
||||
/** The content type for a local art file, or null for an extension we don't serve. */
|
||||
export const mimeFor = (file: string): string | null =>
|
||||
MIME[path.extname(file).toLowerCase()] ?? null;
|
||||
|
||||
/** True for a reference the browser/host can already fetch without our help. */
|
||||
export const isRemote = (reference: string): boolean =>
|
||||
/^(https?:|data:)/i.test(reference);
|
||||
|
||||
/** Read a local image file into a `data:` URL, or null if missing, oversized, or an
|
||||
* unknown type. */
|
||||
export const fileToDataUrl = (
|
||||
file: string | null,
|
||||
maxBytes: number,
|
||||
): string | null => {
|
||||
if (!file) return null;
|
||||
if (isRemote(file)) return file; // already inline-able
|
||||
const mime = mimeFor(file);
|
||||
if (!mime) return null;
|
||||
try {
|
||||
const st = fs.statSync(file);
|
||||
if (!st.isFile() || st.size === 0 || st.size > maxBytes) return null;
|
||||
return `data:${mime};base64,${fs.readFileSync(file).toString("base64")}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/** Build the artwork block for a game per the art config. `undefined` when there is none. */
|
||||
export const buildArtwork = (
|
||||
game: ExportedGame,
|
||||
art: ArtOptions,
|
||||
): Artwork | undefined => {
|
||||
if (art.mode === "off") return undefined;
|
||||
// `host` mode: emit the references verbatim (the exporter only sets one when the file
|
||||
// exists). The host serves the bytes, so the payload stays tiny at any library size.
|
||||
// `dataurl` mode: inline (size-capped).
|
||||
const [portrait, background] =
|
||||
art.mode === "host"
|
||||
? [game.cover, art.includeBackground ? game.background : null]
|
||||
: [
|
||||
fileToDataUrl(game.cover, art.maxBytes),
|
||||
art.includeBackground
|
||||
? fileToDataUrl(game.background, art.maxBytes)
|
||||
: null,
|
||||
];
|
||||
if (!portrait && !background) return undefined;
|
||||
// `Artwork` keys are optionalKey — omit them, never set them to undefined.
|
||||
return {
|
||||
...(portrait ? { portrait } : {}),
|
||||
...(background ? { hero: background } : {}),
|
||||
} satisfies Artwork;
|
||||
};
|
||||
|
||||
/**
|
||||
* Every LOCAL art path the export references — the allow-list the `/api/art` proxy serves
|
||||
* from. Remote references are excluded (the browser loads those itself), so the proxy can
|
||||
* never be talked into reading a file Playnite didn't hand us.
|
||||
*/
|
||||
export const artPaths = (exp: LibraryExport): ReadonlySet<string> => {
|
||||
const out = new Set<string>();
|
||||
for (const game of exp.games) {
|
||||
for (const ref of [game.cover, game.background, game.icon]) {
|
||||
if (ref && !isRemote(ref) && mimeFor(ref) !== null) out.add(ref);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
@@ -0,0 +1,161 @@
|
||||
// Locating and reading the exporter's output — the half of the cross-process contract
|
||||
// that runs on the host. Pure of Effect (plain sync fs) so it unit-tests against tmp dirs;
|
||||
// the engine service wraps it.
|
||||
//
|
||||
// WHICH ACCOUNT can see the file is the whole problem. 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** (`<config_dir>/ingest/playnite/`) that the
|
||||
// exporter also drops the file into — checked FIRST, always. The `%APPDATA%` /
|
||||
// `C:\Users\*` `ExtensionsData` scan still works for a same-user/SYSTEM runner or on Linux
|
||||
// (Wine/portable), and an explicit `playniteDir` override joins the candidate list.
|
||||
//
|
||||
// The `ExtensionsData/<guid>/` folder name is NOT hardcoded: we glob
|
||||
// `ExtensionsData/*/punktfunk-library.json` and take the newest, so the reader stays
|
||||
// decoupled from the exporter's extension id.
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
type Config,
|
||||
EXPORT_FILE,
|
||||
LibraryExport,
|
||||
type Location,
|
||||
} from "@playnite/contract";
|
||||
import { pluginIngestDir } from "@punktfunk/plugin-kit";
|
||||
import { Schema } from "effect";
|
||||
import { PLUGIN_NAME } from "../const.js";
|
||||
|
||||
const exists = (p: string): boolean => {
|
||||
try {
|
||||
fs.accessSync(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/** Candidate Playnite data directories, most-specific first (the config override is
|
||||
* prepended 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"));
|
||||
}
|
||||
return [...new Set(out)];
|
||||
};
|
||||
|
||||
/** 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. The ingest inbox always wins; then a Playnite data dir
|
||||
* that actually holds an export; else the first candidate that at least exists, so the UI
|
||||
* can distinguish "Playnite found, exporter not installed" from "Playnite not found". */
|
||||
export const locateExport = (config: Config): Location => {
|
||||
const override = config.playniteDir?.trim();
|
||||
const playniteDirs = override
|
||||
? [override, ...candidatePlayniteDirs()]
|
||||
: candidatePlayniteDirs();
|
||||
const inbox = pluginIngestDir(PLUGIN_NAME);
|
||||
const candidates = [inbox, ...playniteDirs];
|
||||
|
||||
const inboxFile = path.join(inbox, EXPORT_FILE);
|
||||
try {
|
||||
const st = fs.statSync(inboxFile);
|
||||
return {
|
||||
dir: inbox,
|
||||
candidates,
|
||||
file: inboxFile,
|
||||
mtime: st.mtimeMs,
|
||||
viaIngest: true,
|
||||
};
|
||||
} catch {
|
||||
// no ingest drop yet — fall back to scanning Playnite's own data dirs
|
||||
}
|
||||
|
||||
for (const dir of playniteDirs) {
|
||||
const hit = findExportUnder(dir);
|
||||
if (hit) {
|
||||
return {
|
||||
dir,
|
||||
candidates,
|
||||
file: hit.file,
|
||||
mtime: hit.mtime,
|
||||
viaIngest: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
dir: playniteDirs.find(exists) ?? null,
|
||||
candidates,
|
||||
file: null,
|
||||
mtime: null,
|
||||
viaIngest: false,
|
||||
};
|
||||
};
|
||||
|
||||
/** A missing, corrupt, foreign, or too-new export. Carries a sentence fit for the UI. */
|
||||
export class ExportError extends Error {}
|
||||
|
||||
const decodeExport = Schema.decodeUnknownSync(LibraryExport);
|
||||
|
||||
/**
|
||||
* Read + decode the export file. The schema-version guard lives in the contract Schema
|
||||
* (`schema` is checked `<= SCHEMA`), so a too-new file surfaces here as a decode failure —
|
||||
* one place, shared with the UI's type, no second hand-written check to drift.
|
||||
*/
|
||||
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}`);
|
||||
}
|
||||
try {
|
||||
return decodeExport(parsed);
|
||||
} catch (e) {
|
||||
throw new ExportError(
|
||||
`${file} is not a usable Punktfunk library export: ${e}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,146 @@
|
||||
// The pure core: turn a decoded library export + the config into the declarative entry
|
||||
// list the host reconcile expects, plus a report of what was included and why anything was
|
||||
// skipped. No IO here (art is injected via `artFor`), so it is fully unit-testable.
|
||||
|
||||
import type {
|
||||
Config,
|
||||
ExportedGame,
|
||||
LibraryExport,
|
||||
SyncReport,
|
||||
} from "@playnite/contract";
|
||||
import type { Artwork, ProviderEntry } from "@punktfunk/plugin-kit/wire";
|
||||
|
||||
/** Playnite ids are .NET Guids. Validate the shape before interpolating into a launch
|
||||
* command — the id comes from our own exporter, but the guard is cheap and keeps the
|
||||
* command line unambiguous. */
|
||||
const GUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
export const isGuid = (id: string): boolean => GUID.test(id);
|
||||
|
||||
/** The launch command for a Playnite game: hand the `playnite://` URI to Playnite so IT
|
||||
* performs the real launch (any store or emulator, uniformly). The host runs `command`
|
||||
* values via `cmd.exe /c <value>` in the interactive session, so `start "" "<uri>"`
|
||||
* invokes the URI handler. */
|
||||
export const launchCommand = (
|
||||
id: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): ProviderEntry["launch"] => {
|
||||
const uri = `playnite://playnite/start/${id}`;
|
||||
// Playnite is Windows-only; the Windows host is the supported target. `start ""` gives
|
||||
// cmd an (empty) window title so a quoted URI isn't mistaken for one.
|
||||
if (platform === "win32") {
|
||||
return { kind: "command", value: `start "" "${uri}"` };
|
||||
}
|
||||
// Best-effort elsewhere (Wine/portable): a desktop opener resolves the handler.
|
||||
return { kind: "command", value: `xdg-open "${uri}"` };
|
||||
};
|
||||
|
||||
const sourceKey = (g: ExportedGame): string => g.source ?? "(none)";
|
||||
|
||||
export interface ComputeInput {
|
||||
readonly exp: LibraryExport;
|
||||
readonly config: Config;
|
||||
readonly platform?: NodeJS.Platform;
|
||||
/** Art builder (IO lives in the engine; tests pass a stub). */
|
||||
readonly artFor: (game: ExportedGame) => Artwork | undefined;
|
||||
}
|
||||
|
||||
export interface ComputeResult {
|
||||
readonly entries: ProviderEntry[];
|
||||
readonly report: SyncReport;
|
||||
}
|
||||
|
||||
/** In `dataurl` art mode only: warn above this many entries, since each inlined cover adds
|
||||
* to the reconcile payload and a large library blows past the host's body limit. `host`
|
||||
* mode (the default) sends references, not bytes, so it has no such ceiling. */
|
||||
const WARN_ENTRIES = 3000;
|
||||
|
||||
export const computeEntries = ({
|
||||
exp,
|
||||
config,
|
||||
platform = process.platform,
|
||||
artFor,
|
||||
}: ComputeInput): ComputeResult => {
|
||||
const { filter } = config;
|
||||
const includeSources = new Set(filter.sources.map((s) => s.toLowerCase()));
|
||||
const excludeSources = new Set(
|
||||
filter.excludeSources.map((s) => s.toLowerCase()),
|
||||
);
|
||||
|
||||
const entries: ProviderEntry[] = [];
|
||||
const skipped: SyncReport["skipped"][number][] = [];
|
||||
const excluded: SyncReport["excluded"][number][] = [];
|
||||
const warnings: string[] = [];
|
||||
const perSource: Record<string, number> = {};
|
||||
|
||||
for (const g of exp.games) {
|
||||
const title = g.name.trim();
|
||||
const skip = (reason: string) =>
|
||||
skipped.push({ id: g.id, title: title || g.id, reason });
|
||||
|
||||
if (!title) {
|
||||
skip("no title");
|
||||
continue;
|
||||
}
|
||||
if (!isGuid(g.id)) {
|
||||
skip("invalid game id");
|
||||
continue;
|
||||
}
|
||||
if (filter.installedOnly && !g.installed) {
|
||||
skip("not installed");
|
||||
continue;
|
||||
}
|
||||
if (!filter.includeHidden && g.hidden) {
|
||||
skip("hidden in Playnite");
|
||||
continue;
|
||||
}
|
||||
const src = (g.source ?? "").toLowerCase();
|
||||
if (includeSources.size > 0 && !includeSources.has(src)) {
|
||||
skip(`source "${g.source ?? "(none)"}" not in include list`);
|
||||
continue;
|
||||
}
|
||||
if (excludeSources.has(src)) {
|
||||
skip(`source "${g.source ?? "(none)"}" excluded`);
|
||||
continue;
|
||||
}
|
||||
// Last, so `excluded` only ever holds games that would OTHERWISE be included —
|
||||
// what the Library page offers a "re-include" button for.
|
||||
if (config.gameOverrides[g.id]?.exclude === true) {
|
||||
excluded.push({ id: g.id, title });
|
||||
continue;
|
||||
}
|
||||
|
||||
const art = artFor(g);
|
||||
entries.push({
|
||||
external_id: g.id,
|
||||
title,
|
||||
launch: launchCommand(g.id, platform),
|
||||
// `art` is an optionalKey — omit it, never set it to undefined.
|
||||
...(art ? { art } : {}),
|
||||
});
|
||||
const key = sourceKey(g);
|
||||
perSource[key] = (perSource[key] ?? 0) + 1;
|
||||
}
|
||||
|
||||
// Stable order (by title) so the reconcile fingerprint is deterministic across reads.
|
||||
entries.sort((a, b) => a.title.localeCompare(b.title));
|
||||
|
||||
if (config.art.mode === "dataurl" && entries.length > WARN_ENTRIES) {
|
||||
warnings.push(
|
||||
`${entries.length} entries with art.mode "dataurl" — inlined covers will exceed the host's reconcile body limit. Switch to art.mode "host" (the default), which serves covers by path.`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
entries,
|
||||
report: {
|
||||
generatedAt: exp.generatedAt,
|
||||
schemaVersion: exp.schema,
|
||||
considered: exp.games.length,
|
||||
included: entries.length,
|
||||
skipped,
|
||||
excluded,
|
||||
warnings,
|
||||
perSource,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
// The plugin entry — the default export the runner discovers. Everything is Effect inside
|
||||
// definePluginKit's async-main boundary (see @punktfunk/plugin-kit): start the sync engine,
|
||||
// serve the console UI, park until interruption.
|
||||
//
|
||||
// Provider entries are deliberately LEFT in the host library on shutdown, so the games
|
||||
// survive plugin restarts and host reboots; a clean uninstall is the explicit CLI command.
|
||||
import { definePluginKit, serveUi } from "@punktfunk/plugin-kit";
|
||||
import { Effect } from "effect";
|
||||
import pkg from "../package.json" with { type: "json" };
|
||||
import { PLUGIN_NAME, UI_ICON, UI_TITLE } from "./const.js";
|
||||
import { playniteLayer } from "./layer.js";
|
||||
import { makeApi } from "./services/api.js";
|
||||
import { PlayniteConfig } from "./services/config.js";
|
||||
import { PlayniteSync } from "./services/engine.js";
|
||||
|
||||
export { PLUGIN_NAME, PROVIDER_ID, UI_ICON, UI_TITLE } from "./const.js";
|
||||
|
||||
const plugin = definePluginKit({
|
||||
name: PLUGIN_NAME,
|
||||
version: pkg.version,
|
||||
layer: playniteLayer,
|
||||
main: Effect.gen(function* () {
|
||||
const sync = yield* PlayniteSync;
|
||||
const config = yield* PlayniteConfig;
|
||||
|
||||
// Headless sync first — the library reconciles even if the UI can't come up.
|
||||
yield* sync.engine.start;
|
||||
|
||||
yield* serveUi({
|
||||
title: UI_TITLE,
|
||||
icon: UI_ICON,
|
||||
staticDir: new URL("./ui/", import.meta.url),
|
||||
api: makeApi({ sync, config }),
|
||||
}).pipe(
|
||||
Effect.catch((e) =>
|
||||
Effect.logWarning(
|
||||
`ui: could not serve the console surface (${e.cause}) — engine continues headless`,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
yield* Effect.never;
|
||||
}),
|
||||
});
|
||||
|
||||
export default plugin;
|
||||
@@ -0,0 +1,29 @@
|
||||
// The plugin's service graph over the kit base (HostClient | PluginInfo) — shared by the
|
||||
// runner entry (index.ts), the CLI, and the dev server so they all run the same engine.
|
||||
|
||||
import type { HostClient } from "@punktfunk/plugin-kit";
|
||||
import { type PluginInfo, ProviderClient } from "@punktfunk/plugin-kit";
|
||||
import { Layer } from "effect";
|
||||
import { PlayniteCache } from "./services/cache.js";
|
||||
import { PlayniteConfig } from "./services/config.js";
|
||||
import { PlayniteSync } from "./services/engine.js";
|
||||
|
||||
export type PlayniteServices =
|
||||
| PlayniteConfig
|
||||
| PlayniteCache
|
||||
| PlayniteSync
|
||||
| ProviderClient;
|
||||
|
||||
export const playniteLayer: Layer.Layer<
|
||||
PlayniteServices,
|
||||
never,
|
||||
HostClient | PluginInfo
|
||||
> = PlayniteSync.layer.pipe(
|
||||
Layer.provideMerge(
|
||||
Layer.mergeAll(
|
||||
PlayniteConfig.layer,
|
||||
PlayniteCache.layer,
|
||||
ProviderClient.layer,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,117 @@
|
||||
// The HttpApi implementation + the two non-JSON routes (SSE status feed, cover-art
|
||||
// proxy), composed as the Layer `serveUi` mounts. Built from live service VALUES (not
|
||||
// layers) so the handler runtime shares the plugin runtime's singletons — one engine, one
|
||||
// config service.
|
||||
import {
|
||||
ART_PATH,
|
||||
ConfigInvalid,
|
||||
EVENTS_EVENT,
|
||||
EVENTS_PATH,
|
||||
ExportUnavailable,
|
||||
PlayniteApi,
|
||||
type PlayniteConfigSchema,
|
||||
SyncInProgress,
|
||||
} from "@playnite/contract";
|
||||
import {
|
||||
type ConfigService,
|
||||
httpApiEnv,
|
||||
sseRoute,
|
||||
} from "@punktfunk/plugin-kit";
|
||||
import { Effect, Layer } from "effect";
|
||||
import { HttpRouter, HttpServerResponse } from "effect/unstable/http";
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi";
|
||||
import type { PlayniteSyncService } from "./engine.js";
|
||||
|
||||
export interface ApiDeps {
|
||||
readonly sync: PlayniteSyncService;
|
||||
readonly config: ConfigService<typeof PlayniteConfigSchema>;
|
||||
}
|
||||
|
||||
/**
|
||||
* `GET /api/art?path=<abs>` — the cover-art proxy. Playnite art is local files on the
|
||||
* host, which the SPA cannot load, so it asks us for the bytes. The engine only answers
|
||||
* for a path the CURRENT export references (see `artPaths`), so this is an allow-list
|
||||
* lookup, not an arbitrary file read; anything else is a flat 404.
|
||||
*/
|
||||
const artRoute = (
|
||||
deps: ApiDeps,
|
||||
): Layer.Layer<never, never, HttpRouter.HttpRouter> =>
|
||||
HttpRouter.add("GET", ART_PATH, (request) =>
|
||||
Effect.gen(function* () {
|
||||
const file = new URL(request.url, "http://localhost").searchParams.get(
|
||||
"path",
|
||||
);
|
||||
if (file === null || file === "") {
|
||||
return HttpServerResponse.empty({ status: 400 });
|
||||
}
|
||||
const art = yield* deps.sync.art(file);
|
||||
if (art === undefined) return HttpServerResponse.empty({ status: 404 });
|
||||
return HttpServerResponse.uint8Array(art.bytes, {
|
||||
contentType: art.contentType,
|
||||
// Playnite art files are content-addressed by Playnite; the path changes
|
||||
// when the picture does, so a long private cache is safe.
|
||||
headers: { "cache-control": "private, max-age=3600" },
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
export const makeApi = (
|
||||
deps: ApiDeps,
|
||||
): Layer.Layer<never, never, HttpRouter.HttpRouter> => {
|
||||
const statusGroup = HttpApiBuilder.group(PlayniteApi, "status", (h) =>
|
||||
h.handle("get", () => deps.sync.status),
|
||||
);
|
||||
|
||||
const configGroup = HttpApiBuilder.group(PlayniteApi, "config", (h) =>
|
||||
h
|
||||
.handle("get", () =>
|
||||
deps.config.loadRaw.pipe(
|
||||
Effect.map((raw) => ({ raw: raw as unknown })),
|
||||
Effect.orDie,
|
||||
),
|
||||
)
|
||||
.handle("put", ({ payload }) =>
|
||||
deps.config.saveRaw(payload).pipe(
|
||||
Effect.mapError((e) => new ConfigInvalid({ issues: String(e) })),
|
||||
// Loops restart + resync in the background; the PUT answers immediately.
|
||||
Effect.tap(() => Effect.forkDetach(deps.sync.engine.reconfigure)),
|
||||
Effect.map(() => ({ raw: payload as unknown })),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const libraryGroup = HttpApiBuilder.group(PlayniteApi, "library", (h) =>
|
||||
h.handle("preview", () => deps.sync.preview),
|
||||
);
|
||||
|
||||
const syncGroup = HttpApiBuilder.group(PlayniteApi, "sync", (h) =>
|
||||
h.handle("run", () =>
|
||||
deps.sync.engine.sync("manual").pipe(
|
||||
// A SyncError wraps whatever compute/apply failed with; an unreadable export
|
||||
// is the one case the UI can act on, so it stays a typed 503.
|
||||
Effect.mapError((e) =>
|
||||
e.cause instanceof ExportUnavailable
|
||||
? e.cause
|
||||
: new ExportUnavailable({ message: String(e.cause) }),
|
||||
),
|
||||
Effect.flatMap((outcome) =>
|
||||
outcome._tag === "AlreadyRunning"
|
||||
? Effect.fail(new SyncInProgress())
|
||||
: Effect.succeed(outcome.report),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return Layer.mergeAll(
|
||||
HttpApiBuilder.layer(PlayniteApi).pipe(
|
||||
Layer.provide(
|
||||
Layer.mergeAll(statusGroup, configGroup, libraryGroup, syncGroup),
|
||||
),
|
||||
Layer.provide(httpApiEnv),
|
||||
),
|
||||
// EngineStatus is an identity codec (plain JSON shape) — default JSON encode.
|
||||
sseRoute(EVENTS_PATH, deps.sync.statusChanges, { event: EVENTS_EVENT }),
|
||||
artRoute(deps),
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
// The plugin's disposable derived state (cache.json), on the kit's CacheStore. Playnite's
|
||||
// library IS the source of truth and lives one process away, so there is nothing to cache
|
||||
// but the last reconcile fingerprint — an unchanged export then costs one read and no PUT.
|
||||
// Corrupt/absent → empty; every mutation is write-through.
|
||||
import { LastSync } from "@playnite/contract";
|
||||
import {
|
||||
type CacheStore,
|
||||
makeCacheStore,
|
||||
type PluginInfo,
|
||||
} from "@punktfunk/plugin-kit";
|
||||
import { Context, Layer, Schema } from "effect";
|
||||
|
||||
export const CacheSchema = Schema.Struct({
|
||||
lastSync: Schema.optionalKey(LastSync),
|
||||
});
|
||||
export type Cache = typeof CacheSchema.Type;
|
||||
|
||||
export const emptyCache: Cache = {};
|
||||
|
||||
export class PlayniteCache extends Context.Service<
|
||||
PlayniteCache,
|
||||
CacheStore<typeof CacheSchema>
|
||||
>()("playnite/PlayniteCache") {
|
||||
static readonly layer: Layer.Layer<PlayniteCache, never, PluginInfo> =
|
||||
Layer.effect(PlayniteCache)(
|
||||
makeCacheStore({ schema: CacheSchema, empty: emptyCache }),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// The plugin's config service — the kit's ConfigService over the contract schema.
|
||||
// Raw round-trip by construction: the file on disk is always the authored shape.
|
||||
import { PlayniteConfigSchema } from "@playnite/contract";
|
||||
import {
|
||||
type ConfigService,
|
||||
makeConfigService,
|
||||
type PluginInfo,
|
||||
} from "@punktfunk/plugin-kit";
|
||||
import { Context, Layer } from "effect";
|
||||
|
||||
export class PlayniteConfig extends Context.Service<
|
||||
PlayniteConfig,
|
||||
ConfigService<typeof PlayniteConfigSchema>
|
||||
>()("playnite/PlayniteConfig") {
|
||||
static readonly layer: Layer.Layer<PlayniteConfig, never, PluginInfo> =
|
||||
Layer.effect(PlayniteConfig)(
|
||||
makeConfigService({ schema: PlayniteConfigSchema }),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
// PlayniteSync — the plugin's engine service: wires the pure domain (locate → read →
|
||||
// compute) into the kit's generic SyncEngine (poll/watch/debounce/coalesce/fingerprint)
|
||||
// and the ProviderClient reconcile. Also owns the EngineStatus assembly the API and the
|
||||
// SSE feed share, the side-effect-light preview the Library page uses, and the allow-listed
|
||||
// cover-art reader behind `/api/art`.
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
type Config,
|
||||
type EngineStatus,
|
||||
EXPORT_FILE,
|
||||
ExportUnavailable,
|
||||
type LibraryExport,
|
||||
type Location,
|
||||
type PlayniteInfo,
|
||||
type PreviewResult,
|
||||
resolveConfig,
|
||||
type SyncReport,
|
||||
} from "@playnite/contract";
|
||||
import {
|
||||
type LastSync,
|
||||
makeSyncEngine,
|
||||
ProviderClient,
|
||||
pluginIngestDir,
|
||||
type SyncEngine,
|
||||
} from "@punktfunk/plugin-kit";
|
||||
import type { ProviderEntry } from "@punktfunk/plugin-kit/wire";
|
||||
import {
|
||||
Context,
|
||||
Duration,
|
||||
Effect,
|
||||
Layer,
|
||||
Ref,
|
||||
type Scope,
|
||||
Stream,
|
||||
} from "effect";
|
||||
import { PLUGIN_NAME, PROVIDER_ID } from "../const.js";
|
||||
import { artPaths, buildArtwork, mimeFor } from "../domain/art.js";
|
||||
import { locateExport, readExport } from "../domain/locate.js";
|
||||
import { computeEntries } from "../domain/reconcile.js";
|
||||
import { PlayniteCache } from "./cache.js";
|
||||
import { PlayniteConfig } from "./config.js";
|
||||
|
||||
/** One cover the `/api/art` proxy is willing to serve. */
|
||||
export interface ArtFile {
|
||||
readonly bytes: Uint8Array;
|
||||
readonly contentType: string;
|
||||
}
|
||||
|
||||
export interface PlayniteSyncService {
|
||||
readonly engine: SyncEngine<SyncReport>;
|
||||
/** Read + compute the desired state. NO art embedding, NO reconcile. */
|
||||
readonly preview: Effect.Effect<PreviewResult, ExportUnavailable>;
|
||||
readonly status: Effect.Effect<EngineStatus>;
|
||||
/** The SSE feed: a full EngineStatus on every engine transition. */
|
||||
readonly statusChanges: Stream.Stream<EngineStatus>;
|
||||
/** Where the export was found — the CLI's `where`, without a full read. */
|
||||
readonly locate: Effect.Effect<Location>;
|
||||
/**
|
||||
* Read one local cover, but ONLY if the currently-ingested export references it.
|
||||
* `undefined` for anything else — the browser cannot ask this for an arbitrary file.
|
||||
*/
|
||||
readonly art: (file: string) => Effect.Effect<ArtFile | undefined>;
|
||||
}
|
||||
|
||||
export class PlayniteSync extends Context.Service<
|
||||
PlayniteSync,
|
||||
PlayniteSyncService
|
||||
>()("playnite/PlayniteSync") {
|
||||
// Layer.effect runs `make` in the LAYER's scope (Scope is excluded from R) — the
|
||||
// engine's watch/poll loops live exactly as long as the plugin runtime.
|
||||
static readonly layer: Layer.Layer<
|
||||
PlayniteSync,
|
||||
never,
|
||||
PlayniteConfig | PlayniteCache | ProviderClient
|
||||
> = Layer.effect(PlayniteSync)(make());
|
||||
}
|
||||
|
||||
const NO_LOCATION: Location = {
|
||||
dir: null,
|
||||
candidates: [],
|
||||
file: null,
|
||||
mtime: null,
|
||||
viaIngest: false,
|
||||
};
|
||||
|
||||
/** The art allow-list, pinned to the export document it was derived from. */
|
||||
interface AllowList {
|
||||
readonly file: string;
|
||||
readonly mtime: number;
|
||||
readonly paths: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
function make(): Effect.Effect<
|
||||
PlayniteSyncService,
|
||||
never,
|
||||
PlayniteConfig | PlayniteCache | ProviderClient | Scope.Scope
|
||||
> {
|
||||
return Effect.gen(function* () {
|
||||
const cfg = yield* PlayniteConfig;
|
||||
const cache = yield* PlayniteCache;
|
||||
const provider = yield* ProviderClient;
|
||||
const platform = process.platform;
|
||||
const ingest = pluginIngestDir(PLUGIN_NAME);
|
||||
|
||||
// Observed state the status view reports (the old Engine's private fields).
|
||||
const lastLocation = yield* Ref.make<Location>(NO_LOCATION);
|
||||
const lastError = yield* Ref.make<string | undefined>(undefined);
|
||||
const lastPlaynite = yield* Ref.make<PlayniteInfo | undefined>(undefined);
|
||||
const allowList = yield* Ref.make<AllowList | undefined>(undefined);
|
||||
|
||||
const loadOrDefault = cfg.load.pipe(
|
||||
Effect.orElseSucceed(() => resolveConfig({})),
|
||||
);
|
||||
|
||||
/** Locate + read + decode, refreshing everything the status view and the art proxy
|
||||
* derive from the export. The one place an export failure is recorded. */
|
||||
const readCurrent = (
|
||||
config: Config,
|
||||
): Effect.Effect<LibraryExport, ExportUnavailable> =>
|
||||
Effect.gen(function* () {
|
||||
const location = locateExport(config);
|
||||
yield* Ref.set(lastLocation, location);
|
||||
if (location.file === null) {
|
||||
const message = `no exporter output under ${location.dir ?? "any Playnite data dir"} — is the Punktfunk Sync extension installed in Playnite?`;
|
||||
yield* Ref.set(lastError, message);
|
||||
return yield* Effect.fail(new ExportUnavailable({ message }));
|
||||
}
|
||||
const file = location.file;
|
||||
const exp = yield* Effect.try({
|
||||
try: () => readExport(file),
|
||||
catch: (cause) =>
|
||||
new ExportUnavailable({
|
||||
message: cause instanceof Error ? cause.message : String(cause),
|
||||
}),
|
||||
}).pipe(Effect.tapError((e) => Ref.set(lastError, e.message)));
|
||||
yield* Ref.set(lastError, undefined);
|
||||
yield* Ref.set(lastPlaynite, exp.playnite);
|
||||
yield* Ref.set(allowList, {
|
||||
file,
|
||||
mtime: location.mtime ?? 0,
|
||||
paths: artPaths(exp),
|
||||
});
|
||||
return exp;
|
||||
});
|
||||
|
||||
const computeWith = (
|
||||
embedArt: boolean,
|
||||
): Effect.Effect<
|
||||
{ readonly entries: ProviderEntry[]; readonly report: SyncReport },
|
||||
ExportUnavailable
|
||||
> =>
|
||||
Effect.gen(function* () {
|
||||
const config = yield* loadOrDefault;
|
||||
const exp = yield* readCurrent(config);
|
||||
return computeEntries({
|
||||
exp,
|
||||
config,
|
||||
platform,
|
||||
// The preview skips the (heavy, in `dataurl` mode) art embed.
|
||||
artFor: embedArt
|
||||
? (g) => buildArtwork(g, config.art)
|
||||
: () => undefined,
|
||||
});
|
||||
});
|
||||
|
||||
const engine = yield* makeSyncEngine<
|
||||
SyncReport,
|
||||
ReadonlyArray<ProviderEntry>,
|
||||
never
|
||||
>({
|
||||
compute: () => computeWith(true),
|
||||
apply: (entries) => provider.reconcile(PROVIDER_ID, entries),
|
||||
lastSync: {
|
||||
get: cache.get.pipe(Effect.map((c) => c.lastSync)),
|
||||
set: (last: LastSync) =>
|
||||
cache.update((c) => ({ ...c, lastSync: last })).pipe(Effect.ignore),
|
||||
},
|
||||
settings: loadOrDefault.pipe(
|
||||
Effect.map((config) => ({
|
||||
pollInterval: Duration.minutes(Math.max(1, config.sync.pollMinutes)),
|
||||
watch: config.sync.watch,
|
||||
debounce: Duration.millis(config.sync.debounceMs),
|
||||
watchDirs: watchTargets(config),
|
||||
})),
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
* What to watch: the ingest inbox ALWAYS (the de-privileged runner's only source,
|
||||
* so an exporter drop reconciles within seconds even when the Playnite dir resolved
|
||||
* elsewhere), plus the located Playnite dir's `ExtensionsData` when there is one.
|
||||
* The kit's watcher set is best-effort — an absent dir is silently skipped and the
|
||||
* interval poll covers it.
|
||||
*/
|
||||
function watchTargets(config: Config): string[] {
|
||||
const targets = new Set<string>([ingest]);
|
||||
const located = locateExport(config);
|
||||
if (located.dir !== null && !located.viaIngest) {
|
||||
const extData = path.join(located.dir, "ExtensionsData");
|
||||
targets.add(fs.existsSync(extData) ? extData : located.dir);
|
||||
}
|
||||
return [...targets];
|
||||
}
|
||||
|
||||
const status: Effect.Effect<EngineStatus> = Effect.gen(function* () {
|
||||
const config = yield* loadOrDefault;
|
||||
const engineStatus = yield* engine.status;
|
||||
const location = yield* Ref.get(lastLocation);
|
||||
const exportError = yield* Ref.get(lastError);
|
||||
const playnite = yield* Ref.get(lastPlaynite);
|
||||
return {
|
||||
platform,
|
||||
// Before the first sync the status view would otherwise show nothing at all.
|
||||
location: location.file === null ? locateExport(config) : location,
|
||||
// Explicit nulls, never undefined — see the trap note in contract/domain.ts.
|
||||
exportError: exportError ?? null,
|
||||
playnite: playnite ?? null,
|
||||
artMode: config.art.mode,
|
||||
syncing: engineStatus.syncing,
|
||||
lastSync: engineStatus.lastSync ?? null,
|
||||
lastReport: engineStatus.lastReport ?? null,
|
||||
paths: {
|
||||
dir: path.dirname(cfg.path),
|
||||
config: cfg.path,
|
||||
cache: cache.path,
|
||||
ingest: path.join(ingest, EXPORT_FILE),
|
||||
},
|
||||
} satisfies EngineStatus;
|
||||
});
|
||||
|
||||
const art = (file: string): Effect.Effect<ArtFile | undefined> =>
|
||||
Effect.gen(function* () {
|
||||
const cached = yield* Ref.get(allowList);
|
||||
const config = yield* loadOrDefault;
|
||||
const located = locateExport(config);
|
||||
// Refresh when we have never read an export, or the file moved under us —
|
||||
// otherwise a cover added since the last sync would 404 until the next one.
|
||||
const stale =
|
||||
cached === undefined ||
|
||||
located.file !== cached.file ||
|
||||
(located.mtime ?? 0) !== cached.mtime;
|
||||
if (stale) yield* readCurrent(config).pipe(Effect.ignore);
|
||||
const allow = yield* Ref.get(allowList);
|
||||
if (allow === undefined || !allow.paths.has(file)) return undefined;
|
||||
const contentType = mimeFor(file);
|
||||
if (contentType === null) return undefined;
|
||||
return yield* Effect.try(
|
||||
(): ArtFile => ({ bytes: fs.readFileSync(file), contentType }),
|
||||
).pipe(Effect.orElseSucceed(() => undefined));
|
||||
});
|
||||
|
||||
return {
|
||||
engine,
|
||||
preview: computeWith(false),
|
||||
status,
|
||||
// A fresh subscriber gets the CURRENT status immediately, then one frame per
|
||||
// engine transition — so the console's live view is right the moment it
|
||||
// connects, rather than blank until the next sync. Each frame is a full
|
||||
// snapshot, so the sliver between the two stages costs nothing.
|
||||
statusChanges: Stream.concat(
|
||||
Stream.fromEffect(status),
|
||||
engine.changes.pipe(Stream.mapEffect(() => status)),
|
||||
),
|
||||
locate: loadOrDefault.pipe(Effect.map(locateExport)),
|
||||
art,
|
||||
} satisfies PlayniteSyncService;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user