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>
This commit is contained in:
paul
2026-06-16 12:35:33 +00:00
co-authored by Claude Opus 4.8
parent 9f9858a050
commit 7c695be590
4 changed files with 83 additions and 27 deletions
+24 -12
View File
@@ -10,7 +10,7 @@
import { createServer } from "node:http";
import { readFileSync } from "node:fs";
import { norm, pickArtist, spotifyResult, searchResult } from "./lib.js";
import { norm, pickArtist, spotifyResult, mbResult, relUrls } from "./lib.js";
// --- minimal .env loader (no dependency) ------------------------------------
try {
@@ -58,29 +58,41 @@ async function viaSpotify(name) {
return hit ? spotifyResult(hit) : null;
}
// --- MusicBrainz (zero-cred fallback) ---------------------------------------
// MusicBrainz asks for <=1 request/second, so issuance is serialized + spaced.
// --- 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 mbThrottled(name) {
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.then(() => viaMusicBrainz(name));
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 url = `https://musicbrainz.org/ws/2/artist?fmt=json&limit=5&query=${q}`;
const r = await fetch(url, { headers: { "user-agent": UA, accept: "application/json" } });
if (!r.ok) throw new Error(`musicbrainz http ${r.status}`);
const j = await r.json();
const hit = pickArtist(name, j.artists, { minScore: 90 });
return hit ? searchResult(hit) : null;
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) -----------------------------------------
@@ -89,7 +101,7 @@ async function resolveOne(name) {
if (!key) return null;
if (cache.has(key)) return cache.get(key);
try {
const result = USE_SPOTIFY ? await viaSpotify(name) : await mbThrottled(name);
const result = USE_SPOTIFY ? await viaSpotify(name) : await viaMusicBrainz(name);
cache.set(key, result); // cache hits AND confirmed misses
return result;
} catch (e) {