feat: initial ROM & emulator manager plugin (M0–M5)
CI / build (push) Has been cancelled
CI / publish (push) Has been cancelled

A punktfunk-plugin-* package that scans ROM directories, maps them to
emulators, fetches box art, and reconciles them into the host game library
under provider id `rom-manager` — with a console-hosted web UI.

Engine (pure, unit-tested core):
- Table-driven platform registry (~25 consoles) + emulator registry with
  best-effort per-OS detection (PATH / Flatpak / known paths) and RetroArch
  core discovery.
- Scanner with disc folding (m3u/cue/gdi), archive gating, excludes.
- No-Intro title parsing + optional per-platform region dedupe.
- Security-critical quoting seam: POSIX single-quote + Windows double-quote
  with hostile-name refusal; ROM filenames never reach a shell un-quoted.
- Pure desired-state reconcile (stable external_ids, scale guard, fingerprint
  skip) → full-replace PUT /library/provider/rom-manager.

Box art (like Steam ROM Manager): SteamGridDB primary (portrait/hero/logo/
header, fuzzy match, operator API key) behind a provider seam, with keyless
libretro-thumbnails as the zero-setup fallback (`auto` default).

UI: console-hosted via the SDK `servePluginUi` (zero plugin-side auth) with a
plugin-local REST/SSE API and a self-contained React SPA (Setup / Emulators /
Games / Sync). Standalone password-gated fallback for host-only installs.

CLI: scan / detect / preview / sync / uninstall / set-password.

