// Tiny resolver service: candidate names -> artist/album links, for CONFIRMED names only. // // Zero npm deps; Node 18+ (built-in fetch). The "which words are real artists/albums" problem // is not guessed client-side — it is gated one of two ways: // * SPOTIFY_CLIENT_ID/SECRET set -> a candidate resolves only when Spotify has an artist OR // album whose name matches it case-insensitively (the exact-match precision lever), linking // to the DIRECT Spotify artist/album page. One search call covers both types. // * OLLAMA_URL set -> a local LLM (via ollama HTTP, still zero npm deps) classifies each // candidate as artist/album/none; the kind routes the Spotify lookup, and confirmed names // get a Spotify direct link when available, else a Google->Bandcamp search fallback. This // is the better-matching path. // The two combine: with both set, the LLM gates + routes the kind and Spotify supplies direct // links. Each result carries kind:"artist"|"album". The pure matching/link/prompt logic lives // in lib.js + llm.js (unit-tested). import { createServer } from "node:http"; import { readFileSync } from "node:fs"; import { norm, pickArtist, pickAlbum, linkResult } from "./lib.js"; import { buildClassifyMessages, parseClassification, DEFAULT_MODEL } from "./llm.js"; // --- minimal .env loader (no dependency) ------------------------------------ try { const env = readFileSync(new URL("./.env", import.meta.url), "utf8"); for (const line of env.split("\n")) { const m = line.match(/^\s*([\w.-]+)\s*=\s*(.*?)\s*$/); if (m && !(m[1] in process.env)) process.env[m[1]] = m[2].replace(/^["']|["']$/g, ""); } } catch { /* no .env -> use real env / defaults */ } const PORT = Number(process.env.PORT) || 8787; const SPOTIFY_ID = process.env.SPOTIFY_CLIENT_ID; const SPOTIFY_SECRET = process.env.SPOTIFY_CLIENT_SECRET; const USE_SPOTIFY = Boolean(SPOTIFY_ID && SPOTIFY_SECRET); const OLLAMA_URL = (process.env.OLLAMA_URL || "").replace(/\/$/, ""); // presence enables the LLM gate const OLLAMA_MODEL = process.env.OLLAMA_MODEL || DEFAULT_MODEL; const USE_LLM = Boolean(OLLAMA_URL); const MAX_CANDIDATES = 100; // Cache only the network half (Spotify direct-url lookup); the final link object is rebuilt // per request so a name's result never depends on a stale gate decision. norm(name) -> url|null const spCache = new Map(); // --- Spotify (optional): direct-artist-link lookup -------------------------- let spToken = null; let spTokenExp = 0; async function spotifyToken() { if (spToken && Date.now() < spTokenExp) return spToken; const auth = Buffer.from(`${SPOTIFY_ID}:${SPOTIFY_SECRET}`).toString("base64"); const r = await fetch("https://accounts.spotify.com/api/token", { method: "POST", headers: { authorization: `Basic ${auth}`, "content-type": "application/x-www-form-urlencoded" }, body: "grant_type=client_credentials", }); if (!r.ok) throw new Error(`spotify token http ${r.status}`); const j = await r.json(); spToken = j.access_token; spTokenExp = Date.now() + (j.expires_in - 60) * 1000; return spToken; } // The direct Spotify url for an exact name match across artist AND album, or null (no creds / // no exact hit). One search call covers both types; `kindHint` ("artist" | "album", from the // LLM gate) decides which exact hit wins when BOTH match the same string (e.g. a self-titled // album) — default prefers the artist, matching the artist-first MVP behaviour. Returns // { url, kind } so the caller can shape an artist vs album link. async function spotifyDirect(name, kindHint) { if (!USE_SPOTIFY) return null; const token = await spotifyToken(); const url = `https://api.spotify.com/v1/search?type=artist,album&limit=5&q=${encodeURIComponent(name)}`; const r = await fetch(url, { headers: { authorization: `Bearer ${token}` } }); if (!r.ok) throw new Error(`spotify search http ${r.status}`); const j = await r.json(); const artist = pickArtist(name, j.artists?.items); const album = pickAlbum(name, j.albums?.items); const order = kindHint === "album" ? [["album", album], ["artist", artist]] : [["artist", artist], ["album", album]]; for (const [kind, hit] of order) { const u = hit?.external_urls?.spotify; if (u) return { url: u, kind }; } return null; } async function spotifyDirectCached(name, kindHint) { // Cache key includes the kind hint: the same string can resolve to an artist OR an album // depending on which the gate confirmed, so a hint-blind cache would cross-contaminate. const key = `${kindHint || "artist"}:${norm(name)}`; if (spCache.has(key)) return spCache.get(key); const hit = await spotifyDirect(name, kindHint); spCache.set(key, hit); return hit; } // --- LLM gate (optional): which candidates are artists/albums? -------------- async function ollamaClassify(candidates) { const r = await fetch(`${OLLAMA_URL}/api/chat`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ model: OLLAMA_MODEL, messages: buildClassifyMessages(candidates), stream: false, format: "json", options: { temperature: 0 }, }), }); if (!r.ok) throw new Error(`ollama http ${r.status}`); const j = await r.json(); return parseClassification(j.message?.content ?? "", candidates); } // --- resolve -------------------------------------------------------------- // allowFallback: when the gate has already confirmed this name is an artist/album, a missing // Spotify direct link still yields a Google->Bandcamp fallback link (instead of null). // kindHint ("artist" | "album", from the LLM gate or undefined) biases the Spotify lookup and // the fallback link's kind; the actual exact Spotify hit's kind wins when there is one. async function resolveOne(name, allowFallback, kindHint) { if (!norm(name)) return null; try { const sp = await spotifyDirectCached(name, kindHint); if (sp) return linkResult(name, sp.url, sp.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; } } async function resolveAll(candidates) { const uniq = [...new Set(candidates.map((c) => String(c).trim()).filter(Boolean))].slice(0, MAX_CANDIDATES); // Gate: which names may link? null => no LLM gate, the Spotify exact-match (artist OR album) // is the gate (only names actually on Spotify link). A Map(name -> "artist"|"album") => the // LLM's verdict; names not in it are dropped, names in it may use the Google fallback when // not on Spotify, and the mapped kind routes the Spotify lookup to the right type. let confirmed = null; if (USE_LLM && uniq.length) { try { confirmed = await ollamaClassify(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, confirmed != null, kindHint); }), ); return out; } // --- HTTP ------------------------------------------------------------------- function readBody(req) { return new Promise((resolve, reject) => { let data = ""; req.on("data", (c) => { data += c; if (data.length > 1e6) { reject(new Error("body too large")); req.destroy(); } }); req.on("end", () => resolve(data)); req.on("error", reject); }); } const json = (res, code, obj) => { res.writeHead(code, { "content-type": "application/json" }); res.end(JSON.stringify(obj)); }; const server = createServer(async (req, res) => { res.setHeader("access-control-allow-origin", "*"); res.setHeader("access-control-allow-headers", "content-type"); res.setHeader("access-control-allow-methods", "GET, POST, OPTIONS"); if (req.method === "OPTIONS") { res.writeHead(204); return res.end(); } if (req.method === "GET" && req.url === "/health") { return json(res, 200, { ok: true, spotify: USE_SPOTIFY, llm: USE_LLM ? OLLAMA_MODEL : false, cached: spCache.size }); } if (req.method === "POST" && req.url === "/resolve") { try { const { candidates } = JSON.parse((await readBody(req)) || "{}"); if (!Array.isArray(candidates)) return json(res, 400, { error: "candidates must be an array" }); return json(res, 200, await resolveAll(candidates)); } catch (e) { return json(res, 500, { error: e.message }); } } json(res, 404, { error: "not found" }); }); server.listen(PORT, () => { const sp = USE_SPOTIFY ? "on" : "off"; const llm = USE_LLM ? OLLAMA_MODEL : "off"; console.log(`resolver listening on http://localhost:${PORT} (spotify: ${sp}, llm: ${llm})`); if (!USE_SPOTIFY && !USE_LLM) { console.warn(" ! no gate configured: set SPOTIFY_CLIENT_ID/SECRET and/or OLLAMA_URL, or nothing will link."); } });