// Tiny resolver service: candidate names -> artist links, for CONFIRMED artists only. // // Zero npm deps; Node 18+ (built-in fetch). The "which words are real artists" problem // is delegated to a music API rather than guessed client-side: // * with SPOTIFY_CLIENT_ID/SECRET set -> Spotify Web API (client-credentials) for // DIRECT artist links; // * without creds -> MusicBrainz validation + Spotify/Bandcamp SEARCH links (zero setup). // Either way a candidate only resolves when an artist's name matches it case-insensitively // (the precision lever). The pure matching/link logic lives in lib.js (unit-tested). import { createServer } from "node:http"; import { readFileSync } from "node:fs"; import { norm, pickArtist, spotifyResult, mbResult, relUrls } from "./lib.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 UA = "reddit-spotify-linker/0.1 (https://git.yapplesauce.com/paul/linker)"; const MAX_CANDIDATES = 100; const cache = new Map(); // norm(name) -> { spotify, bandcamp, name } | null (negatives cached too) // --- Spotify (optional) ----------------------------------------------------- let spToken = null; let spTokenExp = 0; async function spotifyToken() { if (spToken && Date.now() < spTokenExp) return spToken; 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", }); if (!r.ok) throw new Error(`spotify token http ${r.status}`); const j = await r.json(); spToken = j.access_token; spTokenExp = Date.now() + (j.expires_in - 60) * 1000; return spToken; } async function viaSpotify(name) { const token = await spotifyToken(); const url = `https://api.spotify.com/v1/search?type=artist&limit=5&q=${encodeURIComponent(name)}`; const r = await fetch(url, { headers: { authorization: `Bearer ${token}` } }); if (!r.ok) throw new Error(`spotify search http ${r.status}`); const j = await r.json(); const hit = pickArtist(name, j.artists?.items); return hit ? spotifyResult(hit) : null; } // --- MusicBrainz (zero-cred path) ------------------------------------------- // MusicBrainz asks for <=1 request/second, so every request is serialized + spaced. const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); let mbGate = Promise.resolve(); let mbLast = 0; function mbSchedule() { const gate = mbGate.then(async () => { const wait = 1100 - (Date.now() - mbLast); if (wait > 0) await sleep(wait); mbLast = Date.now(); }); mbGate = gate.catch(() => {}); // a failure never wedges the queue return gate; } async function mbFetchJson(url) { await mbSchedule(); const r = await fetch(url, { headers: { "user-agent": UA, accept: "application/json" } }); if (!r.ok) throw new Error(`musicbrainz http ${r.status}`); return r.json(); } async function viaMusicBrainz(name) { const q = encodeURIComponent(`artist:"${name}"`); const search = await mbFetchJson(`https://musicbrainz.org/ws/2/artist?fmt=json&limit=5&query=${q}`); const hit = pickArtist(name, search.artists, { minScore: 90 }); if (!hit) return null; // Enrich with MB url-relations -> DIRECT bandcamp/spotify links (best-effort). let links = null; try { const detail = await mbFetchJson(`https://musicbrainz.org/ws/2/artist/${hit.id}?inc=url-rels&fmt=json`); links = relUrls(detail.relations); } catch (e) { console.error(`mb url-rels "${name}": ${e.message}`); // enrichment is best-effort } return mbResult(hit, links); } // --- resolve one candidate (cached) ----------------------------------------- async function resolveOne(name) { const key = norm(name); if (!key) return null; if (cache.has(key)) return cache.get(key); try { const result = USE_SPOTIFY ? await viaSpotify(name) : await viaMusicBrainz(name); cache.set(key, result); // cache hits AND confirmed misses return result; } catch (e) { console.error(`resolve "${name}": ${e.message}`); // transient -> don't cache return null; } } async function resolveAll(candidates) { const uniq = [...new Set(candidates.map((c) => String(c).trim()).filter(Boolean))].slice(0, MAX_CANDIDATES); const out = {}; await Promise.all(uniq.map(async (name) => { out[name] = await resolveOne(name); })); 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)); }; const server = createServer(async (req, res) => { res.setHeader("access-control-allow-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, provider: USE_SPOTIFY ? "spotify" : "musicbrainz", cached: cache.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, () => { console.log(`resolver listening on http://localhost:${PORT} (provider: ${USE_SPOTIFY ? "spotify" : "musicbrainz"})`); });