Third and final M4 sub-plan (Fable #347). Closes M4. Replaces M3's whole-library candidate pool with a 5-way SQL UNION (LB-similar tracks / similar-artist tracks / MB-tag overlap / likes-overlap / random fill), adds a SimilarityScore × SimilarityWeight term to M3's Score(), and the SPA now auto-refreshes the radio queue when 80% consumed via a new ?exclude= query param.
What this ships
Schema (no migration)
Reuses M4b's track_similarity and artist_similarity, M2's general_likes, M0-M1 tracks.genre (with the M3.5 multi-genre split parser).
Backend
LoadRadioCandidatesV2 sqlc query — 5-way UNION:
lb_similar (top 30 from track_similarity joined to seed)
similar_artists (top 30 tracks by artists in artist_similarity from seed's artist; score × 0.5)
random_fill (30 random tracks NOT already returned by similarity sources; score 0)
Outer SELECT joins tracks + likes + LATERAL play_events aggregation; GROUP BY track id, taking max(sim_score) across sources.
Excludes seed, ?exclude= list, and recently-played (within configurable hours).
Score() extension — adds SimilarityScore float64 to ScoringInputs, SimilarityWeight float64 (default 2.0) to ScoringWeights and RecommendationConfig (yaml similarity_weight). Formula gains + in.SimilarityScore * w.SimilarityWeight. Backward compatible: zero-value structs produce M3 behavior.
LoadCandidatesFromSimilarity Go function — primary candidate-pool loader. Same return type ([]Candidate) as M3's LoadCandidates so Shuffle() is unchanged. Takes a CandidateSourceLimits struct (DefaultCandidateSourceLimits() returns the v1 hardcoded constants). Type-asserts sqlc's interface{} similarity_score to float64.
Radio handler (internal/api/radio.go):
Parses ?exclude= (comma-separated UUIDs; malformed entries silently dropped via parseExcludeParam)
Calls LoadCandidatesFromSimilarity first
Falls back to M3's LoadCandidates on ANY error from the similarity path (defense in depth for the central radio surface)
Threads SimilarityWeight from config into ScoringWeights
Strips index-0 (seed) from the response, appends the rest directly to _queue
In-flight guard prevents double-firing
Bug fix during implementation
Task 1's initial sqlc query merged seed_artist + seed_tags into one CTE that filtered out the seed when its genre was NULL/empty. This broke similar-artist matching for any genre-less seed — common case for libraries with sparse tag coverage. Fixed (commit b8e3019) by restoring the spec's two-CTE design.
Test plan
go test -short -race ./... — all 16 packages pass
Full integration suite — green (pre-existing scanner flake unchanged)
golangci-lint run ./... — clean
internal/recommendation coverage: 92.4% (target was ≥80%; was 73% pre-M4c)
cd web && npm run check — 0 errors / 0 warnings (319 files)
cd web && npm test — 185 tests across 30 files (was 180; +5 M4c refresh tests)
cd web && npm run build — succeeds
docker build -t minstrel:m4c-smoke . — succeeds
Manual end-to-end gate (closes M4): radio quality should feel meaningfully better than M3's baseline. After deploy + M4b worker has filled track_similarity for ≥10 played tracks, click radio from a played track → queue should differ noticeably; listen through ~80% → observe queue length increase as auto-refresh fires.
New tests
3 Score() SimilarityScore tests
10 LoadCandidatesFromSimilarity integration tests (all 4 sources + random fill + exclude + recently-played + seed-excluded + dedup-takes-max + empty-library)
4 radio HTTP tests (similarity-pool ranks higher, exclude filters, malformed exclude survives, seed always at index 0)
5 player store auto-refresh tests (fires at 80%, doesn't fire below, appends new tracks excl seed, in-flight guard, non-radio-enqueue resets)
Decisions ledger (recap from spec)
#
Decision
Rationale
1
4-source pool composition + random fill
Cross-source diversity; sparse-fallback automatic via random fill
2
Augment with random to a soft floor
Never returns empty radio; serendipity built in
3
New SimilarityScore × SimilarityWeight term
Wastes the LB scores otherwise
4
No lazy LB fetch
Keeps radio handler synchronous; M4b worker + augmentation cover the gap
5
?exclude=... query param for queue refresh
Stateless, simple, no new server state
6
Per-source K + score: hardcoded for v1
YAGNI; tunable later
7
Fallback to M3 LoadCandidates on similarity-pool error
✅M4c (this) — radio similarity-driven candidate pool + 80% queue refresh
The recommendation engine has all v1 components wired through both backend and frontend. Unblocks M5 (Lidarr quarantine + suggested-additions for tracks not in library).
Third and final M4 sub-plan (Fable #347). **Closes M4.** Replaces M3's whole-library candidate pool with a 5-way SQL UNION (LB-similar tracks / similar-artist tracks / MB-tag overlap / likes-overlap / random fill), adds a `SimilarityScore × SimilarityWeight` term to M3's `Score()`, and the SPA now auto-refreshes the radio queue when 80% consumed via a new `?exclude=` query param.
## What this ships
### Schema (no migration)
Reuses M4b's `track_similarity` and `artist_similarity`, M2's `general_likes`, M0-M1 `tracks.genre` (with the M3.5 multi-genre split parser).
### Backend
- **`LoadRadioCandidatesV2` sqlc query** — 5-way UNION:
- `lb_similar` (top 30 from `track_similarity` joined to seed)
- `similar_artists` (top 30 tracks by artists in `artist_similarity` from seed's artist; score × 0.5)
- `tag_overlap` (top 20 tracks sharing genre tags with seed; jaccard-like score)
- `likes_overlap` (top 20 user-liked tracks sharing genre tags; constant 0.6)
- `random_fill` (30 random tracks NOT already returned by similarity sources; score 0)
- Outer SELECT joins tracks + likes + LATERAL play_events aggregation; GROUP BY track id, taking `max(sim_score)` across sources.
- Excludes seed, `?exclude=` list, and recently-played (within configurable hours).
- **`Score()` extension** — adds `SimilarityScore float64` to `ScoringInputs`, `SimilarityWeight float64` (default 2.0) to `ScoringWeights` and `RecommendationConfig` (yaml `similarity_weight`). Formula gains `+ in.SimilarityScore * w.SimilarityWeight`. Backward compatible: zero-value structs produce M3 behavior.
- **`LoadCandidatesFromSimilarity` Go function** — primary candidate-pool loader. Same return type (`[]Candidate`) as M3's `LoadCandidates` so `Shuffle()` is unchanged. Takes a `CandidateSourceLimits` struct (`DefaultCandidateSourceLimits()` returns the v1 hardcoded constants). Type-asserts sqlc's `interface{}` similarity_score to `float64`.
- **Radio handler** (`internal/api/radio.go`):
- Parses `?exclude=` (comma-separated UUIDs; malformed entries silently dropped via `parseExcludeParam`)
- Calls `LoadCandidatesFromSimilarity` first
- Falls back to M3's `LoadCandidates` on ANY error from the similarity path (defense in depth for the central radio surface)
- Threads `SimilarityWeight` from config into `ScoringWeights`
### Frontend
- **Auto-refresh effect** (`web/src/lib/player/store.svelte.ts`):
- New `_radioSeedId` state set inside `playRadio()`, cleared by `playQueue`/`enqueueTrack`/`enqueueTracks`
- `$effect.root` watches `(_index + 1) / _queue.length`; at ≥0.8 fires `/api/radio?seed_track=<seed>&exclude=<queue>`
- Strips index-0 (seed) from the response, appends the rest directly to `_queue`
- In-flight guard prevents double-firing
### Bug fix during implementation
Task 1's initial sqlc query merged `seed_artist` + `seed_tags` into one CTE that filtered out the seed when its genre was NULL/empty. This broke similar-artist matching for any genre-less seed — common case for libraries with sparse tag coverage. Fixed (commit `b8e3019`) by restoring the spec's two-CTE design.
## Test plan
- [x] `go test -short -race ./...` — all 16 packages pass
- [x] Full integration suite — green (pre-existing scanner flake unchanged)
- [x] `golangci-lint run ./...` — clean
- [x] **`internal/recommendation` coverage: 92.4%** (target was ≥80%; was 73% pre-M4c)
- [x] `cd web && npm run check` — 0 errors / 0 warnings (319 files)
- [x] `cd web && npm test` — **185 tests** across 30 files (was 180; +5 M4c refresh tests)
- [x] `cd web && npm run build` — succeeds
- [x] `docker build -t minstrel:m4c-smoke .` — succeeds
- [ ] Manual end-to-end gate (closes M4): radio quality should feel meaningfully better than M3's baseline. After deploy + M4b worker has filled `track_similarity` for ≥10 played tracks, click radio from a played track → queue should differ noticeably; listen through ~80% → observe queue length increase as auto-refresh fires.
## New tests
- 3 `Score()` SimilarityScore tests
- 10 `LoadCandidatesFromSimilarity` integration tests (all 4 sources + random fill + exclude + recently-played + seed-excluded + dedup-takes-max + empty-library)
- 4 radio HTTP tests (similarity-pool ranks higher, exclude filters, malformed exclude survives, seed always at index 0)
- 5 player store auto-refresh tests (fires at 80%, doesn't fire below, appends new tracks excl seed, in-flight guard, non-radio-enqueue resets)
## Decisions ledger (recap from spec)
| # | Decision | Rationale |
|---|---|---|
| 1 | 4-source pool composition + random fill | Cross-source diversity; sparse-fallback automatic via random fill |
| 2 | Augment with random to a soft floor | Never returns empty radio; serendipity built in |
| 3 | New `SimilarityScore × SimilarityWeight` term | Wastes the LB scores otherwise |
| 4 | No lazy LB fetch | Keeps radio handler synchronous; M4b worker + augmentation cover the gap |
| 5 | `?exclude=...` query param for queue refresh | Stateless, simple, no new server state |
| 6 | Per-source K + score: hardcoded for v1 | YAGNI; tunable later |
| 7 | Fallback to M3 LoadCandidates on similarity-pool error | Defense in depth for the central radio surface |
## M4 closure
After this slice merges, **M4 is complete**:
- ✅ M4a (PR #26) — outbound LB scrobble worker
- ✅ M4b (PR #27) — inbound LB similarity ingest
- ✅ **M4c (this) — radio similarity-driven candidate pool + 80% queue refresh**
The recommendation engine has all v1 components wired through both backend and frontend. Unblocks M5 (Lidarr quarantine + suggested-additions for tracks not in library).
🤖 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.
Third and final M4 sub-plan (Fable #347). Closes M4. Replaces M3's whole-library candidate pool with a 5-way SQL UNION (LB-similar tracks / similar-artist tracks / MB-tag overlap / likes-overlap / random fill), adds a
SimilarityScore × SimilarityWeightterm to M3'sScore(), and the SPA now auto-refreshes the radio queue when 80% consumed via a new?exclude=query param.What this ships
Schema (no migration)
Reuses M4b's
track_similarityandartist_similarity, M2'sgeneral_likes, M0-M1tracks.genre(with the M3.5 multi-genre split parser).Backend
LoadRadioCandidatesV2sqlc query — 5-way UNION:lb_similar(top 30 fromtrack_similarityjoined to seed)similar_artists(top 30 tracks by artists inartist_similarityfrom seed's artist; score × 0.5)tag_overlap(top 20 tracks sharing genre tags with seed; jaccard-like score)likes_overlap(top 20 user-liked tracks sharing genre tags; constant 0.6)random_fill(30 random tracks NOT already returned by similarity sources; score 0)max(sim_score)across sources.?exclude=list, and recently-played (within configurable hours).Score()extension — addsSimilarityScore float64toScoringInputs,SimilarityWeight float64(default 2.0) toScoringWeightsandRecommendationConfig(yamlsimilarity_weight). Formula gains+ in.SimilarityScore * w.SimilarityWeight. Backward compatible: zero-value structs produce M3 behavior.LoadCandidatesFromSimilarityGo function — primary candidate-pool loader. Same return type ([]Candidate) as M3'sLoadCandidatessoShuffle()is unchanged. Takes aCandidateSourceLimitsstruct (DefaultCandidateSourceLimits()returns the v1 hardcoded constants). Type-asserts sqlc'sinterface{}similarity_score tofloat64.Radio handler (
internal/api/radio.go):?exclude=(comma-separated UUIDs; malformed entries silently dropped viaparseExcludeParam)LoadCandidatesFromSimilarityfirstLoadCandidateson ANY error from the similarity path (defense in depth for the central radio surface)SimilarityWeightfrom config intoScoringWeightsFrontend
web/src/lib/player/store.svelte.ts):_radioSeedIdstate set insideplayRadio(), cleared byplayQueue/enqueueTrack/enqueueTracks$effect.rootwatches(_index + 1) / _queue.length; at ≥0.8 fires/api/radio?seed_track=<seed>&exclude=<queue>_queueBug fix during implementation
Task 1's initial sqlc query merged
seed_artist+seed_tagsinto one CTE that filtered out the seed when its genre was NULL/empty. This broke similar-artist matching for any genre-less seed — common case for libraries with sparse tag coverage. Fixed (commitb8e3019) by restoring the spec's two-CTE design.Test plan
go test -short -race ./...— all 16 packages passgolangci-lint run ./...— cleaninternal/recommendationcoverage: 92.4% (target was ≥80%; was 73% pre-M4c)cd web && npm run check— 0 errors / 0 warnings (319 files)cd web && npm test— 185 tests across 30 files (was 180; +5 M4c refresh tests)cd web && npm run build— succeedsdocker build -t minstrel:m4c-smoke .— succeedstrack_similarityfor ≥10 played tracks, click radio from a played track → queue should differ noticeably; listen through ~80% → observe queue length increase as auto-refresh fires.New tests
Score()SimilarityScore testsLoadCandidatesFromSimilarityintegration tests (all 4 sources + random fill + exclude + recently-played + seed-excluded + dedup-takes-max + empty-library)Decisions ledger (recap from spec)
SimilarityScore × SimilarityWeightterm?exclude=...query param for queue refreshM4 closure
After this slice merges, M4 is complete:
The recommendation engine has all v1 components wired through both backend and frontend. Unblocks M5 (Lidarr quarantine + suggested-additions for tracks not in library).
🤖 Generated with Claude Code