Files
linker/service/resolver.js
T
paulandClaude Opus 4.8 2b4babf955 feat: detect & link album mentions, not just artists (closes task/album-detection)
Source: task/album-detection (objective/linker-improvements) — project brief asks for artist OR album linking; MVP linked artists only.

- service/lib.js: pickAlbum (the exact-name gate, named for the album path) + linkResult now carries kind:"artist"|"album" (additive; defaults to artist so older clients keep working).
- service/resolver.js: one Spotify type=artist,album search per candidate, exact-match against either; returns {url,kind}; cache key includes the kind hint. resolveOne/resolveAll thread the LLM kind hint through.
- service/llm.js: parseClassification now returns Map(name -> "artist"|"album") so album-kind candidates route to the album path (.has() still works like the old Set).
- extension/extract.js: albumMatchesIn over two precise signals (quoted strings + album cue phrases "the album X", "X LP/EP"), folded into allMatchesIn.
- extension/content.js + styles.css: album links render italicised (.rsl-album) with an album tooltip; artist behavior unchanged.
- Tests: new pure-logic coverage in lib.test.js / llm.test.js / extract.test.cjs (album shaping, Map-kind routing, album candidate extraction). scripts/check.sh green.
- README.md + AGENTS.md: document album linking.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 22:33:32 +00:00

208 lines
9.0 KiB
JavaScript

// 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.");
}
});