d6cf5f3d36
- 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>
120 lines
4.2 KiB
TypeScript
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;
|
|
};
|