Files
paulandClaude Opus 4.8 e06c983b16 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>
2026-06-24 02:41:30 +00:00

7.8 KiB

Agent rules for the linker repo (reddit → spotify/bandcamp)

A Firefox extension + a tiny Node resolver that turn artist/band names in reddit comments into Spotify/Bandcamp links. This file is the repo-local doctrine; the work is tracked in the towl work-item store under project reddit-spotify-linker.

Stack & layout

  • service/ — zero-dependency Node resolver (Node 18+, built-in fetch/http). resolver.js = the thin HTTP server that wires the real Spotify search + ollama classify into resolve-core; resolve-core.js = the pure resolve orchestration (resolveAll/resolveOne/pickDirect/cacheKey, taking injected search/classify fns — unit-testable without network); lib.js = pure, side-effect-free logic (the exact-name match precision lever + link shaping) so it's unit-testable; llm.js = pure ollama prompt/parse helpers (the optional local-LLM gate, HTTP-only — no in-process model runtime); lib.test.js + llm.test.js + resolver.test.js = node:test. The only music API is Spotify (MusicBrainz removed). The resolver confirms both artists and albums: one type=artist,album Spotify search per candidate, exact case-insensitive name match against either; each result carries kind: "artist" | "album". odesli.js = the optional Odesli (song.link) enrichment client (injected fetch, pure parseOdesli core, HTTP-only — no new deps); odesli.test.js + odesli.fixture.json (a captured real response) cover it.
  • extension/ — Firefox MV2 content script (content.js + extract.js + manifest.json + styles.css). No build step. extract.js is the pure candidate extractor (Title-Case runs + cue phrases for artists; quoted strings + album cue phrases for albums) — node-testable via extract.test.cjs. Album links render italicised (.rsl-album).
    • shreddit shadow DOM: collectTextNodes is shadow-piercing. A TreeWalker can't cross a shadow boundary, so it walks the chosen root's light DOM AND recursively descends into the open shadow roots of elements within that subtree. New reddit's shreddit-* components may encapsulate comment bodies in a shadow root; this reaches them whether reddit slots (light DOM) or encapsulates (shadow DOM). Scope is two-layer: getRoot tries the narrow comment containers first (shreddit-comment-tree, then old reddit's .commentarea) before the broad main/body, so on a new-reddit comment page the root is the comment tree and the walk stays within it; and when getRoot falls back to a broad root (no comment tree present) the recursion descends only into comment-component shadow hosts (tagName starting SHREDDIT-COMMENT), so reddit's chrome shadow roots (nav, sidebar, composer) are never walked. (A single union querySelector could not do this — it returns the first match in document order, and <main> is an ancestor of the comment tree, so it would always win.) It is a no-op on old reddit (no shadow roots). Slotted light-DOM nodes are not double-collected (a shadow walker sees the shadow tree's own nodes, not a <slot>'s assigned light nodes; a Set de-dupes belt-and-braces). Closed shadow roots are unreachable from a content script — a documented limitation.
  • scripts/check.sh — the local check gate.

How work lands (IMPORTANT — this is NOT the towl repo)

linker is hosted at git.yapplesauce.com/paul/linker, a plain repo: there is no PR pipeline and no branch protection. You push to main directly (via the user's PAT). Do not use towl-orchestrate / worktree+PR here.

  1. Make a small, reversible change.
  2. Run the gate: bash scripts/check.sh (must print ALL CHECKS PASSED).
  3. Commit citing the towl ref (e.g. (task/odesli-direct-links)), push main.
  4. Stamp the SHA on the towl item: stamp_commit { ref, sha, repo: "linker" }.
  5. Close the towl item.

NEVER touch the towl or stickball repos from work scoped to this project.

Testing (zero-dep)

node --test over service/*.test.js exercises lib.js (norm + the exact-name match

  • link builders), llm.js (the classifier prompt/parse), and resolve-core.js (the album/artist orchestration — kindHint tie-break, kind-aware cache key, allowFallback gating — driven with injected fake search/classify fns, no network). Keep pure logic in lib.js/resolve-core.js (not resolver.js, which has the server side effect) so it stays testable without opening a port. extension/extract.test.cjs covers the pure extractor (artist/cue/album candidates) and must be extended with each detection change. Add/extend a test with every logic change. The extension's in-browser behavior is not headlessly testable in this environment — validate content.js with node --check and a documented manual check on a real reddit thread (about:debugging → load temporary add-on).

Code stewards (towl plans under objective/linker-stewardship)

Standing, language-appropriate stewards (adapted from towl's SCOUT→FILE doctrine; run them like any other steward — e.g. via a /loop). Each files findings into towl and fixes small things directly.

  • quality (plan/steward-linker-quality) — correctness + regressions; owns and maintains scripts/check.sh and the node:test suite.
  • design (plan/steward-linker-design) — detection precision/recall, the injected-link UX, README accuracy, reddit-DOM drift.
  • security (plan/steward-linker-security) — DOM injection (build nodes via createElement/textContent, never innerHTML; http(s) hrefs only), minimal extension permissions, bounded external API calls (no SSRF), secrets never logged/committed (.env is gitignored).

Standing decisions

  • Firefox MV2 stays — Mozilla has no deprecation timeline; do not migrate to MV3 speculatively.
  • Optional local LLM is the recommended better-matching path (human override, feedback 2026-06-18 — supersedes the old "no ML/NER" rule). The resolver may call ollama over HTTP (OLLAMA_URL enables it, OLLAMA_MODEL defaults qwen2.5:3b) to classify which candidates are real artists/albums. It stays zero-NPM-dep: HTTP only, no in-process model runtime. The heuristic + Spotify exact-match is the zero-dep default when no LLM is configured.
  • Optional Odesli (song.link) enrichment is opt-in, default OFF (ODESLI=1, or ODESLI_URL to override the endpoint — mirrors the ollama opt-in). When ON, an album result that already carries a DIRECT Spotify url is enriched with the album's direct Bandcamp (+ Apple/YouTube) links as ADDITIVE fields (bandcamp/appleMusic/youtube) — the existing name/kind/spotify/ google/primary shape is unchanged, so off the behavior is byte-identical and an older extension keeps rendering. The gate is shouldEnrich (pure, unit-tested): album-kind results only — Odesli rejects ARTIST urls (405 UNSUPPORTED_URL), so artist results are skipped entirely (no wasted round-trip on the common case). Only the Spotify-creds path produces a url to enrich; the no-creds Google fallback is untouched. Bounded (AbortSignal.timeout) + cached per Spotify url + graceful (Odesli down/garbage keeps the current result). The input is our own validated Spotify album url against the fixed host (no SSRF).
  • Detection precision lever (zero-dep default): a candidate links only when Spotify returns an artist or album whose name matches it case-insensitively. A confirmed name links to its Spotify artist/album page if available, else a " bandcamp" Google fallback. Album candidates are over-generated from precise signals only (quoted strings + album cue phrases) because album free-text is noisier than artist names — the Spotify exact-match (and/or LLM kind=album) is the filter.