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:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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.");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user