feat: M4c radio similarity-driven candidate pool + 80% queue refresh (closes M4) #28

Merged
bvandeusen merged 262 commits from dev into main 2026-04-29 10:45:19 -04:00
bvandeusen commented 2026-04-29 08:53:13 -04:00 (Migrated from git.fabledsword.com)

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

  • 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 test185 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 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

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)
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: bvandeusen/minstrel#28