feat: reddit -> spotify/bandcamp artist linker MVP (extension + resolver service)
Firefox MV2 content script that detects artist/band names in reddit comment threads and rewrites them inline into Spotify + Bandcamp links, backed by a tiny zero-dependency Node resolver that confirms each candidate against the Spotify Web API (direct links) or MusicBrainz (zero-setup, search links). Includes build + install instructions in the README. Source: project/reddit-spotify-linker (user 2026-06-12) + user message 2026-06-14 "Full vibe code, small service, minimal code." Closes task/resolver-service, closes task/firefox-extension, closes task/docs-and-publish (plan/mvp-build, objective/ship-mvp-linker). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
# Copy to `.env` (it is gitignored). All values are OPTIONAL.
|
||||
#
|
||||
# Without Spotify credentials the resolver validates names against MusicBrainz and links
|
||||
# to Spotify/Bandcamp SEARCH pages — works with zero setup.
|
||||
#
|
||||
# With credentials it uses the Spotify Web API for DIRECT artist links.
|
||||
# Create a (free) app at https://developer.spotify.com/dashboard to get these.
|
||||
SPOTIFY_CLIENT_ID=
|
||||
SPOTIFY_CLIENT_SECRET=
|
||||
|
||||
# Port the resolver listens on (default 8787). If you change it, also update
|
||||
# RESOLVER_URL in extension/content.js and the host in extension/manifest.json.
|
||||
PORT=8787
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "reddit-spotify-linker-resolver",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Tiny resolver service: candidate names -> confirmed-artist links (Spotify/Bandcamp).",
|
||||
"engines": { "node": ">=18" },
|
||||
"scripts": {
|
||||
"start": "node resolver.js"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// 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 that keeps random capitalized words from being linked).
|
||||
|
||||
import { createServer } from "node:http";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
// --- 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 norm = (s) => String(s).normalize("NFKC").trim().toLowerCase();
|
||||
const bandcampSearch = (n) => `https://bandcamp.com/search?q=${encodeURIComponent(n)}&item_type=b`;
|
||||
const spotifySearch = (n) => `https://open.spotify.com/search/${encodeURIComponent(n)}`;
|
||||
|
||||
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 = (j.artists?.items ?? []).find((a) => norm(a.name) === norm(name));
|
||||
if (!hit) return null;
|
||||
return {
|
||||
name: hit.name,
|
||||
spotify: hit.external_urls?.spotify ?? spotifySearch(hit.name),
|
||||
bandcamp: bandcampSearch(hit.name),
|
||||
};
|
||||
}
|
||||
|
||||
// --- MusicBrainz (zero-cred fallback) ---------------------------------------
|
||||
// MusicBrainz asks for <=1 request/second, so issuance is serialized + spaced.
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
let mbGate = Promise.resolve();
|
||||
let mbLast = 0;
|
||||
function mbThrottled(name) {
|
||||
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.then(() => viaMusicBrainz(name));
|
||||
}
|
||||
|
||||
async function viaMusicBrainz(name) {
|
||||
const q = encodeURIComponent(`artist:"${name}"`);
|
||||
const url = `https://musicbrainz.org/ws/2/artist?fmt=json&limit=5&query=${q}`;
|
||||
const r = await fetch(url, { headers: { "user-agent": UA, accept: "application/json" } });
|
||||
if (!r.ok) throw new Error(`musicbrainz http ${r.status}`);
|
||||
const j = await r.json();
|
||||
const hit = (j.artists ?? []).find((a) => norm(a.name) === norm(name) && (a.score ?? 0) >= 90);
|
||||
if (!hit) return null;
|
||||
return { name: hit.name, spotify: spotifySearch(hit.name), bandcamp: bandcampSearch(hit.name) };
|
||||
}
|
||||
|
||||
// --- 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 mbThrottled(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"})`);
|
||||
});
|
||||
Reference in New Issue
Block a user