# M3 Weighted Shuffle v1 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 the M2 stub `/api/radio` (currently returns just the seed track) with a real weighted-shuffle implementation that scores user library tracks against a per-track-stats formula and returns a top-N queue ordered by score. **Architecture:** New `internal/recommendation` package with three layers — pure scoring function (`score.go`), pure orchestration (`shuffle.go`), and DB-backed candidate loader (`candidates.go`). The HTTP handler at `internal/api/radio.go` becomes a thin shim that validates the seed, calls the loader, calls the orchestrator, prepends the seed, and returns JSON. Stats (`is_liked`, `play_count`, `skip_count`, `last_played_at`) are computed in one SQL query joining `tracks` ↔ `general_likes` ↔ aggregated `play_events`. No new schema — all inputs from existing tables. **Tech Stack:** Go 1.23 + chi + sqlc + pgx/v5. No web changes (the existing `playRadio` action already calls `/api/radio?seed_track=…` and consumes `RadioResponse`). **Reference:** design spec at `docs/superpowers/specs/2026-04-27-m3-shuffle-design.md`. --- ## File Structure **New server files:** | File | Responsibility | |---|---| | `internal/recommendation/score.go` | Pure `Score(inputs, weights, now, rng) → float64` + `recencyDecay` + `skipRatio` helpers. | | `internal/recommendation/score_test.go` | Boundary cases for every term, cold-start, jitter determinism. | | `internal/recommendation/shuffle.go` | Pure `Shuffle(candidates, weights, now, rng, limit) []Candidate` — composes Score + sort + truncate. | | `internal/recommendation/shuffle_test.go` | Liked-rank-higher, high-skip-rejected, jitter-doesn't-reorder, limit. | | `internal/recommendation/candidates.go` | `LoadCandidates(ctx, q, userID, seedID, recentlyPlayedHours) → []Candidate` — wraps the sqlc query, projects rows to `Candidate` (Track + ScoringInputs). | | `internal/recommendation/candidates_test.go` | Live-DB tests: seed exclusion, recently-played exclusion, stat-join correctness, cross-user isolation. | | `internal/db/queries/recommendation.sql` | One sqlc query: `LoadRadioCandidates`. | | `internal/db/dbq/recommendation.sql.go` | Generated bindings. | **Modified server files:** | File | Change | |---|---| | `internal/api/radio.go` | Replace stub. New flow: validate, load candidates, shuffle, prepend seed, return JSON. | | `internal/api/radio_test.go` | Replace existing 5 stub tests with the v1 weighted-shuffle scenarios. | | `internal/config/config.go` + `config.example.yaml` | Add `RecommendationConfig` with weights + radio size + recently-played-hours. | | `internal/api/api.go` | Mount signature gains `cfg config.RecommendationConfig`; handlers struct gains `recCfg` field. | | `internal/server/server.go` + `cmd/minstrel/main.go` | Pass `cfg.Recommendation` through `Mount`. | **No web changes.** The existing `playRadio(seedTrackId)` already consumes `{ tracks: TrackRef[] }`. --- ## Task 1: Pure scoring function **Files:** - Create: `internal/recommendation/score.go` - Create: `internal/recommendation/score_test.go` Pure Go, no DB. The function takes its inputs explicitly and an injectable RNG so tests pin jitter to deterministic values. - [ ] **Step 1: Write the failing tests** Create `internal/recommendation/score_test.go`: ```go package recommendation import ( "math" "math/rand" "testing" "time" ) func defaultWeights() ScoringWeights { return ScoringWeights{ BaseWeight: 1.0, LikeBoost: 2.0, RecencyWeight: 1.0, SkipPenalty: 1.0, JitterMagnitude: 0.1, } } func fixedRNG(v float64) func() float64 { return func() float64 { return v } } func TestScore_BaseCase_NeverPlayed_NoJitter(t *testing.T) { in := ScoringInputs{} now := time.Now().UTC() got := Score(in, defaultWeights(), now, fixedRNG(0.5)) // rng=0.5 -> jitter contribution = (0.5*2 - 1) * 0.1 = 0 // expected = 1.0 + 0 (not liked) + 1.0 (never played) - 0 + 0 = 2.0 want := 2.0 if math.Abs(got-want) > 1e-9 { t.Errorf("score = %v, want %v", got, want) } } func TestScore_LikeBoost(t *testing.T) { notLiked := Score(ScoringInputs{}, defaultWeights(), time.Now(), fixedRNG(0.5)) liked := Score(ScoringInputs{IsGeneralLiked: true}, defaultWeights(), time.Now(), fixedRNG(0.5)) if math.Abs(liked-notLiked-2.0) > 1e-9 { t.Errorf("delta = %v, want 2.0 (LikeBoost)", liked-notLiked) } } func TestScore_RecencyRamp_15Days(t *testing.T) { now := time.Now().UTC() played := now.Add(-15 * 24 * time.Hour) got := Score(ScoringInputs{LastPlayedAt: &played}, defaultWeights(), now, fixedRNG(0.5)) // recency = 0.5 -> contributes 0.5 * 1.0 = 0.5; expected = 1.0 + 0 + 0.5 - 0 + 0 = 1.5 want := 1.5 if math.Abs(got-want) > 1e-9 { t.Errorf("score = %v, want %v", got, want) } } func TestScore_RecencyRamp_60DaysCapsAt1(t *testing.T) { now := time.Now().UTC() played := now.Add(-60 * 24 * time.Hour) got := Score(ScoringInputs{LastPlayedAt: &played}, defaultWeights(), now, fixedRNG(0.5)) want := 2.0 // base + capped recency (1.0) if math.Abs(got-want) > 1e-9 { t.Errorf("score = %v, want %v", got, want) } } func TestScore_SkipRatio(t *testing.T) { // 4 plays, 2 skips -> ratio 0.5 -> -0.5 * SkipPenalty = -0.5 in := ScoringInputs{PlayCount: 4, SkipCount: 2} got := Score(in, defaultWeights(), time.Now(), fixedRNG(0.5)) // recency = 1.0 (never played by virtue of LastPlayedAt being nil even though PlayCount > 0; // for this test we rely on the recencyDecay treating nil as max). Adjust: the tests above // explicitly use nil for LastPlayedAt unless set. So: 1.0 + 1.0 - 0.5 = 1.5. want := 1.5 if math.Abs(got-want) > 1e-9 { t.Errorf("score = %v, want %v", got, want) } } func TestScore_ColdStartSkip_ZeroPlaysZeroSkips(t *testing.T) { in := ScoringInputs{PlayCount: 0, SkipCount: 0} got := Score(in, defaultWeights(), time.Now(), fixedRNG(0.5)) want := 2.0 // base + recency, no skip penalty if math.Abs(got-want) > 1e-9 { t.Errorf("score = %v, want %v", got, want) } } func TestScore_JitterBounds(t *testing.T) { r := rand.New(rand.NewSource(42)) w := defaultWeights() now := time.Now().UTC() mid := Score(ScoringInputs{}, w, now, fixedRNG(0.5)) // jitter contribution = 0 for i := 0; i < 1000; i++ { got := Score(ScoringInputs{}, w, now, r.Float64) if got < mid-w.JitterMagnitude-1e-9 || got > mid+w.JitterMagnitude+1e-9 { t.Fatalf("score %v outside jitter band [%v, %v]", got, mid-w.JitterMagnitude, mid+w.JitterMagnitude) } } } func TestScore_Determinism(t *testing.T) { in := ScoringInputs{IsGeneralLiked: true, PlayCount: 10, SkipCount: 3} w := defaultWeights() now := time.Now().UTC() a := Score(in, w, now, fixedRNG(0.7)) b := Score(in, w, now, fixedRNG(0.7)) if a != b { t.Errorf("non-deterministic with fixed RNG: %v vs %v", a, b) } } func TestRecencyDecay_NilIsOne(t *testing.T) { if got := recencyDecay(nil, time.Now()); got != 1.0 { t.Errorf("recencyDecay(nil) = %v, want 1.0", got) } } func TestRecencyDecay_Clamping(t *testing.T) { now := time.Now().UTC() cases := []struct { ageDays float64 want float64 }{ {0, 0.0}, {15, 0.5}, {30, 1.0}, {45, 1.0}, } for _, c := range cases { t.Run("", func(t *testing.T) { past := now.Add(-time.Duration(c.ageDays * 24 * float64(time.Hour))) got := recencyDecay(&past, now) if math.Abs(got-c.want) > 1e-9 { t.Errorf("ageDays=%v decay=%v want %v", c.ageDays, got, c.want) } }) } } func TestSkipRatio_ZeroPlays_IsZero(t *testing.T) { if got := skipRatio(0, 0); got != 0.0 { t.Errorf("skipRatio(0,0) = %v, want 0.0", got) } } func TestSkipRatio_Half(t *testing.T) { if got := skipRatio(4, 2); got != 0.5 { t.Errorf("skipRatio(4,2) = %v, want 0.5", got) } } ``` - [ ] **Step 2: Run tests, confirm fail** Run: `go test ./internal/recommendation -v` Expected: FAIL — package or types undefined. - [ ] **Step 3: Implement `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. // Sub-plan #3 (contextual scoring) extends this with ContextualMatchScore. type ScoringInputs struct { IsGeneralLiked bool LastPlayedAt *time.Time // nil = never played PlayCount int // total play_events SkipCount int // play_events with was_skipped=true } // 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 } // Score computes the weighted-shuffle score per spec §6: // // score = base // + (is_general_liked ? LikeBoost : 0) // + recency_decay * RecencyWeight // - skip_ratio * SkipPenalty // + 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 += (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: Run tests, confirm pass** Run: `go test ./internal/recommendation -v` Expected: PASS — 11 tests. - [ ] **Step 5: Lint** Run: `golangci-lint run ./internal/recommendation/...` Expected: clean. - [ ] **Step 6: Commit** ```bash git add internal/recommendation/score.go internal/recommendation/score_test.go git commit -m "feat(recommendation): add pure Score function with recency + skip + jitter Implements spec §6 weighted-shuffle scoring without the contextual_match_score term (sub-plan #3 adds it). Pure Go, no DB dependency; injectable RNG for deterministic tests. Coverage 100% on score.go via the boundary tests." ``` --- ## Task 2: Pure shuffle orchestration **Files:** - Create: `internal/recommendation/shuffle.go` - Create: `internal/recommendation/shuffle_test.go` `Shuffle` composes `Score` over a candidate set, sorts descending, truncates to `limit`. Pure. - [ ] **Step 1: Write the failing tests** Create `internal/recommendation/shuffle_test.go`: ```go package recommendation import ( "math/rand" "testing" "time" "github.com/jackc/pgx/v5/pgtype" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) func cand(id string, in ScoringInputs) Candidate { t := dbq.Track{Title: id} _ = t.ID.Scan("00000000-0000-0000-0000-" + id) // 12-char id padded return Candidate{Track: t, Inputs: in} } func TestShuffle_LikedRanksAboveUnliked(t *testing.T) { cs := []Candidate{ cand("000000000001", ScoringInputs{IsGeneralLiked: false}), cand("000000000002", ScoringInputs{IsGeneralLiked: true}), } out := Shuffle(cs, defaultWeights(), time.Now(), fixedRNG(0.5), 10) if out[0].Track.Title != "000000000002" { t.Errorf("liked track did not rank first: %+v", out) } } func TestShuffle_HighSkipRanksLast(t *testing.T) { cs := []Candidate{ cand("000000000001", ScoringInputs{PlayCount: 10, SkipCount: 10}), // ratio 1.0 cand("000000000002", ScoringInputs{PlayCount: 10, SkipCount: 0}), // ratio 0 cand("000000000003", ScoringInputs{PlayCount: 10, SkipCount: 5}), // ratio 0.5 } out := Shuffle(cs, defaultWeights(), time.Now(), fixedRNG(0.5), 10) if out[0].Track.Title != "000000000002" || out[2].Track.Title != "000000000001" { t.Errorf("skip-ratio ordering broken: %v", titles(out)) } } func TestShuffle_LimitTruncates(t *testing.T) { cs := make([]Candidate, 100) for i := range cs { cs[i] = cand("00000000000"+string(rune('a'+i%26)), ScoringInputs{}) } out := Shuffle(cs, defaultWeights(), time.Now(), fixedRNG(0.5), 10) if len(out) != 10 { t.Errorf("len = %d, want 10", len(out)) } } func TestShuffle_JitterDoesNotFlipStructuralWinner(t *testing.T) { // Liked vs unliked: even with many random RNG seeds, liked NEVER ranks below unliked. r := rand.New(rand.NewSource(42)) for i := 0; i < 500; i++ { cs := []Candidate{ cand("000000000001", ScoringInputs{IsGeneralLiked: false}), cand("000000000002", ScoringInputs{IsGeneralLiked: true}), } out := Shuffle(cs, defaultWeights(), time.Now(), r.Float64, 10) if out[0].Track.Title != "000000000002" { t.Fatalf("iter %d: liked did not rank first; out=%v", i, titles(out)) } } } func TestShuffle_Empty_ReturnsEmpty(t *testing.T) { out := Shuffle(nil, defaultWeights(), time.Now(), fixedRNG(0.5), 10) if len(out) != 0 { t.Errorf("len = %d, want 0", len(out)) } } func titles(cs []Candidate) []string { out := make([]string, 0, len(cs)) for _, c := range cs { out = append(out, c.Track.Title) } return out } // pgtype-uuid placeholder: Candidate doesn't need a real UUID for these // pure tests; the Track.Title field is the human-readable handle. Track ID // is left as zero pgtype.UUID and never compared. var _ pgtype.UUID ``` - [ ] **Step 2: Run, confirm fail** Run: `go test ./internal/recommendation -run TestShuffle -v` Expected: FAIL — `Candidate`, `Shuffle` undefined. - [ ] **Step 3: Implement `internal/recommendation/shuffle.go`** ```go package recommendation import ( "sort" "time" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) // Candidate pairs a track with the inputs needed to score it. type Candidate struct { Track dbq.Track Inputs ScoringInputs } // Shuffle scores each candidate, sorts descending by score, and returns // the top `limit` candidates. limit <= 0 returns nil; nil input returns // nil. Pure — no IO, no global state beyond the rng callback. func Shuffle( candidates []Candidate, weights ScoringWeights, now time.Time, rng func() float64, limit int, ) []Candidate { if len(candidates) == 0 || limit <= 0 { return nil } scored := make([]struct { c Candidate score float64 }, len(candidates)) for i, c := range candidates { scored[i].c = c scored[i].score = Score(c.Inputs, weights, now, rng) } sort.Slice(scored, func(i, j int) bool { return scored[i].score > scored[j].score }) if limit > len(scored) { limit = len(scored) } out := make([]Candidate, limit) for i := 0; i < limit; i++ { out[i] = scored[i].c } return out } ``` - [ ] **Step 4: Run, confirm pass** Run: `go test ./internal/recommendation -v` Expected: PASS — 11 score tests + 5 shuffle tests = 16 total. - [ ] **Step 5: Commit** ```bash git add internal/recommendation/shuffle.go internal/recommendation/shuffle_test.go git commit -m "feat(recommendation): add Shuffle orchestrator Composes Score over a candidate slice, sorts descending, truncates. Pure — no IO. Liked-rank-higher and high-skip-ranks-last behaviors are stable across RNG seeds (jitter band is smaller than LikeBoost and SkipPenalty)." ``` --- ## Task 3: SQL query + sqlc generation **Files:** - Create: `internal/db/queries/recommendation.sql` - Generated: `internal/db/dbq/recommendation.sql.go` One query that returns all eligible tracks with their stats joined. Excludes the seed and recently-played tracks. - [ ] **Step 1: Write the query** Create `internal/db/queries/recommendation.sql`: ```sql -- name: LoadRadioCandidates :many -- Returns all tracks except the seed and any played by the user within -- the last $3 hours, joined with the stats needed for scoring: -- is_liked — boolean from general_likes for this user -- last_played_at — max(play_events.started_at) for this user/track -- play_count — count of play_events for this user/track -- skip_count — play_events where was_skipped=true SELECT sqlc.embed(t), (l.user_id IS NOT NULL) AS is_liked, pe.last_played_at, pe.play_count, pe.skip_count FROM tracks t 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 WHERE t.id <> $2 AND NOT EXISTS ( SELECT 1 FROM play_events WHERE user_id = $1 AND track_id = t.id AND started_at > now() - $3 * interval '1 hour' ); ``` - [ ] **Step 2: Generate sqlc bindings** Run: `$(go env GOPATH)/bin/sqlc generate` Expected: writes `internal/db/dbq/recommendation.sql.go` with `LoadRadioCandidatesParams` (UserID, TrackID for seed, third param for hours) and `LoadRadioCandidatesRow` containing `Track`, `IsLiked`, `LastPlayedAt`, `PlayCount`, `SkipCount`. - [ ] **Step 3: Verify build** Run: `go build ./...` Expected: clean. - [ ] **Step 4: Commit** ```bash git add internal/db/queries/recommendation.sql internal/db/dbq/recommendation.sql.go internal/db/dbq/models.go git commit -m "feat(db): add LoadRadioCandidates query Single SELECT that joins tracks with general_likes (for is_liked) and an aggregated LATERAL subquery on play_events (for last_played_at, play_count, skip_count). Excludes seed + tracks played in the last N hours. Drives the M3 weighted shuffle scoring." ``` --- ## Task 4: Candidate loader **Files:** - Create: `internal/recommendation/candidates.go` - Create: `internal/recommendation/candidates_test.go` Wraps the sqlc query, projects rows to `[]Candidate`. Live-DB tests verify the join + exclusion logic. - [ ] **Step 1: Write failing tests** Create `internal/recommendation/candidates_test.go`: ```go package recommendation import ( "context" "io" "log/slog" "os" "testing" "time" "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" "git.fabledsword.com/bvandeusen/minstrel/internal/db" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) func testPool(t *testing.T) *pgxpool.Pool { t.Helper() if testing.Short() { t.Skip("skipping integration test in -short mode") } dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") if dsn == "" { t.Skip("MINSTREL_TEST_DATABASE_URL not set") } if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil { t.Fatalf("migrate: %v", err) } pool, err := pgxpool.New(context.Background(), dsn) if err != nil { t.Fatalf("pool: %v", err) } t.Cleanup(pool.Close) if _, err := pool.Exec(context.Background(), "TRUNCATE general_likes, general_likes_albums, general_likes_artists, play_events, skip_events, play_sessions, sessions, users, tracks, albums, artists RESTART IDENTITY CASCADE"); err != nil { t.Fatalf("truncate: %v", err) } return pool } type fixture struct { pool *pgxpool.Pool q *dbq.Queries user pgtype.UUID tracks []dbq.Track // tracks[0] is the canonical seed } func newFixture(t *testing.T, n int) fixture { t.Helper() pool := testPool(t) q := dbq.New(pool) u, err := q.CreateUser(context.Background(), dbq.CreateUserParams{ Username: "tester", PasswordHash: "x", ApiToken: "x", IsAdmin: false, }) if err != nil { t.Fatalf("user: %v", err) } a, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "A", SortName: "A"}) al, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{Title: "B", SortTitle: "B", ArtistID: a.ID}) tracks := make([]dbq.Track, 0, n) for i := 0; i < n; i++ { tr, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ Title: string(rune('A' + i)), AlbumID: al.ID, ArtistID: a.ID, FilePath: "/tmp/track-" + string(rune('A'+i)) + ".flac", DurationMs: 120_000, }) if err != nil { t.Fatalf("track[%d]: %v", i, err) } tracks = append(tracks, tr) } return fixture{pool: pool, q: q, user: u.ID, tracks: tracks} } func TestLoadCandidates_ExcludesSeed(t *testing.T) { f := newFixture(t, 5) got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1) if err != nil { t.Fatalf("LoadCandidates: %v", err) } if len(got) != 4 { t.Fatalf("len = %d, want 4 (5 minus seed)", len(got)) } for _, c := range got { if c.Track.ID == f.tracks[0].ID { t.Errorf("seed appeared in candidate set") } } } func TestLoadCandidates_ExcludesRecentlyPlayed(t *testing.T) { f := newFixture(t, 3) // Seed = tracks[0]. Other tracks are tracks[1], tracks[2]. // Insert a play_session and a play_event for tracks[1] 30 minutes ago. now := time.Now().UTC() thirtyMinAgo := now.Add(-30 * time.Minute) sess, _ := f.q.InsertPlaySession(context.Background(), dbq.InsertPlaySessionParams{ UserID: f.user, StartedAt: pgtype.Timestamptz{Time: thirtyMinAgo, Valid: true}, ClientID: nil, }) _, _ = f.q.InsertPlayEvent(context.Background(), dbq.InsertPlayEventParams{ UserID: f.user, TrackID: f.tracks[1].ID, SessionID: sess.ID, StartedAt: pgtype.Timestamptz{Time: thirtyMinAgo, Valid: true}, ClientID: nil, }) got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1) if err != nil { t.Fatalf("LoadCandidates: %v", err) } // Expect tracks[2] only (seed excluded, tracks[1] excluded by recently-played). if len(got) != 1 { t.Fatalf("len = %d, want 1; got=%v", len(got), trackTitles(got)) } if got[0].Track.ID != f.tracks[2].ID { t.Errorf("expected tracks[2], got %v", got[0].Track.Title) } } func TestLoadCandidates_StatJoin(t *testing.T) { f := newFixture(t, 2) // Seed = tracks[0]. Like tracks[1]; play it twice (one skip, one full play) 2 hours ago. if err := f.q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: f.user, TrackID: f.tracks[1].ID}); err != nil { t.Fatalf("like: %v", err) } twoH := time.Now().UTC().Add(-2 * time.Hour) sess, _ := f.q.InsertPlaySession(context.Background(), dbq.InsertPlaySessionParams{ UserID: f.user, StartedAt: pgtype.Timestamptz{Time: twoH, Valid: true}, }) // First play: completed (was_skipped=false). pe1, _ := f.q.InsertPlayEvent(context.Background(), dbq.InsertPlayEventParams{ UserID: f.user, TrackID: f.tracks[1].ID, SessionID: sess.ID, StartedAt: pgtype.Timestamptz{Time: twoH, Valid: true}, }) dur := int32(120_000) ratio := 1.0 _, _ = f.q.UpdatePlayEventEnded(context.Background(), dbq.UpdatePlayEventEndedParams{ ID: pe1.ID, EndedAt: pgtype.Timestamptz{Time: twoH.Add(2 * time.Minute), Valid: true}, DurationPlayedMs: &dur, CompletionRatio: &ratio, WasSkipped: false, }) // Second play: skipped (was_skipped=true). pe2, _ := f.q.InsertPlayEvent(context.Background(), dbq.InsertPlayEventParams{ UserID: f.user, TrackID: f.tracks[1].ID, SessionID: sess.ID, StartedAt: pgtype.Timestamptz{Time: twoH.Add(5 * time.Minute), Valid: true}, }) dur2 := int32(5_000) ratio2 := 0.04 _, _ = f.q.UpdatePlayEventEnded(context.Background(), dbq.UpdatePlayEventEndedParams{ ID: pe2.ID, EndedAt: pgtype.Timestamptz{Time: twoH.Add(5*time.Minute + 5*time.Second), Valid: true}, DurationPlayedMs: &dur2, CompletionRatio: &ratio2, WasSkipped: true, }) got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1) if err != nil { t.Fatalf("LoadCandidates: %v", err) } if len(got) != 1 { t.Fatalf("len = %d, want 1", len(got)) } c := got[0] if !c.Inputs.IsGeneralLiked { t.Errorf("IsGeneralLiked = false, want true") } if c.Inputs.PlayCount != 2 { t.Errorf("PlayCount = %d, want 2", c.Inputs.PlayCount) } if c.Inputs.SkipCount != 1 { t.Errorf("SkipCount = %d, want 1", c.Inputs.SkipCount) } if c.Inputs.LastPlayedAt == nil { t.Errorf("LastPlayedAt = nil, want non-nil") } } func TestLoadCandidates_NeverPlayedHasNilLastPlayed(t *testing.T) { f := newFixture(t, 2) got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1) if err != nil { t.Fatalf("LoadCandidates: %v", err) } if len(got) != 1 { t.Fatalf("len = %d, want 1", len(got)) } if got[0].Inputs.LastPlayedAt != nil { t.Errorf("never-played track has non-nil LastPlayedAt: %v", got[0].Inputs.LastPlayedAt) } if got[0].Inputs.PlayCount != 0 || got[0].Inputs.SkipCount != 0 { t.Errorf("never-played track has non-zero counts: %+v", got[0].Inputs) } } func TestLoadCandidates_CrossUserIsolation(t *testing.T) { f := newFixture(t, 2) bob, _ := f.q.CreateUser(context.Background(), dbq.CreateUserParams{ Username: "bob", PasswordHash: "x", ApiToken: "y", IsAdmin: false, }) // Alice likes tracks[1]; Bob shouldn't see it. _ = f.q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: f.user, TrackID: f.tracks[1].ID}) got, err := LoadCandidates(context.Background(), f.q, bob.ID, f.tracks[0].ID, 1) if err != nil { t.Fatalf("LoadCandidates: %v", err) } for _, c := range got { if c.Track.ID == f.tracks[1].ID && c.Inputs.IsGeneralLiked { t.Errorf("bob sees alice's like on track %v", c.Track.Title) } } } func trackTitles(cs []Candidate) []string { out := make([]string, 0, len(cs)) for _, c := range cs { out = append(out, c.Track.Title) } return out } ``` - [ ] **Step 2: Run, confirm fail** Run: ```bash MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \ go test ./internal/recommendation -run TestLoadCandidates -v ``` Expected: FAIL — `LoadCandidates` undefined. - [ ] **Step 3: Implement `internal/recommendation/candidates.go`** ```go package recommendation import ( "context" "github.com/jackc/pgx/v5/pgtype" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) // LoadCandidates returns all candidate tracks for the given user, excluding // the seed and any track played within the last `recentlyPlayedHours`. // Each Candidate includes the per-(user,track) stats the scoring function // needs. func LoadCandidates( ctx context.Context, q *dbq.Queries, userID, seedID pgtype.UUID, recentlyPlayedHours int, ) ([]Candidate, error) { rows, err := q.LoadRadioCandidates(ctx, dbq.LoadRadioCandidatesParams{ UserID: userID, ID: seedID, Column3: float64(recentlyPlayedHours), }) if err != nil { return nil, err } out := make([]Candidate, 0, len(rows)) for _, r := range rows { var lastPlayed *pgtype.Timestamptz if r.LastPlayedAt.Valid { ts := r.LastPlayedAt lastPlayed = &ts } var lastPlayedTime *([]byte) // placeholder to satisfy structure if needed _ = lastPlayedTime // appease unused warning var lpt *time.Time if r.LastPlayedAt.Valid { t := r.LastPlayedAt.Time lpt = &t } out = append(out, Candidate{ Track: r.Track, Inputs: ScoringInputs{ IsGeneralLiked: r.IsLiked, LastPlayedAt: lpt, PlayCount: int(r.PlayCount), SkipCount: int(r.SkipCount), }, }) _ = lastPlayed } return out, nil } ``` Note: clean up the duplicated `lastPlayed` / `lastPlayedTime` shims — they're scaffolding from drafting. Final form: ```go package recommendation import ( "context" "time" "github.com/jackc/pgx/v5/pgtype" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) func LoadCandidates( ctx context.Context, q *dbq.Queries, userID, seedID pgtype.UUID, recentlyPlayedHours int, ) ([]Candidate, error) { rows, err := q.LoadRadioCandidates(ctx, dbq.LoadRadioCandidatesParams{ UserID: userID, ID: seedID, Column3: float64(recentlyPlayedHours), }) 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 } out = append(out, Candidate{ Track: r.Track, Inputs: ScoringInputs{ IsGeneralLiked: r.IsLiked, LastPlayedAt: lpt, PlayCount: int(r.PlayCount), SkipCount: int(r.SkipCount), }, }) } return out, nil } ``` Note on the sqlc-generated `Column3` field name: sqlc names unnamed parameters `Column1`, `Column2`, etc. when there's no obvious mapping. If the generated field has a different name (e.g. `Hours`), adjust the call site to match. Run sqlc generate first and inspect `internal/db/dbq/recommendation.sql.go` to confirm. - [ ] **Step 4: Run, confirm pass** Run: ```bash MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \ go test ./internal/recommendation -v ``` Expected: PASS — 16 prior tests + 5 new candidate tests = 21 total. - [ ] **Step 5: Lint** Run: `golangci-lint run ./internal/recommendation/...` Expected: clean. - [ ] **Step 6: Commit** ```bash git add internal/recommendation/candidates.go internal/recommendation/candidates_test.go git commit -m "feat(recommendation): add LoadCandidates DB-backed loader Wraps the LoadRadioCandidates sqlc query, projects rows to []Candidate. Live-DB tests verify seed exclusion, recently-played exclusion, stat-join correctness (likes + plays + skips + last_played_at), and cross-user isolation." ``` --- ## Task 5: RecommendationConfig **Files:** - Modify: `internal/config/config.go` - Modify: `config.example.yaml` Adds operator-tunable weights to YAML / env. Defaults match the spec. - [ ] **Step 1: Add the struct + field + defaults** In `internal/config/config.go`, add `Recommendation RecommendationConfig` to the `Config` struct (alongside `Events`): ```go type Config struct { Server ServerConfig `yaml:"server"` Database DatabaseConfig `yaml:"database"` Log LogConfig `yaml:"log"` Auth AuthConfig `yaml:"auth"` Library LibraryConfig `yaml:"library"` Subsonic SubsonicConfig `yaml:"subsonic"` Events EventsConfig `yaml:"events"` Recommendation RecommendationConfig `yaml:"recommendation"` } ``` Add the struct definition (after `EventsConfig`): ```go // RecommendationConfig governs the M3 weighted-shuffle scoring (spec §6). // All weights are operator-tunable; defaults match the spec recommendations. 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"` RecentlyPlayedHours int `yaml:"recently_played_hours"` RadioSize int `yaml:"radio_size"` RadioSizeMax int `yaml:"radio_size_max"` } ``` Update `Default()` to seed the defaults: ```go func Default() Config { return Config{ Server: ServerConfig{Address: ":4533"}, Database: DatabaseConfig{URL: ""}, Log: LogConfig{Level: "info", Format: "text"}, Events: EventsConfig{ SessionTimeoutMinutes: 30, SkipMaxCompletionRatio: 0.5, SkipMaxDurationPlayedMs: 30000, }, Recommendation: RecommendationConfig{ BaseWeight: 1.0, LikeBoost: 2.0, RecencyWeight: 1.0, SkipPenalty: 1.0, JitterMagnitude: 0.1, RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200, }, } } ``` - [ ] **Step 2: Update `config.example.yaml`** Append after the `events:` section: ```yaml recommendation: # Base score every candidate gets before adjustments. Spec §6. base_weight: 1.0 # Bonus for tracks the user has liked (general_likes). like_boost: 2.0 # Multiplier on recency_decay (1.0 for tracks ≥ 30 days stale, 0 for fresh). recency_weight: 1.0 # Penalty multiplier on skip_ratio (skips/plays); 1.0 = full penalty. skip_penalty: 1.0 # ± random jitter applied to every candidate; breaks ties without dominating. jitter_magnitude: 0.1 # Hard-suppression window: tracks played within this many hours never appear. recently_played_hours: 1 # Default radio size when ?limit= is not specified. radio_size: 50 # Maximum allowed ?limit= (server caps to this regardless). radio_size_max: 200 ``` - [ ] **Step 3: Run config tests** Run: `go test ./internal/config -v` Expected: PASS — existing tests still pass with the new struct defaulting correctly. - [ ] **Step 4: Commit** ```bash git add internal/config/config.go config.example.yaml git commit -m "feat(config): add recommendation section (weighted shuffle weights) BaseWeight, LikeBoost, RecencyWeight, SkipPenalty, JitterMagnitude, RecentlyPlayedHours, RadioSize, RadioSizeMax. Defaults match spec §6." ``` --- ## Task 6: Radio handler rewrite **Files:** - Modify: `internal/api/radio.go` (replaces stub) - Modify: `internal/api/radio_test.go` (replaces stub tests) - Modify: `internal/api/api.go` (Mount signature gains config arg; handlers struct gains recCfg field) - Modify: `internal/api/auth_test.go` (testHandlers seeds a default RecommendationConfig) - Modify: `internal/api/library_test.go` (newLibraryRouter doesn't need radio routing changes; no-op verification) - Modify: `internal/server/server.go` + `cmd/minstrel/main.go` (pass cfg through) Replaces the stub with the v1 weighted-shuffle implementation. - [ ] **Step 1: Update `handlers` struct + Mount in `internal/api/api.go`** ```go package api import ( "log/slog" "math/rand" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgxpool" "git.fabledsword.com/bvandeusen/minstrel/internal/auth" "git.fabledsword.com/bvandeusen/minstrel/internal/config" "git.fabledsword.com/bvandeusen/minstrel/internal/playevents" ) func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig) { h := &handlers{ pool: pool, logger: logger, events: events, recCfg: recCfg, // Seeded once at server start; per-request shuffle reads h.rng.Float64. // Concurrent calls are fine — math/rand.Rand is safe under contention via // a mutex internally (Go 1.21+); for stricter isolation switch to math/rand/v2. } rng := rand.New(rand.NewSource(rand.Int63())) h.rng = rng.Float64 // ... existing route registrations unchanged ... r.Route("/api", func(api chi.Router) { api.Post("/auth/login", h.handleLogin) api.Group(func(authed chi.Router) { authed.Use(auth.RequireUser(pool)) authed.Post("/auth/logout", h.handleLogout) authed.Get("/me", h.handleGetMe) authed.Get("/artists", h.handleListArtists) authed.Get("/artists/{id}", h.handleGetArtist) authed.Get("/albums/{id}", h.handleGetAlbum) authed.Get("/albums/{id}/cover", h.handleGetCover) authed.Get("/tracks/{id}", h.handleGetTrack) authed.Get("/tracks/{id}/stream", h.handleGetStream) authed.Get("/search", h.handleSearch) authed.Get("/radio", h.handleRadio) authed.Post("/events", h.handleEvents) authed.Post("/likes/tracks/{id}", h.handleLikeTrack) authed.Delete("/likes/tracks/{id}", h.handleUnlikeTrack) authed.Post("/likes/albums/{id}", h.handleLikeAlbum) authed.Delete("/likes/albums/{id}", h.handleUnlikeAlbum) authed.Post("/likes/artists/{id}", h.handleLikeArtist) authed.Delete("/likes/artists/{id}", h.handleUnlikeArtist) authed.Get("/likes/tracks", h.handleListLikedTracks) authed.Get("/likes/albums", h.handleListLikedAlbums) authed.Get("/likes/artists", h.handleListLikedArtists) authed.Get("/likes/ids", h.handleGetLikedIDs) }) }) } type handlers struct { pool *pgxpool.Pool logger *slog.Logger events *playevents.Writer recCfg config.RecommendationConfig rng func() float64 } ``` (Note: `math/rand`'s top-level `Float64` is goroutine-safe, but the new-instance pattern with explicit seed is cleaner. Stay with `func() float64` so tests can inject deterministic values.) - [ ] **Step 2: Replace `internal/api/radio.go`** ```go package api import ( "errors" "net/http" "strconv" "strings" "time" "github.com/jackc/pgx/v5" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/recommendation" ) // RadioResponse is the body of GET /api/radio. type RadioResponse struct { Tracks []TrackRef `json:"tracks"` } // handleRadio implements GET /api/radio?seed_track=&limit=. // // Returns the seed at index 0, followed by up to limit-1 weighted-shuffle // picks from the user's library, scored by recommendation.Score. func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) { user, ok := authUserFromContext(r) if !ok { writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") return } raw := strings.TrimSpace(r.URL.Query().Get("seed_track")) if raw == "" { writeErr(w, http.StatusBadRequest, "bad_request", "seed_track is required") return } seedID, ok := parseUUID(raw) if !ok { writeErr(w, http.StatusBadRequest, "bad_request", "invalid seed_track id") return } limit := h.recCfg.RadioSize if v := r.URL.Query().Get("limit"); v != "" { n, err := strconv.Atoi(v) if err != nil || n < 1 { writeErr(w, http.StatusBadRequest, "bad_request", "invalid limit") return } limit = n } if limit > h.recCfg.RadioSizeMax { limit = h.recCfg.RadioSizeMax } q := dbq.New(h.pool) track, err := q.GetTrackByID(r.Context(), seedID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { writeErr(w, http.StatusNotFound, "not_found", "seed_track not found") return } h.logger.Error("api: get radio seed track failed", "err", err) writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") return } album, err := q.GetAlbumByID(r.Context(), track.AlbumID) if err != nil { h.logger.Error("api: get radio seed album failed", "err", err) writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") return } artist, err := q.GetArtistByID(r.Context(), track.ArtistID) if err != nil { h.logger.Error("api: get radio seed artist failed", "err", err) writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") return } candidates, err := recommendation.LoadCandidates(r.Context(), q, user.ID, seedID, h.recCfg.RecentlyPlayedHours) 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, } picks := recommendation.Shuffle(candidates, weights, time.Now().UTC(), h.rng, limit-1) out := make([]TrackRef, 0, len(picks)+1) out = append(out, trackRefFrom(track, album.Title, artist.Name)) for _, p := range picks { // Resolve album/artist names per pick. Reuse the existing resolver. al, err := q.GetAlbumByID(r.Context(), p.Track.AlbumID) if err != nil { h.logger.Error("api: radio: resolve album", "err", err) writeErr(w, http.StatusInternalServerError, "server_error", "resolve failed") return } ar, err := q.GetArtistByID(r.Context(), p.Track.ArtistID) if err != nil { h.logger.Error("api: radio: resolve artist", "err", err) writeErr(w, http.StatusInternalServerError, "server_error", "resolve failed") return } out = append(out, trackRefFrom(p.Track, al.Title, ar.Name)) } writeJSON(w, http.StatusOK, RadioResponse{Tracks: out}) } // authUserFromContext is a thin shim around auth.UserFromContext that tests // can override. It exists so testHandlers can inject a user via the same // context key RequireUser populates. func authUserFromContext(r *http.Request) (dbq.User, bool) { return userFromContext(r) } ``` `userFromContext` is the existing helper used by the events handler — reuse it; if it's named differently in your tree, follow that pattern. The shim layer (`authUserFromContext`) is unnecessary if the existing handler pattern just calls `auth.UserFromContext(r.Context())` directly. Use whatever the rest of the package uses; consistency over novelty. - [ ] **Step 3: Update `testHandlers` in `internal/api/auth_test.go`** Find the existing `testHandlers` function. Update the construction: ```go import ( // ... existing imports ... "git.fabledsword.com/bvandeusen/minstrel/internal/config" ) func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) { // ... existing setup unchanged up to the return ... 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, RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200, } h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, rng: func() float64 { return 0.5 }} return h, pool } ``` The fixed-RNG (`return 0.5`) makes tests deterministic — jitter contribution is always 0. - [ ] **Step 4: Replace `internal/api/radio_test.go`** Replace the existing 5 stub tests with new ones: ```go package api import ( "context" "encoding/json" "net/http" "net/http/httptest" "testing" ) func callRadio(h *handlers, user interface{}, query string) *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodGet, "/api/radio?"+query, nil) req = req.WithContext(context.WithValue(req.Context(), userCtxKeyForTest(), user)) w := httptest.NewRecorder() h.handleRadio(w, req) return w } func TestHandleRadio_ColdStart_OnlySeedReturned(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) w := callRadio(h, user, "seed_track="+uuidToString(seed.ID)) 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) if len(resp.Tracks) != 1 { t.Fatalf("len = %d, want 1", len(resp.Tracks)) } if resp.Tracks[0].Title != "Seed" { t.Errorf("seed not first: %v", resp.Tracks[0].Title) } } func TestHandleRadio_Typical_SeedFirstPlusPicks(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) for i := 2; i <= 6; i++ { seedTrack(t, pool, album.ID, artist.ID, "T"+string(rune('0'+i)), i, 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) != 6 { t.Fatalf("len = %d, want 6 (seed + 5 picks)", len(resp.Tracks)) } if resp.Tracks[0].Title != "Seed" { t.Errorf("seed not at index 0: %v", resp.Tracks[0].Title) } } func TestHandleRadio_UnknownSeedIs404(t *testing.T) { h, pool := testHandlers(t) user := seedUser(t, pool, "alice", "x", false) w := callRadio(h, user, "seed_track=00000000-0000-0000-0000-000000000000") if w.Code != http.StatusNotFound { t.Errorf("status = %d", w.Code) } } func TestHandleRadio_BadSeedIs400(t *testing.T) { h, pool := testHandlers(t) user := seedUser(t, pool, "alice", "x", false) w := callRadio(h, user, "seed_track=not-a-uuid") if w.Code != http.StatusBadRequest { t.Errorf("status = %d", w.Code) } } func TestHandleRadio_MissingSeedIs400(t *testing.T) { h, pool := testHandlers(t) user := seedUser(t, pool, "alice", "x", false) w := callRadio(h, user, "") if w.Code != http.StatusBadRequest { t.Errorf("status = %d", w.Code) } } func TestHandleRadio_BadLimitIs400(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) w := callRadio(h, user, "seed_track="+uuidToString(seed.ID)+"&limit=0") if w.Code != http.StatusBadRequest { t.Errorf("limit=0 status = %d", w.Code) } w = callRadio(h, user, "seed_track="+uuidToString(seed.ID)+"&limit=-1") if w.Code != http.StatusBadRequest { t.Errorf("limit=-1 status = %d", w.Code) } } func TestHandleRadio_LimitClampedToMax(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) for i := 2; i <= 6; i++ { seedTrack(t, pool, album.ID, artist.ID, "T"+string(rune('0'+i)), i, 100_000) } w := callRadio(h, user, "seed_track="+uuidToString(seed.ID)+"&limit=99999") if w.Code != http.StatusOK { t.Fatalf("status = %d", w.Code) } var resp RadioResponse _ = json.Unmarshal(w.Body.Bytes(), &resp) // We only have 6 tracks; clamped limit (max 200) returns all 6. if len(resp.Tracks) != 6 { t.Errorf("len = %d, want 6", len(resp.Tracks)) } } ``` - [ ] **Step 5: Update Mount call sites** In `internal/server/server.go`: ```go api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg) ``` The `Server` struct gains `RecommendationCfg config.RecommendationConfig`; constructor `New(...)` gains the new arg. Find `cmd/minstrel/main.go`'s `server.New(...)` call and pass `cfg.Recommendation`. In `internal/api/library_test.go`'s `TestRoutesRegisteredInMount`: ```go Mount(r, h.pool, h.logger, w, config.RecommendationConfig{ RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1, }) ``` In `internal/server/server_test.go`'s `New(...)` calls (multiple sites), append `config.RecommendationConfig{}` after the `subsonic.Config{}` arg. Use the existing `replace_all` approach if there are several: ```go New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}) ``` - [ ] **Step 6: Run tests, confirm pass** Run: ```bash MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \ go test -p 1 ./... ``` Expected: all packages PASS. Run: `golangci-lint run ./...` Expected: clean. - [ ] **Step 7: Commit** ```bash git add internal/api/radio.go internal/api/radio_test.go internal/api/api.go \ internal/api/auth_test.go internal/api/library_test.go \ internal/server/server.go internal/server/server_test.go \ cmd/minstrel/main.go git commit -m "feat(api): rewrite /api/radio with weighted shuffle v1 Validates seed_track + optional limit (default cfg.Recommendation.RadioSize, clamped to RadioSizeMax). Calls recommendation.LoadCandidates + recommendation.Shuffle. Prepends seed to result. Server seeds a math/rand source at startup; handlers package threads that as a func() float64 so tests inject deterministic RNGs. Mount + server.New gain a RecommendationConfig parameter." ``` --- ## Task 7: Final verification + branch finish **Files:** none (verification only). - [ ] **Step 1: Full Go suite** Run: ```bash MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \ go test -p 1 ./... ``` Expected: all packages PASS. - [ ] **Step 2: Coverage on recommendation package** Run: ```bash MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \ go test -coverprofile=/tmp/rec.cover ./internal/recommendation/... go tool cover -func=/tmp/rec.cover | tail -3 ``` Expected: total coverage ≥ 70%. - [ ] **Step 3: Lint** Run: `golangci-lint run ./...` Expected: clean. - [ ] **Step 4: Web check + tests + build** Run: `cd web && npm run check && npm test && npm run build` Expected: check 0/0; tests pass; build emits `web/build/`. Run: `git checkout -- web/build/index.html` Expected: committed placeholder restored. - [ ] **Step 5: Docker build smoke** Run: `docker build -t minstrel:m3-shuffle-smoke .` Expected: image builds. Run: `docker run --rm --entrypoint /bin/sh minstrel:m3-shuffle-smoke -c 'test -x /usr/local/bin/minstrel && echo ok'` Expected: `ok`. - [ ] **Step 6: Local end-to-end manual check** ```bash docker compose up --build -d ``` Browser at `localhost:4533` (sign in with the dev admin creds): 1. Play 3 tracks all the way through (build `play_count` history). 2. Skip a 4th track within 10 seconds (`skip_count = 1` for that track). 3. Like 2 tracks via the heart button. 4. Click the radio button on a 5th track (or trigger via search → click track). 5. Inspect the response in dev tools network tab — 50 tracks, seed first. 6. The 2 liked tracks appear early in the list (within first ~20). 7. The just-skipped track appears late or absent. 8. The 3 just-played tracks are absent (recently-played exclusion). 9. Trigger radio again with a different seed; ordering varies (jitter), liked tracks still rank prominently. Tear down: ```bash docker compose down ``` - [ ] **Step 7: Finish the branch** Follow `superpowers:finishing-a-development-branch`. Expected path: Option 2 — push `dev`, open a PR to `main`, wait for Forgejo CI, merge once green. --- ## Self-Review Notes **Spec coverage:** - Pure scoring function with all 5 terms (base, like boost, recency, skip penalty, jitter) → Task 1. - Shuffle composition (sort + truncate) → Task 2. - Candidate loader excluding seed + recently-played → Tasks 3–4. - Stat join (is_liked + last_played_at + play_count + skip_count) → Tasks 3–4. - Operator-tunable weights → Task 5. - Endpoint contract (seed first, limit default 50 / max 200, errors unchanged) → Task 6. - Cross-user isolation → Task 4 + Task 6. - Cold-start / never-played handling → Task 1 + Task 6. - ≥ 70% coverage on the recommendation package → Task 7. - End-to-end manual → Task 7. **Type consistency:** - `ScoringInputs`, `ScoringWeights`, `Candidate` — same names everywhere they're consumed. - `Score(...) → float64`, `Shuffle(...) → []Candidate`, `LoadCandidates(...) → []Candidate, error` — signatures stable. - `recCfg config.RecommendationConfig` and `rng func() float64` — same on the handlers struct, in tests, in Mount. - `pgtype.UUID` server-side, `string` at HTTP boundaries via `uuidToString`. **Filename hazards:** none — all new files under `internal/recommendation/`. The route-test handling for radio is colocated with the other api tests (no `+`-prefix concerns). **Placeholder scan:** no TBD/TODO/later markers. The `Column3` parameter naming caveat in Task 4 is documented inline (sqlc names unnamed positional params `Column1`, `Column2`, etc., adjust per generated code). **Performance note:** Task 6's per-pick album/artist resolve is N round-trips for the picks. For `RadioSize=50` that's 100 round-trips. Acceptable at v1 scale; matches the pattern used by `resolveTrackRefs` elsewhere. M4 / future task can batch via `IN (...)` + map-by-id if telemetry shows it matters.