Files
minstrel/docs/superpowers/plans/2026-04-28-m4c-radio.md
T
bvandeusen 2e6d2ffd03 docs(plan): add M4c radio similarity-driven candidate pool plan
6-task TDD-shaped plan covering: LoadRadioCandidatesV2 sqlc query
(5-way UNION), Score() extension with SimilarityScore + SimilarityWeight,
LoadCandidatesFromSimilarity Go function with CandidateSourceLimits +
10 integration tests covering all sources + dedup + exclude + recently-
played + seed-excluded + empty library, radio handler with exclude
param + parseExcludeParam helper + M3 fallback + 4 new HTTP tests,
frontend queue refresh at 80% with radioSeedId state + clear-on-non-
radio-enqueue + 5 vitest tests, final verification + branch finish
(closes M4).
2026-04-29 06:58:22 -04:00

1392 lines
52 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<typeof vi.fn>).mockResolvedValueOnce({ tracks });
await playRadio('seed1');
// Now queue is the 5 tracks. Reset mock for refresh call.
(apiGet as ReturnType<typeof vi.fn>).mockClear();
(apiGet as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValueOnce({ tracks });
await playRadio('seed1');
(apiGet as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValueOnce({ tracks });
await playRadio('seed1');
(apiGet as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<TrackRef[]>([]);
let _index = $state(0);
let _state = $state<PlayerState>('idle');
let _position = $state(0);
let _duration = $state(0);
let _volume = $state(readStoredVolume());
let _shuffle = $state(false);
let _repeat = $state<RepeatMode>('off');
let _error = $state<string | null>(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<string | null>(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<void> {
const resp = await api.get<RadioResponse>(
`/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<RadioResponse>(
`/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`).