// 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= returns linksByPlatform..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= 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 }; }