feat: M4c radio similarity-driven candidate pool + 80% queue refresh (closes M4) #28
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?
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