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:
paul
2026-06-22 22:33:32 +00:00
co-authored by Claude Opus 4.8
parent ef57761859
commit 2b4babf955
11 changed files with 243 additions and 83 deletions
+13 -6
View File
@@ -9,12 +9,16 @@ export const norm = (s) => String(s).normalize("NFKC").trim().toLowerCase();
export const googleSearch = (n) => `https://www.google.com/search?q=${encodeURIComponent(`${n} bandcamp`)}`;
// The precision lever: return the item whose name matches the candidate
// case-insensitively, else null. Tolerates null/garbage items.
// case-insensitively, else null. Tolerates null/garbage items. Used for both
// Spotify artist items and album items — the exact-name match is the same gate.
export function pickArtist(name, items) {
const want = norm(name);
return (items ?? []).find((a) => a && norm(a.name) === want) ?? null;
}
// Same exact-name gate, named for the album path's readability.
export const pickAlbum = pickArtist;
// A "/search" url means we don't have a direct artist page (just a search fallback).
export const isSearchUrl = (url) => typeof url === "string" && url.includes("/search");
@@ -29,14 +33,17 @@ export function isHttpUrl(u) {
}
}
// Build the single link to show for a confirmed name. Prefer the direct Spotify artist
// Build the single link to show for a confirmed name. Prefer the direct Spotify
// url (validated http(s), not a /search url); otherwise fall back to a Google→Bandcamp
// search. `primary` is what the extension renders ({ platform, url }).
export function linkResult(name, spotifyUrl) {
// search. `primary` is what the extension renders ({ platform, url }); `kind`
// ("artist" | "album") tells it which affordance to show. `kind` is additive — the
// extension defaults to "artist" when absent, so older clients keep working.
export function linkResult(name, spotifyUrl, kind = "artist") {
const k = kind === "album" ? "album" : "artist";
const direct = spotifyUrl && isHttpUrl(spotifyUrl) && !isSearchUrl(spotifyUrl) ? spotifyUrl : null;
if (direct) {
return { name, spotify: direct, primary: { platform: "spotify", url: direct } };
return { name, kind: k, spotify: direct, primary: { platform: "spotify", url: direct } };
}
const url = googleSearch(name);
return { name, google: url, primary: { platform: "google", url } };
return { name, kind: k, google: url, primary: { platform: "google", url } };
}
+26 -1
View File
@@ -1,7 +1,7 @@
// Unit tests for the resolver's pure logic (run with `node --test`). Zero-dep.
import { test } from "node:test";
import assert from "node:assert/strict";
import { norm, googleSearch, pickArtist, linkResult, isSearchUrl, isHttpUrl } from "./lib.js";
import { norm, googleSearch, pickArtist, pickAlbum, linkResult, isSearchUrl, isHttpUrl } from "./lib.js";
test("norm lowercases, trims, NFKC-normalizes", () => {
assert.equal(norm(" Cerebral Bore "), "cerebral bore");
@@ -28,6 +28,14 @@ test("googleSearch points at a Google query for '<name> bandcamp', url-encoded",
assert.ok(googleSearch("A&B").includes("A%26B"));
});
test("pickAlbum is the same exact-name gate as pickArtist (album items)", () => {
const items = [{ name: "Scum" }, { name: "Reek of Putrefaction" }];
assert.equal(pickAlbum("reek of putrefaction", items)?.name, "Reek of Putrefaction");
assert.equal(pickAlbum("SCUM", items)?.name, "Scum");
assert.equal(pickAlbum("Not an album", items), null);
assert.equal(pickAlbum, pickArtist); // intentionally the same function, named for readability
});
test("linkResult prefers a direct Spotify url, primary = spotify", () => {
const r = linkResult("Devourment", "https://open.spotify.com/artist/abc");
assert.equal(r.spotify, "https://open.spotify.com/artist/abc");
@@ -35,6 +43,23 @@ test("linkResult prefers a direct Spotify url, primary = spotify", () => {
assert.equal(r.google, undefined);
});
test("linkResult defaults kind to artist, carries kind=album when asked", () => {
assert.equal(linkResult("Devourment", "https://open.spotify.com/artist/abc").kind, "artist");
const al = linkResult("Reek of Putrefaction", "https://open.spotify.com/album/xyz", "album");
assert.equal(al.kind, "album");
assert.equal(al.spotify, "https://open.spotify.com/album/xyz");
assert.deepEqual(al.primary, { platform: "spotify", url: "https://open.spotify.com/album/xyz" });
// an unknown kind string degrades to "artist" rather than passing through
assert.equal(linkResult("X", "https://open.spotify.com/artist/abc", "bogus").kind, "artist");
});
test("linkResult keeps kind on the Google fallback path too", () => {
const r = linkResult("Some Local Album", null, "album");
assert.equal(r.kind, "album");
assert.equal(r.primary.platform, "google");
assert.match(r.primary.url, /^https:\/\/www\.google\.com\/search\?q=/);
});
test("linkResult falls back to Google->Bandcamp when there is no direct Spotify url", () => {
const r = linkResult("Some Local Band", null);
assert.equal(r.spotify, undefined);
+8 -6
View File
@@ -26,13 +26,15 @@ export function buildClassifyMessages(candidates) {
];
}
// Parse the model's JSON reply into a Set of norm(name) the model marked artist/album.
// Defensive: only names that were actually in the candidate list count (guards against the
// model inventing names); an unparseable reply yields an empty Set, and the caller then
// falls back to the Spotify-exact-match gate.
// Parse the model's JSON reply into a Map of norm(name) -> "artist" | "album" for the
// candidates the model marked as music (the kind routes each to the artist or album Spotify
// lookup). Defensive: only names that were actually in the candidate list count (guards
// against the model inventing names); an unparseable reply yields an empty Map, and the
// caller then falls back to the Spotify-exact-match gate. A Map still answers `.has()` like
// the old Set, so a name absent from it is dropped exactly as before.
export function parseClassification(text, candidates) {
const allowed = new Set(candidates.map(norm));
const confirmed = new Set();
const confirmed = new Map();
let obj;
try {
obj = JSON.parse(text);
@@ -45,7 +47,7 @@ export function parseClassification(text, candidates) {
const k = norm(row.name);
if (!allowed.has(k)) continue;
const kind = String(row.kind ?? "").toLowerCase();
if (kind === "artist" || kind === "album") confirmed.add(k);
if (kind === "artist" || kind === "album") confirmed.set(k, kind);
}
return confirmed;
}
+16
View File
@@ -31,12 +31,28 @@ test("parseClassification keeps only artist/album candidates that were in the li
assert.ok(!got.has("the best part"));
});
test("parseClassification returns a Map of norm(name) -> kind for routing", () => {
const got = parseClassification(
JSON.stringify({
results: [
{ name: "Cattle Decapitation", kind: "artist" },
{ name: "Reek of Putrefaction", kind: "ALBUM" }, // case-insensitive kind
],
}),
["Cattle Decapitation", "Reek of Putrefaction"],
);
assert.ok(got instanceof Map);
assert.equal(got.get("cattle decapitation"), "artist");
assert.equal(got.get("reek of putrefaction"), "album"); // normalized to lowercase kind
});
test("parseClassification accepts album kind and a bare array reply", () => {
const got = parseClassification(
JSON.stringify([{ name: "Reek of Putrefaction", kind: "album" }]),
["Reek of Putrefaction"],
);
assert.ok(got.has("reek of putrefaction"));
assert.equal(got.get("reek of putrefaction"), "album");
});
test("parseClassification ignores hallucinated names not in the candidate list", () => {
+48 -30
View File
@@ -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;