Files
minstrel/docs/superpowers/specs/2026-04-28-m4b-similarity-design.md
T
bvandeusen 86cb8e5cbf docs(spec): add M4b ListenBrainz inbound similarity ingest design
Second M4 sub-plan (Fable #346). Periodic worker pulls track-track and
artist-artist similarity edges from LB's public /explore/* endpoints,
filters to the local library, stores top-20 per source track in two new
tables (track_similarity, artist_similarity). Hourly tick, batch=5,
weekly re-fetch cap per row, passive retry via timer. No auth (public
endpoints). Discovery within library handled by LB's collaborative-
filtering response naturally surfacing unplayed library tracks; spec
notes M4c will add a serendipity floor + lazy fetch + sparse-fallback.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 19:46:23 -04:00

339 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# M4b — ListenBrainz inbound similarity ingest
**Status:** Spec draft, 2026-04-28
**Tracking:** Fable #346
**Milestone:** M4 — ListenBrainz scrobble + similarity + radio
**Builds on:** M4a (outbound scrobble worker — shipped as PR #26)
## 1. Goal
A periodic background worker that pulls track-track and artist-artist similarity
edges from ListenBrainz's `/explore/similar-recordings/{mbid}` and
`/explore/similar-artists/{mbid}` endpoints and stores them in two new tables
(`track_similarity`, `artist_similarity`). Refreshes each row at most once per
7 days; bounded scope to "tracks the user has played" so cost stays
proportional to actual usage.
When this slice ships, M4c can build candidate pools for radio from a
similarity graph rather than the user's whole library.
## 2. Non-goals (explicit)
- **Lazy fetch on radio request** — M4c. If a user clicks a never-played track
as seed and `track_similarity` is empty for it, M4c can synchronously call
`SimilarRecordings` then.
- **Score normalization to [0, 1]** — store raw LB scores
(`DOUBLE PRECISION`); M4c normalizes at query time if its scoring formula
needs it.
- **Symmetric edges** (storing both `(A, B)` and `(B, A)`) — store one-way as
LB returns. M4c queries `WHERE track_a_id = $seed`.
- **`musicbrainz_tag` and `user_cooccurrence` source values** — schema reserves
them via the `source` enum, but M4b only writes `'listenbrainz'`.
- **Suggested-additions / Lidarr integration** (LB-returned MBIDs not in the
library) — M5. Spec line 225 covers it.
- **Configurable LB algorithm parameter** — hardcoded for v1.
- **Per-user similarity overrides** — `track_similarity` has no `user_id`;
data is global per-instance.
- **Force-refresh HTTP endpoint** — operators can `UPDATE … SET fetched_at =
'1970-01-01' WHERE …` for break-glass.
- **Multi-instance worker safety** — single-process worker assumed (matches
M4a).
- **Frontend surface** — M4b is invisible until M4c uses the data.
## 3. Architecture overview
```
┌────────────────────────────────────┐
│ similarity.Worker │ hourly tick
│ - SELECT distinct played tracks │
│ with mbid where (no row OR │
│ fetched_at < now() - 7d) │
│ - LIMIT 510 per tick │
└────────────┬───────────────────────┘
┌──────────────────────────────────────┐
│ listenbrainz.Client │
│ (extended from M4a) │
│ GET /1/explore/similar-recordings/ │
│ {mbid} │
│ GET /1/explore/similar-artists/ │
│ {artist_mbid} │
│ No auth — public endpoints │
└────────────┬─────────────────────────┘
For each LB response:
- Filter to MBIDs we have in our local library
- Take top 20 (sorted by LB score)
- UPSERT into track_similarity / artist_similarity
```
### 3.1 New Go package
**`internal/similarity/`** — the worker:
- `Worker` struct (pool, client, logger, tick, batch, topK)
- `NewWorker(pool, client, logger) *Worker` — production defaults: 1h tick,
batch=5, topK=20
- `(w *Worker) Run(ctx)` — blocks until ctx cancelled
- `(w *Worker) tickOnce(ctx) error` — drains one batch of tracks AND one
batch of artists; injectable for tests
### 3.2 Existing-code extensions
- **`internal/scrobble/listenbrainz/client.go`** — gains two methods on the
existing `Client` (which already houses `SubmitListens` from M4a):
- `SimilarRecordings(ctx, mbid, limit) ([]SimilarRecording, error)`
- `SimilarArtists(ctx, mbid, limit) ([]SimilarArtist, error)`
- Both return the same typed errors as `SubmitListens` (`ErrTransient`,
`ErrPermanent`, `*RetryAfterError`). 401 is defensive only — these are
public endpoints.
- The package stays at its current path. A future cleanup could move it
to `internal/listenbrainz/`, but that's a non-blocking refactor.
- **`cmd/minstrel/main.go`** — start the worker alongside the M4a scrobble
worker:
```go
similarityWorker := similarity.NewWorker(pool, listenbrainz.NewClient(), logger.With("component", "similarity"))
go similarityWorker.Run(ctx)
```
### 3.3 Failure handling
**Passive retry via timer.** Unlike M4a's durable scrobble queue:
- A failed `SimilarRecordings` call does NOT update `fetched_at`. The next
hourly tick selects the row again (it still satisfies the "needs fetch"
predicate) and retries.
- 429 with `Retry-After`: the worker logs the value and **aborts the current
tick** without updating `fetched_at` on any in-flight rows. The next
hourly tick (typically far longer than any LB-suggested back-off) picks
the work back up. Avoids mid-tick sleeps that would block the goroutine.
- ErrPermanent (4xx): logged as a warning + skipped. Permanent errors on
similarity reads typically mean the MBID isn't in LB's graph — there's no
remediation, but `fetched_at` stays old so we'll just retry forever (cheap
no-op since LB returns 4xx fast). Acceptable; could mark "permanently
empty" in a future iteration if telemetry shows it matters.
- ErrTransient (5xx, network): logged + skipped, retry next tick.
No `scrobble_queue`-equivalent table needed; the work list IS the played-tracks
set + the `fetched_at` watermark.
## 4. Database schema
New migration `0009_similarity.up.sql`:
```sql
CREATE TABLE track_similarity (
track_a_id uuid NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
track_b_id uuid NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
score DOUBLE PRECISION NOT NULL,
source TEXT NOT NULL CHECK (source IN ('listenbrainz', 'musicbrainz_tag', 'user_cooccurrence')),
fetched_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (track_a_id, track_b_id, source),
CHECK (track_a_id <> track_b_id)
);
CREATE INDEX track_similarity_a_score_idx
ON track_similarity (track_a_id, score DESC);
CREATE TABLE artist_similarity (
artist_a_id uuid NOT NULL REFERENCES artists(id) ON DELETE CASCADE,
artist_b_id uuid NOT NULL REFERENCES artists(id) ON DELETE CASCADE,
score DOUBLE PRECISION NOT NULL,
source TEXT NOT NULL CHECK (source IN ('listenbrainz', 'musicbrainz_tag', 'user_cooccurrence')),
fetched_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (artist_a_id, artist_b_id, source),
CHECK (artist_a_id <> artist_b_id)
);
CREATE INDEX artist_similarity_a_score_idx
ON artist_similarity (artist_a_id, score DESC);
```
Down migration drops both tables.
**Notes:**
- Primary key includes `source` so the schema can hold multiple parallel
similarity sources (per spec line 119).
- `(track_a_id, score DESC)` index matches the M4c hot-path query: "for seed
T, give me top-N similar tracks descending by score."
- `CHECK (a <> b)` prevents self-edges.
- `ON DELETE CASCADE` from both endpoints so deleting a track cleans up edges
on either side.
## 5. New sqlc queries
`internal/db/queries/similarity.sql`:
```sql
-- name: ListPlayedTracksNeedingSimilarity :many
SELECT DISTINCT t.id, t.mbid
FROM tracks t
JOIN play_events pe ON pe.track_id = t.id
WHERE t.mbid IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM track_similarity ts
WHERE ts.track_a_id = t.id
AND ts.source = 'listenbrainz'
AND ts.fetched_at > now() - interval '7 days'
)
ORDER BY t.id
LIMIT $1;
-- name: ListPlayedArtistsNeedingSimilarity :many
SELECT DISTINCT ar.id, ar.mbid
FROM artists ar
JOIN tracks t ON t.artist_id = ar.id
JOIN play_events pe ON pe.track_id = t.id
WHERE ar.mbid IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM artist_similarity asim
WHERE asim.artist_a_id = ar.id
AND asim.source = 'listenbrainz'
AND asim.fetched_at > now() - interval '7 days'
)
ORDER BY ar.id
LIMIT $1;
-- name: GetTracksByMBIDs :many
SELECT id, mbid FROM tracks WHERE mbid = ANY($1::text[]);
-- name: GetArtistsByMBIDs :many
SELECT id, mbid FROM artists WHERE mbid = ANY($1::text[]);
-- name: UpsertTrackSimilarity :exec
INSERT INTO track_similarity (track_a_id, track_b_id, score, source, fetched_at)
VALUES ($1, $2, $3, 'listenbrainz', now())
ON CONFLICT (track_a_id, track_b_id, source)
DO UPDATE SET score = EXCLUDED.score, fetched_at = EXCLUDED.fetched_at;
-- name: UpsertArtistSimilarity :exec
INSERT INTO artist_similarity (artist_a_id, artist_b_id, score, source, fetched_at)
VALUES ($1, $2, $3, 'listenbrainz', now())
ON CONFLICT (artist_a_id, artist_b_id, source)
DO UPDATE SET score = EXCLUDED.score, fetched_at = EXCLUDED.fetched_at;
```
## 6. Worker algorithm
`tickOnce(ctx)`:
1. **Track pass:**
- `q.ListPlayedTracksNeedingSimilarity(batch=5)` → `[(track_id, mbid)…]`
- For each `(track_id, mbid)`:
- Call `c.SimilarRecordings(ctx, mbid, 100)`
- On 429 → log the `Retry-After` and **return from `tickOnce` early**
(don't update `fetched_at`; the next hourly tick will pick up the
work, which is virtually always longer than LB's `Retry-After`)
- On other error → log warn, skip (no `fetched_at` update; next tick
retries)
- On success: collect returned MBIDs → `q.GetTracksByMBIDs(returnedMBIDs)`
→ take top 20 by score → for each `(local_id, score)` call
`q.UpsertTrackSimilarity(track_id, local_id, score)`
2. **Artist pass:** symmetric, using `ListPlayedArtistsNeedingSimilarity`,
`SimilarArtists`, `GetArtistsByMBIDs`, `UpsertArtistSimilarity`.
**Constants:**
- Tick interval: 1 hour (production); injectable to ms-scale for tests.
- Batch size: 5 (production). 5 LB calls per pass × 2 passes = 10 LB calls/tick
→ ~240/day, well under LB's documented rate limits (~100/5min unauth).
- Top-K: 20 per LB query.
- LB algorithm: hardcoded constant in the client (use LB's documented default
at implementation time).
## 7. Test plan
### 7.1 LB client unit tests (httptest)
In `internal/scrobble/listenbrainz/client_test.go`, add 7 tests for each new
method:
For `SimilarRecordings`:
- 200 + valid body → returns slice ordered by score
- 401 → `ErrAuth` (defensive — public endpoint shouldn't 401)
- 400 → `ErrPermanent`
- 503 → `ErrTransient`
- 429 with `Retry-After` → `*RetryAfterError`
- URL contains `algorithm=…`
- URL contains `limit=N`
Same 7 tests for `SimilarArtists`.
### 7.2 Worker integration tests (live DB + httptest)
In `internal/similarity/worker_integration_test.go`:
- `TickOnce_NoPlayedTracks_NoOp`: empty `play_events` → returns nil, no rows
in `track_similarity`
- `TickOnce_MapsLBResponseToLocalLibrary`: seed one played track with MBID;
LB returns 3 MBIDs (2 in library, 1 not); assert 2 rows inserted
- `TickOnce_TopKEnforced`: LB returns 50; assert
`count(*) WHERE track_a_id = $1` ≤ 20
- `TickOnce_RespectsSevenDayCap`: row with `fetched_at = now()` → not
re-queried (the LB endpoint isn't called)
- `TickOnce_RefreshesStaleRow`: row with `fetched_at = now() - interval '8
days'` → re-fetched, score updated, fetched_at bumps
- `TickOnce_429AbortsTick`: first response 429 with Retry-After → tickOnce
returns early; no `fetched_at` updates; subsequent tracks in the batch
are NOT processed (they're picked up on the next tick)
- `TickOnce_TransientErrorSkipsTrack`: 503 on one track → `fetched_at`
unchanged; other tracks in same batch process normally
- `TickOnce_FiltersInLibrary`: LB returns 5 MBIDs none of which are in the
library → 0 rows inserted (no error)
- `TickOnce_ArtistPassMirrors`: same coverage for the artist branch
- `TickOnce_NoMBIDOnTrack_Skipped`: track with `mbid IS NULL` → not selected
by `ListPlayedTracksNeedingSimilarity`
### 7.3 Coverage target
`internal/similarity` ≥ 75%. The worker has fewer error branches than M4a
because passive retry-via-timer eliminates the durable-queue state machine.
The new `Similar*` methods in `internal/scrobble/listenbrainz` add ~14 tests
to that package; should keep its coverage ≥ 85%.
### 7.4 Manual verification post-merge
1. Restart server with M4b code.
2. After ≥1 hour, `psql -c "SELECT count(*) FROM track_similarity WHERE
source = 'listenbrainz'"` → non-zero (assuming user has any MBID-tagged
played tracks).
3. After ≥7 days, observe a `fetched_at` timestamp that's recent —
confirms re-fetch cadence.
4. If MBID coverage in the library is sparse, the table will be small. Not a
bug — M5 (Lidarr suggested-additions) is the eventual answer for
tracks-not-in-library; tagging coverage via Picard is a separate user-side
improvement.
## 8. Backwards compatibility
- New migration; no changes to existing schema.
- New package; no changes to consumer code (M4c will consume; M3's `Score()`
doesn't need the data until then).
- `MaybeEnqueue` and other M4a paths unchanged.
- Worker is new; production behavior identical to pre-M4b until the worker's
first tick fires (1 hour after restart).
## 9. Decisions ledger
| # | Decision | Rationale |
|---|---|---|
| 1 | Both `track_similarity` and `artist_similarity` in M4b | Symmetric pattern, M4c needs both, single-PR cost is small |
| 2 | Played-tracks-only input scope | Bounds work to user's actual interaction graph; LB's response naturally surfaces in-library tracks the user hasn't played, preserving discovery |
| 3 | Hourly tick, batch=5 | 240 LB calls/day fits well under LB rate limits; initial backfill in 24-48h |
| 4 | Top-K=20 per LB query, in-library only | Cuts long-tail noise; out-of-library tracks deferred to M5/Lidarr |
| 5 | Public endpoint, no auth | Similarity data is global to the instance; LB requires no token for `/explore/*` |
| 6 | Passive retry via timer (no durable queue) | Failure cost is "1 hour of staleness"; durable queue would be over-engineering vs. M4a's "lost scrobble" stakes |
| 7 | Hardcoded `algorithm` parameter | YAGNI; expose as YAML if telemetry warrants |
## 10. Sub-plan progression (M4)
- M4a (done) — outbound scrobble worker (PR #26).
- **M4b (this) — inbound LB similarity ingest.**
- M4c — radio similarity-driven candidate pool + queue refresh at 80%
(closes M4). M4c also picks up the discovery-mitigation work flagged in
brainstorm: serendipity floor (% random library picks), fallback to wider
pool when similarity-row count is sparse, lazy fetch on radio for
never-played seeds.