// 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; /** Read + compute the desired state. NO art embedding, NO reconcile. */ readonly preview: Effect.Effect; readonly status: Effect.Effect; /** The SSE feed: a full EngineStatus on every engine transition. */ readonly statusChanges: Stream.Stream; /** Where the export was found — the CLI's `where`, without a full read. */ readonly locate: Effect.Effect; /** * 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; } 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; } 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(NO_LOCATION); const lastError = yield* Ref.make(undefined); const lastPlaynite = yield* Ref.make(undefined); const allowList = yield* Ref.make(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 => 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, 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([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 = 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 => 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; }); }