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:
@@ -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).
|
||||||
|
|||||||
+16
-5
@@ -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.
|
||||||
|
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,
|
name: hit.name,
|
||||||
spotify: spotifySearch(hit.name),
|
spotify: links?.spotify ?? spotifySearch(hit.name),
|
||||||
bandcamp: bandcampSearch(hit.name),
|
bandcamp: links?.bandcamp ?? bandcampSearch(hit.name),
|
||||||
});
|
};
|
||||||
|
}
|
||||||
|
|||||||
+34
-5
@@ -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
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user