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>
190 lines
7.6 KiB
JavaScript
190 lines
7.6 KiB
JavaScript
// 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
|
|
// 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, linkResult } from "./lib.js";
|
|
import { buildClassifyMessages, parseClassification, DEFAULT_MODEL } from "./llm.js";
|
|
|
|
// --- minimal .env loader (no dependency) ------------------------------------
|
|
try {
|
|
const env = readFileSync(new URL("./.env", import.meta.url), "utf8");
|
|
for (const line of env.split("\n")) {
|
|
const m = line.match(/^\s*([\w.-]+)\s*=\s*(.*?)\s*$/);
|
|
if (m && !(m[1] in process.env)) process.env[m[1]] = m[2].replace(/^["']|["']$/g, "");
|
|
}
|
|
} catch { /* no .env -> use real env / defaults */ }
|
|
|
|
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 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;
|
|
|
|
// 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): direct-artist-link lookup --------------------------
|
|
let spToken = null;
|
|
let spTokenExp = 0;
|
|
async function spotifyToken() {
|
|
if (spToken && Date.now() < spTokenExp) return spToken;
|
|
const auth = Buffer.from(`${SPOTIFY_ID}:${SPOTIFY_SECRET}`).toString("base64");
|
|
const r = await fetch("https://accounts.spotify.com/api/token", {
|
|
method: "POST",
|
|
headers: { authorization: `Basic ${auth}`, "content-type": "application/x-www-form-urlencoded" },
|
|
body: "grant_type=client_credentials",
|
|
});
|
|
if (!r.ok) throw new Error(`spotify token http ${r.status}`);
|
|
const j = await r.json();
|
|
spToken = j.access_token;
|
|
spTokenExp = Date.now() + (j.expires_in - 60) * 1000;
|
|
return spToken;
|
|
}
|
|
|
|
// 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?.external_urls?.spotify ?? null;
|
|
}
|
|
|
|
async function spotifyDirectCached(name) {
|
|
const key = norm(name);
|
|
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 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 -> 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) => {
|
|
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;
|
|
}
|
|
|
|
// --- HTTP -------------------------------------------------------------------
|
|
function readBody(req) {
|
|
return new Promise((resolve, reject) => {
|
|
let data = "";
|
|
req.on("data", (c) => {
|
|
data += c;
|
|
if (data.length > 1e6) { reject(new Error("body too large")); req.destroy(); }
|
|
});
|
|
req.on("end", () => resolve(data));
|
|
req.on("error", reject);
|
|
});
|
|
}
|
|
|
|
const json = (res, code, obj) => {
|
|
res.writeHead(code, { "content-type": "application/json" });
|
|
res.end(JSON.stringify(obj));
|
|
};
|
|
|
|
const server = createServer(async (req, res) => {
|
|
res.setHeader("access-control-allow-origin", "*");
|
|
res.setHeader("access-control-allow-headers", "content-type");
|
|
res.setHeader("access-control-allow-methods", "GET, POST, OPTIONS");
|
|
if (req.method === "OPTIONS") { res.writeHead(204); return res.end(); }
|
|
|
|
if (req.method === "GET" && req.url === "/health") {
|
|
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") {
|
|
try {
|
|
const { candidates } = JSON.parse((await readBody(req)) || "{}");
|
|
if (!Array.isArray(candidates)) return json(res, 400, { error: "candidates must be an array" });
|
|
return json(res, 200, await resolveAll(candidates));
|
|
} catch (e) {
|
|
return json(res, 500, { error: e.message });
|
|
}
|
|
}
|
|
|
|
json(res, 404, { error: "not found" });
|
|
});
|
|
|
|
server.listen(PORT, () => {
|
|
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.");
|
|
}
|
|
});
|