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>
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
// 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;
|
||||
};
|
||||
Reference in New Issue
Block a user