Files
minstrel/docs/superpowers/plans/2026-04-27-m3-shuffle.md
T
bvandeusen b9937d6e3b docs(plan): add M3 weighted shuffle v1 implementation plan
7 tasks: pure score function, pure shuffle orchestrator, sqlc
LoadRadioCandidates query, DB-backed candidate loader,
RecommendationConfig + YAML, /api/radio handler rewrite, final
verification + branch finish.
2026-04-27 07:35:13 -04:00

1589 lines
51 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.
# 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=<uuid>&limit=<int>.
//
// 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 34.
- Stat join (is_liked + last_played_at + play_count + skip_count) → Tasks 34.
- 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.