feat: spotify-only matching + optional local-LLM gate + google→bandcamp fallback (closes task/drop-musicbrainz-google-fallback, closes task/ollama-classifier)

Source: human-feedback note 01KVDYV3A35B57N15CRBQDXPCH (project/reddit-spotify-linker,
2026-06-18) — "remove the musicbrainz integration and just use spotify"; a google search
for "<name> bandcamp" fallback; and "would a local llm be better able to categorise these
... include instructions for running something suitable via ollama (16GB M2)".

- Remove MusicBrainz entirely (viaMusicBrainz + url-rels + rate-gate; lib relUrls/mbResult).
  Spotify is now the only music API; it both gates by exact-name match and supplies the
  direct artist link.
- Optional ollama LLM gate (OLLAMA_URL enables it, OLLAMA_MODEL default qwen2.5:3b) called
  over plain HTTP -> stays zero npm-dep. It classifies which candidates are real
  artists/albums (the matching-quality lever). Pure prompt/parse logic in new llm.js
  (unit-tested in llm.test.js); the fetch + resolveAll gate wiring live in resolver.js.
- A confirmed name links to its direct Spotify page when available, else a Google search for
  "<name> bandcamp". New "google" primary platform rendered by the extension (styles.css
  blue accent + platform-aware link title in content.js).
- README: drop MusicBrainz; document the two gates + a runnable local-LLM setup for a 16GB
  MacBook M2. AGENTS.md: flip the old "no ML/NER" standing decision per the human override.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
