feat: initial ROM & emulator manager plugin (M0–M5)
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user