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>
52 lines
3.0 KiB
JavaScript
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 };
|
|
}
|