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
+54 -6
View File
@@ -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=<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
// 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.");
}