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.
73 lines
3.4 KiB
TypeScript
73 lines
3.4 KiB
TypeScript
// The CROSS-PROCESS contract: the other half of this file is C#.
|
|
//
|
|
// The Playnite exporter (`exporter/`, a GenericPlugin) writes `punktfunk-library.json` in
|
|
// exactly this shape on every library change; this plugin decodes it. Keep it in lockstep
|
|
// with `exporter/Model.cs` — the `[SerializationPropertyName]` attributes there pin the
|
|
// JSON keys these Schemas read. Bump `SCHEMA` on any breaking change; the exporter stamps
|
|
// the version it wrote and the `schema` check below turns a too-new file into a decode
|
|
// error rather than a silent mis-read.
|
|
//
|
|
// Leniency is deliberate and asymmetric: every field except `id`, `schema` and `games` has
|
|
// a decoding default, so an exporter that predates (or postdates) a field addition still
|
|
// decodes. Per-GAME validity — a malformed Guid, a blank title — is NOT a decode failure:
|
|
// those become skip reasons in the pure core, so one bad row can never cost you the whole
|
|
// library.
|
|
import { Effect, Schema } from "effect";
|
|
|
|
/** The file the exporter writes (ingest inbox, and its own `ExtensionsData/<guid>/`). */
|
|
export const EXPORT_FILE = "punktfunk-library.json";
|
|
|
|
/** Schema version this reader understands. A newer major is refused (see `LibraryExport`). */
|
|
export const SCHEMA = 1;
|
|
|
|
const withDefault = <S extends Schema.Top>(schema: S, value: S["Encoded"]) =>
|
|
schema.pipe(
|
|
Schema.withDecodingDefaultKey(Effect.succeed(value), {
|
|
encodingStrategy: "omit",
|
|
}),
|
|
);
|
|
|
|
/** One game as the Playnite exporter sees it. Art fields are ABSOLUTE local paths on the
|
|
* host box (resolved via `IPlayniteAPI.Database.GetFullFilePath`) — except a remote
|
|
* `http(s)` art URL, which the exporter passes through verbatim. */
|
|
export const ExportedGame = Schema.Struct({
|
|
/** Playnite's stable game `Guid` ("d" format) — our reconcile `external_id`. */
|
|
id: Schema.String,
|
|
name: withDefault(Schema.String, ""),
|
|
/** Whether Playnite considers the game installed (drives the `installedOnly` filter). */
|
|
installed: withDefault(Schema.Boolean, false),
|
|
/** Playnite's per-game "Hidden" flag. */
|
|
hidden: withDefault(Schema.Boolean, false),
|
|
/** The library source name ("Steam", "GOG", "Epic", emulator name, …) or null. */
|
|
source: withDefault(Schema.NullOr(Schema.String), null),
|
|
/** Platform display names ("PC (Windows)", "Nintendo Switch", …). */
|
|
platforms: withDefault(Schema.Array(Schema.String), []),
|
|
cover: withDefault(Schema.NullOr(Schema.String), null),
|
|
background: withDefault(Schema.NullOr(Schema.String), null),
|
|
icon: withDefault(Schema.NullOr(Schema.String), null),
|
|
});
|
|
export type ExportedGame = typeof ExportedGame.Type;
|
|
|
|
export const PlayniteInfo = Schema.Struct({
|
|
version: withDefault(Schema.NullOr(Schema.String), null),
|
|
/** "Desktop" | "Fullscreen" — informational. */
|
|
mode: withDefault(Schema.NullOr(Schema.String), null),
|
|
});
|
|
export type PlayniteInfo = typeof PlayniteInfo.Type;
|
|
|
|
/** The whole export document. Decoding it IS the schema-version guard. */
|
|
export const LibraryExport = Schema.Struct({
|
|
schema: Schema.Number.pipe(
|
|
Schema.check(
|
|
Schema.isLessThanOrEqualTo(SCHEMA, {
|
|
description: `a punktfunk-library.json schema this plugin understands (≤ ${SCHEMA}) — a higher one means the Playnite exporter is newer than the plugin; update the plugin`,
|
|
}),
|
|
),
|
|
),
|
|
/** ISO-8601 timestamp the exporter stamped on this document. */
|
|
generatedAt: withDefault(Schema.String, ""),
|
|
playnite: withDefault(PlayniteInfo, {}),
|
|
games: Schema.Array(ExportedGame),
|
|
});
|
|
export type LibraryExport = typeof LibraryExport.Type;
|