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
+7 -3
View File
@@ -48,19 +48,23 @@
// ---- link building / rewriting --------------------------------------------
// Per the user's "if on spotify, link that, else a Bandcamp Google-search fallback"
// rule: one link to the primary platform, colour-coded so you can tell which at a glance.
// `kind` ("artist" | "album", absent => "artist") adds an rsl-album class + an album word
// in the tooltip so album links read as albums; the platform colour is unchanged.
function makeLink(label, links) {
const primary = links.primary || { platform: "spotify", url: links.spotify };
const name = links.name || label;
const platform = primary.platform;
const kind = links.kind === "album" ? "album" : "artist";
const noun = kind === "album" ? "album" : "artist";
const a = document.createElement("a");
a.className = `rsl-link rsl-${platform}`;
a.className = `rsl-link rsl-${platform} rsl-${kind}`;
a.textContent = label;
a.href = primary.url;
a.target = "_blank";
a.rel = "noopener noreferrer";
a.title = platform === "spotify"
? `Open ${name} on Spotify`
: `Find ${name} on Bandcamp (Google search)`;
? `Open ${name} (${noun}) on Spotify`
: `Find ${name} (${noun}) on Bandcamp (Google search)`;
return a;
}
+38 -3
View File
@@ -21,6 +21,14 @@
// the `d` flag gives us the group's position so the match can be wrapped in place.
const CUE_RE = /\b(?:by|check ?out|listen(?: to)?|recommend(?:ed|ations?)?|fan of|into|loves?|playing)\s+([A-Za-z][A-Za-z0-9'.&-]*(?:\s+[A-Za-z0-9][A-Za-z0-9'.&-]*)?)/gid;
// Albums are noisier free-text than artists, so only TWO precise signals generate album
// candidates — the Spotify exact-album match (and/or LLM) is still the precision filter:
// 1. A quoted string ("Reek of Putrefaction" or 'Scum') — a strong "this is a title" cue.
// 2. An album cue phrase ("the album Scum", "Scum LP/EP") naming the next 1-4 words.
// Group 1 of each holds the candidate; the `d` flag gives its position for in-place wrapping.
const QUOTED_RE = /["]([^"]{2,80})["]|[']([^']{2,80})[']/gd;
const ALBUM_CUE_RE = /\b(?:the )?(?:album|record|ep|lp)\s+(?:called\s+|titled\s+|named\s+)?([A-Za-z0-9][A-Za-z0-9'.&\- ]{0,79}?)(?=[.,;:!?)\]"']|\s+(?:is|was|by|and|on|from|which|that)\b|$)|\b([A-Za-z0-9][A-Za-z0-9'.&\- ]{0,79}?)\s+(?:LP|EP)\b/gd;
// Common capitalized words that are almost never the artist being recommended.
// Deliberately small: the resolver's exact-name match is the real filter.
const STOP = new Set([
@@ -76,13 +84,40 @@
return out;
}
// Union of Title-Case + cue matches, sorted for the in-place wrapper
// Album candidates from the two precise signals (quoted strings + album cue phrases).
// Each match is { name, index, length } like the others; the server's exact Spotify
// album match is what actually confirms it (this only over-generates the candidates).
function albumMatchesIn(text) {
const out = [];
QUOTED_RE.lastIndex = 0;
let m;
while ((m = QUOTED_RE.exec(text))) {
const gi = m.indices[1] || m.indices[2]; // double- or single-quoted group
if (!gi) continue;
const name = text.slice(gi[0], gi[1]).trim();
if (name.length < 2 || /^\d+$/.test(name)) continue;
// index/length cover the inner text only (no quotes), so the wrapper links the title.
out.push({ name, index: gi[0], length: gi[1] - gi[0] });
}
ALBUM_CUE_RE.lastIndex = 0;
while ((m = ALBUM_CUE_RE.exec(text))) {
const gi = m.indices[1] || m.indices[2]; // "album X" group or "X LP/EP" group
if (!gi) continue;
const raw = text.slice(gi[0], gi[1]);
const name = raw.replace(TRAILING_PUNCT, "").trim();
if (name.length < 2 || /^\d+$/.test(name)) continue;
out.push({ name, index: gi[0], length: name.length });
}
return out;
}
// Union of Title-Case + cue + album matches, sorted for the in-place wrapper
// (ascending index, longer match first so the wrapper's overlap guard prefers it).
function allMatchesIn(text) {
const all = matchesIn(text).concat(cueMatchesIn(text));
const all = matchesIn(text).concat(cueMatchesIn(text), albumMatchesIn(text));
all.sort((a, b) => a.index - b.index || b.length - a.length);
return all;
}
return { matchesIn, cueMatchesIn, allMatchesIn };
return { matchesIn, cueMatchesIn, albumMatchesIn, allMatchesIn };
});
+34 -3
View File
@@ -2,7 +2,7 @@
// CJS so it can require() extract.js, which is a UMD script (also a content script).
const { test } = require("node:test");
const assert = require("node:assert/strict");
const { matchesIn, cueMatchesIn, allMatchesIn } = require("./extract.js");
const { matchesIn, cueMatchesIn, albumMatchesIn, allMatchesIn } = require("./extract.js");
test("matchesIn finds Title-Case / ALL-CAPS runs, skips stopwords", () => {
// Note: "and" is a connector, so "X and Y" greedily joins into one run (by design,
@@ -34,11 +34,42 @@ test("cueMatchesIn skips lowercase stopwords right after the cue", () => {
assert.ok(!names.includes("the"));
});
test("allMatchesIn merges cue + Title-Case and sorts ascending by index", () => {
const text = "Check out devourment and Cattle Decapitation";
test("albumMatchesIn pulls quoted strings (double + single), excluding the quotes", () => {
const text = `their album "Reek of Putrefaction" and the 'Scum' demo`;
const ms = albumMatchesIn(text);
const names = ms.map((m) => m.name);
assert.ok(names.includes("Reek of Putrefaction"));
assert.ok(names.includes("Scum"));
// index/length cover the inner text only (no quote chars) so the wrapper links the title
for (const m of ms) assert.equal(text.substr(m.index, m.length), m.name);
});
test("albumMatchesIn catches album cue phrases ('the album X', 'X LP/EP')", () => {
const a = albumMatchesIn("the album Scum is a classic").map((m) => m.name);
assert.ok(a.includes("Scum"));
const b = albumMatchesIn("Reign in Blood LP rules").map((m) => m.name);
assert.ok(b.includes("Reign in Blood"));
const c = albumMatchesIn("the record called Bonus Tracks").map((m) => m.name);
assert.ok(c.includes("Bonus Tracks"));
});
test("albumMatchesIn cue positions map back to the text", () => {
const text = "the album Scum is great";
for (const m of albumMatchesIn(text)) assert.equal(text.substr(m.index, m.length), m.name);
});
test("albumMatchesIn skips pure-number and too-short quotes", () => {
const names = albumMatchesIn(`"42" and "X"`).map((m) => m.name);
assert.ok(!names.includes("42"));
assert.ok(!names.includes("X")); // length < 2
});
test("allMatchesIn merges cue + Title-Case + album and sorts ascending by index", () => {
const text = `Check out devourment and Cattle Decapitation; the album "Human Jerky" rules`;
const ms = allMatchesIn(text);
for (let i = 1; i < ms.length; i++) assert.ok(ms[i].index >= ms[i - 1].index);
const names = ms.map((m) => m.name);
assert.ok(names.includes("devourment")); // cue (lowercase)
assert.ok(names.includes("Cattle Decapitation")); // title-case
assert.ok(names.includes("Human Jerky")); // quoted album
});
+7 -2
View File
@@ -1,10 +1,15 @@
/* Injected links — kept subtle so reddit comments stay readable. One link per artist,
coloured by platform: green = Spotify, blue = Google (bandcamp-search fallback). */
/* Injected links — kept subtle so reddit comments stay readable. One link per artist/album,
coloured by platform: green = Spotify, blue = Google (bandcamp-search fallback). Album links
are italicised (the usual title convention) so an album reads differently from an artist at
a glance; the platform colour is unchanged. */
.rsl-link {
text-decoration: underline dotted;
text-underline-offset: 2px;
cursor: pointer;
}
.rsl-album {
font-style: italic;
}
.rsl-spotify {
text-decoration-color: #1db954 !important; /* spotify green */
}