feat: spotify-only matching + optional local-LLM gate + google→bandcamp fallback (closes task/drop-musicbrainz-google-fallback, closes task/ollama-classifier)

Source: human-feedback note 01KVDYV3A35B57N15CRBQDXPCH (project/reddit-spotify-linker,
2026-06-18) — "remove the musicbrainz integration and just use spotify"; a google search
for "<name> bandcamp" fallback; and "would a local llm be better able to categorise these
... include instructions for running something suitable via ollama (16GB M2)".

- Remove MusicBrainz entirely (viaMusicBrainz + url-rels + rate-gate; lib relUrls/mbResult).
  Spotify is now the only music API; it both gates by exact-name match and supplies the
  direct artist link.
- Optional ollama LLM gate (OLLAMA_URL enables it, OLLAMA_MODEL default qwen2.5:3b) called
  over plain HTTP -> stays zero npm-dep. It classifies which candidates are real
  artists/albums (the matching-quality lever). Pure prompt/parse logic in new llm.js
  (unit-tested in llm.test.js); the fetch + resolveAll gate wiring live in resolver.js.
- A confirmed name links to its direct Spotify page when available, else a Google search for
  "<name> bandcamp". New "google" primary platform rendered by the extension (styles.css
  blue accent + platform-aware link title in content.js).
- README: drop MusicBrainz; document the two gates + a runnable local-LLM setup for a 16GB
  MacBook M2. AGENTS.md: flip the old "no ML/NER" standing decision per the human override.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
