# 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 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.
