// 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; } /** 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 ( items: T[], limit: number, fn: (item: T) => Promise, ): Promise => { 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 }, opts?: { concurrency?: number; log?: ArtLog }, ): Promise => { 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; };