From 2b4babf955c62d301abc1dd08c0cfa5e43486f00 Mon Sep 17 00:00:00 2001 From: paul Date: Mon, 22 Jun 2026 22:33:32 +0000 Subject: [PATCH] feat: detect & link album mentions, not just artists (closes task/album-detection) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- AGENTS.md | 26 ++++++++----- README.md | 49 ++++++++++++++---------- extension/content.js | 10 +++-- extension/extract.js | 41 ++++++++++++++++++-- extension/extract.test.cjs | 37 ++++++++++++++++-- extension/styles.css | 9 ++++- service/lib.js | 19 +++++++--- service/lib.test.js | 27 ++++++++++++- service/llm.js | 14 ++++--- service/llm.test.js | 16 ++++++++ service/resolver.js | 78 +++++++++++++++++++++++--------------- 11 files changed, 243 insertions(+), 83 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 960c4a1..c75df73 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`). `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 - prompt/parse helpers (the optional local-LLM gate, HTTP-only — no in-process - model runtime); `lib.test.js` + `llm.test.js` = `node:test`. The only music API - is Spotify (MusicBrainz removed). -- `extension/` — Firefox **MV2** content script (`content.js` + `manifest.json` + - `styles.css`). No build step. + exact-name match precision lever + link shaping) so it's unit-testable; `llm.js` = + pure ollama prompt/parse helpers (the optional local-LLM gate, HTTP-only — no + in-process model runtime); `lib.test.js` + `llm.test.js` = `node:test`. The only + music API is Spotify (MusicBrainz removed). The resolver confirms **both artists + and albums**: one `type=artist,album` Spotify search per candidate, exact + 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. ## 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 zero-dep default when no LLM is configured. - Detection precision lever (zero-dep default): a candidate links **only** when - Spotify returns an artist whose name matches it case-insensitively. A confirmed - name links to its Spotify artist page if available, else a " bandcamp" - Google fallback. + Spotify returns an **artist or album** whose name matches it case-insensitively. A + confirmed name links to its Spotify artist/album page if available, else a + " 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. diff --git a/README.md b/README.md index e1b50e4..1b2d931 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,22 @@ # Reddit → Spotify / Bandcamp linker 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 -**Spotify** and **Bandcamp** — so a recommendation thread like +names and album titles** in the comments (and the post body) and turns them into +inline links to **Spotify** and **Bandcamp** — so a recommendation thread like [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 -with client-side rules, the extension hands candidate names to a tiny local +The hard part is *"which words are artists/albums and which aren't?"*. Rather than +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 -really artists. The resolver confirms names two ways (you enable either or both): -**Spotify** (exact-name match → direct artist pages) and an optional **local LLM** -via ollama (classifies which candidates are artists/albums). A confirmed name -links to its Spotify artist page when available, otherwise to a Google search for -" bandcamp". +really artists or albums. The resolver confirms names two ways (you enable either +or both): **Spotify** (exact-name match across artists **and** albums → direct +Spotify pages) and an optional **local LLM** via ollama (classifies which +candidates are artists/albums). A confirmed name links to its Spotify +artist/album page when available, otherwise to a Google search for +" bandcamp". Album links are italicised so you can tell an album from an +artist at a glance. ``` reddit page content script resolver service @@ -56,8 +59,9 @@ warns at startup and nothing links. ### Spotify credentials (one of the two gates) 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 -enable it: +that **artist or album** by exact name match) and supplies the **direct** Spotify +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 and copy its **Client ID** and **Client Secret**. @@ -129,8 +133,9 @@ with `xpinstall.signatures.required = false`.) 1. Make sure the resolver service is running. 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, - otherwise to **Bandcamp** — colour-coded (green = Spotify, teal = Bandcamp). New +3. Artist names and album titles become a single link — to **Spotify** if it's on + 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. 4. The toolbar icon shows the extension is active; its badge shows how many links were added on the current page. @@ -162,13 +167,17 @@ let the content script reach it). ## Limitations (MVP) -- **Detection is mostly capitalization-based**, with a cue-phrase pass that also catches - lowercase names right after "check out / by / fan of / recommend …". Names split across - inline formatting (e.g. **bold** mid-name) can still be missed. The resolver's +- **Artist detection is mostly capitalization-based**, with a cue-phrase pass that also + catches lowercase names right after "check out / by / fan of / recommend …". Names split + 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*. -- **Links are direct to Spotify when Spotify has the artist**; otherwise a confirmed - name links to a Google search for " bandcamp" (which surfaces the artist's - Bandcamp page). +- **Album detection leans on two precise signals** — quoted titles (`"Reek of Putrefaction"`) + and album cue phrases ("the album X", "X LP/EP"). Album free-text is noisier than artist + 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 " bandcamp" (which surfaces the Bandcamp page). - **Without Spotify creds or the LLM, nothing links** — at least one gate must be configured. - The optional LLM adds a little latency on the **first** call (ollama warms the diff --git a/extension/content.js b/extension/content.js index 1beeb14..c0400a9 100644 --- a/extension/content.js +++ b/extension/content.js @@ -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; } diff --git a/extension/extract.js b/extension/extract.js index ff1d678..5f5afa6 100644 --- a/extension/extract.js +++ b/extension/extract.js @@ -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 }; }); diff --git a/extension/extract.test.cjs b/extension/extract.test.cjs index 0ca24fa..687ecbf 100644 --- a/extension/extract.test.cjs +++ b/extension/extract.test.cjs @@ -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 }); diff --git a/extension/styles.css b/extension/styles.css index 6efa46f..e64367d 100644 --- a/extension/styles.css +++ b/extension/styles.css @@ -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 */ } diff --git a/service/lib.js b/service/lib.js index af2a8e3..f31e8ac 100644 --- a/service/lib.js +++ b/service/lib.js @@ -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 } }; } diff --git a/service/lib.test.js b/service/lib.test.js index 143dcc4..9125c87 100644 --- a/service/lib.test.js +++ b/service/lib.test.js @@ -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 ' 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); diff --git a/service/llm.js b/service/llm.js index f961cd8..f11b3bf 100644 --- a/service/llm.js +++ b/service/llm.js @@ -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; } diff --git a/service/llm.test.js b/service/llm.test.js index 010cac1..dd4eaf7 100644 --- a/service/llm.test.js +++ b/service/llm.test.js @@ -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", () => { diff --git a/service/resolver.js b/service/resolver.js index 7939f3d..ad2db26 100644 --- a/service/resolver.js +++ b/service/resolver.js @@ -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;