feat: M4b inbound ListenBrainz similarity ingest #27
Reference in New Issue
Block a user
Delete Branch "dev"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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