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>
224 lines
11 KiB
JavaScript
224 lines
11 KiB
JavaScript
// Tiny resolver service: candidate names -> artist/album links, for CONFIRMED names only.
|
|
//
|
|
// Zero npm deps; Node 18+ (built-in fetch). The "which words are real artists/albums" problem
|
|
// is not guessed client-side — it is gated one of two ways:
|
|
// * SPOTIFY_CLIENT_ID/SECRET set -> a candidate resolves only when Spotify has an artist OR
|
|
// album whose name matches it case-insensitively (the exact-match precision lever), linking
|
|
// to the DIRECT Spotify artist/album page. One search call covers both types.
|
|
// * OLLAMA_URL set -> a local LLM (via ollama HTTP, still zero npm deps) classifies each
|
|
// candidate as artist/album/none; the kind routes the Spotify lookup, and confirmed names
|
|
// get a Spotify direct link when available, else a Google->Bandcamp search fallback. This
|
|
// is the better-matching path.
|
|
// The two combine: with both set, the LLM gates + routes the kind and Spotify supplies direct
|
|
// links. Each result carries kind:"artist"|"album". The pure matching/link/prompt logic lives
|
|
// in lib.js + llm.js (unit-tested).
|
|
|
|
import { createServer } from "node:http";
|
|
import { readFileSync } from "node:fs";
|
|
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 {
|
|
const env = readFileSync(new URL("./.env", import.meta.url), "utf8");
|
|
for (const line of env.split("\n")) {
|
|
const m = line.match(/^\s*([\w.-]+)\s*=\s*(.*?)\s*$/);
|
|
if (m && !(m[1] in process.env)) process.env[m[1]] = m[2].replace(/^["']|["']$/g, "");
|
|
}
|
|
} catch { /* no .env -> use real env / defaults */ }
|
|
|
|
const PORT = Number(process.env.PORT) || 8787;
|
|
const SPOTIFY_ID = process.env.SPOTIFY_CLIENT_ID;
|
|
const SPOTIFY_SECRET = process.env.SPOTIFY_CLIENT_SECRET;
|
|
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
|
|
// resolves) shares ONE POST /api/token instead of stampeding the rate-limited token endpoint.
|
|
// fetchToken does the real POST; the cache + (expires_in-60)*1000 expiry math live in the factory.
|
|
const spTokens = makeSpotifyToken({
|
|
fetchToken: async () => {
|
|
const auth = Buffer.from(`${SPOTIFY_ID}:${SPOTIFY_SECRET}`).toString("base64");
|
|
const r = await fetch("https://accounts.spotify.com/api/token", {
|
|
method: "POST",
|
|
headers: { authorization: `Basic ${auth}`, "content-type": "application/x-www-form-urlencoded" },
|
|
body: "grant_type=client_credentials",
|
|
signal: AbortSignal.timeout(5000), // fail fast on a hung upstream (caught per-candidate -> null)
|
|
});
|
|
if (!r.ok) throw new Error(`spotify token http ${r.status}`);
|
|
return r.json();
|
|
},
|
|
});
|
|
|
|
// One bare Spotify search (no token mgmt): returns { ok, status, headers, body } so the
|
|
// search-with-retry layer can branch on 401/429 without an exception masking the status.
|
|
async function doSpotifySearch(name, token) {
|
|
const url = `https://api.spotify.com/v1/search?type=artist,album&limit=5&q=${encodeURIComponent(name)}`;
|
|
const r = await fetch(url, {
|
|
headers: { authorization: `Bearer ${token}` },
|
|
signal: AbortSignal.timeout(5000), // fail fast on a hung upstream (caught per-candidate -> null)
|
|
});
|
|
// 401/429 carry no usable body for our purposes; only parse JSON on a real hit.
|
|
const body = r.ok ? await r.json() : null;
|
|
return { ok: r.ok, status: r.status, headers: r.headers, body };
|
|
}
|
|
|
|
// 401 -> invalidate + refresh + retry once; 429 -> bounded Retry-After + retry once; else throw.
|
|
const spSearch = makeSpotifySearch({ doSearch: doSpotifySearch, tokens: spTokens });
|
|
|
|
// The injected `search` for resolve-core: one type=artist,album Spotify search per candidate,
|
|
// returning the exact-name-match hits as { artist, album } where each is the external Spotify url
|
|
// (string) or null. No creds -> both null (nothing links). resolve-core's pickDirect applies the
|
|
// kindHint tie-break and shapes the link; the cache (spCache) is owned by resolve-core too.
|
|
async function search(name) {
|
|
if (!USE_SPOTIFY) return { artist: null, album: null };
|
|
const j = await spSearch(name);
|
|
return {
|
|
artist: pickArtist(name, j.artists?.items)?.external_urls?.spotify ?? null,
|
|
album: pickAlbum(name, j.albums?.items)?.external_urls?.spotify ?? null,
|
|
};
|
|
}
|
|
|
|
// --- LLM gate (optional): which candidates are artists/albums? --------------
|
|
async function ollamaClassify(candidates) {
|
|
const r = await fetch(`${OLLAMA_URL}/api/chat`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
model: OLLAMA_MODEL,
|
|
messages: buildClassifyMessages(candidates),
|
|
stream: false,
|
|
format: "json",
|
|
options: { temperature: 0 },
|
|
}),
|
|
signal: AbortSignal.timeout(5000), // fail fast on a hung upstream (caught per-candidate -> null)
|
|
});
|
|
if (!r.ok) throw new Error(`ollama http ${r.status}`);
|
|
const j = await r.json();
|
|
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. 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) {
|
|
return new Promise((resolve, reject) => {
|
|
let data = "";
|
|
req.on("data", (c) => {
|
|
data += c;
|
|
if (data.length > 1e6) { reject(new Error("body too large")); req.destroy(); }
|
|
});
|
|
req.on("end", () => resolve(data));
|
|
req.on("error", reject);
|
|
});
|
|
}
|
|
|
|
const json = (res, code, obj) => {
|
|
res.writeHead(code, { "content-type": "application/json" });
|
|
res.end(JSON.stringify(obj));
|
|
};
|
|
|
|
// CORS scoped to the extension origin: the background script sends
|
|
// `Origin: moz-extension://<uuid>`; a page-context fetch (file://, sandboxed) can send
|
|
// `Origin: null`. Reflect only those — never blanket `*` — so an arbitrary website the
|
|
// user visits can't POST to localhost:8787 and read resolver results.
|
|
const allowedOrigin = (origin) =>
|
|
typeof origin === "string" && (origin.startsWith("moz-extension://") || origin === "null");
|
|
|
|
const server = createServer(async (req, res) => {
|
|
const origin = req.headers.origin;
|
|
if (allowedOrigin(origin)) res.setHeader("access-control-allow-origin", origin);
|
|
res.setHeader("vary", "origin"); // the allow-origin header varies by request Origin
|
|
res.setHeader("access-control-allow-headers", "content-type");
|
|
res.setHeader("access-control-allow-methods", "GET, POST, OPTIONS");
|
|
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, odesli: USE_ODESLI, cached: spCache.size });
|
|
}
|
|
|
|
if (req.method === "POST" && req.url === "/resolve") {
|
|
try {
|
|
const { candidates } = JSON.parse((await readBody(req)) || "{}");
|
|
if (!Array.isArray(candidates)) return json(res, 400, { error: "candidates must be an array" });
|
|
return json(res, 200, await resolveAll(candidates));
|
|
} catch (e) {
|
|
return json(res, 500, { error: e.message });
|
|
}
|
|
}
|
|
|
|
json(res, 404, { error: "not found" });
|
|
});
|
|
|
|
server.listen(PORT, () => {
|
|
const sp = USE_SPOTIFY ? "on" : "off";
|
|
const llm = USE_LLM ? OLLAMA_MODEL : "off";
|
|
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.");
|
|
}
|
|
});
|