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>
This commit is contained in:
+48
-30
@@ -1,19 +1,21 @@
|
||||
// Tiny resolver service: candidate names -> artist links, for CONFIRMED artists only.
|
||||
// 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" 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
|
||||
// whose name matches it case-insensitively (the exact-match precision lever), linking to
|
||||
// the DIRECT Spotify artist page.
|
||||
// * OLLAMA_URL set -> a local LLM (via ollama HTTP, still zero npm deps) classifies which
|
||||
// candidates are artists/albums; 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 and Spotify supplies direct links.
|
||||
// The pure matching/link/prompt logic lives in lib.js + llm.js (unit-tested).
|
||||
// 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, linkResult } from "./lib.js";
|
||||
import { norm, pickArtist, pickAlbum, linkResult } from "./lib.js";
|
||||
import { buildClassifyMessages, parseClassification, DEFAULT_MODEL } from "./llm.js";
|
||||
|
||||
// --- minimal .env loader (no dependency) ------------------------------------
|
||||
@@ -56,24 +58,36 @@ async function spotifyToken() {
|
||||
return spToken;
|
||||
}
|
||||
|
||||
// The direct Spotify artist url for an exact name match, or null (no creds / no exact hit).
|
||||
async function spotifyDirect(name) {
|
||||
// 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&limit=5&q=${encodeURIComponent(name)}`;
|
||||
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 hit = pickArtist(name, j.artists?.items);
|
||||
return hit?.external_urls?.spotify ?? null;
|
||||
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) {
|
||||
const key = norm(name);
|
||||
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 url = await spotifyDirect(name);
|
||||
spCache.set(key, url);
|
||||
return url;
|
||||
const hit = await spotifyDirect(name, kindHint);
|
||||
spCache.set(key, hit);
|
||||
return hit;
|
||||
}
|
||||
|
||||
// --- LLM gate (optional): which candidates are artists/albums? --------------
|
||||
@@ -97,12 +111,14 @@ async function ollamaClassify(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).
|
||||
async function resolveOne(name, allowFallback) {
|
||||
// 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);
|
||||
if (sp) return linkResult(name, sp); // direct Spotify page
|
||||
return allowFallback ? linkResult(name, null) : null; // google fallback, or unconfirmed
|
||||
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;
|
||||
@@ -112,9 +128,10 @@ async function resolveOne(name, allowFallback) {
|
||||
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 is the gate
|
||||
// (only names actually on Spotify link). A Set => the LLM's verdict; names not in it are
|
||||
// dropped, names in it may use the Google fallback when not on Spotify.
|
||||
// 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 {
|
||||
@@ -132,7 +149,8 @@ async function resolveAll(candidates) {
|
||||
out[name] = null; // LLM said this is not an artist/album
|
||||
return;
|
||||
}
|
||||
out[name] = await resolveOne(name, confirmed != null);
|
||||
const kindHint = confirmed ? confirmed.get(norm(name)) : undefined;
|
||||
out[name] = await resolveOne(name, confirmed != null, kindHint);
|
||||
}),
|
||||
);
|
||||
return out;
|
||||
|
||||
Reference in New Issue
Block a user