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>
This commit is contained in:
paul
2026-06-24 02:41:30 +00:00
co-authored by Claude Opus 4.8
parent 5ceea28948
commit e06c983b16
9 changed files with 544 additions and 8 deletions
+14 -1
View File
@@ -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 `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 and albums**: one `type=artist,album` Spotify search per candidate, exact
case-insensitive name match against either; each result carries 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` + - `extension/` — Firefox **MV2** content script (`content.js` + `extract.js` +
`manifest.json` + `styles.css`). No build step. `extract.js` is the pure candidate `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 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**: 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 HTTP only, no in-process model runtime. The heuristic + Spotify exact-match is the
zero-dep default when no LLM is configured. 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 - Detection precision lever (zero-dep default): a candidate links **only** when
Spotify returns an **artist or album** whose name matches it case-insensitively. A 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 confirmed name links to its Spotify artist/album page if available, else a
+25
View File
@@ -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 direct links; with the LLM alone, confirmed names get a Spotify direct link if one
exists, else the Google fallback. 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 "<name> 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=<base>` 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 ## 2. Install the extension in Firefox
The extension is unpacked / unsigned, so load it as a **temporary add-on**: The extension is unpacked / unsigned, so load it as a **temporary add-on**:
+26 -1
View File
@@ -109,6 +109,24 @@
return a; 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() // `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. // and reused here — the regex battery runs once per node per scan, not twice.
function wrapNode(node, matches) { function wrapNode(node, matches) {
@@ -124,9 +142,16 @@
if (m.index < last) continue; // skip overlaps if (m.index < last) continue; // skip overlaps
if (m.index > last) frag.appendChild(document.createTextNode(text.slice(last, m.index))); if (m.index > last) frag.appendChild(document.createTextNode(text.slice(last, m.index)));
const matchText = text.substr(m.index, m.length); 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. // makeLink returns null for an unsafe (non-http(s)) href: keep the plain text.
frag.appendChild(link || document.createTextNode(matchText)); 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; last = m.index + m.length;
} }
if (last < text.length) frag.appendChild(document.createTextNode(text.slice(last))); if (last < text.length) frag.appendChild(document.createTextNode(text.slice(last)));
+10
View File
@@ -16,6 +16,16 @@
.rsl-google { .rsl-google {
text-decoration-color: #4a72d0 !important; /* google-ish blue */ 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 { .rsl-link:hover {
text-decoration-style: solid !important; text-decoration-style: solid !important;
} }
+11
View File
@@ -13,6 +13,17 @@ SPOTIFY_CLIENT_SECRET=
# OLLAMA_URL=http://localhost:11434 # OLLAMA_URL=http://localhost:11434
# OLLAMA_MODEL=qwen2.5:3b # 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 # 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. # extension/background.js and the host in extension/manifest.json permissions.
PORT=8787 PORT=8787
+196
View File
@@ -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"
}
}
}
+51
View File
@@ -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=<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 };
}
+157
View File
@@ -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=<encoded spotify 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");
});
+54 -6
View File
@@ -15,10 +15,11 @@
import { createServer } from "node:http"; import { createServer } from "node:http";
import { readFileSync } from "node:fs"; 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 { buildClassifyMessages, parseClassification, DEFAULT_MODEL } from "./llm.js";
import { resolveAll as resolveAllCore } from "./resolve-core.js"; import { resolveAll as resolveAllCore } from "./resolve-core.js";
import { makeSpotifyToken, makeSpotifySearch } from "./spotify.js"; import { makeSpotifyToken, makeSpotifySearch } from "./spotify.js";
import { makeOdesli, shouldEnrich } from "./odesli.js";
// --- minimal .env loader (no dependency) ------------------------------------ // --- minimal .env loader (no dependency) ------------------------------------
try { 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_URL = (process.env.OLLAMA_URL || "").replace(/\/$/, ""); // presence enables the LLM gate
const OLLAMA_MODEL = process.env.OLLAMA_MODEL || DEFAULT_MODEL; const OLLAMA_MODEL = process.env.OLLAMA_MODEL || DEFAULT_MODEL;
const USE_LLM = Boolean(OLLAMA_URL); 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=<base> 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 // 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 // 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). // includes the kind hint (see resolve-core's cacheKey).
const spCache = new Map(); 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 -------------------------- // --- Spotify (optional): direct-artist-link lookup --------------------------
// The single-flight token getter: a cold/expired-cache fan-out (up to MAX_CANDIDATES concurrent // 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); 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 -------------------------------------------------------------- // --- resolve --------------------------------------------------------------
// Run the pure orchestration (resolve-core) with the live clients injected: the Spotify `search`, // 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 // the ollama `classify` gate (null when no LLM is configured -> the Spotify exact-match is the
// gate), and the shared spCache. // gate), and the shared spCache. When Odesli is enabled, enrich each Spotify-resolved result with
const resolveAll = (candidates) => // its direct cross-platform links; when disabled, the map is returned untouched (no extra calls).
resolveAllCore(candidates, { search, classify: USE_LLM ? ollamaClassify : null, cache: spCache }); 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 ------------------------------------------------------------------- // --- HTTP -------------------------------------------------------------------
function readBody(req) { 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 === "OPTIONS") { res.writeHead(204); return res.end(); }
if (req.method === "GET" && req.url === "/health") { 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") { if (req.method === "POST" && req.url === "/resolve") {
@@ -168,7 +215,8 @@ const server = createServer(async (req, res) => {
server.listen(PORT, () => { server.listen(PORT, () => {
const sp = USE_SPOTIFY ? "on" : "off"; const sp = USE_SPOTIFY ? "on" : "off";
const llm = USE_LLM ? OLLAMA_MODEL : "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) { if (!USE_SPOTIFY && !USE_LLM) {
console.warn(" ! no gate configured: set SPOTIFY_CLIENT_ID/SECRET and/or OLLAMA_URL, or nothing will link."); console.warn(" ! no gate configured: set SPOTIFY_CLIENT_ID/SECRET and/or OLLAMA_URL, or nothing will link.");
} }