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:
+24
-12
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user