48 engine tests, typecheck + biome clean, SPA builds. Verified end-to-end:
scan → detect → reconcile PUT, fingerprint idempotence, and the standalone
UI serving SPA + REST.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-18 02:40:59 +02:00
commit 3a6c80558d
59 changed files with 6051 additions and 0 deletions
+87
View File
@@ -0,0 +1,87 @@
// The keyless fallback art provider: libretro-thumbnails (design §7) — HTTPS, redirect-free,
// CDN-backed, no API key. Box art only (portrait); hero/logo/header are SteamGridDB-only. Thumbnail
// files are named after the No-Intro name with a documented character-substitution set; we walk a
// fallback ladder (exact → region-swap → bare title) and verify each candidate with one probe.
import type { ParsedTitle } from "../engine/titles.js";
import type { Platform } from "../platforms.js";
import type { Artwork } from "../wire.js";
import type { ArtProvider } from "./provider.js";
const BASE = "https://thumbnails.libretro.com";
/**
* libretro's filename character substitutions: ampersand, asterisk, slash, colon, backtick, angle
* brackets, question mark, backslash, pipe and double-quote all become `_` (the rest of the name,
* including spaces and parentheses, is kept verbatim and URL-encoded per path segment).
*/
export const sanitizeName = (name: string): string =>
name.replace(/[&*/:`<>?\\|"]/g, "_").trim();
/** Build the Named_Boxarts URL for a system + already-sanitized name. */
export const buildUrl = (system: string, sanitizedName: string): string =>
`${BASE}/${encodeURIComponent(system)}/Named_Boxarts/${encodeURIComponent(sanitizedName)}.png`;
/** Common single-region fallbacks tried when the exact-name probe misses. */
const REGION_FALLBACKS = ["USA", "World", "Europe", "Japan"];
/**
* Ordered candidate names for a title (design §7 ladder), de-duplicated:
* 1. the exact No-Intro name (with all tags),
* 2. `<base> (<its own region>)` — drops revision/flag tags but keeps region,
* 3. `<base> (USA|World|Europe|Japan)` — region-relaxed,
* 4. the bare base title.
*/
export const candidateNames = (parsed: ParsedTitle): string[] => {
const names: string[] = [parsed.noIntroName];
const base = parsed.displayTitle;
if (parsed.region) names.push(`${base} (${parsed.region})`);
for (const r of REGION_FALLBACKS) names.push(`${base} (${r})`);
names.push(base);
return [...new Set(names.map((n) => n.trim()).filter(Boolean))];
};
/** Probe a URL for existence. Returns true on a 2xx. Injected in tests; the default uses HEAD. */
export type ArtProbe = (url: string) => Promise<boolean>;
export const defaultProbe: ArtProbe = async (url) => {
try {
const res = await fetch(url, { method: "HEAD", redirect: "manual" });
return res.status >= 200 && res.status < 300;
} catch {
return false;
}
};
/** Resolve a portrait URL for a title, or `null` if no candidate exists (walks the candidate ladder). */
export const resolveLibretroUrl = async (
system: string,
parsed: ParsedTitle,
probe: ArtProbe = defaultProbe,
): Promise<string | null> => {
for (const name of candidateNames(parsed)) {
const url = buildUrl(system, sanitizeName(name));
if (await probe(url)) return url;
}
return null;
};
/** The libretro-thumbnails provider (portrait only; needs a platform with a mapped libretro system). */
export class LibretroProvider implements ArtProvider {
readonly id = "libretro";
readonly matcherVersion = 1;
constructor(private readonly probe: ArtProbe = defaultProbe) {}
async resolve(
platform: Platform,
parsed: ParsedTitle,
): Promise<Artwork | null> {
if (!platform.libretroSystem) return null;
const portrait = await resolveLibretroUrl(
platform.libretroSystem,
parsed,
this.probe,
);
return portrait ? { portrait } : null;
}
}
+104
View File
@@ -0,0 +1,104 @@
// The art-provider seam (design §7, revised). Steam ROM Manager sources art from SteamGridDB; we make
// that the preferred provider (full portrait + hero + logo + header, fuzzy title match) and keep the
// keyless libretro-thumbnails source as a zero-setup fallback. Both implement `ArtProvider`, so the
// reconcile/warm code is provider-agnostic; the choice is `config.art.provider` (default `auto` =
// SteamGridDB when an API key is set, else libretro).
import type { Config } from "../config.js";
import type { ParsedTitle } from "../engine/titles.js";
import { log } from "../log.js";
import type { Platform } from "../platforms.js";
import type { ArtVerdict } from "../state.js";
import type { Artwork } from "../wire.js";
import { LibretroProvider } from "./libretro.js";
import { SteamGridDbProvider } from "./steamgriddb.js";
export interface ArtProvider {
/** Stable id, stored in the cache verdict so a provider switch re-resolves. */
readonly id: string;
/** Bump to invalidate this provider's cached verdicts after an algorithm change. */
readonly matcherVersion: number;
/** Resolve artwork for a title, or `null` if nothing was found. Best-effort — never throws. */
resolve(platform: Platform, parsed: ParsedTitle): Promise<Artwork | null>;
}
/** Pick the active provider for a config, or `null` when art is disabled / unavailable. */
export const selectArtProvider = (config: Config): ArtProvider | null => {
if (!config.art.enabled) return null;
const key = config.art.steamGridDbKey?.trim();
switch (config.art.provider) {
case "libretro":
return new LibretroProvider();
case "steamgriddb":
if (key) return new SteamGridDbProvider(key);
log(
"art: provider=steamgriddb but no API key set — no art will be fetched (set art.steamGridDbKey or use provider=auto)",
);
return null;
default: // "auto"
return key ? new SteamGridDbProvider(key) : new LibretroProvider();
}
};
/** One title needing art — the reconcile enumeration output the warmer iterates. */
export interface ArtTarget {
externalId: string;
platform: Platform;
parsed: ParsedTitle;
}
/** Run `fn` over `items` with bounded concurrency (art providers are network-bound; stay polite). */
const mapPool = async <T>(
items: T[],
limit: number,
fn: (item: T) => Promise<void>,
): Promise<void> => {
let i = 0;
const workers = Array.from(
{ length: Math.min(limit, items.length) },
async () => {
while (i < items.length) {
const idx = i++;
const item = items[idx];
if (item !== undefined) await fn(item);
}
},
);
await Promise.all(workers);
};
/**
* Populate `cache.art` (keyed by `external_id`) for every target missing a fresh verdict — a verdict is
* fresh when it was produced by the *current* provider at its current matcher version. Returns the
* number of titles (re)resolved. Verdicts (including "no art") are cached so we never re-probe.
*/
export const warmArt = async (
provider: ArtProvider,
targets: ArtTarget[],
cache: { art: Record<string, ArtVerdict> },
opts?: { concurrency?: number },
): Promise<number> => {
const stale = targets.filter((t) => {
const v = cache.art[t.externalId];
return (
!v || v.provider !== provider.id || v.matcher !== provider.matcherVersion
);
});
let resolved = 0;
await mapPool(stale, opts?.concurrency ?? 4, async (t) => {
try {
// A definitive result (artwork OR "no art") is cached; a *transient* failure throws and is
// NOT cached, so the next sync retries it rather than pinning a false "no art".
const art: Artwork | null = await provider.resolve(t.platform, t.parsed);
cache.art[t.externalId] = {
art,
provider: provider.id,
matcher: provider.matcherVersion,
};
resolved++;
} catch (e) {
log(`art: ${t.parsed.displayTitle}: ${e}`);
}
});
return resolved;
};
+132
View File
@@ -0,0 +1,132 @@
// The preferred art provider: SteamGridDB (what Steam ROM Manager uses). Fuzzy-matches a title to a
// SteamGridDB game, then pulls the four art types the host's `Artwork` wants: a 600x900 portrait
// capsule (grid), a horizontal header (grid), a hero background, and a transparent logo.
//
// Auth is a Bearer API key (free, per-user, from steamgriddb.com profile preferences) — so, unlike
// libretro, this is opt-in. The cache verdict is keyed on a hash of the key (`id` below), so changing
// or adding a key re-resolves everything; a bad key fails fast (one 401), logs once, and short-circuits
// the rest of the run instead of hammering the API.
import { createHash } from "node:crypto";
import type { ParsedTitle } from "../engine/titles.js";
import { log } from "../log.js";
import type { Platform } from "../platforms.js";
import type { Artwork } from "../wire.js";
import type { ArtProvider } from "./provider.js";
const BASE = "https://www.steamgriddb.com/api/v2";
interface SgdbGame {
id: number;
name: string;
}
interface SgdbImage {
url: string;
width: number;
height: number;
}
/** Injectable fetch (tests) — defaults to global fetch. */
export type SgdbFetch = typeof fetch;
export class SteamGridDbProvider implements ArtProvider {
readonly id: string;
readonly matcherVersion = 1;
private authFailed = false;
constructor(
private readonly apiKey: string,
private readonly fetchImpl: SgdbFetch = fetch,
) {
// The key fingerprint scopes cached verdicts to this key: change the key → re-resolve, without
// ever storing the key itself in the cache file.
const fp = createHash("sha256").update(apiKey).digest("hex").slice(0, 8);
this.id = `steamgriddb:${fp}`;
}
/** GET a SteamGridDB endpoint → its `data`. `null` = auth-disabled (skip); throws on transient errors. */
private async get(pathAndQuery: string): Promise<unknown[] | null> {
if (this.authFailed) return null;
const res = await this.fetchImpl(`${BASE}${pathAndQuery}`, {
headers: { authorization: `Bearer ${this.apiKey}` },
});
if (res.status === 401 || res.status === 403) {
this.authFailed = true;
log(
"art: SteamGridDB rejected the API key (401/403) — check art.steamGridDbKey",
);
return null;
}
if (!res.ok) throw new Error(`SteamGridDB HTTP ${res.status}`);
const body = (await res.json()) as { success?: boolean; data?: unknown };
return Array.isArray(body.data) ? body.data : [];
}
/** Like `get`, but a transient failure yields `[]` (used for the nice-to-have hero/logo fetches). */
private async getSafe(pathAndQuery: string): Promise<unknown[]> {
try {
return (await this.get(pathAndQuery)) ?? [];
} catch {
return [];
}
}
async resolve(
_platform: Platform,
parsed: ParsedTitle,
): Promise<Artwork | null> {
const term = (parsed.displayTitle || parsed.noIntroName).trim();
if (!term) return null;
const games = (await this.get(
`/search/autocomplete/${encodeURIComponent(term)}`,
)) as SgdbGame[] | null;
if (games === null) return null; // auth disabled
const game = games[0];
if (!game) return null; // definitive: no match
// Portrait + header both come from grids (classified by orientation); hero/logo are separate,
// best-effort endpoints. Grids failing transiently should retry, so it isn't wrapped in getSafe.
const grids = (await this.get(
`/grids/game/${game.id}?types=static&nsfw=false&humor=false`,
)) as SgdbImage[] | null;
if (grids === null) return null; // auth disabled mid-run
const [heroes, logos] = await Promise.all([
this.getSafe(`/heroes/game/${game.id}?types=static`) as Promise<
SgdbImage[]
>,
this.getSafe(`/logos/game/${game.id}`) as Promise<SgdbImage[]>,
]);
const art: Artwork = {};
const portrait = pickGrid(grids, (i) => i.height > i.width, [
[600, 900],
[660, 930],
[342, 482],
]);
const header = pickGrid(grids, (i) => i.width > i.height, [
[460, 215],
[920, 430],
]);
if (portrait) art.portrait = portrait;
if (header) art.header = header;
if (heroes[0]?.url) art.hero = heroes[0].url;
if (logos[0]?.url) art.logo = logos[0].url;
return Object.keys(art).length > 0 ? art : null;
}
}
/** Pick the best grid matching `orient`, preferring the listed exact dimensions, else the first match. */
const pickGrid = (
grids: SgdbImage[],
orient: (i: SgdbImage) => boolean,
preferred: [number, number][],
): string | undefined => {
const matching = grids.filter((g) => g?.url && orient(g));
for (const [w, h] of preferred) {
const exact = matching.find((g) => g.width === w && g.height === h);
if (exact) return exact.url;
}
return matching[0]?.url;
};