paul
2026-06-19 12:50:07 +00:00
co-authored by Claude Opus 4.8
parent b97c82d92e
commit ef57761859
10 changed files with 343 additions and 249 deletions
+87 -61
View File
@@ -1,16 +1,20 @@
// Tiny resolver service: candidate names -> artist links, for CONFIRMED artists only.
//
// Zero npm deps; Node 18+ (built-in fetch). The "which words are real artists" problem
// is delegated to a music API rather than guessed client-side:
// * with SPOTIFY_CLIENT_ID/SECRET set -> Spotify Web API (client-credentials) for
// DIRECT artist links;
// * without creds -> MusicBrainz validation + Spotify/Bandcamp SEARCH links (zero setup).
// Either way a candidate only resolves when an artist's name matches it case-insensitively
// (the precision lever). The pure matching/link logic lives in lib.js (unit-tested).
// 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).
import { createServer } from "node:http";
import { readFileSync } from "node:fs";
import { norm, pickArtist, spotifyResult, mbResult, relUrls } from "./lib.js";
import { norm, pickArtist, linkResult } from "./lib.js";
import { buildClassifyMessages, parseClassification, DEFAULT_MODEL } from "./llm.js";
// --- minimal .env loader (no dependency) ------------------------------------
try {
@@ -25,12 +29,16 @@ const PORT = Number(process.env.PORT) || 8787;
const SPOTIFY_ID = process.env.SPOTIFY_CLIENT_ID;
const SPOTIFY_SECRET = process.env.SPOTIFY_CLIENT_SECRET;
const USE_SPOTIFY = Boolean(SPOTIFY_ID && SPOTIFY_SECRET);
const UA = "reddit-spotify-linker/0.1 (https://git.yapplesauce.com/paul/linker)";
const OLLAMA_URL = (process.env.OLLAMA_URL || "").replace(/\/$/, ""); // presence enables the LLM gate
const OLLAMA_MODEL = process.env.OLLAMA_MODEL || DEFAULT_MODEL;
const USE_LLM = Boolean(OLLAMA_URL);
const MAX_CANDIDATES = 100;
const cache = new Map(); // norm(name) -> { spotify, bandcamp, name } | null (negatives cached too)
// Cache only the network half (Spotify direct-url lookup); the final link object is rebuilt
// per request so a name's result never depends on a stale gate decision. norm(name) -> url|null
const spCache = new Map();
// --- Spotify (optional) -----------------------------------------------------
// --- Spotify (optional): direct-artist-link lookup --------------------------
let spToken = null;
let spTokenExp = 0;
async function spotifyToken() {
@@ -48,72 +56,85 @@ async function spotifyToken() {
return spToken;
}
async function viaSpotify(name) {
// The direct Spotify artist url for an exact name match, or null (no creds / no exact hit).
async function spotifyDirect(name) {
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 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 ? spotifyResult(hit) : null;
return hit?.external_urls?.spotify ?? null;
}
// --- MusicBrainz (zero-cred path) -------------------------------------------
// MusicBrainz asks for <=1 request/second, so every request is serialized + spaced.
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let mbGate = Promise.resolve();
let mbLast = 0;
function mbSchedule() {
const gate = mbGate.then(async () => {
const wait = 1100 - (Date.now() - mbLast);
if (wait > 0) await sleep(wait);
mbLast = Date.now();
});
mbGate = gate.catch(() => {}); // a failure never wedges the queue
return gate;
}
async function mbFetchJson(url) {
await mbSchedule();
const r = await fetch(url, { headers: { "user-agent": UA, accept: "application/json" } });
if (!r.ok) throw new Error(`musicbrainz http ${r.status}`);
return r.json();
}
async function viaMusicBrainz(name) {
const q = encodeURIComponent(`artist:"${name}"`);
const search = await mbFetchJson(`https://musicbrainz.org/ws/2/artist?fmt=json&limit=5&query=${q}`);
const hit = pickArtist(name, search.artists, { minScore: 90 });
if (!hit) return null;
// Enrich with MB url-relations -> DIRECT bandcamp/spotify links (best-effort).
let links = null;
try {
const detail = await mbFetchJson(`https://musicbrainz.org/ws/2/artist/${hit.id}?inc=url-rels&fmt=json`);
links = relUrls(detail.relations);
} catch (e) {
console.error(`mb url-rels "${name}": ${e.message}`); // enrichment is best-effort
}
return mbResult(hit, links);
}
// --- resolve one candidate (cached) -----------------------------------------
async function resolveOne(name) {
async function spotifyDirectCached(name) {
const key = norm(name);
if (!key) return null;
if (cache.has(key)) return cache.get(key);
if (spCache.has(key)) return spCache.get(key);
const url = await spotifyDirect(name);
spCache.set(key, url);
return url;
}
// --- LLM gate (optional): which candidates are artists/albums? --------------
async function ollamaClassify(candidates) {
const r = await fetch(`${OLLAMA_URL}/api/chat`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
model: OLLAMA_MODEL,
messages: buildClassifyMessages(candidates),
stream: false,
format: "json",
options: { temperature: 0 },
}),
});
if (!r.ok) throw new Error(`ollama http ${r.status}`);
const j = await r.json();
return parseClassification(j.message?.content ?? "", 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) {
if (!norm(name)) return null;
try {
const result = USE_SPOTIFY ? await viaSpotify(name) : await viaMusicBrainz(name);
cache.set(key, result); // cache hits AND confirmed misses
return result;
const sp = await spotifyDirectCached(name);
if (sp) return linkResult(name, sp); // direct Spotify page
return allowFallback ? linkResult(name, null) : null; // google fallback, or unconfirmed
} catch (e) {
console.error(`resolve "${name}": ${e.message}`); // transient -> don't cache
console.error(`resolve "${name}": ${e.message}`); // transient -> caller sees null this time
return null;
}
}
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.
let confirmed = null;
if (USE_LLM && uniq.length) {
try {
confirmed = await ollamaClassify(uniq);
} catch (e) {
console.error(`llm classify: ${e.message}`); // LLM down -> fall back to the Spotify gate
confirmed = null;
}
}
const out = {};
await Promise.all(uniq.map(async (name) => { out[name] = await resolveOne(name); }));
await Promise.all(
uniq.map(async (name) => {
if (confirmed && !confirmed.has(norm(name))) {
out[name] = null; // LLM said this is not an artist/album
return;
}
out[name] = await resolveOne(name, confirmed != null);
}),
);
return out;
}
@@ -142,7 +163,7 @@ const server = createServer(async (req, res) => {
if (req.method === "OPTIONS") { res.writeHead(204); return res.end(); }
if (req.method === "GET" && req.url === "/health") {
return json(res, 200, { ok: true, provider: USE_SPOTIFY ? "spotify" : "musicbrainz", cached: cache.size });
return json(res, 200, { ok: true, spotify: USE_SPOTIFY, llm: USE_LLM ? OLLAMA_MODEL : false, cached: spCache.size });
}
if (req.method === "POST" && req.url === "/resolve") {
@@ -159,5 +180,10 @@ const server = createServer(async (req, res) => {
});
server.listen(PORT, () => {
console.log(`resolver listening on http://localhost:${PORT} (provider: ${USE_SPOTIFY ? "spotify" : "musicbrainz"})`);
const sp = USE_SPOTIFY ? "on" : "off";
const llm = USE_LLM ? OLLAMA_MODEL : "off";
console.log(`resolver listening on http://localhost:${PORT} (spotify: ${sp}, llm: ${llm})`);
if (!USE_SPOTIFY && !USE_LLM) {
console.warn(" ! no gate configured: set SPOTIFY_CLIENT_ID/SECRET and/or OLLAMA_URL, or nothing will link.");
}
});