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 in two new tables. Hourly tick, 7-day re-fetch cap, passive retry via timer. No frontend changes — invisible until M4c reads the data.
What this ships
Schema (migration 0009_similarity)
Two symmetric tables with multi-source primary keys:
The source enum reserves room for musicbrainz_tag and user_cooccurrence even though M4b only writes 'listenbrainz' rows. (a, score DESC) index satisfies M4c's hot path: "for seed S, top-N by score."
Track pass: ListPlayedTracksNeedingSimilarity(batch=5) → for each, SimilarRecordings(mbid, 100) → bulk GetTracksByMBIDs to filter to library → sort by score desc → take top 20 → UpsertTrackSimilarity rows.
Artist pass: symmetric using SimilarArtists + GetArtistsByMBIDs + UpsertArtistSimilarity.
Failure handling:
*RetryAfterError (429) → log + abort the entire tick. The next 1h tick (vastly longer than typical Retry-After) picks up naturally.
ErrTransient / ErrPermanent → log + skip that single track; next tick retries.
All other transient errors leave fetched_at unchanged so the row is re-selected next pass.
No durable queue (vs M4a's scrobble_queue) — losing a refresh attempt is "1 hour of staleness," fine.
Same listenbrainz.NewClient() used by both M4a scrobble and M4b similarity workers (pure HTTP client, no per-instance state).
Test plan
go test -short -race ./... — all 16 packages pass (now including internal/similarity)
Full integration suite — green (pre-existing TestScanner_Integration flake unchanged)
golangci-lint run ./... — clean
internal/similarity coverage: 73.0% (target 75%; gap is in less-critical logging branches in upsertArtistSimilar)
internal/scrobble/listenbrainz coverage: 74.7% (was 70%; new methods well-covered, total dragged by un-tested edge cases in shared helpers from M4a — slightly under the 85% spec target)
cd web && npm run check — 0 errors / 0 warnings (no web changes)
cd web && npm test — 180 tests across 30 files (unchanged from M4a)
cd web && npm run build — succeeds
docker build -t minstrel:m4b-smoke . — succeeds
Worker boot smoke (5s go run) — migrations applied version=9, listening, no panic
Manual: wait ≥1h after deploy → psql -c "SELECT count(*) FROM track_similarity WHERE source='listenbrainz'" shows non-zero rows (assuming MBID-tagged played tracks)
Decisions ledger
#
Decision
Rationale
1
Both track_similarity and artist_similarity in M4b
M4c needs both; symmetric pattern, single-PR cost is small
2
Played-tracks-only input scope
Bounds work; 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
6
Passive retry via timer (no durable queue)
Failure cost is "1 hour of staleness"
7
Hardcoded algorithm parameter
YAGNI; expose as YAML if telemetry warrants
Out of scope (deferred to M4c)
Lazy fetch on radio request (never-played seed coverage)
Score normalization to [0,1]
Symmetric edge storage (we store one-way as LB returns)
Discovery-mitigation: serendipity floor + sparse-fallback to wider pool
"Suggested additions" for tracks not in library (M5/Lidarr)
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 in two new tables. Hourly tick, 7-day re-fetch cap, passive retry via timer. No frontend changes — invisible until M4c reads the data.
## What this ships
### Schema (migration `0009_similarity`)
Two symmetric tables with multi-source primary keys:
```sql
track_similarity (track_a_id, track_b_id, score, source, fetched_at)
PK (a, b, source); CHECK (a <> b); index (a, score DESC)
artist_similarity (artist_a_id, artist_b_id, score, source, fetched_at)
PK (a, b, source); CHECK (a <> b); index (a, score DESC)
```
The `source` enum reserves room for `musicbrainz_tag` and `user_cooccurrence` even though M4b only writes `'listenbrainz'` rows. `(a, score DESC)` index satisfies M4c's hot path: "for seed S, top-N by score."
### LB client extensions (`internal/scrobble/listenbrainz/`)
Added two read-only methods alongside M4a's `SubmitListens`:
- **`SimilarRecordings(ctx, mbid, limit)`** — `/1/explore/similar-recordings/{mbid}`
- **`SimilarArtists(ctx, mbid, limit)`** — `/1/explore/similar-artists/{mbid}`
Both return the same typed errors as `SubmitListens` (`ErrAuth`, `ErrPermanent`, `ErrTransient`, `*RetryAfterError`). 14 new httptest tests (7 per method).
LB algorithm parameter hardcoded for v1; LB endpoints are public (no auth required).
### Worker (`internal/similarity/`)
```go
type Worker struct {
pool *pgxpool.Pool
client *listenbrainz.Client
logger *slog.Logger
tick time.Duration // 1h production
batch int32 // 5 production
topK int // 20 production
}
```
`tickOnce(ctx)` algorithm:
1. **Track pass**: `ListPlayedTracksNeedingSimilarity(batch=5)` → for each, `SimilarRecordings(mbid, 100)` → bulk `GetTracksByMBIDs` to filter to library → sort by score desc → take top 20 → `UpsertTrackSimilarity` rows.
2. **Artist pass**: symmetric using `SimilarArtists` + `GetArtistsByMBIDs` + `UpsertArtistSimilarity`.
**Failure handling**:
- `*RetryAfterError` (429) → log + abort the entire tick. The next 1h tick (vastly longer than typical Retry-After) picks up naturally.
- `ErrTransient` / `ErrPermanent` → log + skip that single track; next tick retries.
- All other transient errors leave `fetched_at` unchanged so the row is re-selected next pass.
No durable queue (vs M4a's `scrobble_queue`) — losing a refresh attempt is "1 hour of staleness," fine.
10 integration tests covering: no-op-empty, in-library mapping, top-K=20 enforcement, 7-day cap, stale-row refresh, 429-abort-tick, transient-skip-continues, in-library filter, artist pass mirrors track, no-MBID skip.
### Boot wiring (`cmd/minstrel/main.go`)
```go
similarityWorker := similarity.NewWorker(pool, listenbrainz.NewClient(), logger.With("component", "similarity"))
go similarityWorker.Run(ctx)
```
Same `listenbrainz.NewClient()` used by both M4a scrobble and M4b similarity workers (pure HTTP client, no per-instance state).
## Test plan
- [x] `go test -short -race ./...` — all 16 packages pass (now including `internal/similarity`)
- [x] Full integration suite — green (pre-existing `TestScanner_Integration` flake unchanged)
- [x] `golangci-lint run ./...` — clean
- [x] `internal/similarity` coverage: **73.0%** (target 75%; gap is in less-critical logging branches in `upsertArtistSimilar`)
- [x] `internal/scrobble/listenbrainz` coverage: **74.7%** (was 70%; new methods well-covered, total dragged by un-tested edge cases in shared helpers from M4a — slightly under the 85% spec target)
- [x] `cd web && npm run check` — 0 errors / 0 warnings (no web changes)
- [x] `cd web && npm test` — 180 tests across 30 files (unchanged from M4a)
- [x] `cd web && npm run build` — succeeds
- [x] `docker build -t minstrel:m4b-smoke .` — succeeds
- [x] Worker boot smoke (5s `go run`) — `migrations applied version=9`, `listening`, no panic
- [ ] Manual: wait ≥1h after deploy → `psql -c "SELECT count(*) FROM track_similarity WHERE source='listenbrainz'"` shows non-zero rows (assuming MBID-tagged played tracks)
## Decisions ledger
| # | Decision | Rationale |
|---|---|---|
| 1 | Both `track_similarity` and `artist_similarity` in M4b | M4c needs both; symmetric pattern, single-PR cost is small |
| 2 | Played-tracks-only input scope | Bounds work; 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 |
| 6 | Passive retry via timer (no durable queue) | Failure cost is "1 hour of staleness" |
| 7 | Hardcoded `algorithm` parameter | YAGNI; expose as YAML if telemetry warrants |
## Out of scope (deferred to M4c)
- Lazy fetch on radio request (never-played seed coverage)
- Score normalization to [0,1]
- Symmetric edge storage (we store one-way as LB returns)
- Discovery-mitigation: serendipity floor + sparse-fallback to wider pool
- "Suggested additions" for tracks not in library (M5/Lidarr)
## 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)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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 in two new tables. Hourly tick, 7-day re-fetch cap, passive retry via timer. No frontend changes — invisible until M4c reads the data.What this ships
Schema (migration
0009_similarity)Two symmetric tables with multi-source primary keys:
The
sourceenum reserves room formusicbrainz_taganduser_cooccurrenceeven though M4b only writes'listenbrainz'rows.(a, score DESC)index satisfies M4c's hot path: "for seed S, top-N by score."LB client extensions (
internal/scrobble/listenbrainz/)Added two read-only methods alongside M4a's
SubmitListens:SimilarRecordings(ctx, mbid, limit)—/1/explore/similar-recordings/{mbid}SimilarArtists(ctx, mbid, limit)—/1/explore/similar-artists/{mbid}Both return the same typed errors as
SubmitListens(ErrAuth,ErrPermanent,ErrTransient,*RetryAfterError). 14 new httptest tests (7 per method).LB algorithm parameter hardcoded for v1; LB endpoints are public (no auth required).
Worker (
internal/similarity/)tickOnce(ctx)algorithm:ListPlayedTracksNeedingSimilarity(batch=5)→ for each,SimilarRecordings(mbid, 100)→ bulkGetTracksByMBIDsto filter to library → sort by score desc → take top 20 →UpsertTrackSimilarityrows.SimilarArtists+GetArtistsByMBIDs+UpsertArtistSimilarity.Failure handling:
*RetryAfterError(429) → log + abort the entire tick. The next 1h tick (vastly longer than typical Retry-After) picks up naturally.ErrTransient/ErrPermanent→ log + skip that single track; next tick retries.fetched_atunchanged so the row is re-selected next pass.No durable queue (vs M4a's
scrobble_queue) — losing a refresh attempt is "1 hour of staleness," fine.10 integration tests covering: no-op-empty, in-library mapping, top-K=20 enforcement, 7-day cap, stale-row refresh, 429-abort-tick, transient-skip-continues, in-library filter, artist pass mirrors track, no-MBID skip.
Boot wiring (
cmd/minstrel/main.go)Same
listenbrainz.NewClient()used by both M4a scrobble and M4b similarity workers (pure HTTP client, no per-instance state).Test plan
go test -short -race ./...— all 16 packages pass (now includinginternal/similarity)TestScanner_Integrationflake unchanged)golangci-lint run ./...— cleaninternal/similaritycoverage: 73.0% (target 75%; gap is in less-critical logging branches inupsertArtistSimilar)internal/scrobble/listenbrainzcoverage: 74.7% (was 70%; new methods well-covered, total dragged by un-tested edge cases in shared helpers from M4a — slightly under the 85% spec target)cd web && npm run check— 0 errors / 0 warnings (no web changes)cd web && npm test— 180 tests across 30 files (unchanged from M4a)cd web && npm run build— succeedsdocker build -t minstrel:m4b-smoke .— succeedsgo run) —migrations applied version=9,listening, no panicpsql -c "SELECT count(*) FROM track_similarity WHERE source='listenbrainz'"shows non-zero rows (assuming MBID-tagged played tracks)Decisions ledger
track_similarityandartist_similarityin M4balgorithmparameterOut of scope (deferred to M4c)
Sub-plan progression (M4)
🤖 Generated with Claude Code