paul
2026-06-19 12:50:07 +00:00
co-authored by Claude Opus 4.8
parent b97c82d92e
commit ef57761859
10 changed files with 343 additions and 249 deletions
+29 -86
View File
@@ -1,7 +1,7 @@
// Unit tests for the resolver's pure logic (run with `node --test`). Zero-dep.
import { test } from "node:test";
import assert from "node:assert/strict";
import { norm, bandcampSearch, spotifySearch, pickArtist, spotifyResult, relUrls, mbResult, primaryOf, isSearchUrl, isHttpUrl } from "./lib.js";
import { norm, googleSearch, pickArtist, linkResult, isSearchUrl, isHttpUrl } from "./lib.js";
test("norm lowercases, trims, NFKC-normalizes", () => {
assert.equal(norm(" Cerebral Bore "), "cerebral bore");
@@ -15,78 +15,44 @@ test("pickArtist matches case-insensitively, rejects non-matches", () => {
assert.equal(pickArtist("Notarealband", items), null);
});
test("pickArtist enforces minScore when given (MusicBrainz path)", () => {
const items = [{ name: "Devourment", score: 100 }, { name: "Devourment", score: 50 }];
assert.equal(pickArtist("Devourment", items, { minScore: 90 })?.score, 100);
assert.equal(pickArtist("Devourment", [{ name: "Devourment", score: 50 }], { minScore: 90 }), null);
});
test("pickArtist tolerates empty / garbage input", () => {
assert.equal(pickArtist("x", null), null);
assert.equal(pickArtist("x", []), null);
assert.equal(pickArtist("x", [null, { name: "y" }]), null);
});
test("spotifyResult prefers the direct url, falls back to search", () => {
const direct = spotifyResult({
name: "Devourment",
external_urls: { spotify: "https://open.spotify.com/artist/abc" },
});
assert.equal(direct.spotify, "https://open.spotify.com/artist/abc");
assert.match(direct.bandcamp, /^https:\/\/bandcamp\.com\/search\?q=Devourment/);
assert.deepEqual(direct.primary, { platform: "spotify", url: "https://open.spotify.com/artist/abc" });
const noUrl = spotifyResult({ name: "Devourment" });
assert.equal(noUrl.spotify, "https://open.spotify.com/search/Devourment");
assert.equal(noUrl.primary.platform, "spotify"); // search fallback still points at spotify
test("googleSearch points at a Google query for '<name> bandcamp', url-encoded", () => {
const u = googleSearch("Cerebral Bore");
assert.match(u, /^https:\/\/www\.google\.com\/search\?q=/);
assert.ok(u.includes("Cerebral%20Bore%20bandcamp"));
assert.ok(googleSearch("A&B").includes("A%26B"));
});
test("primaryOf / isSearchUrl: prefer direct spotify, else direct bandcamp, else spotify search", () => {
test("linkResult prefers a direct Spotify url, primary = spotify", () => {
const r = linkResult("Devourment", "https://open.spotify.com/artist/abc");
assert.equal(r.spotify, "https://open.spotify.com/artist/abc");
assert.deepEqual(r.primary, { platform: "spotify", url: "https://open.spotify.com/artist/abc" });
assert.equal(r.google, undefined);
});
test("linkResult falls back to Google->Bandcamp when there is no direct Spotify url", () => {
const r = linkResult("Some Local Band", null);
assert.equal(r.spotify, undefined);
assert.equal(r.primary.platform, "google");
assert.match(r.primary.url, /^https:\/\/www\.google\.com\/search\?q=/);
assert.equal(r.google, r.primary.url);
});
test("linkResult rejects non-http(s) / search Spotify urls and falls back to Google", () => {
for (const bad of ["javascript:alert(1)", "ftp://x/y", "https://open.spotify.com/search/X", "not a url"]) {
const r = linkResult("X", bad);
assert.equal(r.primary.platform, "google", `expected fallback for ${bad}`);
}
});
test("isSearchUrl detects /search urls", () => {
assert.equal(isSearchUrl("https://open.spotify.com/search/X"), true);
assert.equal(isSearchUrl("https://open.spotify.com/artist/abc"), false);
assert.deepEqual(
primaryOf({ spotify: "https://open.spotify.com/artist/abc", bandcamp: "https://bandcamp.com/search?q=X&item_type=b" }),
{ platform: "spotify", url: "https://open.spotify.com/artist/abc" },
);
assert.deepEqual(
primaryOf({ spotify: "https://open.spotify.com/search/X", bandcamp: "https://x.bandcamp.com/" }),
{ platform: "bandcamp", url: "https://x.bandcamp.com/" },
);
assert.deepEqual(
primaryOf({ spotify: "https://open.spotify.com/search/X", bandcamp: "https://bandcamp.com/search?q=X&item_type=b" }),
{ platform: "spotify", url: "https://open.spotify.com/search/X" },
);
});
test("relUrls picks direct bandcamp + spotify from MB relations, tolerates junk", () => {
const relations = [
{ url: { resource: "https://cattledecapitation.bandcamp.com/" } },
{ url: { resource: "https://open.spotify.com/artist/67ZMMtA88DDO0gTuRrzGjn" } },
{ url: { resource: "https://en.wikipedia.org/wiki/Cattle_Decapitation" } },
null,
{ url: null },
];
const links = relUrls(relations);
assert.equal(links.bandcamp, "https://cattledecapitation.bandcamp.com/");
assert.equal(links.spotify, "https://open.spotify.com/artist/67ZMMtA88DDO0gTuRrzGjn");
});
test("relUrls returns nulls when relations absent/empty", () => {
assert.deepEqual(relUrls(null), { bandcamp: null, spotify: null });
assert.deepEqual(relUrls([]), { bandcamp: null, spotify: null });
});
test("relUrls rejects non-http(s) and look-alike hosts (hardening)", () => {
const relations = [
{ url: { resource: "javascript:alert('bandcamp.com')" } }, // not http(s)
{ url: { resource: "ftp://files.bandcamp.com/x" } }, // not http(s)
{ url: { resource: "https://evilbandcamp.com/" } }, // host doesn't end in .bandcamp.com
{ url: { resource: "https://x.bandcamp.com/" } }, // valid subdomain
];
const links = relUrls(relations);
assert.equal(links.bandcamp, "https://x.bandcamp.com/");
assert.equal(links.spotify, null);
});
test("isHttpUrl accepts http/https only", () => {
@@ -97,26 +63,3 @@ test("isHttpUrl accepts http/https only", () => {
assert.equal(isHttpUrl("not a url"), false);
assert.equal(isHttpUrl(null), false);
});
test("mbResult prefers direct links, falls back to search per-platform", () => {
const direct = mbResult(
{ name: "Cattle Decapitation" },
{ bandcamp: "https://cattledecapitation.bandcamp.com/", spotify: "https://open.spotify.com/artist/x" },
);
assert.equal(direct.bandcamp, "https://cattledecapitation.bandcamp.com/");
assert.equal(direct.spotify, "https://open.spotify.com/artist/x");
assert.deepEqual(direct.primary, { platform: "spotify", url: "https://open.spotify.com/artist/x" });
const fallback = mbResult({ name: "Cerebral Bore" }, null);
assert.equal(fallback.spotify, "https://open.spotify.com/search/Cerebral%20Bore");
assert.equal(fallback.bandcamp, "https://bandcamp.com/search?q=Cerebral%20Bore&item_type=b");
const partial = mbResult({ name: "X" }, { bandcamp: null, spotify: "https://open.spotify.com/artist/y" });
assert.equal(partial.spotify, "https://open.spotify.com/artist/y");
assert.match(partial.bandcamp, /bandcamp\.com\/search/);
});
test("link builders url-encode", () => {
assert.equal(spotifySearch("A B"), "https://open.spotify.com/search/A%20B");
assert.ok(bandcampSearch("A&B").includes("A%26B"));
});