From e06c983b160f78cf23d3f17002dec229b50af8ba Mon Sep 17 00:00:00 2001 From: paul Date: Wed, 24 Jun 2026 02:37:16 +0000 Subject: [PATCH] feat: optional direct Bandcamp/Apple/YouTube links via Odesli (flag-gated) (closes task/odesli-direct-cross-platform-links) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- AGENTS.md | 15 ++- README.md | 25 +++++ extension/content.js | 27 ++++- extension/styles.css | 10 ++ service/.env.example | 11 ++ service/odesli.fixture.json | 196 ++++++++++++++++++++++++++++++++++++ service/odesli.js | 51 ++++++++++ service/odesli.test.js | 157 +++++++++++++++++++++++++++++ service/resolver.js | 60 +++++++++-- 9 files changed, 544 insertions(+), 8 deletions(-) create mode 100644 service/odesli.fixture.json create mode 100644 service/odesli.js create mode 100644 service/odesli.test.js diff --git a/AGENTS.md b/AGENTS.md index cd16c3a..2110d44 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,9 @@ is tracked in the **towl** work-item store under project **`reddit-spotify-linke `node:test`. The only music API is Spotify (MusicBrainz removed). The resolver confirms **both artists and albums**: one `type=artist,album` Spotify search per candidate, exact case-insensitive name match against either; each result carries - `kind: "artist" | "album"`. + `kind: "artist" | "album"`. `odesli.js` = the optional Odesli (song.link) enrichment client + (injected `fetch`, pure `parseOdesli` core, HTTP-only — no new deps); `odesli.test.js` + + `odesli.fixture.json` (a captured real response) cover it. - `extension/` — Firefox **MV2** content script (`content.js` + `extract.js` + `manifest.json` + `styles.css`). No build step. `extract.js` is the pure candidate extractor (Title-Case runs + cue phrases for artists; quoted strings + album cue @@ -94,6 +96,17 @@ fixes small things directly. to classify which candidates are real artists/albums. It stays **zero-NPM-dep**: HTTP only, no in-process model runtime. The heuristic + Spotify exact-match is the zero-dep default when no LLM is configured. +- **Optional Odesli (song.link) enrichment** is opt-in, default OFF (`ODESLI=1`, or `ODESLI_URL` + to override the endpoint — mirrors the ollama opt-in). When ON, an **album** result that already + carries a DIRECT Spotify url is enriched with the album's direct Bandcamp (+ Apple/YouTube) links + as ADDITIVE fields (`bandcamp`/`appleMusic`/`youtube`) — the existing `name`/`kind`/`spotify`/ + `google`/`primary` shape is unchanged, so off the behavior is byte-identical and an older + extension keeps rendering. The gate is `shouldEnrich` (pure, unit-tested): **album-kind results + only** — Odesli rejects ARTIST urls (405 UNSUPPORTED_URL), so artist results are skipped entirely + (no wasted round-trip on the common case). Only the Spotify-creds path produces a url to enrich; + the no-creds Google fallback is untouched. Bounded (`AbortSignal.timeout`) + cached per Spotify + url + graceful (Odesli down/garbage keeps the current result). The input is our own validated + Spotify album url against the fixed host (no SSRF). - Detection precision lever (zero-dep default): a candidate links **only** when Spotify returns an **artist or album** whose name matches it case-insensitively. A confirmed name links to its Spotify artist/album page if available, else a diff --git a/README.md b/README.md index b852e2a..a6526db 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,31 @@ With both gates, the LLM decides which names are artists and Spotify supplies th direct links; with the LLM alone, confirmed names get a Spotify direct link if one exists, else the Google fallback. +## Direct Bandcamp / Apple / YouTube links via Odesli (optional) + +By default a name that isn't on Spotify links to a Google " bandcamp" search — one click +from the real Bandcamp page. With **Odesli** (the free, no-auth [song.link](https://odesli.co) +API) enabled, an **album** result that resolved to a direct Spotify page is also enriched with the +album's **direct** Bandcamp (and, when Odesli has them, Apple Music / YouTube) links, so the +extension shows a small `bc` link straight to Bandcamp beside the Spotify link. + +- **Opt-in, default OFF** — set `ODESLI=1` in `service/.env` (or `ODESLI_URL=` to override the + endpoint). With it off, no Odesli call is made and behavior is unchanged. +- **Album results only** — Odesli resolves album/track *entity* urls, not artist urls (an artist + url 405s), so only **album** results are enriched; artist results are skipped (no wasted call). +- **Needs the Spotify gate** — an album result only carries a direct Spotify url when Spotify + confirmed it, so set the Spotify credentials above to have a url to enrich. The no-creds Google + fallback path is untouched. +- **Bounded + graceful** — one cached Odesli call per Spotify url, with a timeout; if Odesli is + down/slow/garbled the current result is kept and nothing breaks. + +```sh +ODESLI=1 SPOTIFY_CLIENT_ID=… SPOTIFY_CLIENT_SECRET=… node resolver.js +# resolver listening on http://localhost:8787 (spotify: on, llm: off, odesli: on) +``` + +`curl localhost:8787/health` then shows `"odesli":true`. + ## 2. Install the extension in Firefox The extension is unpacked / unsigned, so load it as a **temporary add-on**: diff --git a/extension/content.js b/extension/content.js index d0998bf..f5fe9e6 100644 --- a/extension/content.js +++ b/extension/content.js @@ -109,6 +109,24 @@ return a; } + // Optional, additive: a tiny secondary "bc" link to the DIRECT Bandcamp page when the resolver + // enriched the result with one (the Odesli leg). Absent => null => nothing extra rendered, so a + // result with no `bandcamp` field looks exactly as before. The href is re-validated the same way + // makeLink does — an unsafe scheme yields null and no link is injected. + function makeBandcampLink(links) { + const url = links.bandcamp; + if (typeof url !== "string" || !Extract.isSafeHttpUrl(url)) return null; + const name = links.name || ""; + const a = document.createElement("a"); + a.className = "rsl-link rsl-bandcamp rsl-direct"; + a.textContent = "bc"; + a.href = url; + a.target = "_blank"; + a.rel = "noopener noreferrer"; + a.title = `Open ${name} on Bandcamp (direct)`; + return a; + } + // `matches` is the node's allMatchesIn(text) result, computed once per scan in scan() // and reused here — the regex battery runs once per node per scan, not twice. function wrapNode(node, matches) { @@ -124,9 +142,16 @@ if (m.index < last) continue; // skip overlaps if (m.index > last) frag.appendChild(document.createTextNode(text.slice(last, m.index))); const matchText = text.substr(m.index, m.length); - const link = makeLink(matchText, resolved.get(m.name)); + const links = resolved.get(m.name); + const link = makeLink(matchText, links); // makeLink returns null for an unsafe (non-http(s)) href: keep the plain text. frag.appendChild(link || document.createTextNode(matchText)); + // Additive: when the primary link rendered AND the resolver supplied a direct Bandcamp url, + // append a tiny secondary "bc" link. No bandcamp field => nothing extra (unchanged rendering). + if (link) { + const bc = makeBandcampLink(links); + if (bc) frag.appendChild(bc); + } last = m.index + m.length; } if (last < text.length) frag.appendChild(document.createTextNode(text.slice(last))); diff --git a/extension/styles.css b/extension/styles.css index e64367d..116b0b3 100644 --- a/extension/styles.css +++ b/extension/styles.css @@ -16,6 +16,16 @@ .rsl-google { text-decoration-color: #4a72d0 !important; /* google-ish blue */ } +/* Optional secondary "bc" link to a DIRECT Bandcamp page (the Odesli enrichment). Small and + superscript so it sits unobtrusively beside the primary artist/album link. */ +.rsl-bandcamp { + text-decoration-color: #629aa9 !important; /* bandcamp teal */ +} +.rsl-direct { + font-size: 0.72em; + vertical-align: super; + margin-left: 2px; +} .rsl-link:hover { text-decoration-style: solid !important; } diff --git a/service/.env.example b/service/.env.example index e63b225..6f0b9bd 100644 --- a/service/.env.example +++ b/service/.env.example @@ -13,6 +13,17 @@ SPOTIFY_CLIENT_SECRET= # OLLAMA_URL=http://localhost:11434 # OLLAMA_MODEL=qwen2.5:3b +# Optional direct Bandcamp/Apple/YouTube links via Odesli (song.link) — opt-in, default OFF. +# When ON, an ALBUM result that resolved to a DIRECT Spotify url (the creds path above) is enriched +# with the album's DIRECT cross-platform links from the free, no-auth Odesli API, so the extension +# can offer a direct Bandcamp link instead of a Google search. Album results only — Odesli rejects +# artist urls (405), so artist results are skipped (no wasted call). Needs the Spotify gate to have +# produced a url to enrich (the no-creds Google fallback path is untouched). Bounded + graceful: +# Odesli down/timeout/garbage keeps the current result. When OFF, no Odesli call is made and +# behavior is unchanged. Set ODESLI=1 to enable; ODESLI_URL overrides the endpoint (also enables it). +# ODESLI=1 +# ODESLI_URL=https://api.song.link/v1-alpha.1 + # Port the resolver listens on (default 8787). If you change it, also update RESOLVER_URL in # extension/background.js and the host in extension/manifest.json permissions. PORT=8787 diff --git a/service/odesli.fixture.json b/service/odesli.fixture.json new file mode 100644 index 0000000..48e98de --- /dev/null +++ b/service/odesli.fixture.json @@ -0,0 +1,196 @@ +{ + "entityUniqueId": "SPOTIFY_ALBUM::0SdKqbCAKdkEFPW5NyzK7R", + "userCountry": "US", + "pageUrl": "https://album.link/s/0SdKqbCAKdkEFPW5NyzK7R", + "entitiesByUniqueId": { + "AMAZON_ALBUM::B07Y5X5BRQ": { + "id": "B07Y5X5BRQ", + "type": "album", + "title": "Hidden History of the Human Race", + "artistName": "Blood Incantation", + "thumbnailUrl": "https://m.media-amazon.com/images/I/51mV80OoCjL.jpg", + "thumbnailWidth": 500, + "thumbnailHeight": 500, + "apiProvider": "amazon", + "platforms": [ + "amazonMusic", + "amazonStore" + ] + }, + "ANGHAMI_ALBUM::1009122604": { + "id": "1009122604", + "type": "album", + "title": "Hidden History of the Human Race", + "artistName": "Blood Incantation", + "thumbnailUrl": "https://artwork.anghcdn.co/cover/105602469/320", + "thumbnailWidth": 1024, + "thumbnailHeight": 1024, + "apiProvider": "anghami", + "platforms": [ + "anghami" + ] + }, + "BANDCAMP_ALBUM::744542709": { + "id": "744542709", + "type": "album", + "title": "Hidden History of the Human Race", + "artistName": "Blood Incantation", + "thumbnailUrl": "https://f4.bcbits.com/img/a0485761639_5.jpg", + "thumbnailWidth": 700, + "thumbnailHeight": 700, + "apiProvider": "bandcamp", + "platforms": [ + "bandcamp" + ] + }, + "BOOMPLAY_ALBUM::4983454": { + "id": "4983454", + "type": "album", + "title": "Hidden History of the Human Race", + "artistName": "Blood Incantation", + "thumbnailUrl": "https://source.boomplaymusic.com/group10/M00/CE/A9/52dc8f2dce1c48bcb7274bfc4cace0c9_464_464.jpg", + "thumbnailWidth": 464, + "thumbnailHeight": 464, + "apiProvider": "boomplay", + "platforms": [ + "boomplay" + ] + }, + "DEEZER_ALBUM::112372942": { + "id": "112372942", + "type": "album", + "title": "Hidden History of the Human Race", + "artistName": "Blood Incantation", + "thumbnailUrl": "https://cdns-images.dzcdn.net/images/cover/3306ffd2111049b9b3bd327125fa0117/500x500-000000-80-0-0.jpg", + "thumbnailWidth": 500, + "thumbnailHeight": 500, + "apiProvider": "deezer", + "platforms": [ + "deezer" + ] + }, + "NAPSTER_ALBUM::alb.409396148": { + "id": "alb.409396148", + "type": "album", + "title": "Hidden History of the Human Race", + "artistName": "Blood Incantation", + "thumbnailUrl": "https://direct.rhapsody.com/imageserver/images/alb.409396148/385x385.jpeg", + "thumbnailWidth": 385, + "thumbnailHeight": 385, + "apiProvider": "napster", + "platforms": [ + "napster" + ] + }, + "PANDORA_ALBUM::AL:3339906": { + "id": "AL:3339906", + "type": "album", + "title": "Hidden History of the Human Race", + "artistName": "Blood Incantation", + "thumbnailUrl": "https://content-images.p-cdn.com/images/1e/39/68/15/eec44ac2b7c49e6190b40ef9/_500W_500H.jpg", + "thumbnailWidth": 500, + "thumbnailHeight": 500, + "apiProvider": "pandora", + "platforms": [ + "pandora" + ] + }, + "SPOTIFY_ALBUM::0SdKqbCAKdkEFPW5NyzK7R": { + "id": "0SdKqbCAKdkEFPW5NyzK7R", + "type": "album", + "title": "Hidden History of the Human Race", + "artistName": "Blood Incantation", + "thumbnailUrl": "https://i.scdn.co/image/ab67616d0000b2734e452c006f620599a5882109", + "thumbnailWidth": 640, + "thumbnailHeight": 640, + "apiProvider": "spotify", + "platforms": [ + "spotify" + ] + }, + "TIDAL_ALBUM::118729475": { + "id": "118729475", + "type": "album", + "title": "Hidden History of the Human Race", + "artistName": "Blood Incantation", + "thumbnailUrl": "https://resources.tidal.com/images/6e71fc44/4f33/4880/b089/9f0f71a3ac98/640x640.jpg", + "thumbnailWidth": 640, + "thumbnailHeight": 640, + "apiProvider": "tidal", + "platforms": [ + "tidal" + ] + }, + "YANDEX_ALBUM::9130616": { + "id": "9130616", + "type": "album", + "title": "Hidden History of the Human Race", + "artistName": "Blood Incantation", + "thumbnailUrl": "https://avatars.yandex.net/get-music-content/2390047/8aeeac44.a.9130616-1/600x600", + "thumbnailWidth": 600, + "thumbnailHeight": 600, + "apiProvider": "yandex", + "platforms": [ + "yandex" + ] + } + }, + "linksByPlatform": { + "amazonMusic": { + "country": "US", + "url": "https://music.amazon.com/albums/B07Y5X5BRQ", + "entityUniqueId": "AMAZON_ALBUM::B07Y5X5BRQ" + }, + "amazonStore": { + "country": "US", + "url": "https://amazon.com/dp/B07Y5X5BRQ", + "entityUniqueId": "AMAZON_ALBUM::B07Y5X5BRQ" + }, + "anghami": { + "country": "US", + "url": "https://play.anghami.com/album/1009122604?refer=linktree", + "entityUniqueId": "ANGHAMI_ALBUM::1009122604" + }, + "bandcamp": { + "country": "US", + "url": "https://darkdescentrecords.bandcamp.com/album/hidden-history-of-the-human-race", + "entityUniqueId": "BANDCAMP_ALBUM::744542709" + }, + "boomplay": { + "country": "US", + "url": "https://www.boomplay.com/albums/4983454", + "entityUniqueId": "BOOMPLAY_ALBUM::4983454" + }, + "deezer": { + "country": "US", + "url": "https://www.deezer.com/album/112372942", + "entityUniqueId": "DEEZER_ALBUM::112372942" + }, + "napster": { + "country": "US", + "url": "https://play.napster.com/album/alb.409396148", + "entityUniqueId": "NAPSTER_ALBUM::alb.409396148" + }, + "pandora": { + "country": "US", + "url": "https://www.pandora.com/AL:3339906", + "entityUniqueId": "PANDORA_ALBUM::AL:3339906" + }, + "tidal": { + "country": "US", + "url": "https://listen.tidal.com/album/118729475", + "entityUniqueId": "TIDAL_ALBUM::118729475" + }, + "yandex": { + "country": "RU", + "url": "https://music.yandex.ru/album/9130616", + "entityUniqueId": "YANDEX_ALBUM::9130616" + }, + "spotify": { + "country": "US", + "url": "https://open.spotify.com/album/0SdKqbCAKdkEFPW5NyzK7R", + "nativeAppUriDesktop": "spotify:album:0SdKqbCAKdkEFPW5NyzK7R", + "entityUniqueId": "SPOTIFY_ALBUM::0SdKqbCAKdkEFPW5NyzK7R" + } + } +} diff --git a/service/odesli.js b/service/odesli.js new file mode 100644 index 0000000..dd1f225 --- /dev/null +++ b/service/odesli.js @@ -0,0 +1,51 @@ +// 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 }; +} diff --git a/service/odesli.test.js b/service/odesli.test.js new file mode 100644 index 0000000..42a3e26 --- /dev/null +++ b/service/odesli.test.js @@ -0,0 +1,157 @@ +// Unit tests for the optional Odesli (song.link) enrichment client (run with `node --test`). +// Zero-dep, hermetic: parseOdesli runs against a REAL captured response (odesli.fixture.json — +// Blood Incantation "Hidden History of the Human Race", a Spotify ALBUM url that Odesli resolves +// to a direct Bandcamp page), and getLinks runs against an injected fake fetch (no network). +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { parseOdesli, makeOdesli, shouldEnrich } from "./odesli.js"; + +// The captured live response. Its linksByPlatform has bandcamp (+ many other stores) but no +// appleMusic / youtube — exactly the real-world underground-album case the linker targets. +const fixture = JSON.parse(readFileSync(new URL("./odesli.fixture.json", import.meta.url), "utf8")); + +test("parseOdesli: extracts the direct Bandcamp url from the real captured response", () => { + const out = parseOdesli(fixture); + assert.equal( + out.bandcamp, + "https://darkdescentrecords.bandcamp.com/album/hidden-history-of-the-human-race", + "the real bandcamp url is pulled from linksByPlatform.bandcamp.url", + ); +}); + +test("parseOdesli: a platform absent from the response is OMITTED (not null/undefined keys)", () => { + const out = parseOdesli(fixture); + // This album has no appleMusic / youtube on Odesli — those fields must be absent, not present. + assert.ok(!("appleMusic" in out), "absent appleMusic -> field omitted"); + assert.ok(!("youtube" in out), "absent youtube -> field omitted"); + assert.deepEqual(Object.keys(out).sort(), ["bandcamp"], "only the present platform appears"); +}); + +test("parseOdesli: extracts bandcamp + appleMusic + youtube when all three are present", () => { + // Odesli's live Apple/YouTube enrichment can be empty for a given album (as in the fixture), so a + // shape-complete case proves all three extract correctly when the platform IS present. + const all = { + linksByPlatform: { + bandcamp: { url: "https://artist.bandcamp.com/album/x" }, + appleMusic: { url: "https://music.apple.com/us/album/x/123" }, + youtube: { url: "https://www.youtube.com/watch?v=abc" }, + tidal: { url: "https://tidal.com/album/999" }, // a platform we don't surface -> ignored + }, + }; + assert.deepEqual(parseOdesli(all), { + bandcamp: "https://artist.bandcamp.com/album/x", + appleMusic: "https://music.apple.com/us/album/x/123", + youtube: "https://www.youtube.com/watch?v=abc", + }); +}); + +test("parseOdesli: junk / empty / missing linksByPlatform -> {}", () => { + assert.deepEqual(parseOdesli(null), {}, "null body"); + assert.deepEqual(parseOdesli(undefined), {}, "undefined body"); + assert.deepEqual(parseOdesli({}), {}, "no linksByPlatform key"); + assert.deepEqual(parseOdesli({ linksByPlatform: null }), {}, "null linksByPlatform"); + assert.deepEqual(parseOdesli({ linksByPlatform: "nope" }), {}, "non-object linksByPlatform"); + assert.deepEqual(parseOdesli({ linksByPlatform: {} }), {}, "empty linksByPlatform"); + assert.deepEqual(parseOdesli("garbage"), {}, "string body"); + assert.deepEqual( + parseOdesli({ linksByPlatform: { bandcamp: {} } }), + {}, + "platform entry with no url -> omitted", + ); + assert.deepEqual( + parseOdesli({ linksByPlatform: { bandcamp: { url: 42 } } }), + {}, + "non-string url -> omitted", + ); +}); + +// --- shouldEnrich gate (album-only) -------------------------------------------- + +test("shouldEnrich: TRUE only for an album result with a direct Spotify url", () => { + const sp = "https://open.spotify.com/album/0SdKqbCAKdkEFPW5NyzK7R"; + assert.equal(shouldEnrich({ kind: "album", spotify: sp }), true, "album + spotify url -> enrich"); + // Artist results are skipped — Odesli 405s on artist urls, so calling it would be a wasted trip. + assert.equal(shouldEnrich({ kind: "artist", spotify: sp }), false, "artist -> skip"); + // A Google-fallback result (no spotify url) is never enriched, regardless of kind. + assert.equal(shouldEnrich({ kind: "album", google: "https://www.google.com/search?q=x" }), false, "no spotify url -> skip"); + assert.equal(shouldEnrich({ kind: "album", spotify: null }), false, "null spotify -> skip"); + assert.equal(shouldEnrich(null), false, "null result -> skip"); + assert.equal(shouldEnrich({ spotify: sp }), false, "missing kind (defaults artist-ish) -> skip"); +}); + +test("enrichment gate: an artist result makes ZERO Odesli calls; an album result makes one", async () => { + // A getLinks counter standing in for the resolver's enrichWithOdesli, which guards on shouldEnrich. + const state = { calls: 0 }; + const getLinks = async () => { state.calls += 1; return { bandcamp: "https://x.bandcamp.com/album/y" }; }; + const enrich = async (result) => { + if (!shouldEnrich(result)) return result; // the real guard + const links = await getLinks(result.spotify); // only album+spotify reaches here + if (typeof links.bandcamp === "string") result.bandcamp = links.bandcamp; + return result; + }; + const sp = "https://open.spotify.com/album/0SdKqbCAKdkEFPW5NyzK7R"; + + const artist = await enrich({ name: "Carcass", kind: "artist", spotify: sp }); + assert.equal(state.calls, 0, "artist result -> Odesli NOT called (no wasted 405)"); + assert.ok(!("bandcamp" in artist), "artist result is left unenriched"); + + const album = await enrich({ name: "Heartwork", kind: "album", spotify: sp }); + assert.equal(state.calls, 1, "album result -> exactly one Odesli call"); + assert.equal(album.bandcamp, "https://x.bandcamp.com/album/y", "album result is enriched"); +}); + +// --- getLinks (injected fetch) ------------------------------------------------- + +// A fetch fake that records the requested url and returns a scripted response (or throws). +function fakeFetch({ ok = true, status = 200, body = {}, throws = null } = {}) { + const state = { calls: 0, url: null, hadSignal: false }; + const fetch = async (url, opts) => { + state.calls += 1; + state.url = url; + state.hadSignal = Boolean(opts && opts.signal); + if (throws) throw throws; + return { ok, status, json: async () => body }; + }; + return { fetch, state }; +} + +test("getLinks: a 200 is parsed; the request hits the right endpoint with a timeout signal", async () => { + const { fetch, state } = fakeFetch({ body: fixture }); + const od = makeOdesli({ fetch }); + const spUrl = "https://open.spotify.com/album/0SdKqbCAKdkEFPW5NyzK7R"; + const out = await od.getLinks(spUrl); + + assert.equal(out.bandcamp, "https://darkdescentrecords.bandcamp.com/album/hidden-history-of-the-human-race"); + assert.equal(state.calls, 1); + assert.ok( + state.url === `https://api.song.link/v1-alpha.1/links?url=${encodeURIComponent(spUrl)}`, + "GET /links?url= against the default base", + ); + assert.ok(state.hadSignal, "an AbortSignal.timeout is passed (bounded like the other calls)"); +}); + +test("getLinks: a non-200 returns {} (graceful — e.g. 405 on a non-entity url)", async () => { + const { fetch } = fakeFetch({ ok: false, status: 405, body: { code: "UNSUPPORTED_URL" } }); + const od = makeOdesli({ fetch }); + assert.deepEqual(await od.getLinks("https://open.spotify.com/artist/xyz"), {}); +}); + +test("getLinks: a network/timeout throw returns {} (no crash)", async () => { + const { fetch } = fakeFetch({ throws: new Error("AbortError: timed out") }); + const od = makeOdesli({ fetch }); + assert.deepEqual(await od.getLinks("https://open.spotify.com/album/abc"), {}); +}); + +test("getLinks: a JSON parse error returns {} (graceful)", async () => { + const fetch = async () => ({ ok: true, status: 200, json: async () => { throw new SyntaxError("bad json"); } }); + const od = makeOdesli({ fetch }); + assert.deepEqual(await od.getLinks("https://open.spotify.com/album/abc"), {}); +}); + +test("getLinks: baseUrl override is honored", async () => { + const { fetch, state } = fakeFetch({ body: { linksByPlatform: {} } }); + const od = makeOdesli({ fetch, baseUrl: "http://localhost:9999/v1" }); + await od.getLinks("https://open.spotify.com/album/abc"); + assert.ok(state.url.startsWith("http://localhost:9999/v1/links?url="), "uses the injected baseUrl"); +}); diff --git a/service/resolver.js b/service/resolver.js index 3c9d108..7582492 100644 --- a/service/resolver.js +++ b/service/resolver.js @@ -15,10 +15,11 @@ import { createServer } from "node:http"; import { readFileSync } from "node:fs"; -import { pickArtist, pickAlbum } from "./lib.js"; +import { pickArtist, pickAlbum, isHttpUrl } from "./lib.js"; import { buildClassifyMessages, parseClassification, DEFAULT_MODEL } from "./llm.js"; import { resolveAll as resolveAllCore } from "./resolve-core.js"; import { makeSpotifyToken, makeSpotifySearch } from "./spotify.js"; +import { makeOdesli, shouldEnrich } from "./odesli.js"; // --- minimal .env loader (no dependency) ------------------------------------ try { @@ -36,11 +37,20 @@ const USE_SPOTIFY = Boolean(SPOTIFY_ID && SPOTIFY_SECRET); const OLLAMA_URL = (process.env.OLLAMA_URL || "").replace(/\/$/, ""); // presence enables the LLM gate const OLLAMA_MODEL = process.env.OLLAMA_MODEL || DEFAULT_MODEL; const USE_LLM = Boolean(OLLAMA_URL); +// Optional Odesli enrichment: opt-in, default OFF (mirrors the ollama opt-in). ODESLI=1 (or any +// truthy non-"0"/"false" value), or ODESLI_URL= to override the endpoint, turns it on. When +// OFF, no Odesli call is made and no platform fields are added — behavior is byte-identical. +const ODESLI_URL = (process.env.ODESLI_URL || "").replace(/\/$/, ""); +const ODESLI_FLAG = (process.env.ODESLI || "").trim().toLowerCase(); +const USE_ODESLI = Boolean(ODESLI_URL) || (ODESLI_FLAG !== "" && ODESLI_FLAG !== "0" && ODESLI_FLAG !== "false"); // Cache only the network half (the Spotify direct-url lookup); resolve-core rebuilds the final // link object per request so a name's result never depends on a stale gate decision. The key // includes the kind hint (see resolve-core's cacheKey). const spCache = new Map(); +// Odesli results cached per Spotify entity URL (the input is the cache key — a stable identity for +// the enrichment, independent of which candidate name resolved to it). +const odCache = new Map(); // --- Spotify (optional): direct-artist-link lookup -------------------------- // The single-flight token getter: a cold/expired-cache fan-out (up to MAX_CANDIDATES concurrent @@ -108,12 +118,49 @@ async function ollamaClassify(candidates) { return parseClassification(j.message?.content ?? "", candidates); } +// --- Odesli (optional): direct cross-platform links for a Spotify-resolved name ---------------- +// Enriches a result that already carries a DIRECT Spotify url with the album/track's direct +// Bandcamp (+ Apple Music / YouTube) links. Only the Spotify-creds path produces such a url, so +// the no-creds Google fallback path is never touched. Each returned platform url is re-validated +// (isHttpUrl) before it is added, and the lookup is cached per Spotify url (odCache). +const odesli = USE_ODESLI + ? makeOdesli({ fetch, ...(ODESLI_URL ? { baseUrl: ODESLI_URL } : {}) }) + : null; + +// Enrich one ALBUM result in place with Odesli platform links, when it has a direct Spotify url. +// The added fields (bandcamp / appleMusic / youtube) are ADDITIVE — name/kind/spotify/google/ +// primary are untouched, so an older extension keeps rendering exactly as before. Odesli down / +// timeout / garbage -> getLinks returns {} -> the result is unchanged (graceful). Mutated + returned. +async function enrichWithOdesli(result) { + // shouldEnrich: an ALBUM result with a direct Spotify url. Artist results are skipped (Odesli + // 405s on artist urls) so the common artist case makes no wasted round-trip. + if (!shouldEnrich(result)) return result; + const spUrl = result.spotify; + let links; + if (odCache.has(spUrl)) { + links = odCache.get(spUrl); + } else { + links = await odesli.getLinks(spUrl); + odCache.set(spUrl, links); + } + for (const platform of ["bandcamp", "appleMusic", "youtube"]) { + const url = links[platform]; + if (typeof url === "string" && isHttpUrl(url)) result[platform] = url; // validated additive field + } + return result; +} + // --- resolve -------------------------------------------------------------- // Run the pure orchestration (resolve-core) with the live clients injected: the Spotify `search`, // the ollama `classify` gate (null when no LLM is configured -> the Spotify exact-match is the -// gate), and the shared spCache. -const resolveAll = (candidates) => - resolveAllCore(candidates, { search, classify: USE_LLM ? ollamaClassify : null, cache: spCache }); +// gate), and the shared spCache. When Odesli is enabled, enrich each Spotify-resolved result with +// its direct cross-platform links; when disabled, the map is returned untouched (no extra calls). +async function resolveAll(candidates) { + const out = await resolveAllCore(candidates, { search, classify: USE_LLM ? ollamaClassify : null, cache: spCache }); + if (!odesli) return out; + await Promise.all(Object.values(out).map((r) => enrichWithOdesli(r))); + return out; +} // --- HTTP ------------------------------------------------------------------- function readBody(req) { @@ -149,7 +196,7 @@ const server = createServer(async (req, res) => { if (req.method === "OPTIONS") { res.writeHead(204); return res.end(); } if (req.method === "GET" && req.url === "/health") { - return json(res, 200, { ok: true, spotify: USE_SPOTIFY, llm: USE_LLM ? OLLAMA_MODEL : false, cached: spCache.size }); + return json(res, 200, { ok: true, spotify: USE_SPOTIFY, llm: USE_LLM ? OLLAMA_MODEL : false, odesli: USE_ODESLI, cached: spCache.size }); } if (req.method === "POST" && req.url === "/resolve") { @@ -168,7 +215,8 @@ const server = createServer(async (req, res) => { server.listen(PORT, () => { const sp = USE_SPOTIFY ? "on" : "off"; const llm = USE_LLM ? OLLAMA_MODEL : "off"; - console.log(`resolver listening on http://localhost:${PORT} (spotify: ${sp}, llm: ${llm})`); + const od = USE_ODESLI ? "on" : "off"; + console.log(`resolver listening on http://localhost:${PORT} (spotify: ${sp}, llm: ${llm}, odesli: ${od})`); if (!USE_SPOTIFY && !USE_LLM) { console.warn(" ! no gate configured: set SPOTIFY_CLIENT_ID/SECRET and/or OLLAMA_URL, or nothing will link."); }