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
+8 -4
View File
@@ -44,8 +44,9 @@ node resolver.js
``` ```
That's it. With no configuration the resolver validates names against That's it. With no configuration the resolver validates names against
**MusicBrainz** (no account required) and links to Spotify/Bandcamp **search** **MusicBrainz** (no account required). When MusicBrainz knows an artist's official
pages. links (its `url-relations`), you get **direct** Spotify and Bandcamp artist pages;
otherwise it falls back to Spotify/Bandcamp **search** URLs.
### Optional: direct Spotify links ### Optional: direct Spotify links
@@ -62,7 +63,8 @@ API credentials:
``` ```
3. Restart the service. It will report `provider: spotify` on startup. 3. Restart the service. It will report `provider: spotify` on startup.
(Bandcamp has no public API, so Bandcamp links are always a search URL.) (Bandcamp has no public API; direct Bandcamp links come from MusicBrainz
`url-relations` when present, else a Bandcamp search URL.)
Quick check that it works: Quick check that it works:
@@ -107,7 +109,9 @@ let the content script reach it).
across formatting (e.g. **bold** in the middle of a name), can be missed. The across formatting (e.g. **bold** in the middle of a name), can be missed. The
resolver's exact-name match keeps false *positives* low; the trade-off is some resolver's exact-name match keeps false *positives* low; the trade-off is some
false *negatives*. false *negatives*.
- **Bandcamp links are searches**, not direct artist pages (no public API). - **Links are direct when MusicBrainz has the artist's `url-relations`** (official
Bandcamp / Spotify pages); otherwise they fall back to a search URL. (Each
confirmed artist costs one extra throttled MusicBrainz lookup, cached after.)
- The resolver caches results in memory; it resets when you restart it. - The resolver caches results in memory; it resets when you restart it.
- Without Spotify credentials the MusicBrainz path is rate-limited to ~1 lookup/ - Without Spotify credentials the MusicBrainz path is rate-limited to ~1 lookup/
second, so the first scan of a large thread fills in gradually (cached after). second, so the first scan of a large thread fills in gradually (cached after).
+17 -6
View File
@@ -26,9 +26,20 @@ export const spotifyResult = (hit) => ({
bandcamp: bandcampSearch(hit.name), bandcamp: bandcampSearch(hit.name),
}); });
// Link object for a confirmed MusicBrainz hit (search urls — MB gives no platform ids). // Extract direct links from a MusicBrainz artist's url-relations (zero-cred: MB stores
export const searchResult = (hit) => ({ // official Bandcamp / streaming URLs). Either field may be null.
name: hit.name, export function relUrls(relations) {
spotify: spotifySearch(hit.name), const urls = (relations ?? []).map((r) => r && r.url && r.url.resource).filter(Boolean);
bandcamp: bandcampSearch(hit.name), const find = (host) => urls.find((u) => u.includes(host)) ?? null;
}); return { bandcamp: find("bandcamp.com"), spotify: find("open.spotify.com") };
}
// Link object for a confirmed MusicBrainz hit: prefer the direct url-rel links, fall back
// to search urls when MB has no relation for that platform.
export function mbResult(hit, links) {
return {
name: hit.name,
spotify: links?.spotify ?? spotifySearch(hit.name),
bandcamp: links?.bandcamp ?? bandcampSearch(hit.name),
};
}
+34 -5
View File
@@ -1,7 +1,7 @@
// Unit tests for the resolver's pure logic (run with `node --test`). Zero-dep. // Unit tests for the resolver's pure logic (run with `node --test`). Zero-dep.
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { norm, bandcampSearch, spotifySearch, pickArtist, spotifyResult, searchResult } from "./lib.js"; import { norm, bandcampSearch, spotifySearch, pickArtist, spotifyResult, relUrls, mbResult } from "./lib.js";
test("norm lowercases, trims, NFKC-normalizes", () => { test("norm lowercases, trims, NFKC-normalizes", () => {
assert.equal(norm(" Cerebral Bore "), "cerebral bore"); assert.equal(norm(" Cerebral Bore "), "cerebral bore");
@@ -39,10 +39,39 @@ test("spotifyResult prefers the direct url, falls back to search", () => {
assert.equal(noUrl.spotify, "https://open.spotify.com/search/Devourment"); assert.equal(noUrl.spotify, "https://open.spotify.com/search/Devourment");
}); });
test("searchResult builds spotify + bandcamp search urls", () => { test("relUrls picks direct bandcamp + spotify from MB relations, tolerates junk", () => {
const r = searchResult({ name: "Cerebral Bore" }); const relations = [
assert.equal(r.spotify, "https://open.spotify.com/search/Cerebral%20Bore"); { url: { resource: "https://cattledecapitation.bandcamp.com/" } },
assert.equal(r.bandcamp, "https://bandcamp.com/search?q=Cerebral%20Bore&item_type=b"); { url: { resource: "https://open.spotify.com/artist/67ZMMtA88DDO0gTuRrzGjn" } },
{ url: { resource: "https://en.wikipedia.org/wiki/Cattle_Decapitation" } },
null,
{ url: null },
];
const links = relUrls(relations);
assert.equal(links.bandcamp, "https://cattledecapitation.bandcamp.com/");
assert.equal(links.spotify, "https://open.spotify.com/artist/67ZMMtA88DDO0gTuRrzGjn");
});
test("relUrls returns nulls when relations absent/empty", () => {
assert.deepEqual(relUrls(null), { bandcamp: null, spotify: null });
assert.deepEqual(relUrls([]), { bandcamp: null, spotify: null });
});
test("mbResult prefers direct links, falls back to search per-platform", () => {
const direct = mbResult(
{ name: "Cattle Decapitation" },
{ bandcamp: "https://cattledecapitation.bandcamp.com/", spotify: "https://open.spotify.com/artist/x" },
);
assert.equal(direct.bandcamp, "https://cattledecapitation.bandcamp.com/");
assert.equal(direct.spotify, "https://open.spotify.com/artist/x");
const fallback = mbResult({ name: "Cerebral Bore" }, null);
assert.equal(fallback.spotify, "https://open.spotify.com/search/Cerebral%20Bore");
assert.equal(fallback.bandcamp, "https://bandcamp.com/search?q=Cerebral%20Bore&item_type=b");
const partial = mbResult({ name: "X" }, { bandcamp: null, spotify: "https://open.spotify.com/artist/y" });
assert.equal(partial.spotify, "https://open.spotify.com/artist/y");
assert.match(partial.bandcamp, /bandcamp\.com\/search/);
}); });
test("link builders url-encode", () => { test("link builders url-encode", () => {
+24 -12
View File
@@ -10,7 +10,7 @@
import { createServer } from "node:http"; import { createServer } from "node:http";
import { readFileSync } from "node:fs"; 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) ------------------------------------ // --- minimal .env loader (no dependency) ------------------------------------
try { try {
@@ -58,29 +58,41 @@ async function viaSpotify(name) {
return hit ? spotifyResult(hit) : null; return hit ? spotifyResult(hit) : null;
} }
// --- MusicBrainz (zero-cred fallback) --------------------------------------- // --- MusicBrainz (zero-cred path) -------------------------------------------
// MusicBrainz asks for <=1 request/second, so issuance is serialized + spaced. // MusicBrainz asks for <=1 request/second, so every request is serialized + spaced.
const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let mbGate = Promise.resolve(); let mbGate = Promise.resolve();
let mbLast = 0; let mbLast = 0;
function mbThrottled(name) { function mbSchedule() {
const gate = mbGate.then(async () => { const gate = mbGate.then(async () => {
const wait = 1100 - (Date.now() - mbLast); const wait = 1100 - (Date.now() - mbLast);
if (wait > 0) await sleep(wait); if (wait > 0) await sleep(wait);
mbLast = Date.now(); mbLast = Date.now();
}); });
mbGate = gate.catch(() => {}); // a failure never wedges the queue 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) { async function viaMusicBrainz(name) {
const q = encodeURIComponent(`artist:"${name}"`); const q = encodeURIComponent(`artist:"${name}"`);
const url = `https://musicbrainz.org/ws/2/artist?fmt=json&limit=5&query=${q}`; const search = await mbFetchJson(`https://musicbrainz.org/ws/2/artist?fmt=json&limit=5&query=${q}`);
const r = await fetch(url, { headers: { "user-agent": UA, accept: "application/json" } }); const hit = pickArtist(name, search.artists, { minScore: 90 });
if (!r.ok) throw new Error(`musicbrainz http ${r.status}`); if (!hit) return null;
const j = await r.json(); // Enrich with MB url-relations -> DIRECT bandcamp/spotify links (best-effort).
const hit = pickArtist(name, j.artists, { minScore: 90 }); let links = null;
return hit ? searchResult(hit) : 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) ----------------------------------------- // --- resolve one candidate (cached) -----------------------------------------
@@ -89,7 +101,7 @@ async function resolveOne(name) {
if (!key) return null; if (!key) return null;
if (cache.has(key)) return cache.get(key); if (cache.has(key)) return cache.get(key);
try { 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 cache.set(key, result); // cache hits AND confirmed misses
return result; return result;
} catch (e) { } catch (e) {