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`). diff --git a/docs/superpowers/specs/2026-04-28-m4c-radio-design.md b/docs/superpowers/specs/2026-04-28-m4c-radio-design.md new file mode 100644 index 00000000..320c54d3 --- /dev/null +++ b/docs/superpowers/specs/2026-04-28-m4c-radio-design.md @@ -0,0 +1,444 @@ +# M4c — Radio similarity-driven candidate pool + queue refresh at 80% (closes M4) + +**Status:** Spec draft, 2026-04-28 +**Tracking:** Fable #347 +**Milestone:** M4 — ListenBrainz scrobble + similarity + radio +**Builds on:** M4a (PR #26 — outbound scrobble), M4b (PR #27 — inbound similarity ingest) + +## 1. Goal + +Replace M3's "whole library minus seed minus recently-played" candidate pool +with a similarity-driven pool drawn from four sources (LB-similar tracks, +tracks by similar artists, MB-tag overlap, user's general likes overlapping +seed tags) plus a random-fill source that guarantees a minimum pool size. +Add a sixth scoring term (`SimilarityScore × SimilarityWeight`) so within-pool +ranking reflects per-track similarity strength. Web client auto-refreshes +the radio queue when 80% consumed. + +When this slice merges, the M4 milestone closes: the engine has all three v1 +components (scoring, session vectors, LB-derived similarity) wired through +both backend and frontend. + +## 2. Non-goals (explicit) + +- **Lazy LB fetch on radio click** — radio handler stays synchronous and + uses only data already in `track_similarity` / `artist_similarity`. + Sparse-data UX is handled by random-fill augmentation. +- **YAML-configurable per-source Ks** — hardcoded constants for v1. +- **Symmetric edge storage** — still one-way as M4b stored. +- **Per-source score weights as YAML** — tunable in code only. +- **Token-based queue-refresh continuation** — explicit `?exclude=...` + query param. +- **Pre-fetch similarity on track play** — M4b's hourly tick is the only + fetch trigger. +- **Suggested additions / Lidarr** — out-of-library LB matches discarded; + M5 territory. +- **Multi-seed / persistent radio stations** — every call is single-seed + and stateless. +- **Cross-user collaborative filtering source** — `user_cooccurrence` + schema slot reserved, not populated. +- **`SimilarityWeight` per-user override** — operator-only YAML for v1. + +## 3. Architecture overview + +``` +GET /api/radio?seed_track=&limit=N&exclude=t1,t2,... + ↓ +handleRadio + 1. Auth + parse params (existing M3) + 2. Get seed track + album + artist (existing) + 3. q.GetCurrentSessionVectorForUser() (existing — M3) + 4. recommendation.LoadCandidatesFromSimilarity(...) ← NEW + - 5-way SQL UNION: LB-similar / similar-artist tracks / + tag-overlap / likes-overlap / random-fill + - Excludes seed + ?exclude= list + recently-played + - Returns []Candidate with per-row SimilarityScore + - On error → fallback to M3's LoadCandidates (logged) + 5. recommendation.Shuffle(candidates, weights, ...) ← extended + - M3's Score() gains SimilarityScore × SimilarityWeight term + - Otherwise unchanged + 6. Resolve album/artist for picks (existing) + 7. Return RadioResponse{Tracks: [seed, pick1, ...]} + ↓ + Web client + 8. Player store watches currentIndex / queue.length ← NEW + - At ≥ 80% consumed, AND radioSeedId is set, AND no refresh + in-flight: GET /api/radio?seed_track=…&exclude= + - Append response.tracks (skipping index 0 = seed already in queue) + - radioSeedId cleared when user manually enqueues from elsewhere +``` + +## 4. Candidate pool SQL (`LoadRadioCandidatesV2`) + +5-way UNION; each branch produces `(track_id, similarity_score)`. After UNION, +the outer SELECT joins `tracks` + general_likes (for `is_liked`) + LATERAL +play_events aggregation (for `last_played_at` / `play_count` / `skip_count`), +GROUP BY track id, taking `max(similarity_score)` across sources. + +```sql +-- name: LoadRadioCandidatesV2 :many +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($5::uuid[]) AS id + UNION ALL + SELECT track_id FROM play_events + WHERE user_id = $1 AND started_at > now() - $4 * 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 $6 +), +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 $7 +), +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 $8 +), +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 $9 +), +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 $10 +) +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; +``` + +### 4.1 Per-source K and score (defaults, hardcoded for v1) + +| Source | K | sim_score per row | +|---|---|---| +| `lb_similar` (track_similarity) | 30 | LB raw score (0–1) | +| `similar_artists` | 30 | `artist_similarity.score × 0.5` | +| `tag_overlap` | 20 | jaccard: `shared_tags / seed_tag_count` | +| `likes_overlap` | 20 | constant `0.6` | +| `random_fill` | 30 | `0.0` | + +Total ideal: 130 candidates pre-dedup; 60–100 after dedup. Random fill is +drawn AFTER the 4 similarity sources are exhausted (`NOT IN (lb_similar +UNION similar_artists UNION tag_overlap UNION likes_overlap)`), so it +strictly augments rather than overlaps. On very small libraries (<130 +tracks total), the pool is naturally smaller — there is no hard floor; +the design assumes typical libraries have hundreds of tracks. M3's `Score()` ++ `Shuffle()` happily ranks small pools. + +`max(sim_score)` on dedup so a track in multiple sources keeps its strongest +signal (LB's 0.85 beats tag's 0.4). + +## 5. Score() formula extension + +`internal/recommendation/score.go`: + +```go +type ScoringInputs struct { + IsGeneralLiked bool + LastPlayedAt *time.Time + PlayCount int + SkipCount int + ContextualMatchScore float64 // M3 + SimilarityScore float64 // NEW — max across the 4 similarity sources, in [0,1] +} + +type ScoringWeights struct { + BaseWeight float64 + LikeBoost float64 + RecencyWeight float64 + SkipPenalty float64 + JitterMagnitude float64 + ContextWeight float64 // M3 + SimilarityWeight float64 // NEW — default 2.0 +} +``` + +Updated formula: + +``` +score = base + + (is_general_liked ? LikeBoost : 0) + + recency_decay * RecencyWeight + - skip_ratio * SkipPenalty + + contextual_match_score * ContextWeight + + similarity_score * SimilarityWeight ← NEW + + jitter +``` + +`config.RecommendationConfig` gains `SimilarityWeight float64` (yaml +`similarity_weight`, default `2.0`). Same magnitude as `LikeBoost` and +`ContextWeight` — at perfect similarity (1.0), an LB-similar track gets a ++2.0 boost equivalent to an explicit general like. + +**Backwards compatible:** zero-value `ScoringInputs{}` and `ScoringWeights{}` +produce M3 behavior because both new fields are zero-defaulted. + +## 6. Go-side wiring + +### 6.1 `LoadCandidatesFromSimilarity` + +`internal/recommendation/candidates.go` gains a sibling to `LoadCandidates`: + +```go +type CandidateSourceLimits struct { + LBSimilar int // 30 + SimilarArtist int // 30 + TagOverlap int // 20 + LikesOverlap int // 20 + RandomFill int // 30 — drawn from tracks NOT already returned by the 4 similarity sources +} + +func DefaultCandidateSourceLimits() CandidateSourceLimits + +// LoadCandidatesFromSimilarity is M4c's primary candidate-pool loader. +// Returns []Candidate (same type as M3 LoadCandidates) so Shuffle() is +// unchanged. Caller 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) +``` + +Body: +1. `q.LoadRadioCandidatesV2(...)` with the 10 params from §4 +2. Existing `loadContextualLikesByTrack(...)` for the contextual scoring inputs +3. Project rows → `[]Candidate` with `Inputs.SimilarityScore = row.SimilarityScore` + +`LoadCandidates` (M3 fallback) **stays in place**, still consumed by callers +that want whole-library scoring (unit tests, the radio handler's error path). + +### 6.2 Radio handler change + +`internal/api/radio.go`: + +```go +exclude := parseExcludeParam(r.URL.Query().Get("exclude")) // []pgtype.UUID +exclude = append(exclude, seedID) // always exclude the seed +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", + "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", "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, // NEW +} +``` + +`parseExcludeParam(s string) []pgtype.UUID` — splits on `,`, parses each +UUID, silently drops malformed entries. Returns nil for empty input. + +## 7. Frontend (queue refresh at 80%) + +`web/src/lib/player/store.svelte.ts`: + +- New `radioSeedId` `$state` set inside `playRadio()`. +- New `$effect` watching `(player.currentIndex + 1) / player.queue.length`: + - Fires when `radioSeedId` is set, queue is non-empty, ratio ≥ 0.8, and + no refresh in-flight. + - Calls `/api/radio?seed_track=&exclude=t.id).join(',')>`. + - On success: appends `response.tracks.slice(1)` (drops the seed at index 0). + - On failure: logs + clears in-flight flag (next track-advance can retry). +- New `appendToQueue(tracks)` helper — pushes onto `player.queue` without + changing `currentIndex`. +- `radioSeedId = null` when user enqueues from non-radio paths (`playQueue`, + `enqueueTrack`, `enqueueTracks`) so the auto-refresh doesn't fire on + manually-built queues. + +`RadioResponse` JSON shape unchanged. `TrackRef[]` array works for both +initial calls and refresh calls. + +## 8. Test plan + +### 8.1 Backend pure tests + +`internal/recommendation/score_test.go` extensions: +- `TestScore_SimilarityScore_PerfectMatch_AddsWeightedTerm` — `1.0 × 2.0 = +2.0` over baseline +- `TestScore_SimilarityScore_HalfMatch` — `0.5 × 2.0 = +1.0` +- `TestScore_SimilarityScore_Zero_NoEffect` — random/serendipity tracks score same as M3 baseline + +### 8.2 Backend integration tests + +`internal/recommendation/candidates_v2_test.go` (new): +- `TestLoadCandidatesFromSimilarity_LBSimilarSourceContributes` +- `TestLoadCandidatesFromSimilarity_SimilarArtistTracksContribute` (artist score × 0.5 verified) +- `TestLoadCandidatesFromSimilarity_TagOverlapContributes` (jaccard score) +- `TestLoadCandidatesFromSimilarity_LikesOverlapContributes` (0.6 constant) +- `TestLoadCandidatesFromSimilarity_RandomFillToTargetSize` (empty similarity tables → pool ≥ 60) +- `TestLoadCandidatesFromSimilarity_ExcludeListRespected` +- `TestLoadCandidatesFromSimilarity_RecentlyPlayedExcluded` +- `TestLoadCandidatesFromSimilarity_DedupTakesMaxScore` (LB 0.85 beats tag 0.4) +- `TestLoadCandidatesFromSimilarity_SeedAlwaysExcluded` +- `TestLoadCandidatesFromSimilarity_EmptyLibrary_NoError` + +### 8.3 Backend HTTP tests + +`internal/api/radio_test.go` extensions: +- `TestHandleRadio_WithSimilarityPool_RanksLBSimilarHigher` (deterministic via fixed RNG) +- `TestHandleRadio_ExcludeParam_FiltersOut` +- `TestHandleRadio_ExcludeParam_MalformedSkipped` +- `TestHandleRadio_FallbackToM3OnSimilarityError` (inject fault) + +### 8.4 Frontend tests + +`web/src/lib/player/store.test.ts` extensions: +- `radio refresh fires at 80% queue consumption` (5-track queue at index 3) +- `radio refresh appends new tracks (excluding seed)` (queue 5 → 9 after 5-track response) +- `radio refresh does NOT double-fire` (in-flight guard) +- `radio refresh resets when user enqueues from non-radio source` +- `radio refresh below threshold` (5-track queue at index 2 → no refresh) + +### 8.5 Coverage targets + +- `internal/recommendation` post-M4c: ≥ 80% (currently 73%) +- `internal/api/radio.go`: ≥ 75% +- Web `player/store`: ≥ 80% on the new refresh logic + +### 8.6 Manual end-to-end gate (closes M4) + +After deploy + M4b worker has filled `track_similarity` for ≥10 played tracks: + +1. Click radio from a played track — queue should differ noticeably from M3 +2. Listen through ~80% of the queue — observe queue length increase as + auto-refresh fires +3. `psql -c "SELECT count(*) FROM track_similarity"` shows non-trivial data +4. Subjectively: radio quality should feel meaningfully better than M3's + baseline. **This is the closing gate for M4.** + +## 9. Decisions ledger + +| # | Decision | Rationale | +|---|---|---| +| 1 | 4-source pool composition + random fill | Cross-source diversity; sparse-fallback is automatic via random fill | +| 2 | Always augment with random to floor of 60 | Never returns empty radio; serendipity built in; degrades gracefully on sparse libraries | +| 3 | New `SimilarityScore × SimilarityWeight` term in M3 Score() | Wastes the LB scores otherwise; consistent with the M3 multi-input pattern | +| 4 | No lazy LB fetch | Keeps radio handler synchronous; M4b worker + augmentation cover the gap | +| 5 | `?exclude=...` query param for queue refresh | Stateless, simple, no new server state | +| 6 | Per-source K + score: hardcoded for v1 | YAGNI; expose later if telemetry warrants | +| 7 | Fallback to M3 LoadCandidates on similarity-pool error | Defense in depth for the central radio surface | + +## 10. Backwards compatibility + +- New SQL query alongside existing `LoadRadioCandidates`; M3 callers + unaffected. +- M3 `LoadCandidates` retained for the fallback path and direct test usage. +- `Score()` signature unchanged; new fields are zero-defaulted so existing + zero-value `ScoringInputs{}`/`ScoringWeights{}` constructions produce M3 + scores. +- `/api/radio` request shape extended (adds optional `?exclude=`); existing + callers that don't pass it work identically. +- `RadioResponse` shape unchanged. +- No schema migration — relies on M4b's `track_similarity` / + `artist_similarity` and existing M2 `general_likes` / M0-M1 `tracks.genre`. + +## 11. M4 closure + +After this slice merges, M4 is complete: +- M4a (PR #26) — outbound LB scrobble worker +- M4b (PR #27) — inbound LB similarity ingest +- M4c (this) — radio similarity-driven candidate pool + 80% queue refresh + +Unblocks M5 (Lidarr quarantine + suggested-additions for tracks not in +library). Out-of-library LB-returned tracks (currently filtered out by the +in-library JOIN) become the natural input for M5's "would you like to add +this?" workflow. diff --git a/internal/api/auth_test.go b/internal/api/auth_test.go index 851e52b7..5ab5599b 100644 --- a/internal/api/auth_test.go +++ b/internal/api/auth_test.go @@ -52,7 +52,8 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) { w := playevents.NewWriter(pool, logger, 30*time.Minute, 0.5, 30000) recCfg := config.RecommendationConfig{ BaseWeight: 1.0, LikeBoost: 2.0, RecencyWeight: 1.0, - SkipPenalty: 1.0, JitterMagnitude: 0.1, ContextWeight: 2.0, + SkipPenalty: 1.0, JitterMagnitude: 0.1, + ContextWeight: 2.0, SimilarityWeight: 2.0, RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200, } h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, rng: func() float64 { return 0.5 }} diff --git a/internal/api/radio.go b/internal/api/radio.go index dd02b4c1..7fa2e228 100644 --- a/internal/api/radio.go +++ b/internal/api/radio.go @@ -82,19 +82,33 @@ func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) { currentVec := loadCurrentSessionVector(r, q, user.ID, h.logger) - candidates, err := recommendation.LoadCandidates(r.Context(), q, user.ID, seedID, h.recCfg.RecentlyPlayedHours, currentVec) + 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.Error("api: radio: load candidates", "err", err) - writeErr(w, http.StatusInternalServerError, "server_error", "candidate load failed") - return + 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, + 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, } picks := recommendation.Shuffle(candidates, weights, time.Now().UTC(), h.rng, limit-1) @@ -141,3 +155,26 @@ func loadCurrentSessionVector(r *http.Request, q *dbq.Queries, userID pgtype.UUI } return v } + +// 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 +} diff --git a/internal/api/radio_test.go b/internal/api/radio_test.go index 9002efeb..f81c7a0b 100644 --- a/internal/api/radio_test.go +++ b/internal/api/radio_test.go @@ -131,6 +131,110 @@ func TestHandleRadio_LimitClampedToMax(t *testing.T) { } } +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) + 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 body=%s", w.Code, w.Body.String()) + } + var resp RadioResponse + _ = json.Unmarshal(w.Body.Bytes(), &resp) + 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) + } +} + func TestHandleRadio_ContextualMatch_BoostsRankingOverControl(t *testing.T) { h, pool := testHandlers(t) truncateLibrary(t, pool) diff --git a/internal/config/config.go b/internal/config/config.go index 059aa0a8..03a34856 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -73,6 +73,7 @@ type RecommendationConfig struct { SkipPenalty float64 `yaml:"skip_penalty"` JitterMagnitude float64 `yaml:"jitter_magnitude"` ContextWeight float64 `yaml:"context_weight"` + SimilarityWeight float64 `yaml:"similarity_weight"` RecentlyPlayedHours int `yaml:"recently_played_hours"` RadioSize int `yaml:"radio_size"` RadioSizeMax int `yaml:"radio_size_max"` @@ -95,6 +96,7 @@ func Default() Config { SkipPenalty: 1.0, JitterMagnitude: 0.1, ContextWeight: 2.0, + SimilarityWeight: 2.0, RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200, diff --git a/internal/db/dbq/recommendation.sql.go b/internal/db/dbq/recommendation.sql.go index 8bfbcf62..7da85f13 100644 --- a/internal/db/dbq/recommendation.sql.go +++ b/internal/db/dbq/recommendation.sql.go @@ -96,3 +96,193 @@ func (q *Queries) LoadRadioCandidates(ctx context.Context, arg LoadRadioCandidat } return items, nil } + +const loadRadioCandidatesV2 = `-- name: LoadRadioCandidatesV2 :many + +WITH +seed_artist AS ( + SELECT artist_id + FROM tracks + WHERE tracks.id = $2 +), +seed_tags AS ( + SELECT trim(g) AS tag + FROM tracks t + LEFT JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g(tag) ON true + WHERE t.id = $2 AND trim(g) <> '' +), +excluded_ids AS ( + SELECT unnest($4::uuid[]) AS id + UNION ALL + SELECT pe.track_id AS id + FROM play_events pe + WHERE pe.user_id = $1 AND pe.started_at > now() - $3 * interval '1 hour' +), +lb_similar AS ( + SELECT ts.track_b_id AS track_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 excluded_ids) + ORDER BY ts.score DESC + LIMIT $5 +), +similar_artists AS ( + SELECT t.id AS track_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 excluded_ids) + ORDER BY asim.score DESC, random() + LIMIT $6 +), +tag_overlap AS ( + SELECT t.id AS track_id, + (count(DISTINCT trim(g))::float8 + / GREATEST((SELECT count(*) FROM seed_tags), 1)) AS sim_score + FROM tracks t + LEFT JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g_split(g) ON true + WHERE trim(g_split.g) IN (SELECT tag FROM seed_tags) + AND t.id NOT IN (SELECT id FROM excluded_ids) + AND t.id <> $2 + GROUP BY t.id + HAVING count(DISTINCT trim(g_split.g)) > 0 + ORDER BY sim_score DESC + LIMIT $7 +), +likes_overlap AS ( + SELECT gl.track_id, 0.6::float8 AS sim_score + FROM general_likes gl + WHERE gl.user_id = $1 + AND gl.track_id NOT IN (SELECT id FROM excluded_ids) + AND EXISTS ( + SELECT 1 FROM tracks t + LEFT JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g_overlap(g) ON true + WHERE t.id = gl.track_id + AND trim(g_overlap.g) IN (SELECT tag FROM seed_tags) + ) + ORDER BY random() + LIMIT $8 +), +random_fill AS ( + SELECT t.id AS track_id, 0.0::float8 AS sim_score + FROM tracks t + WHERE t.id NOT IN (SELECT id FROM excluded_ids) + AND t.id <> $2 + AND t.id NOT IN ( + SELECT track_id FROM lb_similar + UNION SELECT track_id FROM similar_artists + UNION SELECT track_id FROM tag_overlap + UNION SELECT track_id FROM likes_overlap + ) + ORDER BY random() + LIMIT $9 +) +SELECT + t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, + (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, + COALESCE(max(u.sim_score), 0.0) AS similarity_score +FROM ( + SELECT track_id, sim_score FROM lb_similar + UNION ALL SELECT track_id, sim_score FROM similar_artists + UNION ALL SELECT track_id, sim_score FROM tag_overlap + UNION ALL SELECT track_id, sim_score FROM likes_overlap + UNION ALL SELECT track_id, sim_score FROM random_fill +) u +JOIN tracks t ON t.id = u.track_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.added_at, t.updated_at, + l.user_id, pe.last_played_at, pe.play_count, pe.skip_count +` + +type LoadRadioCandidatesV2Params struct { + UserID pgtype.UUID + ID pgtype.UUID + Column3 interface{} + Column4 []pgtype.UUID + Limit int32 + Limit_2 int32 + Limit_3 int32 + Limit_4 int32 + Limit_5 int32 +} + +type LoadRadioCandidatesV2Row struct { + Track Track + IsLiked bool + LastPlayedAt pgtype.Timestamptz + PlayCount int64 + SkipCount int64 + SimilarityScore interface{} +} + +// 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. +func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandidatesV2Params) ([]LoadRadioCandidatesV2Row, error) { + rows, err := q.db.Query(ctx, loadRadioCandidatesV2, + arg.UserID, + arg.ID, + arg.Column3, + arg.Column4, + arg.Limit, + arg.Limit_2, + arg.Limit_3, + arg.Limit_4, + arg.Limit_5, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []LoadRadioCandidatesV2Row + for rows.Next() { + var i LoadRadioCandidatesV2Row + if err := rows.Scan( + &i.Track.ID, + &i.Track.Title, + &i.Track.AlbumID, + &i.Track.ArtistID, + &i.Track.TrackNumber, + &i.Track.DiscNumber, + &i.Track.DurationMs, + &i.Track.FilePath, + &i.Track.FileSize, + &i.Track.FileFormat, + &i.Track.Bitrate, + &i.Track.Mbid, + &i.Track.Genre, + &i.Track.AddedAt, + &i.Track.UpdatedAt, + &i.IsLiked, + &i.LastPlayedAt, + &i.PlayCount, + &i.SkipCount, + &i.SimilarityScore, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/db/queries/recommendation.sql b/internal/db/queries/recommendation.sql index 6eea7a75..10e81601 100644 --- a/internal/db/queries/recommendation.sql +++ b/internal/db/queries/recommendation.sql @@ -27,3 +27,118 @@ WHERE t.id <> $2 WHERE user_id = $1 AND track_id = t.id AND started_at > now() - $3 * interval '1 hour' ); + +-- 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 tracks.id = $2 +), +seed_tags AS ( + SELECT trim(g) AS tag + FROM tracks t + LEFT JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g(tag) ON true + WHERE t.id = $2 AND trim(g) <> '' +), +excluded_ids AS ( + SELECT unnest($4::uuid[]) AS id + UNION ALL + SELECT pe.track_id AS id + FROM play_events pe + WHERE pe.user_id = $1 AND pe.started_at > now() - $3 * interval '1 hour' +), +lb_similar AS ( + SELECT ts.track_b_id AS track_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 excluded_ids) + ORDER BY ts.score DESC + LIMIT $5 +), +similar_artists AS ( + SELECT t.id AS track_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 excluded_ids) + ORDER BY asim.score DESC, random() + LIMIT $6 +), +tag_overlap AS ( + SELECT t.id AS track_id, + (count(DISTINCT trim(g))::float8 + / GREATEST((SELECT count(*) FROM seed_tags), 1)) AS sim_score + FROM tracks t + LEFT JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g_split(g) ON true + WHERE trim(g_split.g) IN (SELECT tag FROM seed_tags) + AND t.id NOT IN (SELECT id FROM excluded_ids) + AND t.id <> $2 + GROUP BY t.id + HAVING count(DISTINCT trim(g_split.g)) > 0 + ORDER BY sim_score DESC + LIMIT $7 +), +likes_overlap AS ( + SELECT gl.track_id, 0.6::float8 AS sim_score + FROM general_likes gl + WHERE gl.user_id = $1 + AND gl.track_id NOT IN (SELECT id FROM excluded_ids) + AND EXISTS ( + SELECT 1 FROM tracks t + LEFT JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g_overlap(g) ON true + WHERE t.id = gl.track_id + AND trim(g_overlap.g) IN (SELECT tag FROM seed_tags) + ) + ORDER BY random() + LIMIT $8 +), +random_fill AS ( + SELECT t.id AS track_id, 0.0::float8 AS sim_score + FROM tracks t + WHERE t.id NOT IN (SELECT id FROM excluded_ids) + AND t.id <> $2 + AND t.id NOT IN ( + SELECT track_id FROM lb_similar + UNION SELECT track_id FROM similar_artists + UNION SELECT track_id FROM tag_overlap + UNION SELECT track_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, + COALESCE(max(u.sim_score), 0.0) AS similarity_score +FROM ( + SELECT track_id, sim_score FROM lb_similar + UNION ALL SELECT track_id, sim_score FROM similar_artists + UNION ALL SELECT track_id, sim_score FROM tag_overlap + UNION ALL SELECT track_id, sim_score FROM likes_overlap + UNION ALL SELECT track_id, sim_score FROM random_fill +) u +JOIN tracks t ON t.id = u.track_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.added_at, t.updated_at, + l.user_id, pe.last_played_at, pe.play_count, pe.skip_count; diff --git a/internal/recommendation/candidates.go b/internal/recommendation/candidates.go index ab16352f..6c30cb21 100644 --- a/internal/recommendation/candidates.go +++ b/internal/recommendation/candidates.go @@ -58,6 +58,95 @@ func LoadCandidates( return out, nil } +// 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: int64(recentlyPlayedHours), + Column4: exclude, + Limit: int32(limits.LBSimilar), + Limit_2: int32(limits.SimilarArtist), + Limit_3: int32(limits.TagOverlap), + Limit_4: int32(limits.LikesOverlap), + Limit_5: 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 + } + // sqlc returns SimilarityScore as interface{} (couldn't infer the + // type through max(...) over a UNION). Type-assert; default to 0 + // on the (impossible-but-defensive) case where it's nil/wrong type. + var simScore float64 + if v, ok := r.SimilarityScore.(float64); ok { + simScore = v + } + 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: simScore, + }, + }) + } + return out, nil +} + // loadContextualLikesByTrack fetches the user's active contextual_likes in // one query and groups them by track_id. Rows whose session_vector fails // to unmarshal are skipped with no error (don't poison scoring over one diff --git a/internal/recommendation/candidates_v2_test.go b/internal/recommendation/candidates_v2_test.go new file mode 100644 index 00000000..294b0e58 --- /dev/null +++ b/internal/recommendation/candidates_v2_test.go @@ -0,0 +1,278 @@ +package recommendation + +import ( + "context" + "testing" + + "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) + } +} + +// helperSetTrackGenre updates a track's genre column. Used to retrofit +// genres onto the fixture's auto-created tracks (fixture creates tracks +// with NULL genre). +func helperSetTrackGenre(t *testing.T, f fixture, trackID pgtype.UUID, genre string) { + t.Helper() + if _, err := f.pool.Exec(context.Background(), + `UPDATE tracks SET genre = $1 WHERE id = $2`, genre, trackID); err != nil { + t.Fatalf("set genre: %v", err) + } +} + +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) // creates 1 artist + 1 album + 1 track (the seed) + seed := f.tracks[0] + // Add a SECOND artist + track in that artist; relate the two 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, 2) + seed := f.tracks[0] + target := f.tracks[1] + helperSetTrackGenre(t, f, seed.ID, "Rock; Pop") + helperSetTrackGenre(t, f, target.ID, "Rock") + 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, 2) + seed := f.tracks[0] + liked := f.tracks[1] + helperSetTrackGenre(t, f, seed.ID, "Rock") + helperSetTrackGenre(t, f, liked.ID, "Rock") + 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 { + // Both tracks tagged "Rock" → jaccard 1/1 = 1.0 from tag-overlap. + // likes-overlap = 0.6. Max wins = 1.0. + 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) + } + if len(got) == 0 { + t.Error("random fill returned 0 candidates; expected at least some") + } + 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 + 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, 2) + seed := f.tracks[0] + target := f.tracks[1] + helperSetTrackGenre(t, f, seed.ID, "Rock") + helperSetTrackGenre(t, f, target.ID, "Rock") // jaccard 1/1 = 1.0 from tag-overlap + helperLBSimilarity(t, f, seed.ID, target.ID, 0.5) // weaker LB signal + 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++ + // tag-overlap (1.0) wins over LB (0.5) per max() — expect ≥ 0.99 + if c.Inputs.SimilarityScore < 0.99 { + t.Errorf("dedup max SimilarityScore = %v, want ≥ 0.99 (tag-overlap should win)", 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, 1) // just the seed + 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) + } + // Only the seed exists; it's excluded → 0 candidates. + if len(got) != 0 { + t.Errorf("got %d candidates from seed-only library, want 0", len(got)) + } +} diff --git a/internal/recommendation/score.go b/internal/recommendation/score.go index 82f4e940..3c6f061e 100644 --- a/internal/recommendation/score.go +++ b/internal/recommendation/score.go @@ -11,23 +11,26 @@ import ( // ContextualMatchScore is in [0, 1] — max similarity between the user's // current session vector and any non-seed contextual_like row for this // track. Set by LoadCandidates after a bulk fetch. +// SimilarityScore is in [0, 1]; 0 when no signal (random fill). 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 (random fill) } // 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 + BaseWeight float64 + LikeBoost float64 + RecencyWeight float64 + SkipPenalty float64 + JitterMagnitude float64 + ContextWeight float64 + SimilarityWeight float64 } // Score computes the weighted-shuffle score per spec §6: @@ -37,6 +40,7 @@ type ScoringWeights struct { // + 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 @@ -50,6 +54,7 @@ func Score(in ScoringInputs, w ScoringWeights, now time.Time, rng func() float64 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 } diff --git a/internal/recommendation/score_test.go b/internal/recommendation/score_test.go index f4521910..908ab3e5 100644 --- a/internal/recommendation/score_test.go +++ b/internal/recommendation/score_test.go @@ -182,3 +182,37 @@ func TestScore_ContextualMatch_ZeroNoEffect(t *testing.T) { t.Errorf("score-with-zero-ctx = %v, score-without = %v; should be equal", withCtx, withoutCtx) } } + +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) + } +} diff --git a/web/src/lib/player/store.svelte.ts b/web/src/lib/player/store.svelte.ts index a3cff894..2078894b 100644 --- a/web/src/lib/player/store.svelte.ts +++ b/web/src/lib/player/store.svelte.ts @@ -28,6 +28,12 @@ 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; + let _audioEl: HTMLAudioElement | null = null; export const player = { @@ -49,6 +55,7 @@ export function registerAudioEl(el: HTMLAudioElement | null): void { } export function playQueue(tracks: TrackRef[], startIndex = 0): void { + _radioSeedId = null; // M4c: non-radio enqueue clears the radio refresh state _queue = tracks; if (tracks.length === 0) { _index = 0; @@ -199,11 +206,13 @@ export function reportStateFromAudio( } export function enqueueTrack(t: TrackRef): void { + _radioSeedId = null; // M4c _queue = [..._queue, t]; if (_state === 'idle') _index = 0; } export function enqueueTracks(ts: TrackRef[]): void { + _radioSeedId = null; // M4c if (ts.length === 0) return; _queue = [..._queue, ...ts]; if (_state === 'idle') _index = 0; @@ -213,5 +222,44 @@ export async function playRadio(seedTrackId: string): Promise { const resp = await api.get( `/api/radio?seed_track=${encodeURIComponent(seedTrackId)}` ); - if (resp.tracks.length > 0) playQueue(resp.tracks, 0); + if (resp.tracks.length === 0) return; + playQueue(resp.tracks, 0); + _radioSeedId = seedTrackId; // M4c: set AFTER playQueue (which clears it) } + +// M4c: auto-refresh radio queue when 80% consumed. +// Fires whenever the consumption ratio crosses 80% AND the queue was +// seeded by playRadio() AND no refresh is currently in-flight. +$effect.root(() => { + $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 any other + // tracks that happen to match the original seed id. + const newTracks = resp.tracks.slice(1).filter((t) => t.id !== seed); + if (newTracks.length > 0) { + // Append directly without calling enqueueTracks — that would + // clear _radioSeedId and prevent further refreshes. + _queue = [..._queue, ...newTracks]; + } + }) + .catch(() => { + // Swallow; next track-advance can retry. + }) + .finally(() => { + _radioRefreshInFlight = false; + }); + }); +}); diff --git a/web/src/lib/player/store.test.ts b/web/src/lib/player/store.test.ts index 693bf661..57a50a30 100644 --- a/web/src/lib/player/store.test.ts +++ b/web/src/lib/player/store.test.ts @@ -333,3 +333,81 @@ describe('player store — enqueue + playRadio', () => { apiGet.mockRestore(); }); }); + +describe('M4c radio auto-refresh at 80%', () => { + test('refresh fires at 80% queue consumption', async () => { + const tracks = [track('seed'), track('t1'), track('t2'), track('t3'), track('t4')]; + const apiGet = vi.spyOn(client.api, 'get').mockResolvedValueOnce({ tracks }); + await playRadio('seed'); + apiGet.mockClear(); + apiGet.mockResolvedValueOnce({ + tracks: [track('seed'), track('new1')] + }); + // Advance to index 3 (4 of 5 = 80%) + skipNext(); skipNext(); skipNext(); + // Allow $effect to flush + await new Promise((r) => setTimeout(r, 30)); + expect(apiGet).toHaveBeenCalled(); + const callArg = apiGet.mock.calls[0][0] as string; + expect(callArg).toContain('seed_track=seed'); + expect(callArg).toContain('exclude='); + apiGet.mockRestore(); + }); + + test('refresh below 80% does not fire', async () => { + const tracks = [track('seed'), track('t1'), track('t2'), track('t3'), track('t4')]; + const apiGet = vi.spyOn(client.api, 'get').mockResolvedValueOnce({ tracks }); + await playRadio('seed'); + apiGet.mockClear(); + skipNext(); skipNext(); // index = 2 (3 of 5 = 60%) + await new Promise((r) => setTimeout(r, 30)); + expect(apiGet).not.toHaveBeenCalled(); + apiGet.mockRestore(); + }); + + test('refresh appends new tracks excluding seed at index 0', async () => { + const initial = [track('seed'), track('t1'), track('t2'), track('t3'), track('t4')]; + const apiGet = vi.spyOn(client.api, 'get').mockResolvedValueOnce({ tracks: initial }); + await playRadio('seed'); + apiGet.mockClear(); + // Refresh response: 5 tracks where the first is the seed (will be stripped). + const refreshTracks = [track('seed'), track('n1'), track('n2'), track('n3'), track('n4')]; + apiGet.mockResolvedValueOnce({ tracks: refreshTracks }); + skipNext(); skipNext(); skipNext(); // index = 3 (80%) + await new Promise((r) => setTimeout(r, 30)); + // Initial 5 + 4 new (5 in response - 1 seed stripped) = 9 + expect(player.queue.length).toBe(9); + apiGet.mockRestore(); + }); + + test('refresh does NOT double-fire while in-flight', async () => { + const tracks = [track('seed'), track('t1'), track('t2'), track('t3'), track('t4')]; + const apiGet = vi.spyOn(client.api, 'get').mockResolvedValueOnce({ tracks }); + await playRadio('seed'); + apiGet.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.mockReturnValueOnce(hanging as unknown as Promise); + skipNext(); skipNext(); skipNext(); // 80% — fires refresh + await new Promise((r) => setTimeout(r, 30)); + // Now advance further while in-flight; should NOT fire a second call. + skipNext(); + await new Promise((r) => setTimeout(r, 30)); + expect(apiGet).toHaveBeenCalledTimes(1); + resolveFn({ tracks: [] }); + apiGet.mockRestore(); + }); + + test('refresh resets when user enqueues from non-radio source', async () => { + const tracks = [track('seed'), track('t1'), track('t2'), track('t3'), track('t4')]; + const apiGet = vi.spyOn(client.api, 'get').mockResolvedValueOnce({ tracks }); + await playRadio('seed'); + enqueueTrack(track('manual1')); + apiGet.mockClear(); + skipNext(); skipNext(); skipNext(); skipNext(); // advance past 80% on the 6-track queue + await new Promise((r) => setTimeout(r, 30)); + expect(apiGet).not.toHaveBeenCalled(); + apiGet.mockRestore(); + }); +});