// Pure album/artist resolution orchestration, decoupled from the HTTP server and the live // Spotify/ollama clients so `node --test` can drive it with injected fakes. resolver.js wires // the real `search` (a type=artist,album Spotify lookup behind the exact-name gate) and `classify` // (the ollama LLM gate) into these functions; the cache-key logic, the kindHint tie-break // ordering, and the allowFallback gating live here as pure logic. import { norm, linkResult } from "./lib.js"; // Cap on how many distinct candidates one resolveAll call considers. export const MAX_CANDIDATES = 100; // Decide the single direct-link {url, kind} for a name from a search result, or null. `result` // is what an injected `search(name)` returns: { artist, album } where each is the exact-match // hit's external url (string) or null/absent. `kindHint` ("artist" | "album", from the LLM gate) // breaks the tie when BOTH an artist and an album match the same string (e.g. a self-titled // album): album-first when hinted "album", else artist-first (the artist-first MVP default). export function pickDirect(result, kindHint) { const artist = result?.artist ?? null; const album = result?.album ?? null; const order = kindHint === "album" ? [["album", album], ["artist", artist]] : [["artist", artist], ["album", album]]; for (const [kind, url] of order) { if (url) return { url, kind }; } return null; } // The cache key for a name's direct-link lookup. It includes the kind hint because the same // string can resolve to an artist OR an album depending on which the gate confirmed, so a // hint-blind key would cross-contaminate the two. export const cacheKey = (name, kindHint) => `${kindHint || "artist"}:${norm(name)}`; // Resolve one name to its link object, or null. `search(name) -> { artist, album }` is injected // (the live Spotify lookup, or a fake). `cache` is a Map(cacheKey -> {url, kind}|null) memoizing // the search half; the link object is rebuilt per call so a name's result never depends on a // stale gate decision. allowFallback: when the gate already confirmed this name, a missing direct // link still yields a Google->Bandcamp fallback link (instead of null). kindHint biases the // lookup tie-break and the fallback link's kind; an actual exact hit's kind wins when there is one. export async function resolveOne(name, { search, cache, allowFallback, kindHint }) { if (!norm(name)) return null; try { const key = cacheKey(name, kindHint); let hit; if (cache && cache.has(key)) { hit = cache.get(key); } else { hit = pickDirect(await search(name, kindHint), kindHint); if (cache) cache.set(key, hit); } if (hit) return linkResult(name, hit.url, hit.kind); // direct Spotify page (artist or album) return allowFallback ? linkResult(name, null, kindHint) : null; // google fallback, or unconfirmed } catch (e) { console.error(`resolve "${name}": ${e.message}`); // transient -> caller sees null this time return null; } } // Resolve a batch of candidate names to a { name -> link|null } map. Dependencies are injected: // search(name, kindHint) -> { artist, album } (live Spotify lookup or a fake; required) // classify(names) -> Map(norm(name) -> "artist"|"album") | null (the optional LLM gate; when // it returns null — no gate, or LLM down — the Spotify exact-match IS the gate: only names // actually on Spotify link. When it returns a Map, names absent from it are dropped, names in // it may use the Google fallback when not on Spotify, and the mapped kind routes the lookup.) // cache Map for memoizing the search half (optional) export async function resolveAll(candidates, { search, classify, cache } = {}) { const uniq = [...new Set(candidates.map((c) => String(c).trim()).filter(Boolean))].slice(0, MAX_CANDIDATES); let confirmed = null; if (classify && uniq.length) { try { confirmed = await classify(uniq); } catch (e) { console.error(`llm classify: ${e.message}`); // LLM down -> fall back to the Spotify gate confirmed = null; } } const out = {}; await Promise.all( uniq.map(async (name) => { if (confirmed && !confirmed.has(norm(name))) { out[name] = null; // LLM said this is not an artist/album return; } const kindHint = confirmed ? confirmed.get(norm(name)) : undefined; out[name] = await resolveOne(name, { search, cache, allowFallback: confirmed != null, kindHint, }); }), ); return out; }