diff --git a/docs/superpowers/plans/2026-04-28-m4c-radio.md b/docs/superpowers/plans/2026-04-28-m4c-radio.md new file mode 100644 index 00000000..737f890a --- /dev/null +++ b/docs/superpowers/plans/2026-04-28-m4c-radio.md @@ -0,0 +1,1391 @@ +# 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`).