# M4c — Radio Similarity-Driven Candidate Pool + Queue Refresh Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Replace M3's whole-library candidate pool with a 5-way SQL UNION of similarity sources (LB-similar tracks, similar-artist tracks, MB-tag overlap, likes-overlap, random fill), add a `SimilarityScore × SimilarityWeight` term to M3's `Score()`, and have the SPA auto-refresh the radio queue at 80% consumed via a new `?exclude=` query param. Closes M4. **Architecture:** New sqlc query `LoadRadioCandidatesV2` performs the 5-way UNION + per-source scoring + final dedup-by-max in SQL. New `LoadCandidatesFromSimilarity` Go function projects rows to `[]Candidate` (same return type as M3's `LoadCandidates`, so `Shuffle()` is unchanged). Radio handler uses the new function as primary, falls back to M3's `LoadCandidates` on any error. Web client tracks `radioSeedId` and fires a refresh fetch when 80% consumed; clears on non-radio enqueues. **Tech Stack:** Go 1.23 + sqlc + pgx/v5. SvelteKit 2 + Svelte 5 (runes) + TanStack Query. No schema migrations — relies on M4b's `track_similarity` / `artist_similarity` and existing M2 `general_likes` / M0-M1 `tracks.genre`. **Reference:** design spec at `docs/superpowers/specs/2026-04-28-m4c-radio-design.md`. --- ## File Structure **New server files:** | File | Responsibility | |---|---| | `internal/db/queries/radio.sql` (modify) | Append `LoadRadioCandidatesV2 :many` — 5-way UNION + dedup-by-max. | | `internal/db/dbq/radio.sql.go` | Generated bindings. | Note: `internal/db/queries/recommendation.sql` currently holds `LoadRadioCandidates` (M3). I'll **add the V2 query to the same file** to keep radio-related queries co-located. **Modified server files:** | File | Change | |---|---| | `internal/recommendation/score.go` | `ScoringInputs` gains `SimilarityScore float64`, `ScoringWeights` gains `SimilarityWeight float64`. `Score()` adds `+ in.SimilarityScore * w.SimilarityWeight`. | | `internal/recommendation/score_test.go` | 3 new tests for the new term. | | `internal/recommendation/candidates.go` | Adds `CandidateSourceLimits` struct, `DefaultCandidateSourceLimits()`, `LoadCandidatesFromSimilarity()`. M3's `LoadCandidates` retained unchanged for fallback + tests. | | `internal/recommendation/candidates_test.go` (or new `candidates_v2_test.go`) | 10 new integration tests for the similarity-pool path. | | `internal/config/config.go` | `RecommendationConfig` gains `SimilarityWeight float64` (default `2.0`, yaml `similarity_weight`). | | `internal/api/radio.go` | Parse `?exclude=`, build limits struct, call `LoadCandidatesFromSimilarity` first then fallback to `LoadCandidates`, thread `SimilarityWeight` into `ScoringWeights`. | | `internal/api/radio_test.go` | 4 new tests for similarity ranking, exclude param, malformed exclude, fallback path. | | `internal/api/auth_test.go` (or wherever `recCfg` test fixture lives) | Add `SimilarityWeight: 2.0` to the test recCfg literal. | **Frontend files:** | File | Responsibility | |---|---| | `web/src/lib/player/store.svelte.ts` | Add `radioSeedId` state, `appendRadioTracks()` helper, refresh effect, clear-on-non-radio-enqueue logic. | | `web/src/lib/player/store.test.ts` | 5 new tests covering refresh behavior. | --- ## Task 1: sqlc query `LoadRadioCandidatesV2` **Files:** - Modify: `internal/db/queries/recommendation.sql` (append) - Generated: `internal/db/dbq/recommendation.sql.go` - [ ] **Step 1: Append the query** Append to `internal/db/queries/recommendation.sql`: ```sql -- name: LoadRadioCandidatesV2 :many -- M4c: similarity-driven candidate pool. 5-way UNION: -- $1 user_id, $2 seed_track_id, $3 recently_played_hours, -- $4 exclude (uuid[]), $5 lb_similar K, $6 similar_artists K, -- $7 tag_overlap K, $8 likes_overlap K, $9 random_fill K. -- Returns same shape as LoadRadioCandidates plus similarity_score column. WITH seed_artist AS (SELECT artist_id FROM tracks WHERE id = $2), seed_tags AS ( SELECT trim(g) AS tag FROM tracks t, regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g WHERE t.id = $2 AND trim(g) <> '' ), exclude_set AS ( SELECT unnest($4::uuid[]) AS id UNION ALL SELECT track_id FROM play_events WHERE user_id = $1 AND started_at > now() - $3 * interval '1 hour' ), lb_similar AS ( SELECT ts.track_b_id AS id, ts.score AS sim_score FROM track_similarity ts WHERE ts.track_a_id = $2 AND ts.source = 'listenbrainz' AND ts.track_b_id NOT IN (SELECT id FROM exclude_set) ORDER BY ts.score DESC LIMIT $5 ), similar_artists AS ( SELECT t.id, asim.score * 0.5 AS sim_score FROM artist_similarity asim JOIN tracks t ON t.artist_id = asim.artist_b_id JOIN seed_artist sa ON asim.artist_a_id = sa.artist_id WHERE asim.source = 'listenbrainz' AND t.id NOT IN (SELECT id FROM exclude_set) ORDER BY asim.score DESC, random() LIMIT $6 ), tag_overlap AS ( SELECT t.id, (count(DISTINCT trim(g_raw))::float8 / GREATEST((SELECT count(*) FROM seed_tags), 1)) AS sim_score FROM tracks t, regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g_raw WHERE trim(g_raw) IN (SELECT tag FROM seed_tags) AND t.id NOT IN (SELECT id FROM exclude_set) AND t.id <> $2 GROUP BY t.id HAVING count(DISTINCT trim(g_raw)) > 0 ORDER BY sim_score DESC LIMIT $7 ), likes_overlap AS ( SELECT gl.track_id AS id, 0.6::float8 AS sim_score FROM general_likes gl JOIN tracks t ON t.id = gl.track_id WHERE gl.user_id = $1 AND t.id NOT IN (SELECT id FROM exclude_set) AND EXISTS ( SELECT 1 FROM regexp_split_to_table(coalesce(t.genre, ''), '[;,]') g WHERE trim(g) IN (SELECT tag FROM seed_tags) ) ORDER BY random() LIMIT $8 ), random_fill AS ( SELECT t.id, 0.0::float8 AS sim_score FROM tracks t WHERE t.id NOT IN (SELECT id FROM exclude_set) AND t.id <> $2 AND t.id NOT IN ( SELECT id FROM lb_similar UNION SELECT id FROM similar_artists UNION SELECT id FROM tag_overlap UNION SELECT id FROM likes_overlap ) ORDER BY random() LIMIT $9 ) SELECT sqlc.embed(t), (l.user_id IS NOT NULL)::bool AS is_liked, pe.last_played_at::timestamptz AS last_played_at, pe.play_count, pe.skip_count, max(u.sim_score) AS similarity_score FROM ( SELECT * FROM lb_similar UNION ALL SELECT * FROM similar_artists UNION ALL SELECT * FROM tag_overlap UNION ALL SELECT * FROM likes_overlap UNION ALL SELECT * FROM random_fill ) u JOIN tracks t ON t.id = u.id LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id LEFT JOIN LATERAL ( SELECT max(started_at) AS last_played_at, count(*) AS play_count, count(*) FILTER (WHERE was_skipped) AS skip_count FROM play_events WHERE user_id = $1 AND track_id = t.id ) pe ON true GROUP BY t.id, t.title, t.album_id, t.artist_id, t.duration_ms, t.file_path, t.file_format, t.file_size, t.bitrate, t.track_number, t.disc_number, t.mbid, t.genre, t.created_at, t.updated_at, l.user_id, pe.last_played_at, pe.play_count, pe.skip_count; ``` - [ ] **Step 2: Run sqlc generate** Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && make generate` Expected: `internal/db/dbq/recommendation.sql.go` regenerated with `LoadRadioCandidatesV2(ctx, arg LoadRadioCandidatesV2Params) ([]LoadRadioCandidatesV2Row, error)`. Inspect to confirm: - The `LoadRadioCandidatesV2Params` struct has 9 fields. sqlc names them after column hints when possible; otherwise positional like `Column3`, `Column4`. Note actual generated names — Task 3 will use them. - The `LoadRadioCandidatesV2Row` struct has the embedded `Track` plus `IsLiked bool`, `LastPlayedAt pgtype.Timestamptz`, `PlayCount int64`, `SkipCount int64`, `SimilarityScore float64`. - [ ] **Step 3: Verify compile** Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go build ./...` Expected: clean. - [ ] **Step 4: Commit** ```bash cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel git add internal/db/queries/recommendation.sql internal/db/dbq/ git commit -m "feat(db): add LoadRadioCandidatesV2 sqlc query (5-way UNION)" ``` --- ## Task 2: `Score()` + `RecommendationConfig` extension **Files:** - Modify: `internal/recommendation/score.go` - Modify: `internal/recommendation/score_test.go` - Modify: `internal/config/config.go` - [ ] **Step 1: Write the failing tests** Append to `internal/recommendation/score_test.go`: ```go func TestScore_SimilarityScore_PerfectMatchAtWeight2(t *testing.T) { w := defaultWeights() w.SimilarityWeight = 2.0 in := ScoringInputs{SimilarityScore: 1.0} got := Score(in, w, time.Now(), fixedRNG(0.5)) // base 1.0 + recency 1.0 (never played) + similarity 2.0 = 4.0 want := 4.0 if math.Abs(got-want) > 1e-9 { t.Errorf("score = %v, want %v", got, want) } } func TestScore_SimilarityScore_HalfMatchAtWeight2(t *testing.T) { w := defaultWeights() w.SimilarityWeight = 2.0 in := ScoringInputs{SimilarityScore: 0.5} got := Score(in, w, time.Now(), fixedRNG(0.5)) // base 1.0 + recency 1.0 + similarity 1.0 = 3.0 want := 3.0 if math.Abs(got-want) > 1e-9 { t.Errorf("score = %v, want %v", got, want) } } func TestScore_SimilarityScore_ZeroNoEffect(t *testing.T) { wWithSim := defaultWeights() wWithSim.SimilarityWeight = 2.0 withSim := Score(ScoringInputs{SimilarityScore: 0}, wWithSim, time.Now(), fixedRNG(0.5)) withoutSim := Score(ScoringInputs{}, defaultWeights(), time.Now(), fixedRNG(0.5)) if math.Abs(withSim-withoutSim) > 1e-9 { t.Errorf("score-with-zero-sim = %v, score-without = %v; should be equal", withSim, withoutSim) } } ``` - [ ] **Step 2: Run tests to verify they fail** Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go test ./internal/recommendation/ -run Score -v` Expected: FAIL — `ScoringInputs` has no `SimilarityScore`, `ScoringWeights` has no `SimilarityWeight`. - [ ] **Step 3: Extend `score.go`** Replace `internal/recommendation/score.go`: ```go // Package recommendation implements the weighted-shuffle scoring engine // from spec §6. The Score function is pure and takes an injectable RNG so // tests can pin jitter to deterministic values. package recommendation import ( "time" ) // ScoringInputs are the per-track facts the score function consumes. // ContextualMatchScore is in [0, 1] — max similarity between the user's // current session vector and any non-seed contextual_like row for this // track. SimilarityScore is in [0, 1] — max similarity-source score for // this candidate (LB-similar / similar-artist × 0.5 / tag-overlap jaccard // / likes-overlap constant; random fill = 0). Both set by candidate // loaders before scoring. type ScoringInputs struct { IsGeneralLiked bool LastPlayedAt *time.Time // nil = never played PlayCount int // total play_events SkipCount int // play_events with was_skipped=true ContextualMatchScore float64 // [0, 1]; 0 when no signal SimilarityScore float64 // [0, 1]; 0 when no signal (M4c) } // ScoringWeights are the operator-tunable knobs. Defaults live in // config.RecommendationConfig and are propagated here per request. type ScoringWeights struct { BaseWeight float64 LikeBoost float64 RecencyWeight float64 SkipPenalty float64 JitterMagnitude float64 ContextWeight float64 SimilarityWeight float64 // M4c } // Score computes the weighted-shuffle score per spec §6: // // score = base // + (is_general_liked ? LikeBoost : 0) // + recency_decay * RecencyWeight // - skip_ratio * SkipPenalty // + contextual_match_score * ContextWeight // + similarity_score * SimilarityWeight // + small_random_jitter // // Higher score = more likely to surface. rng is a function returning a // uniform sample in [0,1) — pass math/rand.Float64 in production, a fixed // value in tests. func Score(in ScoringInputs, w ScoringWeights, now time.Time, rng func() float64) float64 { s := w.BaseWeight if in.IsGeneralLiked { s += w.LikeBoost } s += recencyDecay(in.LastPlayedAt, now) * w.RecencyWeight s -= skipRatio(in.PlayCount, in.SkipCount) * w.SkipPenalty s += in.ContextualMatchScore * w.ContextWeight s += in.SimilarityScore * w.SimilarityWeight s += (rng()*2 - 1) * w.JitterMagnitude return s } // recencyDecay returns a value in [0, 1]: // - never played → 1.0 (cold-start tracks compete favorably with stale ones). // - age < 30 days → linear ramp age_days / 30. // - age ≥ 30 days → 1.0 (capped). // // Negative ages (clock skew) clamp to 0 to avoid math weirdness. func recencyDecay(lastPlayed *time.Time, now time.Time) float64 { if lastPlayed == nil { return 1.0 } age := now.Sub(*lastPlayed) days := age.Hours() / 24 if days < 0 { return 0.0 } if days >= 30 { return 1.0 } return days / 30.0 } // skipRatio returns skips/plays in [0, 1]; never-played tracks return 0 // rather than dividing by zero, so they aren't penalized. func skipRatio(plays, skips int) float64 { if plays == 0 { return 0.0 } return float64(skips) / float64(plays) } ``` - [ ] **Step 4: Extend `RecommendationConfig`** In `internal/config/config.go`, modify `RecommendationConfig`: ```go type RecommendationConfig struct { BaseWeight float64 `yaml:"base_weight"` LikeBoost float64 `yaml:"like_boost"` RecencyWeight float64 `yaml:"recency_weight"` SkipPenalty float64 `yaml:"skip_penalty"` JitterMagnitude float64 `yaml:"jitter_magnitude"` ContextWeight float64 `yaml:"context_weight"` SimilarityWeight float64 `yaml:"similarity_weight"` // M4c RecentlyPlayedHours int `yaml:"recently_played_hours"` RadioSize int `yaml:"radio_size"` RadioSizeMax int `yaml:"radio_size_max"` } ``` In the same file, modify `Default()`'s `Recommendation` block to include the new field: ```go Recommendation: RecommendationConfig{ BaseWeight: 1.0, LikeBoost: 2.0, RecencyWeight: 1.0, SkipPenalty: 1.0, JitterMagnitude: 0.1, ContextWeight: 2.0, SimilarityWeight: 2.0, RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200, }, ``` - [ ] **Step 5: Run tests to verify they pass** Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go test ./internal/recommendation/ -run Score -v` Expected: PASS for all existing Score tests + 3 new similarity tests. Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go build ./...` Expected: clean. - [ ] **Step 6: Verify lint clean** Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && golangci-lint run ./...` Expected: clean. - [ ] **Step 7: Commit** ```bash cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel git add internal/recommendation/score.go internal/recommendation/score_test.go internal/config/config.go git commit -m "feat(recommendation): extend Score with SimilarityScore + SimilarityWeight" ``` --- ## Task 3: `LoadCandidatesFromSimilarity` Go function + integration tests **Files:** - Modify: `internal/recommendation/candidates.go` (append) - Create: `internal/recommendation/candidates_v2_test.go` - [ ] **Step 1: Write the failing integration tests** Create `internal/recommendation/candidates_v2_test.go`: ```go package recommendation import ( "context" "testing" "time" "github.com/jackc/pgx/v5/pgtype" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) // helperLBSimilarity inserts a track_similarity row. func helperLBSimilarity(t *testing.T, f fixture, a, b pgtype.UUID, score float64) { t.Helper() if _, err := f.pool.Exec(context.Background(), `INSERT INTO track_similarity (track_a_id, track_b_id, score, source) VALUES ($1, $2, $3, 'listenbrainz')`, a, b, score); err != nil { t.Fatalf("insert track_similarity: %v", err) } } // helperArtistSimilarity inserts an artist_similarity row. func helperArtistSimilarity(t *testing.T, f fixture, a, b pgtype.UUID, score float64) { t.Helper() if _, err := f.pool.Exec(context.Background(), `INSERT INTO artist_similarity (artist_a_id, artist_b_id, score, source) VALUES ($1, $2, $3, 'listenbrainz')`, a, b, score); err != nil { t.Fatalf("insert artist_similarity: %v", err) } } // helperSeedTrackWithGenre creates a track with a specified genre + mbid. func helperSeedTrackWithGenre(t *testing.T, f fixture, title, genre, mbid string) dbq.Track { t.Helper() tr, err := f.q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ Title: title, AlbumID: f.tracks[0].AlbumID, ArtistID: f.tracks[0].ArtistID, FilePath: "/tmp/" + title + ".flac", DurationMs: 180_000, Genre: &genre, Mbid: &mbid, }) if err != nil { t.Fatalf("track: %v", err) } return tr } func defaultLimits() CandidateSourceLimits { return DefaultCandidateSourceLimits() } func TestLoadCandidatesFromSimilarity_LBSimilarSourceContributes(t *testing.T) { f := newFixture(t, 5) seed := f.tracks[0] target := f.tracks[1] helperLBSimilarity(t, f, seed.ID, target.ID, 0.85) got, err := LoadCandidatesFromSimilarity( context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), ) if err != nil { t.Fatalf("load: %v", err) } var found *Candidate for i := range got { if got[i].Track.ID == target.ID { found = &got[i] break } } if found == nil { t.Fatal("LB-similar target missing from candidates") } if found.Inputs.SimilarityScore < 0.84 || found.Inputs.SimilarityScore > 0.86 { t.Errorf("LB-similar SimilarityScore = %v, want ~0.85", found.Inputs.SimilarityScore) } } func TestLoadCandidatesFromSimilarity_SimilarArtistTracksContribute(t *testing.T) { f := newFixture(t, 1) // seed only seed := f.tracks[0] // Add a second artist + track in that artist; relate the artists via artist_similarity. otherArtist, _ := f.q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "OtherArtist", SortName: "OtherArtist"}) otherAlbum, _ := f.q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{Title: "OtherAlbum", SortTitle: "OtherAlbum", ArtistID: otherArtist.ID}) otherTrack, _ := f.q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ Title: "OtherTrack", AlbumID: otherAlbum.ID, ArtistID: otherArtist.ID, FilePath: "/tmp/other.flac", DurationMs: 180_000, }) helperArtistSimilarity(t, f, seed.ArtistID, otherArtist.ID, 0.8) got, err := LoadCandidatesFromSimilarity( context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), ) if err != nil { t.Fatalf("load: %v", err) } for _, c := range got { if c.Track.ID == otherTrack.ID { // 0.8 × 0.5 = 0.4 if c.Inputs.SimilarityScore < 0.39 || c.Inputs.SimilarityScore > 0.41 { t.Errorf("similar-artist SimilarityScore = %v, want ~0.4 (0.8 × 0.5)", c.Inputs.SimilarityScore) } return } } t.Error("similar-artist track missing from candidates") } func TestLoadCandidatesFromSimilarity_TagOverlapContributes(t *testing.T) { f := newFixture(t, 0) seed := helperSeedTrackWithGenre(t, f, "Seed", "Rock; Pop", "11111111-1111-1111-1111-111111111111") target := helperSeedTrackWithGenre(t, f, "Target", "Rock", "22222222-2222-2222-2222-222222222222") got, err := LoadCandidatesFromSimilarity( context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), ) if err != nil { t.Fatalf("load: %v", err) } for _, c := range got { if c.Track.ID == target.ID { // Seed has 2 tags; target shares 1 → jaccard 1/2 = 0.5. if c.Inputs.SimilarityScore < 0.49 || c.Inputs.SimilarityScore > 0.51 { t.Errorf("tag-overlap SimilarityScore = %v, want ~0.5", c.Inputs.SimilarityScore) } return } } t.Error("tag-overlap target missing from candidates") } func TestLoadCandidatesFromSimilarity_LikesOverlapContributes(t *testing.T) { f := newFixture(t, 0) seed := helperSeedTrackWithGenre(t, f, "Seed", "Rock", "11111111-1111-1111-1111-111111111111") liked := helperSeedTrackWithGenre(t, f, "Liked", "Rock", "22222222-2222-2222-2222-222222222222") if _, err := f.q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: f.user, TrackID: liked.ID}); err != nil { t.Fatalf("like: %v", err) } got, err := LoadCandidatesFromSimilarity( context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), ) if err != nil { t.Fatalf("load: %v", err) } for _, c := range got { if c.Track.ID == liked.ID { // Could come from tag-overlap (1.0 jaccard) OR likes-overlap (0.6) — max wins. // Since both tracks are tagged "Rock" identically, jaccard = 1/1 = 1.0, which beats 0.6. if c.Inputs.SimilarityScore < 0.59 { t.Errorf("likes-overlap candidate SimilarityScore = %v, want ≥ 0.6", c.Inputs.SimilarityScore) } return } } t.Error("liked track with shared tag missing from candidates") } func TestLoadCandidatesFromSimilarity_RandomFillReturnsTracks(t *testing.T) { f := newFixture(t, 10) // 10 tracks; no similarity data seed := f.tracks[0] got, err := LoadCandidatesFromSimilarity( context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), ) if err != nil { t.Fatalf("load: %v", err) } // Random fill should provide at least some of the 9 non-seed tracks. if len(got) == 0 { t.Error("random fill returned 0 candidates; expected at least some") } // All should have SimilarityScore = 0 (no similarity sources). for _, c := range got { if c.Inputs.SimilarityScore != 0 { t.Errorf("random-fill track %s has SimilarityScore = %v, want 0", c.Track.Title, c.Inputs.SimilarityScore) } } } func TestLoadCandidatesFromSimilarity_ExcludeListRespected(t *testing.T) { f := newFixture(t, 5) seed := f.tracks[0] excluded := f.tracks[1].ID got, err := LoadCandidatesFromSimilarity( context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, []pgtype.UUID{excluded}, defaultLimits(), ) if err != nil { t.Fatalf("load: %v", err) } for _, c := range got { if c.Track.ID == excluded { t.Error("excluded track appeared in candidates") } } } func TestLoadCandidatesFromSimilarity_SeedAlwaysExcluded(t *testing.T) { f := newFixture(t, 5) seed := f.tracks[0] got, err := LoadCandidatesFromSimilarity( context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), ) if err != nil { t.Fatalf("load: %v", err) } for _, c := range got { if c.Track.ID == seed.ID { t.Error("seed track appeared in candidates") } } } func TestLoadCandidatesFromSimilarity_RecentlyPlayedExcluded(t *testing.T) { f := newFixture(t, 5) seed := f.tracks[0] recent := f.tracks[1].ID // Insert a play_event within the last hour. var sessionID pgtype.UUID if err := f.pool.QueryRow(context.Background(), `INSERT INTO play_sessions (user_id, started_at, last_event_at, client_id) VALUES ($1, now() - interval '5 minutes', now(), 'test') RETURNING id`, f.user).Scan(&sessionID); err != nil { t.Fatalf("session: %v", err) } if _, err := f.pool.Exec(context.Background(), `INSERT INTO play_events (user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped) VALUES ($1, $2, $3, now() - interval '30 minutes', now() - interval '20 minutes', 200000, 0.9, false)`, f.user, recent, sessionID); err != nil { t.Fatalf("play_event: %v", err) } got, err := LoadCandidatesFromSimilarity( context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), ) if err != nil { t.Fatalf("load: %v", err) } for _, c := range got { if c.Track.ID == recent { t.Error("recently-played track appeared in candidates") } } } func TestLoadCandidatesFromSimilarity_DedupTakesMaxScore(t *testing.T) { f := newFixture(t, 0) seed := helperSeedTrackWithGenre(t, f, "Seed", "Rock", "11111111-1111-1111-1111-111111111111") // Target shares the LB-similar source (high score) AND tag-overlap (low score). target := helperSeedTrackWithGenre(t, f, "Target", "Rock", "22222222-2222-2222-2222-222222222222") helperLBSimilarity(t, f, seed.ID, target.ID, 0.95) got, err := LoadCandidatesFromSimilarity( context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), ) if err != nil { t.Fatalf("load: %v", err) } count := 0 for _, c := range got { if c.Track.ID == target.ID { count++ // LB 0.95 should win over tag-overlap (jaccard 1/1 = 1.0). Wait — 1.0 > 0.95. Adjust: // seed and target both have only "Rock" tag → jaccard = 1.0 from tag-overlap. // LB sim = 0.95. Max = 1.0. So this test verifies max() picks tag-overlap here. if c.Inputs.SimilarityScore < 0.99 { t.Errorf("dedup max SimilarityScore = %v, want 1.0 (tag-overlap wins)", c.Inputs.SimilarityScore) } } } if count != 1 { t.Errorf("target appeared %d times, want 1 (dedup failed)", count) } } func TestLoadCandidatesFromSimilarity_EmptyLibrary_NoError(t *testing.T) { f := newFixture(t, 0) // Seed an isolated track to query against. seed := helperSeedTrackWithGenre(t, f, "Seed", "Rock", "11111111-1111-1111-1111-111111111111") got, err := LoadCandidatesFromSimilarity( context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), ) if err != nil { t.Fatalf("load: %v", err) } if len(got) != 0 { t.Errorf("got %d candidates from empty library, want 0", len(got)) } _ = time.Now() // silence unused-import warning if loop never runs } ``` The `fixture` and `newFixture` helpers come from `candidates_test.go` (existing M3 fixture). If `newFixture(t, 0)` doesn't exist (i.e. it requires ≥1 track), peek at the existing helper's signature and adjust the test to use the smallest legal value. - [ ] **Step 2: Run tests to verify they fail** Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test ./internal/recommendation/ -run LoadCandidatesFromSimilarity -v` Expected: FAIL — `undefined: LoadCandidatesFromSimilarity`, `undefined: CandidateSourceLimits`, `undefined: DefaultCandidateSourceLimits`. - [ ] **Step 3: Implement `LoadCandidatesFromSimilarity`** Append to `internal/recommendation/candidates.go`: ```go // CandidateSourceLimits controls per-source K values for the M4c // similarity-driven pool. Defaults via DefaultCandidateSourceLimits(). type CandidateSourceLimits struct { LBSimilar int SimilarArtist int TagOverlap int LikesOverlap int RandomFill int } // DefaultCandidateSourceLimits returns the v1 hardcoded constants per spec. func DefaultCandidateSourceLimits() CandidateSourceLimits { return CandidateSourceLimits{ LBSimilar: 30, SimilarArtist: 30, TagOverlap: 20, LikesOverlap: 20, RandomFill: 30, } } // LoadCandidatesFromSimilarity is M4c's primary candidate-pool loader. // 5-way SQL UNION (LB-similar / similar-artist tracks / MB-tag overlap / // likes-overlap / random fill) + dedup-by-max sim_score. Returns // []Candidate (same shape as LoadCandidates) so Shuffle is unchanged. // // Caller (radio handler) falls back to LoadCandidates on error. func LoadCandidatesFromSimilarity( ctx context.Context, q *dbq.Queries, userID, seedID pgtype.UUID, recentlyPlayedHours int, currentVector SessionVector, exclude []pgtype.UUID, limits CandidateSourceLimits, ) ([]Candidate, error) { if exclude == nil { exclude = []pgtype.UUID{} } rows, err := q.LoadRadioCandidatesV2(ctx, dbq.LoadRadioCandidatesV2Params{ UserID: userID, ID: seedID, Column3: float64(recentlyPlayedHours), Column4: exclude, Column5: int32(limits.LBSimilar), Column6: int32(limits.SimilarArtist), Column7: int32(limits.TagOverlap), Column8: int32(limits.LikesOverlap), Column9: int32(limits.RandomFill), }) if err != nil { return nil, err } likes, err := loadContextualLikesByTrack(ctx, q, userID) if err != nil { return nil, err } out := make([]Candidate, 0, len(rows)) for _, r := range rows { var lpt *time.Time if r.LastPlayedAt.Valid { t := r.LastPlayedAt.Time lpt = &t } ctxScore := ContextualMatchScore(currentVector, likes[r.Track.ID], DefaultSimilarityWeights) out = append(out, Candidate{ Track: r.Track, Inputs: ScoringInputs{ IsGeneralLiked: r.IsLiked, LastPlayedAt: lpt, PlayCount: int(r.PlayCount), SkipCount: int(r.SkipCount), ContextualMatchScore: ctxScore, SimilarityScore: r.SimilarityScore, }, }) } return out, nil } ``` **Note on sqlc-generated param field names:** the `LoadRadioCandidatesV2Params` field names depend on sqlc's auto-generated names from the SQL parameter positions. After Task 1's `make generate`, inspect `internal/db/dbq/recommendation.sql.go` and adjust the field references (`UserID`, `ID`, `Column3`, etc.) to match what sqlc actually emitted. The actual names may be `Column3` for `$3` (recently_played_hours), `Column4` for `$4` (exclude), etc., or sqlc may pick smarter names from context. Rename to match. - [ ] **Step 4: Run tests to verify they pass** Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test ./internal/recommendation/ -run LoadCandidatesFromSimilarity -v` Expected: PASS for all 10 tests. - [ ] **Step 5: Verify lint clean** Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && golangci-lint run ./internal/recommendation/...` Expected: clean. - [ ] **Step 6: Commit** ```bash cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel git add internal/recommendation/candidates.go internal/recommendation/candidates_v2_test.go git commit -m "feat(recommendation): add LoadCandidatesFromSimilarity (5-source candidate pool)" ``` --- ## Task 4: Radio handler — exclude param + similarity pool + fallback **Files:** - Modify: `internal/api/radio.go` - Modify: `internal/api/radio_test.go` - Modify: `internal/api/auth_test.go` (testHandlers' `recCfg` literal) - [ ] **Step 1: Write the failing tests** Append to `internal/api/radio_test.go`: ```go func TestHandleRadio_WithSimilarityPool_RanksLBSimilarHigher(t *testing.T) { h, pool := testHandlers(t) truncateLibrary(t, pool) user := seedUser(t, pool, "alice", "x", false) artist := seedArtist(t, pool, "X") album := seedAlbum(t, pool, artist.ID, "X", 1990) seed := seedTrack(t, pool, album.ID, artist.ID, "Seed", 1, 100_000) // Two non-seed tracks: one LB-similar (high score), one not. target := seedTrack(t, pool, album.ID, artist.ID, "Target", 2, 100_000) control := seedTrack(t, pool, album.ID, artist.ID, "Control", 3, 100_000) if _, err := pool.Exec(context.Background(), `INSERT INTO track_similarity (track_a_id, track_b_id, score, source) VALUES ($1, $2, 0.95, 'listenbrainz')`, seed.ID, target.ID); err != nil { t.Fatalf("insert sim: %v", err) } w := callRadio(h, user, "seed_track="+uuidToString(seed.ID)+"&limit=3") if w.Code != http.StatusOK { t.Fatalf("status = %d", w.Code) } var resp RadioResponse _ = json.Unmarshal(w.Body.Bytes(), &resp) // resp.Tracks[0] is seed; check whether target ranks above control. var targetIdx, controlIdx int targetIdx, controlIdx = -1, -1 for i, tr := range resp.Tracks { if tr.ID == uuidToString(target.ID) { targetIdx = i } if tr.ID == uuidToString(control.ID) { controlIdx = i } } if targetIdx < 0 || controlIdx < 0 { t.Fatalf("target=%d control=%d (one not present)", targetIdx, controlIdx) } if targetIdx >= controlIdx { t.Errorf("target ranked at %d, control at %d — LB-similar should rank higher", targetIdx, controlIdx) } } func TestHandleRadio_ExcludeParamFiltersOut(t *testing.T) { h, pool := testHandlers(t) truncateLibrary(t, pool) user := seedUser(t, pool, "alice", "x", false) artist := seedArtist(t, pool, "X") album := seedAlbum(t, pool, artist.ID, "X", 1990) seed := seedTrack(t, pool, album.ID, artist.ID, "Seed", 1, 100_000) excluded := seedTrack(t, pool, album.ID, artist.ID, "Excluded", 2, 100_000) for i := 3; i <= 6; i++ { seedTrack(t, pool, album.ID, artist.ID, "T"+string(rune('0'+i)), i, 100_000) } q := "seed_track=" + uuidToString(seed.ID) + "&limit=10&exclude=" + uuidToString(excluded.ID) w := callRadio(h, user, q) if w.Code != http.StatusOK { t.Fatalf("status = %d", w.Code) } var resp RadioResponse _ = json.Unmarshal(w.Body.Bytes(), &resp) for _, tr := range resp.Tracks { if tr.ID == uuidToString(excluded.ID) { t.Error("excluded track present in response") } } } func TestHandleRadio_ExcludeParamMalformedSkipped(t *testing.T) { h, pool := testHandlers(t) truncateLibrary(t, pool) user := seedUser(t, pool, "alice", "x", false) artist := seedArtist(t, pool, "X") album := seedAlbum(t, pool, artist.ID, "X", 1990) seed := seedTrack(t, pool, album.ID, artist.ID, "Seed", 1, 100_000) other := seedTrack(t, pool, album.ID, artist.ID, "Other", 2, 100_000) q := "seed_track=" + uuidToString(seed.ID) + "&limit=5&exclude=not-a-uuid," + uuidToString(other.ID) w := callRadio(h, user, q) if w.Code != http.StatusOK { t.Fatalf("status = %d (malformed UUID should be silently dropped)", w.Code) } var resp RadioResponse _ = json.Unmarshal(w.Body.Bytes(), &resp) for _, tr := range resp.Tracks { if tr.ID == uuidToString(other.ID) { t.Error("other track should still be excluded after dropping malformed entry") } } } func TestHandleRadio_SeedAlwaysAtIndex0(t *testing.T) { // Defensive: even when the similarity pool returns no candidates, // the seed track must still be the first track in the response. h, pool := testHandlers(t) truncateLibrary(t, pool) user := seedUser(t, pool, "alice", "x", false) artist := seedArtist(t, pool, "X") album := seedAlbum(t, pool, artist.ID, "X", 1990) seed := seedTrack(t, pool, album.ID, artist.ID, "Seed", 1, 100_000) w := callRadio(h, user, "seed_track="+uuidToString(seed.ID)) if w.Code != http.StatusOK { t.Fatalf("status = %d", w.Code) } var resp RadioResponse _ = json.Unmarshal(w.Body.Bytes(), &resp) if len(resp.Tracks) == 0 || resp.Tracks[0].ID != uuidToString(seed.ID) { t.Errorf("seed not at index 0: %v", resp.Tracks) } } ``` - [ ] **Step 2: Update `auth_test.go::testHandlers` recCfg literal** In `internal/api/auth_test.go`, find the `recCfg := config.RecommendationConfig{...}` literal in `testHandlers` and add `SimilarityWeight: 2.0`: ```go recCfg := config.RecommendationConfig{ BaseWeight: 1.0, LikeBoost: 2.0, RecencyWeight: 1.0, SkipPenalty: 1.0, JitterMagnitude: 0.1, ContextWeight: 2.0, SimilarityWeight: 2.0, RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200, } ``` - [ ] **Step 3: Update the radio handler** In `internal/api/radio.go`, replace the `LoadCandidates` call site. Before it, parse the exclude param + build the limits struct + add the seed to the exclude list. Keep the M3 `LoadCandidates` call as a fallback path. Replace the section from `currentVec := loadCurrentSessionVector(...)` through `weights := recommendation.ScoringWeights{...}`: ```go currentVec := loadCurrentSessionVector(r, q, user.ID, h.logger) exclude := parseExcludeParam(r.URL.Query().Get("exclude")) limits := recommendation.DefaultCandidateSourceLimits() candidates, err := recommendation.LoadCandidatesFromSimilarity( r.Context(), q, user.ID, seedID, h.recCfg.RecentlyPlayedHours, currentVec, exclude, limits, ) if err != nil { h.logger.Warn("api: radio: similarity-pool failed; falling back to whole-library", "err", err) candidates, err = recommendation.LoadCandidates( r.Context(), q, user.ID, seedID, h.recCfg.RecentlyPlayedHours, currentVec, ) if err != nil { h.logger.Error("api: radio: load candidates fallback failed", "err", err) writeErr(w, http.StatusInternalServerError, "server_error", "candidate load failed") return } } weights := recommendation.ScoringWeights{ BaseWeight: h.recCfg.BaseWeight, LikeBoost: h.recCfg.LikeBoost, RecencyWeight: h.recCfg.RecencyWeight, SkipPenalty: h.recCfg.SkipPenalty, JitterMagnitude: h.recCfg.JitterMagnitude, ContextWeight: h.recCfg.ContextWeight, SimilarityWeight: h.recCfg.SimilarityWeight, } ``` Append the helper at the bottom of the same file: ```go // parseExcludeParam parses a comma-separated list of UUIDs from the // `exclude` query string, silently dropping malformed entries. Returns // nil for empty or all-malformed input. func parseExcludeParam(raw string) []pgtype.UUID { if raw == "" { return nil } parts := strings.Split(raw, ",") out := make([]pgtype.UUID, 0, len(parts)) for _, p := range parts { p = strings.TrimSpace(p) if p == "" { continue } id, ok := parseUUID(p) if !ok { continue } out = append(out, id) } return out } ``` `parseUUID` already exists in `internal/api/`. Confirm that `strings` is in the import block (it should already be). - [ ] **Step 4: Run tests to verify they pass** Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test ./internal/api/ -v 2>&1 | tail -30` Expected: PASS for all existing API tests + 4 new radio tests. - [ ] **Step 5: Verify lint clean** Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && golangci-lint run ./...` Expected: clean. - [ ] **Step 6: Commit** ```bash cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel git add internal/api/radio.go internal/api/radio_test.go internal/api/auth_test.go git commit -m "feat(api): radio uses similarity pool with exclude param + M3 fallback" ``` --- ## Task 5: Frontend — radio queue refresh at 80% **Files:** - Modify: `web/src/lib/player/store.svelte.ts` - Modify: `web/src/lib/player/store.test.ts` - [ ] **Step 1: Write the failing tests** Append to `web/src/lib/player/store.test.ts`: ```ts describe('radio refresh at 80%', () => { beforeEach(() => { vi.clearAllMocks(); }); test('refresh fires at 80% queue consumption', async () => { const tracks: TrackRef[] = Array.from({ length: 5 }, (_, i) => ({ id: `t${i}`, title: `T${i}`, album_id: 'al', album_title: 'Al', artist_id: 'ar', artist_name: 'Ar', track_number: i + 1, disc_number: 1, duration_sec: 100, stream_url: `/api/tracks/t${i}/stream` })); (apiGet as ReturnType).mockResolvedValueOnce({ tracks }); await playRadio('seed1'); // Now queue is the 5 tracks. Reset mock for refresh call. (apiGet as ReturnType).mockClear(); (apiGet as ReturnType).mockResolvedValueOnce({ tracks: [tracks[0], { ...tracks[0], id: 'new1', title: 'New1' }] }); // Advance to index 4 (5th of 5 = 100%, but cross 80% at index 3) // Skip to index 3 (4th track played; 4/5 = 80%) skipNext(); skipNext(); skipNext(); // Allow $effect to fire await new Promise((r) => setTimeout(r, 10)); expect(apiGet).toHaveBeenCalled(); const callArg = (apiGet as ReturnType).mock.calls[0][0]; expect(callArg).toContain('seed_track=seed1'); expect(callArg).toContain('exclude='); }); test('refresh below 80% does not fire', async () => { const tracks: TrackRef[] = Array.from({ length: 5 }, (_, i) => ({ id: `t${i}`, title: `T${i}`, album_id: 'al', album_title: 'Al', artist_id: 'ar', artist_name: 'Ar', track_number: i + 1, disc_number: 1, duration_sec: 100, stream_url: `/api/tracks/t${i}/stream` })); (apiGet as ReturnType).mockResolvedValueOnce({ tracks }); await playRadio('seed1'); (apiGet as ReturnType).mockClear(); skipNext(); skipNext(); // index = 2 (3 of 5 = 60%) await new Promise((r) => setTimeout(r, 10)); expect(apiGet).not.toHaveBeenCalled(); }); test('refresh appends new tracks excluding seed at index 0', async () => { const initial: TrackRef[] = Array.from({ length: 5 }, (_, i) => ({ id: `t${i}`, title: `T${i}`, album_id: 'al', album_title: 'Al', artist_id: 'ar', artist_name: 'Ar', track_number: i + 1, disc_number: 1, duration_sec: 100, stream_url: `/api/tracks/t${i}/stream` })); (apiGet as ReturnType).mockResolvedValueOnce({ tracks: initial }); await playRadio('seed1'); // Refresh response: 5 tracks where the first is the seed (will be stripped). const refreshTracks: TrackRef[] = Array.from({ length: 5 }, (_, i) => ({ id: i === 0 ? initial[0].id : `n${i}`, title: i === 0 ? initial[0].title : `N${i}`, album_id: 'al', album_title: 'Al', artist_id: 'ar', artist_name: 'Ar', track_number: i + 1, disc_number: 1, duration_sec: 100, stream_url: '/api/tracks/x/stream' })); (apiGet as ReturnType).mockResolvedValueOnce({ tracks: refreshTracks }); skipNext(); skipNext(); skipNext(); // index = 3 (80%) await new Promise((r) => setTimeout(r, 10)); // Initial 5 + 4 new (5 in response - 1 seed stripped) = 9 expect(player.queue.length).toBe(9); }); test('refresh does NOT double-fire while in-flight', async () => { const tracks: TrackRef[] = Array.from({ length: 5 }, (_, i) => ({ id: `t${i}`, title: `T${i}`, album_id: 'al', album_title: 'Al', artist_id: 'ar', artist_name: 'Ar', track_number: i + 1, disc_number: 1, duration_sec: 100, stream_url: `/api/tracks/t${i}/stream` })); (apiGet as ReturnType).mockResolvedValueOnce({ tracks }); await playRadio('seed1'); (apiGet as ReturnType).mockClear(); // Hang the refresh call so the in-flight flag stays set. let resolveFn!: (v: unknown) => void; const hanging = new Promise((r) => { resolveFn = r; }); (apiGet as ReturnType).mockReturnValueOnce(hanging); skipNext(); skipNext(); skipNext(); // 80% — fires refresh await new Promise((r) => setTimeout(r, 10)); // Now advance further while in-flight; should NOT fire a second call. skipNext(); await new Promise((r) => setTimeout(r, 10)); expect(apiGet).toHaveBeenCalledTimes(1); resolveFn({ tracks: [] }); }); test('refresh resets when user enqueues from non-radio source', async () => { const tracks: TrackRef[] = Array.from({ length: 5 }, (_, i) => ({ id: `t${i}`, title: `T${i}`, album_id: 'al', album_title: 'Al', artist_id: 'ar', artist_name: 'Ar', track_number: i + 1, disc_number: 1, duration_sec: 100, stream_url: `/api/tracks/t${i}/stream` })); (apiGet as ReturnType).mockResolvedValueOnce({ tracks }); await playRadio('seed1'); enqueueTrack({ id: 'manual1', title: 'Manual', album_id: 'al', album_title: 'Al', artist_id: 'ar', artist_name: 'Ar', track_number: 99, disc_number: 1, duration_sec: 100, stream_url: '/api/tracks/manual1/stream' }); (apiGet as ReturnType).mockClear(); skipNext(); skipNext(); skipNext(); skipNext(); // advance past 80% on the now-6-track queue await new Promise((r) => setTimeout(r, 10)); expect(apiGet).not.toHaveBeenCalled(); }); }); ``` The existing test setup may already mock `$lib/api/client` (where `apiGet` lives) — match the existing pattern in the file. If `apiGet` isn't currently mocked, add a mock at the top of `store.test.ts`: ```ts vi.mock('$lib/api/client', () => ({ api: { get: vi.fn(), put: vi.fn(), post: vi.fn(), del: vi.fn() } })); import { api } from '$lib/api/client'; const apiGet = api.get; ``` Match the existing import-style in this test file; if it already imports from `$lib/api/client`, reuse that. - [ ] **Step 2: Run tests to verify they fail** Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web && npm test -- --run src/lib/player/store 2>&1 | tail -10` Expected: most/all of the new tests fail because the refresh logic doesn't exist yet. - [ ] **Step 3: Modify the player store** In `web/src/lib/player/store.svelte.ts`, find the existing `playRadio` function and: 1. Add a module-level `radioSeedId` state variable 2. Add a `radioRefreshInFlight` flag 3. Modify `playRadio` to set `radioSeedId` 4. Modify `playQueue`, `enqueueTrack`, `enqueueTracks` to clear `radioSeedId` (these are explicit non-radio enqueues) 5. Add an `$effect` watching consumption ratio; trigger refresh when conditions met Replace the existing `playRadio` and the surrounding state declarations: ```ts let _queue = $state([]); let _index = $state(0); let _state = $state('idle'); let _position = $state(0); let _duration = $state(0); let _volume = $state(readStoredVolume()); let _shuffle = $state(false); let _repeat = $state('off'); let _error = $state(null); // M4c: track when the queue was seeded by a radio call so we can // auto-refresh at 80% consumption. Cleared when the user enqueues // from a non-radio source. let _radioSeedId = $state(null); let _radioRefreshInFlight = false; ``` Modify `playQueue`, `enqueueTrack`, `enqueueTracks` to clear `_radioSeedId`. Existing code: ```ts export function playQueue(tracks: TrackRef[], startIndex = 0): void { _queue = tracks; // ... existing logic ... } ``` Add `_radioSeedId = null;` as the first line in `playQueue`, `enqueueTrack`, and `enqueueTracks`. Replace `playRadio`: ```ts export async function playRadio(seedTrackId: string): Promise { const resp = await api.get( `/api/radio?seed_track=${encodeURIComponent(seedTrackId)}` ); if (resp.tracks.length === 0) return; // playQueue clears _radioSeedId; set it back AFTER playQueue finishes. playQueue(resp.tracks, 0); _radioSeedId = seedTrackId; } ``` Add the auto-refresh effect. Place after the state declarations (effects are top-level in `.svelte.ts` files): ```ts $effect(() => { if (_radioSeedId === null) return; if (_radioRefreshInFlight) return; if (_queue.length === 0) return; const consumedRatio = (_index + 1) / _queue.length; if (consumedRatio < 0.8) return; _radioRefreshInFlight = true; const seed = _radioSeedId; const exclude = _queue.map((t) => t.id).join(','); api .get( `/api/radio?seed_track=${encodeURIComponent(seed)}&exclude=${exclude}` ) .then((resp) => { // Strip the seed at index 0 (already in our queue) and append the rest. const newTracks = resp.tracks.slice(1).filter((t) => t.id !== seed); if (newTracks.length > 0) { // Append without clearing radio state — this IS a radio refresh. _queue = [..._queue, ...newTracks]; } }) .catch(() => { // Swallow; next track-advance can retry. }) .finally(() => { _radioRefreshInFlight = false; }); }); ``` Note: the existing code uses `_queue = [..._queue, ...newTracks]` style (immutable append) consistent with `enqueueTrack`/`enqueueTracks`. We don't call `enqueueTracks` here because it would clear `_radioSeedId`. - [ ] **Step 4: Run tests to verify they pass** Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web && npm test -- --run src/lib/player/store 2>&1 | tail -10` Expected: PASS for all existing tests + 5 new refresh tests. - [ ] **Step 5: Verify svelte-check + build** Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web && npm run check 2>&1 | tail -3` Expected: 0 errors, 0 warnings. Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web && npm run build 2>&1 | tail -5` Expected: adapter-static emits `web/build/`. - [ ] **Step 6: Commit** ```bash cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel git add web/ git commit -m "feat(web): radio queue auto-refreshes at 80% via ?exclude= param" ``` --- ## Task 6: Final verification + branch finish **Files:** none (verification only) - [ ] **Step 1: Full Go test suite (-short -race)** Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go test -short -race ./...` Expected: PASS across all 16 packages. - [ ] **Step 2: Full integration suite** Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test -race -p 1 ./...` Expected: PASS apart from the pre-existing `TestScanner_Integration` flake (CI runs `-short` which skips it). - [ ] **Step 3: Lint** Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && golangci-lint run ./...` Expected: clean. - [ ] **Step 4: Coverage check** Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test -p 1 -coverprofile=/tmp/cover-m4c.out ./internal/recommendation/... ./internal/api/ && go tool cover -func=/tmp/cover-m4c.out | tail -5` Expected: `internal/recommendation` ≥ 80% (was 73% pre-M4c); `internal/api/radio.go` should be well-covered by the existing radio tests + 4 new ones. - [ ] **Step 5: Web verification** Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web && npm run check && npm test -- --run && npm run build` Expected: svelte-check 0/0; vitest passes (180 + 5 = 185); build succeeds. - [ ] **Step 6: Docker smoke** Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && docker build -t minstrel:m4c-smoke .` Expected: container builds. - [ ] **Step 7: Manual end-to-end gate (closes M4)** Set up: 1. `docker compose up --build -d minstrel` — pick up new code 2. Wait for M4b's similarity worker to fill `track_similarity` for ≥10 played tracks (≥1 hour of uptime + previously-played tracks, OR seed manually for testing) 3. In SPA: click radio from a played track. Queue should differ noticeably from M3's output. 4. Listen through ~80% of the queue. Observe queue length increases as auto-refresh fires (browser DevTools network tab shows the `/api/radio?seed_track=…&exclude=…` request). 5. `psql -c "SELECT count(*) FROM track_similarity WHERE source = 'listenbrainz'"` shows non-trivial similarity data. 6. Subjectively: radio quality should feel meaningfully better than M3's baseline. This is the **closing gate for M4**. - [ ] **Step 8: Update Fable task #347** After PR opens, mark `in_progress`. After PR merges, mark `done` with the closing summary in the body. Note that #347 closes the M4 milestone. - [ ] **Step 9: Finishing the branch** **REQUIRED SUB-SKILL:** Use `superpowers:finishing-a-development-branch` to verify tests, present completion options, and execute the user's choice. Per established cadence: this slice will land as a single-purpose PR. After merge, M4 milestone is fully closed; M5 (Lidarr quarantine + suggested-additions for tracks not in library) becomes the natural next milestone. --- ## Self-Review **Spec coverage:** - §3 architecture overview → Tasks 1, 3, 4, 5 ✓ - §4 candidate pool SQL → Task 1 ✓ - §5 Score() formula extension → Task 2 ✓ - §6 Go-side wiring → Tasks 3, 4 ✓ - §7 frontend queue refresh → Task 5 ✓ - §8 test plan → embedded across Tasks 2-5; verified in Task 6 ✓ - §9 decisions ledger → all 7 baked into the plan defaults ✓ - §10 backwards compatibility → preserved by zero-default new fields + LoadCandidates retained ✓ No gaps. **Placeholder scan:** No "TBD"/"TODO" content. The "use whatever sqlc emits at make generate time" guidance in Task 3 is a known sqlc-specific instruction, not a placeholder. **Type consistency:** - `ScoringInputs.SimilarityScore` and `ScoringWeights.SimilarityWeight` defined in Task 2; consumed in Tasks 3 and 4. - `CandidateSourceLimits` defined in Task 3; consumed in Task 4. - `LoadCandidatesFromSimilarity` signature matches across Tasks 3 and 4. - `parseExcludeParam` signature consistent with `[]pgtype.UUID` return type. - Frontend: `_radioSeedId`, `_radioRefreshInFlight` declared at the top of Task 5 step 3; effect references them; `playRadio`/`playQueue`/etc. mutate them consistently. - sqlc-generated `LoadRadioCandidatesV2Params` field names: deferred to implementation time (sqlc's auto-naming is positional unless column hints help; the Task 3 code uses `Column3..Column9` placeholders the implementer adjusts after `make generate`).