diff --git a/README.md b/README.md index df69497..bfce6fd 100644 --- a/README.md +++ b/README.md @@ -44,8 +44,9 @@ node resolver.js ``` That's it. With no configuration the resolver validates names against -**MusicBrainz** (no account required) and links to Spotify/Bandcamp **search** -pages. +**MusicBrainz** (no account required). When MusicBrainz knows an artist's official +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 @@ -62,7 +63,8 @@ API credentials: ``` 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: @@ -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 resolver's exact-name match keeps false *positives* low; the trade-off is some 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. - 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). diff --git a/service/lib.js b/service/lib.js index c8c9fc6..c7d9782 100644 --- a/service/lib.js +++ b/service/lib.js @@ -26,9 +26,20 @@ export const spotifyResult = (hit) => ({ bandcamp: bandcampSearch(hit.name), }); -// Link object for a confirmed MusicBrainz hit (search urls — MB gives no platform ids). -export const searchResult = (hit) => ({ - name: hit.name, - spotify: spotifySearch(hit.name), - bandcamp: bandcampSearch(hit.name), -}); +// Extract direct links from a MusicBrainz artist's url-relations (zero-cred: MB stores +// official Bandcamp / streaming URLs). Either field may be null. +export function relUrls(relations) { + const urls = (relations ?? []).map((r) => r && r.url && r.url.resource).filter(Boolean); + 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), + }; +} diff --git a/service/lib.test.js b/service/lib.test.js index cbad08d..145f6e9 100644 --- a/service/lib.test.js +++ b/service/lib.test.js @@ -1,7 +1,7 @@ // Unit tests for the resolver's pure logic (run with `node --test`). Zero-dep. import { test } from "node:test"; 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", () => { 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"); }); -test("searchResult builds spotify + bandcamp search urls", () => { - const r = searchResult({ name: "Cerebral Bore" }); - assert.equal(r.spotify, "https://open.spotify.com/search/Cerebral%20Bore"); - assert.equal(r.bandcamp, "https://bandcamp.com/search?q=Cerebral%20Bore&item_type=b"); +test("relUrls picks direct bandcamp + spotify from MB relations, tolerates junk", () => { + const relations = [ + { url: { resource: "https://cattledecapitation.bandcamp.com/" } }, + { 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", () => { diff --git a/service/resolver.js b/service/resolver.js index c928ede..af2d78a 100644 --- a/service/resolver.js +++ b/service/resolver.js @@ -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) {