Files
punktfunk-plugin-rom-manager/plugin/src/art/provider.ts
T
enricobuehler d6cf5f3d36 refactor: workspace restructure — contract package + pure domain ported, tests green
- bun workspaces: contract/ (Schemas = source of truth: config raw↔resolved,
  domain DTOs, HttpApi contract, API errors) + plugin/ + ui/
- pure domain moved src/{engine,art}→plugin/src/{domain,art}, types now
  imported from @rom-manager/contract and @punktfunk/plugin-kit/wire (the
  hand-copied wire.ts dies), loggers injected instead of module-global
- ui.* and devEntry dropped from the config shape (standalone server is
  gone; stale keys tolerated on decode, dropped by the next save)
- all 48 domain tests ported and green BEFORE any Effect wiring

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 18:54:38 +02:00

120 lines
4.2 KiB
TypeScript

// 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 { Artwork } from "@punktfunk/plugin-kit/wire";
import type { Config } from "@rom-manager/contract";
import type { Platform } from "../domain/platforms.js";
import type { ParsedTitle } from "../domain/titles.js";
import { LibretroProvider } from "./libretro.js";
import { SteamGridDbProvider } from "./steamgriddb.js";
export type ArtLog = (line: string) => void;
const noopLog: ArtLog = () => {};
/** One cached art verdict: the resolved artwork (or `null` = nothing found), tagged with
* the provider and its matcher version so a provider switch or algorithm bump re-resolves. */
export interface ArtVerdict {
art: Artwork | null;
provider: string;
matcher: number;
}
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,
log: ArtLog = noopLog,
): 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, fetch, log);
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, fetch, log)
: 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; log?: ArtLog },
): Promise<number> => {
const log = opts?.log ?? noopLog;
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;
};