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.
51 KiB
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:
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
handlersstruct + Mount ininternal/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
testHandlersininternal/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):
- Play 3 tracks all the way through (build
play_counthistory). - Skip a 4th track within 10 seconds (
skip_count = 1for that track). - Like 2 tracks via the heart button.
- Click the radio button on a 5th track (or trigger via search → click track).
- Inspect the response in dev tools network tab — 50 tracks, seed first.
- The 2 liked tracks appear early in the list (within first ~20).
- The just-skipped track appears late or absent.
- The 3 just-played tracks are absent (recently-played exclusion).
- 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 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.RecommendationConfigandrng func() float64— same on the handlers struct, in tests, in Mount.pgtype.UUIDserver-side,stringat HTTP boundaries viauuidToString.
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.