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
+17 -9
View File
@@ -8,12 +8,18 @@ is tracked in the **towl** work-item store under project **`reddit-spotify-linke
- `service/` — zero-dependency Node resolver (Node 18+, built-in `fetch`/`http`). - `service/` — zero-dependency Node resolver (Node 18+, built-in `fetch`/`http`).
`resolver.js` = the HTTP server; `lib.js` = pure, side-effect-free logic (the `resolver.js` = the HTTP server; `lib.js` = pure, side-effect-free logic (the
artist-match precision lever) so it's unit-testable; `llm.js` = pure ollama exact-name match precision lever + link shaping) so it's unit-testable; `llm.js` =
prompt/parse helpers (the optional local-LLM gate, HTTP-only — no in-process pure ollama prompt/parse helpers (the optional local-LLM gate, HTTP-only — no
model runtime); `lib.test.js` + `llm.test.js` = `node:test`. The only music API in-process model runtime); `lib.test.js` + `llm.test.js` = `node:test`. The only
is Spotify (MusicBrainz removed). music API is Spotify (MusicBrainz removed). The resolver confirms **both artists
- `extension/` — Firefox **MV2** content script (`content.js` + `manifest.json` + and albums**: one `type=artist,album` Spotify search per candidate, exact
`styles.css`). No build step. case-insensitive name match against either; each result carries
`kind: "artist" | "album"`.
- `extension/` — Firefox **MV2** content script (`content.js` + `extract.js` +
`manifest.json` + `styles.css`). No build step. `extract.js` is the pure candidate
extractor (Title-Case runs + cue phrases for artists; quoted strings + album cue
phrases for albums) — node-testable via `extract.test.cjs`. Album links render
italicised (`.rsl-album`).
- `scripts/check.sh` — the local check gate. - `scripts/check.sh` — the local check gate.
## How work lands (IMPORTANT — this is NOT the towl repo) ## How work lands (IMPORTANT — this is NOT the towl repo)
@@ -66,6 +72,8 @@ fixes small things directly.
HTTP only, no in-process model runtime. The heuristic + Spotify exact-match is the HTTP only, no in-process model runtime. The heuristic + Spotify exact-match is the
zero-dep default when no LLM is configured. zero-dep default when no LLM is configured.
- Detection precision lever (zero-dep default): a candidate links **only** when - Detection precision lever (zero-dep default): a candidate links **only** when
Spotify returns an artist whose name matches it case-insensitively. A confirmed Spotify returns an **artist or album** whose name matches it case-insensitively. A
name links to its Spotify artist page if available, else a "<name> bandcamp" confirmed name links to its Spotify artist/album page if available, else a
Google fallback. "<name> bandcamp" Google fallback. Album candidates are over-generated from precise
signals only (quoted strings + album cue phrases) because album free-text is noisier
than artist names — the Spotify exact-match (and/or LLM `kind=album`) is the filter.
+29 -20
View File
@@ -1,19 +1,22 @@
# Reddit → Spotify / Bandcamp linker # Reddit → Spotify / Bandcamp linker
A Firefox extension that, while you browse a Reddit thread, finds **artist / band A Firefox extension that, while you browse a Reddit thread, finds **artist / band
names** in the comments (and the post body) and turns them into inline links to names and album titles** in the comments (and the post body) and turns them into
**Spotify** and **Bandcamp** — so a recommendation thread like inline links to **Spotify** and **Bandcamp** — so a recommendation thread like
[this one](https://www.reddit.com/r/SlamDeathMetal/comments/1txh2r1/) becomes a [this one](https://www.reddit.com/r/SlamDeathMetal/comments/1txh2r1/) becomes a
list of clickable artists instead of names you have to copy-paste into search. list of clickable artists and albums instead of names you have to copy-paste into
search.
The hard part is *"which words are artists and which aren't?"*. Rather than guess The hard part is *"which words are artists/albums and which aren't?"*. Rather than
with client-side rules, the extension hands candidate names to a tiny local guess with client-side rules, the extension hands candidate names to a tiny local
**resolver service** that confirms each one and only links the names that are **resolver service** that confirms each one and only links the names that are
really artists. The resolver confirms names two ways (you enable either or both): really artists or albums. The resolver confirms names two ways (you enable either
**Spotify** (exact-name match → direct artist pages) and an optional **local LLM** or both): **Spotify** (exact-name match across artists **and** albums → direct
via ollama (classifies which candidates are artists/albums). A confirmed name Spotify pages) and an optional **local LLM** via ollama (classifies which
links to its Spotify artist page when available, otherwise to a Google search for candidates are artists/albums). A confirmed name links to its Spotify
"<name> bandcamp". artist/album page when available, otherwise to a Google search for
"<name> bandcamp". Album links are italicised so you can tell an album from an
artist at a glance.
``` ```
reddit page content script resolver service reddit page content script resolver service
@@ -56,8 +59,9 @@ warns at startup and nothing links.
### Spotify credentials (one of the two gates) ### Spotify credentials (one of the two gates)
Spotify does two jobs: it **gates** a name (a candidate links only when Spotify has Spotify does two jobs: it **gates** a name (a candidate links only when Spotify has
that artist by exact name match) and supplies the **direct** artist-page link. To that **artist or album** by exact name match) and supplies the **direct** Spotify
enable it: page link. A single search call covers both types, so albums confirm and link with
no extra setup. To enable it:
1. Create a free app at <https://developer.spotify.com/dashboard> and copy its 1. Create a free app at <https://developer.spotify.com/dashboard> and copy its
**Client ID** and **Client Secret**. **Client ID** and **Client Secret**.
@@ -129,8 +133,9 @@ with `xpinstall.signatures.required = false`.)
1. Make sure the resolver service is running. 1. Make sure the resolver service is running.
2. Open any Reddit comments thread (a URL with `/comments/` in it). 2. Open any Reddit comments thread (a URL with `/comments/` in it).
3. Artist names become a single link — to **Spotify** if the artist is on Spotify, 3. Artist names and album titles become a single link — to **Spotify** if it's on
otherwise to **Bandcamp** — colour-coded (green = Spotify, teal = Bandcamp). New Spotify, otherwise to **Bandcamp** — colour-coded (green = Spotify, blue =
Google/Bandcamp); album links are italicised so they read as albums. New
comments loaded as you scroll are linked automatically. comments loaded as you scroll are linked automatically.
4. The toolbar icon shows the extension is active; its badge shows how many links were 4. The toolbar icon shows the extension is active; its badge shows how many links were
added on the current page. added on the current page.
@@ -162,13 +167,17 @@ let the content script reach it).
## Limitations (MVP) ## Limitations (MVP)
- **Detection is mostly capitalization-based**, with a cue-phrase pass that also catches - **Artist detection is mostly capitalization-based**, with a cue-phrase pass that also
lowercase names right after "check out / by / fan of / recommend …". Names split across catches lowercase names right after "check out / by / fan of / recommend …". Names split
inline formatting (e.g. **bold** mid-name) can still be missed. The resolver's across inline formatting (e.g. **bold** mid-name) can still be missed. The resolver's
exact-name match keeps false *positives* low; the trade-off is some false *negatives*. exact-name match keeps false *positives* low; the trade-off is some false *negatives*.
- **Links are direct to Spotify when Spotify has the artist**; otherwise a confirmed - **Album detection leans on two precise signals** — quoted titles (`"Reek of Putrefaction"`)
name links to a Google search for "<name> bandcamp" (which surfaces the artist's and album cue phrases ("the album X", "X LP/EP"). Album free-text is noisier than artist
Bandcamp page). names, so it deliberately over-generates only from these cues and lets the Spotify
exact-album match (and/or the LLM) be the precision filter. A bare album title written with
no quotes or cue may be missed.
- **Links are direct to Spotify when Spotify has the artist/album**; otherwise a confirmed
name links to a Google search for "<name> bandcamp" (which surfaces the Bandcamp page).
- **Without Spotify creds or the LLM, nothing links** — at least one gate must be - **Without Spotify creds or the LLM, nothing links** — at least one gate must be
configured. configured.
- The optional LLM adds a little latency on the **first** call (ollama warms the - The optional LLM adds a little latency on the **first** call (ollama warms the
+7 -3
View File
@@ -48,19 +48,23 @@
// ---- link building / rewriting -------------------------------------------- // ---- link building / rewriting --------------------------------------------
// Per the user's "if on spotify, link that, else a Bandcamp Google-search fallback" // 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. // 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) { function makeLink(label, links) {
const primary = links.primary || { platform: "spotify", url: links.spotify }; const primary = links.primary || { platform: "spotify", url: links.spotify };
const name = links.name || label; const name = links.name || label;
const platform = primary.platform; const platform = primary.platform;
const kind = links.kind === "album" ? "album" : "artist";
const noun = kind === "album" ? "album" : "artist";
const a = document.createElement("a"); const a = document.createElement("a");
a.className = `rsl-link rsl-${platform}`; a.className = `rsl-link rsl-${platform} rsl-${kind}`;
a.textContent = label; a.textContent = label;
a.href = primary.url; a.href = primary.url;
a.target = "_blank"; a.target = "_blank";
a.rel = "noopener noreferrer"; a.rel = "noopener noreferrer";
a.title = platform === "spotify" a.title = platform === "spotify"
? `Open ${name} on Spotify` ? `Open ${name} (${noun}) on Spotify`
: `Find ${name} on Bandcamp (Google search)`; : `Find ${name} (${noun}) on Bandcamp (Google search)`;
return a; 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. // 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; 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. // Common capitalized words that are almost never the artist being recommended.
// Deliberately small: the resolver's exact-name match is the real filter. // Deliberately small: the resolver's exact-name match is the real filter.
const STOP = new Set([ const STOP = new Set([
@@ -76,13 +84,40 @@
return out; 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). // (ascending index, longer match first so the wrapper's overlap guard prefers it).
function allMatchesIn(text) { 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); all.sort((a, b) => a.index - b.index || b.length - a.length);
return all; 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). // CJS so it can require() extract.js, which is a UMD script (also a content script).
const { test } = require("node:test"); const { test } = require("node:test");
const assert = require("node:assert/strict"); 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", () => { 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, // 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")); assert.ok(!names.includes("the"));
}); });
test("allMatchesIn merges cue + Title-Case and sorts ascending by index", () => { test("albumMatchesIn pulls quoted strings (double + single), excluding the quotes", () => {
const text = "Check out devourment and Cattle Decapitation"; 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); const ms = allMatchesIn(text);
for (let i = 1; i < ms.length; i++) assert.ok(ms[i].index >= ms[i - 1].index); for (let i = 1; i < ms.length; i++) assert.ok(ms[i].index >= ms[i - 1].index);
const names = ms.map((m) => m.name); const names = ms.map((m) => m.name);
assert.ok(names.includes("devourment")); // cue (lowercase) assert.ok(names.includes("devourment")); // cue (lowercase)
assert.ok(names.includes("Cattle Decapitation")); // title-case 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, /* Injected links — kept subtle so reddit comments stay readable. One link per artist/album,
coloured by platform: green = Spotify, blue = Google (bandcamp-search fallback). */ 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 { .rsl-link {
text-decoration: underline dotted; text-decoration: underline dotted;
text-underline-offset: 2px; text-underline-offset: 2px;
cursor: pointer; cursor: pointer;
} }
.rsl-album {
font-style: italic;
}
.rsl-spotify { .rsl-spotify {
text-decoration-color: #1db954 !important; /* spotify green */ text-decoration-color: #1db954 !important; /* spotify green */
} }
+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`)}`; export const googleSearch = (n) => `https://www.google.com/search?q=${encodeURIComponent(`${n} bandcamp`)}`;
// The precision lever: return the item whose name matches the candidate // 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) { export function pickArtist(name, items) {
const want = norm(name); const want = norm(name);
return (items ?? []).find((a) => a && norm(a.name) === want) ?? null; 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). // 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"); 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 // url (validated http(s), not a /search url); otherwise fall back to a Google→Bandcamp
// search. `primary` is what the extension renders ({ platform, url }). // search. `primary` is what the extension renders ({ platform, url }); `kind`
export function linkResult(name, spotifyUrl) { // ("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; const direct = spotifyUrl && isHttpUrl(spotifyUrl) && !isSearchUrl(spotifyUrl) ? spotifyUrl : null;
if (direct) { 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); 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. // Unit tests for the resolver's pure logic (run with `node --test`). Zero-dep.
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; 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", () => { test("norm lowercases, trims, NFKC-normalizes", () => {
assert.equal(norm(" Cerebral Bore "), "cerebral bore"); 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")); 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", () => { test("linkResult prefers a direct Spotify url, primary = spotify", () => {
const r = linkResult("Devourment", "https://open.spotify.com/artist/abc"); const r = linkResult("Devourment", "https://open.spotify.com/artist/abc");
assert.equal(r.spotify, "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); 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", () => { test("linkResult falls back to Google->Bandcamp when there is no direct Spotify url", () => {
const r = linkResult("Some Local Band", null); const r = linkResult("Some Local Band", null);
assert.equal(r.spotify, undefined); 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. // Parse the model's JSON reply into a Map of norm(name) -> "artist" | "album" for the
// Defensive: only names that were actually in the candidate list count (guards against the // candidates the model marked as music (the kind routes each to the artist or album Spotify
// model inventing names); an unparseable reply yields an empty Set, and the caller then // lookup). Defensive: only names that were actually in the candidate list count (guards
// falls back to the Spotify-exact-match gate. // 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) { export function parseClassification(text, candidates) {
const allowed = new Set(candidates.map(norm)); const allowed = new Set(candidates.map(norm));
const confirmed = new Set(); const confirmed = new Map();
let obj; let obj;
try { try {
obj = JSON.parse(text); obj = JSON.parse(text);
@@ -45,7 +47,7 @@ export function parseClassification(text, candidates) {
const k = norm(row.name); const k = norm(row.name);
if (!allowed.has(k)) continue; if (!allowed.has(k)) continue;
const kind = String(row.kind ?? "").toLowerCase(); 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; 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")); 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", () => { test("parseClassification accepts album kind and a bare array reply", () => {
const got = parseClassification( const got = parseClassification(
JSON.stringify([{ name: "Reek of Putrefaction", kind: "album" }]), JSON.stringify([{ name: "Reek of Putrefaction", kind: "album" }]),
["Reek of Putrefaction"], ["Reek of Putrefaction"],
); );
assert.ok(got.has("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", () => { 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 // Zero npm deps; Node 18+ (built-in fetch). The "which words are real artists/albums" problem
// not guessed client-side — it is gated one of two ways: // 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 // * SPOTIFY_CLIENT_ID/SECRET set -> a candidate resolves only when Spotify has an artist OR
// whose name matches it case-insensitively (the exact-match precision lever), linking to // album whose name matches it case-insensitively (the exact-match precision lever), linking
// the DIRECT Spotify artist page. // 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 which // * OLLAMA_URL set -> a local LLM (via ollama HTTP, still zero npm deps) classifies each
// candidates are artists/albums; confirmed names get a Spotify direct link when available, // candidate as artist/album/none; the kind routes the Spotify lookup, and confirmed names
// else a Google->Bandcamp search fallback. This is the better-matching path. // get a Spotify direct link when available, else a Google->Bandcamp search fallback. This
// The two combine: with both set, the LLM gates and Spotify supplies direct links. // is the better-matching path.
// The pure matching/link/prompt logic lives in lib.js + llm.js (unit-tested). // 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 { createServer } from "node:http";
import { readFileSync } from "node:fs"; 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"; import { buildClassifyMessages, parseClassification, DEFAULT_MODEL } from "./llm.js";
// --- minimal .env loader (no dependency) ------------------------------------ // --- minimal .env loader (no dependency) ------------------------------------
@@ -56,24 +58,36 @@ async function spotifyToken() {
return spToken; return spToken;
} }
// The direct Spotify artist url for an exact name match, or null (no creds / no exact hit). // The direct Spotify url for an exact name match across artist AND album, or null (no creds /
async function spotifyDirect(name) { // 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; if (!USE_SPOTIFY) return null;
const token = await spotifyToken(); 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}` } }); const r = await fetch(url, { headers: { authorization: `Bearer ${token}` } });
if (!r.ok) throw new Error(`spotify search http ${r.status}`); if (!r.ok) throw new Error(`spotify search http ${r.status}`);
const j = await r.json(); const j = await r.json();
const hit = pickArtist(name, j.artists?.items); const artist = pickArtist(name, j.artists?.items);
return hit?.external_urls?.spotify ?? null; 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) { async function spotifyDirectCached(name, kindHint) {
const key = norm(name); // 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); if (spCache.has(key)) return spCache.get(key);
const url = await spotifyDirect(name); const hit = await spotifyDirect(name, kindHint);
spCache.set(key, url); spCache.set(key, hit);
return url; return hit;
} }
// --- LLM gate (optional): which candidates are artists/albums? -------------- // --- LLM gate (optional): which candidates are artists/albums? --------------
@@ -97,12 +111,14 @@ async function ollamaClassify(candidates) {
// --- resolve -------------------------------------------------------------- // --- resolve --------------------------------------------------------------
// allowFallback: when the gate has already confirmed this name is an artist/album, a missing // 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). // 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; if (!norm(name)) return null;
try { try {
const sp = await spotifyDirectCached(name); const sp = await spotifyDirectCached(name, kindHint);
if (sp) return linkResult(name, sp); // direct Spotify page if (sp) return linkResult(name, sp.url, sp.kind); // direct Spotify page (artist or album)
return allowFallback ? linkResult(name, null) : null; // google fallback, or unconfirmed return allowFallback ? linkResult(name, null, kindHint) : null; // google fallback, or unconfirmed
} catch (e) { } catch (e) {
console.error(`resolve "${name}": ${e.message}`); // transient -> caller sees null this time console.error(`resolve "${name}": ${e.message}`); // transient -> caller sees null this time
return null; return null;
@@ -112,9 +128,10 @@ async function resolveOne(name, allowFallback) {
async function resolveAll(candidates) { async function resolveAll(candidates) {
const uniq = [...new Set(candidates.map((c) => String(c).trim()).filter(Boolean))].slice(0, MAX_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 // Gate: which names may link? null => no LLM gate, the Spotify exact-match (artist OR album)
// (only names actually on Spotify link). A Set => the LLM's verdict; names not in it are // is the gate (only names actually on Spotify link). A Map(name -> "artist"|"album") => the
// dropped, names in it may use the Google fallback when not on Spotify. // 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; let confirmed = null;
if (USE_LLM && uniq.length) { if (USE_LLM && uniq.length) {
try { try {
@@ -132,7 +149,8 @@ async function resolveAll(candidates) {
out[name] = null; // LLM said this is not an artist/album out[name] = null; // LLM said this is not an artist/album
return; 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; return out;