Files
paulandClaude Opus 4.8 e06c983b16 feat: optional direct Bandcamp/Apple/YouTube links via Odesli (flag-gated) (closes task/odesli-direct-cross-platform-links)
Source: task/odesli-direct-cross-platform-links (plan/improvement-backlog) — bandcamp was only a Google search; Odesli gives direct cross-platform links for Spotify-resolved names.

- service/odesli.js: makeOdesli({fetch,baseUrl}) -> getLinks(spotifyUrl); pure parseOdesli core; bounded AbortSignal.timeout; non-200/timeout/garbage -> {} (graceful).
- resolver.js: opt-in ODESLI / ODESLI_URL flag (default OFF). When ON, enrich each Spotify-resolved result with additive bandcamp/appleMusic/youtube fields (isHttpUrl-validated), cached per Spotify url. OFF -> no call, byte-identical behavior. /health + startup log surface the flag.
- extension: a tiny additive "bc" link to a direct Bandcamp page when result.bandcamp is present (reuses isSafeHttpUrl); primary artist/album/spotify/google rendering unchanged. styles.css: bandcamp-teal secondary link.
- odesli.test.js + odesli.fixture.json: parseOdesli unit-tested against a real captured Odesli response (Blood Incantation album -> direct bandcamp; absent apple/youtube omitted; junk -> {}); getLinks 200/non-200/throw/parse-error paths.
- docs: .env.example, README, AGENTS.md note the opt-in flag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 02:41:30 +00:00

52 lines
3.0 KiB
JavaScript

// Optional Odesli (song.link) enrichment: given a Spotify entity URL, fetch the DIRECT
// cross-platform links (Bandcamp / Apple Music / YouTube). Decoupled from resolver.js's HTTP
// side effects (an injected `fetch`, like spotify.js) so `node --test` can drive it hermetically.
//
// Odesli's GET /links?url=<entity url> returns linksByPlatform.<platform>.url for each platform
// it knows. The input is always our OWN already-validated Spotify URL against the fixed host
// api.song.link, so there is no user-controlled host (no SSRF). Odesli rejects ARTIST urls
// (405 UNSUPPORTED_URL); only album/track entity urls enrich — the caller only passes those.
// Pure parse of an Odesli /links JSON body into the additive platform fields we surface. Returns
// only the platforms actually present with a string url; an absent platform is OMITTED (the terse
// "absent == empty" contract the extension consumes). Tolerant of garbage/empty/missing
// linksByPlatform -> {}. Kept pure (no fetch) so it is unit-testable against a captured fixture.
export function parseOdesli(json) {
const by = json && typeof json === "object" ? json.linksByPlatform : null;
if (!by || typeof by !== "object") return {};
const out = {};
for (const platform of ["bandcamp", "appleMusic", "youtube"]) {
const url = by[platform]?.url;
if (typeof url === "string" && url) out[platform] = url;
}
return out;
}
// Pure gate: which results may be enriched. TRUE only for an ALBUM result that carries a direct
// Spotify url (a string `spotify` field). Odesli resolves album/track ENTITY urls only — an artist
// url 405s (UNSUPPORTED_URL) — so an artist result is skipped entirely, avoiding a wasted round-trip
// for the common artist case. Kept pure (no fetch) so the gate is unit-testable.
export function shouldEnrich(result) {
return Boolean(result) && typeof result.spotify === "string" && result.kind === "album";
}
// Odesli client factory. `fetch` is injected (resolver.js wires the live global fetch; tests
// inject a fake). `baseUrl` defaults to the public v1-alpha.1 endpoint. Returns { getLinks }:
// getLinks(spotifyUrl) -> { bandcamp?, appleMusic?, youtube? } (absent platforms omitted)
// GETs ${baseUrl}/links?url=<encoded spotifyUrl> with a bounded AbortSignal.timeout (like the
// other outbound calls). GRACEFUL: a non-200, a network/timeout throw, or unparseable JSON all
// return {} so enrichment never crashes a resolve — the existing result is kept unchanged.
export function makeOdesli({ fetch, baseUrl = "https://api.song.link/v1-alpha.1", timeoutMs = 5000 }) {
async function getLinks(spotifyUrl) {
try {
const url = `${baseUrl}/links?url=${encodeURIComponent(spotifyUrl)}`;
const r = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
if (!r.ok) return {}; // 4xx/5xx (incl. 405 on a non-entity url) -> no enrichment
return parseOdesli(await r.json());
} catch {
return {}; // network error / timeout / JSON parse error -> graceful no-op
}
}
return { getLinks };
}