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
10 KiB
Markdown
224 lines
10 KiB
Markdown
# Reddit → Spotify / Bandcamp linker
|
|
|
|
A Firefox extension that, while you browse a Reddit thread, finds **artist / band
|
|
names and album titles** in the comments (and the post body) and turns them into
|
|
inline links to **Spotify** and **Bandcamp** — so a recommendation thread like
|
|
[this one](https://www.reddit.com/r/SlamDeathMetal/comments/1txh2r1/) becomes a
|
|
list of clickable artists and albums instead of names you have to copy-paste into
|
|
search.
|
|
|
|
The hard part is *"which words are artists/albums and which aren't?"*. Rather than
|
|
guess with client-side rules, the extension hands candidate names to a tiny local
|
|
**resolver service** that confirms each one and only links the names that are
|
|
really artists or albums. The resolver confirms names two ways (you enable either
|
|
or both): **Spotify** (exact-name match across artists **and** albums → direct
|
|
Spotify pages) and an optional **local LLM** via ollama (classifies which
|
|
candidates are artists/albums). A confirmed name links to its Spotify
|
|
artist/album page when available, otherwise to a Google search for
|
|
"<name> bandcamp". Album links are italicised so you can tell an album from an
|
|
artist at a glance.
|
|
|
|
```
|
|
reddit page content script resolver service
|
|
─────────── ────────────── ────────────────
|
|
comment text ──extract──> candidate names ──POST /resolve──> Spotify API (exact-match + direct links)
|
|
and/or
|
|
<a> links <──rewrite── confirmed links <───────────────── local LLM (ollama, optional)
|
|
```
|
|
|
|
## Layout
|
|
|
|
```
|
|
service/ tiny Node.js resolver (one file, zero npm deps)
|
|
resolver.js
|
|
package.json
|
|
.env.example
|
|
extension/ Firefox MV2 extension
|
|
manifest.json
|
|
background.js (does the resolver fetch; talks to the content script)
|
|
content.js
|
|
styles.css
|
|
icon.svg
|
|
```
|
|
|
|
## 1. Run the resolver service
|
|
|
|
Requirements: **Node 18+** (uses the built-in `fetch`). No `npm install` needed —
|
|
there are no dependencies.
|
|
|
|
```sh
|
|
cd service
|
|
node resolver.js
|
|
# resolver listening on http://localhost:8787 (spotify: off, llm: off)
|
|
```
|
|
|
|
For anything to link you need **at least one** of two gates configured —
|
|
**Spotify credentials** or the **local LLM** (or both). With neither, the resolver
|
|
warns at startup and nothing links.
|
|
|
|
### Spotify credentials (one of the two gates)
|
|
|
|
Spotify does two jobs: it **gates** a name (a candidate links only when Spotify has
|
|
that **artist or album** by exact name match) and supplies the **direct** Spotify
|
|
page link. A single search call covers both types, so albums confirm and link with
|
|
no extra setup. To enable it:
|
|
|
|
1. Create a free app at <https://developer.spotify.com/dashboard> and copy its
|
|
**Client ID** and **Client Secret**.
|
|
2. `cp .env.example .env` and fill them in (the `.env` file is gitignored):
|
|
|
|
```
|
|
SPOTIFY_CLIENT_ID=your_id
|
|
SPOTIFY_CLIENT_SECRET=your_secret
|
|
```
|
|
3. Restart the service. Startup reports `(spotify: on, llm: off)`.
|
|
|
|
With Spotify alone, names that aren't on Spotify don't link — precise, but it
|
|
misses real artists Spotify doesn't carry. The local LLM gate (below) catches
|
|
those.
|
|
|
|
Quick check that it works:
|
|
|
|
```sh
|
|
curl -s localhost:8787/resolve \
|
|
-H 'content-type: application/json' \
|
|
-d '{"candidates":["Cerebral Bore","Devourment","Notarealband Xyzzy"]}'
|
|
# {"Cerebral Bore":{...},"Devourment":{...},"Notarealband Xyzzy":null}
|
|
```
|
|
|
|
A confirmed name links to its Spotify artist page when available, otherwise to a
|
|
Google search for "<name> bandcamp" (which surfaces the artist's Bandcamp page).
|
|
|
|
## Better matching with a local LLM (optional)
|
|
|
|
The optional LLM gate is the **matching-quality lever**: it classifies which
|
|
candidate strings are real artists/albums, so confirmed names that aren't on
|
|
Spotify still link (via the "<name> bandcamp" Google fallback). The resolver calls
|
|
ollama over **plain HTTP**, so it stays **zero npm dependencies**.
|
|
|
|
On a 16GB MacBook (M2):
|
|
|
|
1. Install ollama — <https://ollama.com/download> or `brew install ollama`.
|
|
2. Pull a small model: `ollama pull qwen2.5:3b` (~2GB; runs comfortably on a 16GB
|
|
M2; the first resolve call warms the model so it's a little slow once, then
|
|
fast).
|
|
3. In `service/.env`, point the resolver at ollama (and optionally override the
|
|
model):
|
|
|
|
```
|
|
OLLAMA_URL=http://localhost:11434
|
|
OLLAMA_MODEL=qwen2.5:3b
|
|
```
|
|
4. Restart the resolver. Startup reports `(spotify: off, llm: qwen2.5:3b)` (or
|
|
`(spotify: on, llm: qwen2.5:3b)` if you also set Spotify creds).
|
|
5. Confirm: `curl localhost:8787/health` should show `"llm":"qwen2.5:3b"`.
|
|
|
|
With both gates, the LLM decides which names are artists and Spotify supplies the
|
|
direct links; with the LLM alone, confirmed names get a Spotify direct link if one
|
|
exists, else the Google fallback.
|
|
|
|
## Direct Bandcamp / Apple / YouTube links via Odesli (optional)
|
|
|
|
By default a name that isn't on Spotify links to a Google "<name> bandcamp" search — one click
|
|
from the real Bandcamp page. With **Odesli** (the free, no-auth [song.link](https://odesli.co)
|
|
API) enabled, an **album** result that resolved to a direct Spotify page is also enriched with the
|
|
album's **direct** Bandcamp (and, when Odesli has them, Apple Music / YouTube) links, so the
|
|
extension shows a small `bc` link straight to Bandcamp beside the Spotify link.
|
|
|
|
- **Opt-in, default OFF** — set `ODESLI=1` in `service/.env` (or `ODESLI_URL=<base>` to override the
|
|
endpoint). With it off, no Odesli call is made and behavior is unchanged.
|
|
- **Album results only** — Odesli resolves album/track *entity* urls, not artist urls (an artist
|
|
url 405s), so only **album** results are enriched; artist results are skipped (no wasted call).
|
|
- **Needs the Spotify gate** — an album result only carries a direct Spotify url when Spotify
|
|
confirmed it, so set the Spotify credentials above to have a url to enrich. The no-creds Google
|
|
fallback path is untouched.
|
|
- **Bounded + graceful** — one cached Odesli call per Spotify url, with a timeout; if Odesli is
|
|
down/slow/garbled the current result is kept and nothing breaks.
|
|
|
|
```sh
|
|
ODESLI=1 SPOTIFY_CLIENT_ID=… SPOTIFY_CLIENT_SECRET=… node resolver.js
|
|
# resolver listening on http://localhost:8787 (spotify: on, llm: off, odesli: on)
|
|
```
|
|
|
|
`curl localhost:8787/health` then shows `"odesli":true`.
|
|
|
|
## 2. Install the extension in Firefox
|
|
|
|
The extension is unpacked / unsigned, so load it as a **temporary add-on**:
|
|
|
|
1. Open `about:debugging#/runtime/this-firefox`.
|
|
2. **Load Temporary Add-on…**
|
|
3. Select `extension/manifest.json`.
|
|
|
|
It's now active until you restart Firefox. (To keep it permanently you'd sign it
|
|
via [AMO](https://addons.mozilla.org/developers/) or use Firefox Developer/ESR
|
|
with `xpinstall.signatures.required = false`.)
|
|
|
|
## 3. Use it
|
|
|
|
1. Make sure the resolver service is running.
|
|
2. Open any Reddit comments thread (a URL with `/comments/` in it).
|
|
3. Artist names and album titles become a single link — to **Spotify** if it's on
|
|
Spotify, otherwise to **Bandcamp** — colour-coded (green = Spotify, blue =
|
|
Google/Bandcamp); album links are italicised so they read as albums. New
|
|
comments loaded as you scroll are linked automatically.
|
|
4. The toolbar icon shows the extension is active; its badge shows how many links were
|
|
added on the current page.
|
|
|
|
## Troubleshooting
|
|
|
|
If nothing gets linked:
|
|
|
|
1. Confirm the resolver is running — `curl localhost:8787/health` should return
|
|
`{"ok":true,"spotify":<bool>,"llm":<model|false>,"cached":<n>}`. If both
|
|
`spotify` and `llm` are off, nothing can link — configure at least one gate
|
|
above.
|
|
2. On the reddit thread, open the browser console (F12) and look for `[rsl]` logs: they
|
|
report the root element, candidate count, and resolver responses on each scan. A
|
|
`resolve failed` line means the background script can't reach the resolver (is it on
|
|
`:8787`?); `candidates found: 0` means the comment text wasn't matched (please report
|
|
the thread).
|
|
3. The content script reaches the resolver **through the extension's background script**
|
|
— a page-context fetch to `http://localhost` is blocked by reddit's CSP — so make sure
|
|
the add-on loaded without errors in `about:debugging`.
|
|
|
|
## Configuration
|
|
|
|
The resolver URL is the constant `RESOLVER_URL` at the top of
|
|
`extension/background.js` (default `http://localhost:8787`). If you change the host
|
|
or port, update **both** that constant **and** the matching entry in
|
|
`extension/manifest.json` → `permissions` (Firefox needs the host listed there to
|
|
let the content script reach it).
|
|
|
|
## Limitations (MVP)
|
|
|
|
- **Artist detection is mostly capitalization-based**, with a cue-phrase pass that also
|
|
catches lowercase names right after "check out / by / fan of / recommend …". Names split
|
|
across inline formatting (e.g. **bold** mid-name) can still be missed. The resolver's
|
|
exact-name match keeps false *positives* low; the trade-off is some false *negatives*.
|
|
- **Album detection leans on two precise signals** — quoted titles (`"Reek of Putrefaction"`)
|
|
and album cue phrases ("the album X", "X LP/EP"). Album free-text is noisier than artist
|
|
names, so it deliberately over-generates only from these cues and lets the Spotify
|
|
exact-album match (and/or the LLM) be the precision filter. A bare album title written with
|
|
no quotes or cue may be missed.
|
|
- **Links are direct to Spotify when Spotify has the artist/album**; otherwise a confirmed
|
|
name links to a Google search for "<name> bandcamp" (which surfaces the Bandcamp page).
|
|
- **Without Spotify creds or the LLM, nothing links** — at least one gate must be
|
|
configured.
|
|
- **New reddit (shreddit) shadow DOM** — the scan roots at the comment tree
|
|
(`shreddit-comment-tree`, falling back to old reddit's `.commentarea`, then `main`/`body`)
|
|
and pierces the **open** shadow roots of the `shreddit-*` web components within it, so it
|
|
links comment bodies whether reddit slots them into light DOM or encapsulates them in a
|
|
shadow root. On a broad fallback root (no comment tree found) only comment-component shadow
|
|
roots are descended, so reddit's chrome (nav, sidebar, composer) is never collected.
|
|
**Closed** shadow roots are unreachable from a content script and stay unlinked (a browser
|
|
limitation, not a setting).
|
|
- The optional LLM adds a little latency on the **first** call (ollama warms the
|
|
model), then is fast.
|
|
- The resolver caches results in memory; it resets when you restart it.
|
|
- Built and tested as a Firefox MV2 extension.
|
|
|
|
## License
|
|
|
|
Personal project — do as you like.
|