Files
linker/service/resolver.js
T
paulandClaude Opus 4.8 7c695be590 feat: direct Spotify/Bandcamp links via MusicBrainz url-relations (task/odesli-direct-links)
The Odesli/Songlink API rejects artist URLs (405 UNSUPPORTED_URL — it is song/album
only), so the planned Odesli hop is not viable for artist linking. Pivoted to
MusicBrainz `inc=url-rels`, which returns the artist's official Bandcamp + Spotify
URLs directly, zero-cred:
- service/lib.js: relUrls() extracts direct bandcamp/spotify from MB relations;
  mbResult() prefers them, falls back to search URLs per-platform (replaces searchResult).
- service/resolver.js: viaMusicBrainz now does search -> url-rels enrichment (both
  throttled through one gate); resolveOne calls it directly.
- service/lib.test.js: +3 tests (relUrls + mbResult); 9 total, green.
- README: document the direct-link behavior.

Verified zero-cred end to end: "Cattle Decapitation" -> open.spotify.com/artist/... +
cattledecapitation.bandcamp.com/ (both direct, not search); nonsense -> null.

Source: task/odesli-direct-links (objective/linker-improvements); approach changed from
Odesli to MB url-rels after the API probe; goal (direct Bandcamp) achieved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 12:35:33 +00:00

164 lines
6.5 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 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).
import { createServer } from "node:http";
import { readFileSync } from "node:fs";
import { norm, pickArtist, spotifyResult, mbResult, relUrls } from "./lib.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 UA = "reddit-spotify-linker/0.1 (https://git.yapplesauce.com/paul/linker)";
const MAX_CANDIDATES = 100;
const cache = new Map(); // norm(name) -> { spotify, bandcamp, name } | null (negatives cached too)
// --- Spotify (optional) -----------------------------------------------------
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;
}
async function viaSpotify(name) {
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;
}
// --- 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) {
const key = norm(name);
if (!key) return null;
if (cache.has(key)) return cache.get(key);
try {
const result = USE_SPOTIFY ? await viaSpotify(name) : await viaMusicBrainz(name);
cache.set(key, result); // cache hits AND confirmed misses
return result;
} catch (e) {
console.error(`resolve "${name}": ${e.message}`); // transient -> don't cache
return null;
}
}
async function resolveAll(candidates) {
const uniq = [...new Set(candidates.map((c) => String(c).trim()).filter(Boolean))].slice(0, MAX_CANDIDATES);
const out = {};
await Promise.all(uniq.map(async (name) => { out[name] = await resolveOne(name); }));
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, provider: USE_SPOTIFY ? "spotify" : "musicbrainz", cached: cache.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, () => {
console.log(`resolver listening on http://localhost:${PORT} (provider: ${USE_SPOTIFY ? "spotify" : "musicbrainz"})`);
});