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

51 KiB
Raw Blame History

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 tracksgeneral_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:

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
// 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
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:

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
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
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:

-- 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
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:

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:

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
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:

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:

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
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):

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):

// 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:

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:


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
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
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
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:

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:

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:

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:

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:

New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{})
  • Step 6: Run tests, confirm pass

Run:

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
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:

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:

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
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:

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.