diff --git a/cmd/minstrel/main.go b/cmd/minstrel/main.go index 0c912bd5..91461b74 100644 --- a/cmd/minstrel/main.go +++ b/cmd/minstrel/main.go @@ -75,7 +75,7 @@ func run() error { srv := server.New(logger, pool, scanner, subsonic.Config{ AllowPlaintextPassword: cfg.Subsonic.AllowPlaintextPassword, - }, cfg.Events) + }, cfg.Events, cfg.Recommendation) httpServer := &http.Server{ Addr: cfg.Server.Address, Handler: srv.Router(), diff --git a/config.example.yaml b/config.example.yaml index b9a2ed80..6cc062d9 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -45,3 +45,21 @@ events: # this threshold AND duration played (ms) is below the next threshold. skip_max_completion_ratio: 0.5 skip_max_duration_played_ms: 30000 + +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 diff --git a/docs/superpowers/plans/2026-04-27-m3-shuffle.md b/docs/superpowers/plans/2026-04-27-m3-shuffle.md new file mode 100644 index 00000000..652bb836 --- /dev/null +++ b/docs/superpowers/plans/2026-04-27-m3-shuffle.md @@ -0,0 +1,1588 @@ +# M3 Weighted Shuffle v1 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the M2 stub `/api/radio` (currently returns just the seed track) with a real weighted-shuffle implementation that scores user library tracks against a per-track-stats formula and returns a top-N queue ordered by score. + +**Architecture:** New `internal/recommendation` package with three layers — pure scoring function (`score.go`), pure orchestration (`shuffle.go`), and DB-backed candidate loader (`candidates.go`). The HTTP handler at `internal/api/radio.go` becomes a thin shim that validates the seed, calls the loader, calls the orchestrator, prepends the seed, and returns JSON. Stats (`is_liked`, `play_count`, `skip_count`, `last_played_at`) are computed in one SQL query joining `tracks` ↔ `general_likes` ↔ aggregated `play_events`. No new schema — all inputs from existing tables. + +**Tech Stack:** Go 1.23 + chi + sqlc + pgx/v5. No web changes (the existing `playRadio` action already calls `/api/radio?seed_track=…` and consumes `RadioResponse`). + +**Reference:** design spec at `docs/superpowers/specs/2026-04-27-m3-shuffle-design.md`. + +--- + +## File Structure + +**New server files:** + +| File | Responsibility | +|---|---| +| `internal/recommendation/score.go` | Pure `Score(inputs, weights, now, rng) → float64` + `recencyDecay` + `skipRatio` helpers. | +| `internal/recommendation/score_test.go` | Boundary cases for every term, cold-start, jitter determinism. | +| `internal/recommendation/shuffle.go` | Pure `Shuffle(candidates, weights, now, rng, limit) []Candidate` — composes Score + sort + truncate. | +| `internal/recommendation/shuffle_test.go` | Liked-rank-higher, high-skip-rejected, jitter-doesn't-reorder, limit. | +| `internal/recommendation/candidates.go` | `LoadCandidates(ctx, q, userID, seedID, recentlyPlayedHours) → []Candidate` — wraps the sqlc query, projects rows to `Candidate` (Track + ScoringInputs). | +| `internal/recommendation/candidates_test.go` | Live-DB tests: seed exclusion, recently-played exclusion, stat-join correctness, cross-user isolation. | +| `internal/db/queries/recommendation.sql` | One sqlc query: `LoadRadioCandidates`. | +| `internal/db/dbq/recommendation.sql.go` | Generated bindings. | + +**Modified server files:** + +| File | Change | +|---|---| +| `internal/api/radio.go` | Replace stub. New flow: validate, load candidates, shuffle, prepend seed, return JSON. | +| `internal/api/radio_test.go` | Replace existing 5 stub tests with the v1 weighted-shuffle scenarios. | +| `internal/config/config.go` + `config.example.yaml` | Add `RecommendationConfig` with weights + radio size + recently-played-hours. | +| `internal/api/api.go` | Mount signature gains `cfg config.RecommendationConfig`; handlers struct gains `recCfg` field. | +| `internal/server/server.go` + `cmd/minstrel/main.go` | Pass `cfg.Recommendation` through `Mount`. | + +**No web changes.** The existing `playRadio(seedTrackId)` already consumes `{ tracks: TrackRef[] }`. + +--- + +## Task 1: Pure scoring function + +**Files:** +- Create: `internal/recommendation/score.go` +- Create: `internal/recommendation/score_test.go` + +Pure Go, no DB. The function takes its inputs explicitly and an injectable RNG so tests pin jitter to deterministic values. + +- [ ] **Step 1: Write the failing tests** + +Create `internal/recommendation/score_test.go`: + +```go +package recommendation + +import ( + "math" + "math/rand" + "testing" + "time" +) + +func defaultWeights() ScoringWeights { + return ScoringWeights{ + BaseWeight: 1.0, + LikeBoost: 2.0, + RecencyWeight: 1.0, + SkipPenalty: 1.0, + JitterMagnitude: 0.1, + } +} + +func fixedRNG(v float64) func() float64 { + return func() float64 { return v } +} + +func TestScore_BaseCase_NeverPlayed_NoJitter(t *testing.T) { + in := ScoringInputs{} + now := time.Now().UTC() + got := Score(in, defaultWeights(), now, fixedRNG(0.5)) + // rng=0.5 -> jitter contribution = (0.5*2 - 1) * 0.1 = 0 + // expected = 1.0 + 0 (not liked) + 1.0 (never played) - 0 + 0 = 2.0 + want := 2.0 + if math.Abs(got-want) > 1e-9 { + t.Errorf("score = %v, want %v", got, want) + } +} + +func TestScore_LikeBoost(t *testing.T) { + notLiked := Score(ScoringInputs{}, defaultWeights(), time.Now(), fixedRNG(0.5)) + liked := Score(ScoringInputs{IsGeneralLiked: true}, defaultWeights(), time.Now(), fixedRNG(0.5)) + if math.Abs(liked-notLiked-2.0) > 1e-9 { + t.Errorf("delta = %v, want 2.0 (LikeBoost)", liked-notLiked) + } +} + +func TestScore_RecencyRamp_15Days(t *testing.T) { + now := time.Now().UTC() + played := now.Add(-15 * 24 * time.Hour) + got := Score(ScoringInputs{LastPlayedAt: &played}, defaultWeights(), now, fixedRNG(0.5)) + // recency = 0.5 -> contributes 0.5 * 1.0 = 0.5; expected = 1.0 + 0 + 0.5 - 0 + 0 = 1.5 + want := 1.5 + if math.Abs(got-want) > 1e-9 { + t.Errorf("score = %v, want %v", got, want) + } +} + +func TestScore_RecencyRamp_60DaysCapsAt1(t *testing.T) { + now := time.Now().UTC() + played := now.Add(-60 * 24 * time.Hour) + got := Score(ScoringInputs{LastPlayedAt: &played}, defaultWeights(), now, fixedRNG(0.5)) + want := 2.0 // base + capped recency (1.0) + if math.Abs(got-want) > 1e-9 { + t.Errorf("score = %v, want %v", got, want) + } +} + +func TestScore_SkipRatio(t *testing.T) { + // 4 plays, 2 skips -> ratio 0.5 -> -0.5 * SkipPenalty = -0.5 + in := ScoringInputs{PlayCount: 4, SkipCount: 2} + got := Score(in, defaultWeights(), time.Now(), fixedRNG(0.5)) + // recency = 1.0 (never played by virtue of LastPlayedAt being nil even though PlayCount > 0; + // for this test we rely on the recencyDecay treating nil as max). Adjust: the tests above + // explicitly use nil for LastPlayedAt unless set. So: 1.0 + 1.0 - 0.5 = 1.5. + want := 1.5 + if math.Abs(got-want) > 1e-9 { + t.Errorf("score = %v, want %v", got, want) + } +} + +func TestScore_ColdStartSkip_ZeroPlaysZeroSkips(t *testing.T) { + in := ScoringInputs{PlayCount: 0, SkipCount: 0} + got := Score(in, defaultWeights(), time.Now(), fixedRNG(0.5)) + want := 2.0 // base + recency, no skip penalty + if math.Abs(got-want) > 1e-9 { + t.Errorf("score = %v, want %v", got, want) + } +} + +func TestScore_JitterBounds(t *testing.T) { + r := rand.New(rand.NewSource(42)) + w := defaultWeights() + now := time.Now().UTC() + mid := Score(ScoringInputs{}, w, now, fixedRNG(0.5)) // jitter contribution = 0 + for i := 0; i < 1000; i++ { + got := Score(ScoringInputs{}, w, now, r.Float64) + if got < mid-w.JitterMagnitude-1e-9 || got > mid+w.JitterMagnitude+1e-9 { + t.Fatalf("score %v outside jitter band [%v, %v]", + got, mid-w.JitterMagnitude, mid+w.JitterMagnitude) + } + } +} + +func TestScore_Determinism(t *testing.T) { + in := ScoringInputs{IsGeneralLiked: true, PlayCount: 10, SkipCount: 3} + w := defaultWeights() + now := time.Now().UTC() + a := Score(in, w, now, fixedRNG(0.7)) + b := Score(in, w, now, fixedRNG(0.7)) + if a != b { + t.Errorf("non-deterministic with fixed RNG: %v vs %v", a, b) + } +} + +func TestRecencyDecay_NilIsOne(t *testing.T) { + if got := recencyDecay(nil, time.Now()); got != 1.0 { + t.Errorf("recencyDecay(nil) = %v, want 1.0", got) + } +} + +func TestRecencyDecay_Clamping(t *testing.T) { + now := time.Now().UTC() + cases := []struct { + ageDays float64 + want float64 + }{ + {0, 0.0}, + {15, 0.5}, + {30, 1.0}, + {45, 1.0}, + } + for _, c := range cases { + t.Run("", func(t *testing.T) { + past := now.Add(-time.Duration(c.ageDays * 24 * float64(time.Hour))) + got := recencyDecay(&past, now) + if math.Abs(got-c.want) > 1e-9 { + t.Errorf("ageDays=%v decay=%v want %v", c.ageDays, got, c.want) + } + }) + } +} + +func TestSkipRatio_ZeroPlays_IsZero(t *testing.T) { + if got := skipRatio(0, 0); got != 0.0 { + t.Errorf("skipRatio(0,0) = %v, want 0.0", got) + } +} + +func TestSkipRatio_Half(t *testing.T) { + if got := skipRatio(4, 2); got != 0.5 { + t.Errorf("skipRatio(4,2) = %v, want 0.5", got) + } +} +``` + +- [ ] **Step 2: Run tests, confirm fail** + +Run: `go test ./internal/recommendation -v` +Expected: FAIL — package or types undefined. + +- [ ] **Step 3: Implement `internal/recommendation/score.go`** + +```go +// Package recommendation implements the weighted-shuffle scoring engine +// from spec §6. The Score function is pure and takes an injectable RNG so +// tests can pin jitter to deterministic values. +package recommendation + +import ( + "time" +) + +// ScoringInputs are the per-track facts the score function consumes. +// Sub-plan #3 (contextual scoring) extends this with ContextualMatchScore. +type ScoringInputs struct { + IsGeneralLiked bool + LastPlayedAt *time.Time // nil = never played + PlayCount int // total play_events + SkipCount int // play_events with was_skipped=true +} + +// ScoringWeights are the operator-tunable knobs. Defaults live in +// config.RecommendationConfig and are propagated here per request. +type ScoringWeights struct { + BaseWeight float64 + LikeBoost float64 + RecencyWeight float64 + SkipPenalty float64 + JitterMagnitude float64 +} + +// Score computes the weighted-shuffle score per spec §6: +// +// score = base +// + (is_general_liked ? LikeBoost : 0) +// + recency_decay * RecencyWeight +// - skip_ratio * SkipPenalty +// + small_random_jitter +// +// Higher score = more likely to surface. rng is a function returning a +// uniform sample in [0,1) — pass math/rand.Float64 in production, a fixed +// value in tests. +func Score(in ScoringInputs, w ScoringWeights, now time.Time, rng func() float64) float64 { + s := w.BaseWeight + if in.IsGeneralLiked { + s += w.LikeBoost + } + s += recencyDecay(in.LastPlayedAt, now) * w.RecencyWeight + s -= skipRatio(in.PlayCount, in.SkipCount) * w.SkipPenalty + s += (rng()*2 - 1) * w.JitterMagnitude + return s +} + +// recencyDecay returns a value in [0, 1]: +// - never played → 1.0 (cold-start tracks compete favorably with stale ones). +// - age < 30 days → linear ramp age_days / 30. +// - age ≥ 30 days → 1.0 (capped). +// +// Negative ages (clock skew) clamp to 0 to avoid math weirdness. +func recencyDecay(lastPlayed *time.Time, now time.Time) float64 { + if lastPlayed == nil { + return 1.0 + } + age := now.Sub(*lastPlayed) + days := age.Hours() / 24 + if days < 0 { + return 0.0 + } + if days >= 30 { + return 1.0 + } + return days / 30.0 +} + +// skipRatio returns skips/plays in [0, 1]; never-played tracks return 0 +// rather than dividing by zero, so they aren't penalized. +func skipRatio(plays, skips int) float64 { + if plays == 0 { + return 0.0 + } + return float64(skips) / float64(plays) +} +``` + +- [ ] **Step 4: Run tests, confirm pass** + +Run: `go test ./internal/recommendation -v` +Expected: PASS — 11 tests. + +- [ ] **Step 5: Lint** + +Run: `golangci-lint run ./internal/recommendation/...` +Expected: clean. + +- [ ] **Step 6: Commit** + +```bash +git add internal/recommendation/score.go internal/recommendation/score_test.go +git commit -m "feat(recommendation): add pure Score function with recency + skip + jitter + +Implements spec §6 weighted-shuffle scoring without the +contextual_match_score term (sub-plan #3 adds it). Pure Go, no DB +dependency; injectable RNG for deterministic tests. Coverage 100% +on score.go via the boundary tests." +``` + +--- + +## Task 2: Pure shuffle orchestration + +**Files:** +- Create: `internal/recommendation/shuffle.go` +- Create: `internal/recommendation/shuffle_test.go` + +`Shuffle` composes `Score` over a candidate set, sorts descending, truncates to `limit`. Pure. + +- [ ] **Step 1: Write the failing tests** + +Create `internal/recommendation/shuffle_test.go`: + +```go +package recommendation + +import ( + "math/rand" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgtype" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +func cand(id string, in ScoringInputs) Candidate { + t := dbq.Track{Title: id} + _ = t.ID.Scan("00000000-0000-0000-0000-" + id) // 12-char id padded + return Candidate{Track: t, Inputs: in} +} + +func TestShuffle_LikedRanksAboveUnliked(t *testing.T) { + cs := []Candidate{ + cand("000000000001", ScoringInputs{IsGeneralLiked: false}), + cand("000000000002", ScoringInputs{IsGeneralLiked: true}), + } + out := Shuffle(cs, defaultWeights(), time.Now(), fixedRNG(0.5), 10) + if out[0].Track.Title != "000000000002" { + t.Errorf("liked track did not rank first: %+v", out) + } +} + +func TestShuffle_HighSkipRanksLast(t *testing.T) { + cs := []Candidate{ + cand("000000000001", ScoringInputs{PlayCount: 10, SkipCount: 10}), // ratio 1.0 + cand("000000000002", ScoringInputs{PlayCount: 10, SkipCount: 0}), // ratio 0 + cand("000000000003", ScoringInputs{PlayCount: 10, SkipCount: 5}), // ratio 0.5 + } + out := Shuffle(cs, defaultWeights(), time.Now(), fixedRNG(0.5), 10) + if out[0].Track.Title != "000000000002" || out[2].Track.Title != "000000000001" { + t.Errorf("skip-ratio ordering broken: %v", titles(out)) + } +} + +func TestShuffle_LimitTruncates(t *testing.T) { + cs := make([]Candidate, 100) + for i := range cs { + cs[i] = cand("00000000000"+string(rune('a'+i%26)), ScoringInputs{}) + } + out := Shuffle(cs, defaultWeights(), time.Now(), fixedRNG(0.5), 10) + if len(out) != 10 { + t.Errorf("len = %d, want 10", len(out)) + } +} + +func TestShuffle_JitterDoesNotFlipStructuralWinner(t *testing.T) { + // Liked vs unliked: even with many random RNG seeds, liked NEVER ranks below unliked. + r := rand.New(rand.NewSource(42)) + for i := 0; i < 500; i++ { + cs := []Candidate{ + cand("000000000001", ScoringInputs{IsGeneralLiked: false}), + cand("000000000002", ScoringInputs{IsGeneralLiked: true}), + } + out := Shuffle(cs, defaultWeights(), time.Now(), r.Float64, 10) + if out[0].Track.Title != "000000000002" { + t.Fatalf("iter %d: liked did not rank first; out=%v", i, titles(out)) + } + } +} + +func TestShuffle_Empty_ReturnsEmpty(t *testing.T) { + out := Shuffle(nil, defaultWeights(), time.Now(), fixedRNG(0.5), 10) + if len(out) != 0 { + t.Errorf("len = %d, want 0", len(out)) + } +} + +func titles(cs []Candidate) []string { + out := make([]string, 0, len(cs)) + for _, c := range cs { + out = append(out, c.Track.Title) + } + return out +} + +// pgtype-uuid placeholder: Candidate doesn't need a real UUID for these +// pure tests; the Track.Title field is the human-readable handle. Track ID +// is left as zero pgtype.UUID and never compared. +var _ pgtype.UUID +``` + +- [ ] **Step 2: Run, confirm fail** + +Run: `go test ./internal/recommendation -run TestShuffle -v` +Expected: FAIL — `Candidate`, `Shuffle` undefined. + +- [ ] **Step 3: Implement `internal/recommendation/shuffle.go`** + +```go +package recommendation + +import ( + "sort" + "time" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// Candidate pairs a track with the inputs needed to score it. +type Candidate struct { + Track dbq.Track + Inputs ScoringInputs +} + +// Shuffle scores each candidate, sorts descending by score, and returns +// the top `limit` candidates. limit <= 0 returns nil; nil input returns +// nil. Pure — no IO, no global state beyond the rng callback. +func Shuffle( + candidates []Candidate, + weights ScoringWeights, + now time.Time, + rng func() float64, + limit int, +) []Candidate { + if len(candidates) == 0 || limit <= 0 { + return nil + } + scored := make([]struct { + c Candidate + score float64 + }, len(candidates)) + for i, c := range candidates { + scored[i].c = c + scored[i].score = Score(c.Inputs, weights, now, rng) + } + sort.Slice(scored, func(i, j int) bool { + return scored[i].score > scored[j].score + }) + if limit > len(scored) { + limit = len(scored) + } + out := make([]Candidate, limit) + for i := 0; i < limit; i++ { + out[i] = scored[i].c + } + return out +} +``` + +- [ ] **Step 4: Run, confirm pass** + +Run: `go test ./internal/recommendation -v` +Expected: PASS — 11 score tests + 5 shuffle tests = 16 total. + +- [ ] **Step 5: Commit** + +```bash +git add internal/recommendation/shuffle.go internal/recommendation/shuffle_test.go +git commit -m "feat(recommendation): add Shuffle orchestrator + +Composes Score over a candidate slice, sorts descending, truncates. +Pure — no IO. Liked-rank-higher and high-skip-ranks-last behaviors +are stable across RNG seeds (jitter band is smaller than LikeBoost +and SkipPenalty)." +``` + +--- + +## Task 3: SQL query + sqlc generation + +**Files:** +- Create: `internal/db/queries/recommendation.sql` +- Generated: `internal/db/dbq/recommendation.sql.go` + +One query that returns all eligible tracks with their stats joined. Excludes the seed and recently-played tracks. + +- [ ] **Step 1: Write the query** + +Create `internal/db/queries/recommendation.sql`: + +```sql +-- name: LoadRadioCandidates :many +-- Returns all tracks except the seed and any played by the user within +-- the last $3 hours, joined with the stats needed for scoring: +-- is_liked — boolean from general_likes for this user +-- last_played_at — max(play_events.started_at) for this user/track +-- play_count — count of play_events for this user/track +-- skip_count — play_events where was_skipped=true +SELECT + sqlc.embed(t), + (l.user_id IS NOT NULL) AS is_liked, + pe.last_played_at, + pe.play_count, + pe.skip_count +FROM tracks t +LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id +LEFT JOIN LATERAL ( + SELECT + max(started_at) AS last_played_at, + count(*) AS play_count, + count(*) FILTER (WHERE was_skipped) AS skip_count + FROM play_events + WHERE user_id = $1 AND track_id = t.id +) pe ON true +WHERE t.id <> $2 + AND NOT EXISTS ( + SELECT 1 FROM play_events + WHERE user_id = $1 AND track_id = t.id + AND started_at > now() - $3 * interval '1 hour' + ); +``` + +- [ ] **Step 2: Generate sqlc bindings** + +Run: `$(go env GOPATH)/bin/sqlc generate` +Expected: writes `internal/db/dbq/recommendation.sql.go` with `LoadRadioCandidatesParams` (UserID, TrackID for seed, third param for hours) and `LoadRadioCandidatesRow` containing `Track`, `IsLiked`, `LastPlayedAt`, `PlayCount`, `SkipCount`. + +- [ ] **Step 3: Verify build** + +Run: `go build ./...` +Expected: clean. + +- [ ] **Step 4: Commit** + +```bash +git add internal/db/queries/recommendation.sql internal/db/dbq/recommendation.sql.go internal/db/dbq/models.go +git commit -m "feat(db): add LoadRadioCandidates query + +Single SELECT that joins tracks with general_likes (for is_liked) and +an aggregated LATERAL subquery on play_events (for last_played_at, +play_count, skip_count). Excludes seed + tracks played in the last +N hours. Drives the M3 weighted shuffle scoring." +``` + +--- + +## Task 4: Candidate loader + +**Files:** +- Create: `internal/recommendation/candidates.go` +- Create: `internal/recommendation/candidates_test.go` + +Wraps the sqlc query, projects rows to `[]Candidate`. Live-DB tests verify the join + exclusion logic. + +- [ ] **Step 1: Write failing tests** + +Create `internal/recommendation/candidates_test.go`: + +```go +package recommendation + +import ( + "context" + "io" + "log/slog" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +func testPool(t *testing.T) *pgxpool.Pool { + t.Helper() + if testing.Short() { + t.Skip("skipping integration test in -short mode") + } + dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil { + t.Fatalf("migrate: %v", err) + } + pool, err := pgxpool.New(context.Background(), dsn) + if err != nil { + t.Fatalf("pool: %v", err) + } + t.Cleanup(pool.Close) + if _, err := pool.Exec(context.Background(), + "TRUNCATE general_likes, general_likes_albums, general_likes_artists, play_events, skip_events, play_sessions, sessions, users, tracks, albums, artists RESTART IDENTITY CASCADE"); err != nil { + t.Fatalf("truncate: %v", err) + } + return pool +} + +type fixture struct { + pool *pgxpool.Pool + q *dbq.Queries + user pgtype.UUID + tracks []dbq.Track // tracks[0] is the canonical seed +} + +func newFixture(t *testing.T, n int) fixture { + t.Helper() + pool := testPool(t) + q := dbq.New(pool) + u, err := q.CreateUser(context.Background(), dbq.CreateUserParams{ + Username: "tester", PasswordHash: "x", ApiToken: "x", IsAdmin: false, + }) + if err != nil { + t.Fatalf("user: %v", err) + } + a, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "A", SortName: "A"}) + al, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{Title: "B", SortTitle: "B", ArtistID: a.ID}) + tracks := make([]dbq.Track, 0, n) + for i := 0; i < n; i++ { + tr, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ + Title: string(rune('A' + i)), + AlbumID: al.ID, + ArtistID: a.ID, + FilePath: "/tmp/track-" + string(rune('A'+i)) + ".flac", + DurationMs: 120_000, + }) + if err != nil { + t.Fatalf("track[%d]: %v", i, err) + } + tracks = append(tracks, tr) + } + return fixture{pool: pool, q: q, user: u.ID, tracks: tracks} +} + +func TestLoadCandidates_ExcludesSeed(t *testing.T) { + f := newFixture(t, 5) + got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1) + if err != nil { + t.Fatalf("LoadCandidates: %v", err) + } + if len(got) != 4 { + t.Fatalf("len = %d, want 4 (5 minus seed)", len(got)) + } + for _, c := range got { + if c.Track.ID == f.tracks[0].ID { + t.Errorf("seed appeared in candidate set") + } + } +} + +func TestLoadCandidates_ExcludesRecentlyPlayed(t *testing.T) { + f := newFixture(t, 3) + // Seed = tracks[0]. Other tracks are tracks[1], tracks[2]. + // Insert a play_session and a play_event for tracks[1] 30 minutes ago. + now := time.Now().UTC() + thirtyMinAgo := now.Add(-30 * time.Minute) + sess, _ := f.q.InsertPlaySession(context.Background(), dbq.InsertPlaySessionParams{ + UserID: f.user, + StartedAt: pgtype.Timestamptz{Time: thirtyMinAgo, Valid: true}, + ClientID: nil, + }) + _, _ = f.q.InsertPlayEvent(context.Background(), dbq.InsertPlayEventParams{ + UserID: f.user, + TrackID: f.tracks[1].ID, + SessionID: sess.ID, + StartedAt: pgtype.Timestamptz{Time: thirtyMinAgo, Valid: true}, + ClientID: nil, + }) + + got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1) + if err != nil { + t.Fatalf("LoadCandidates: %v", err) + } + // Expect tracks[2] only (seed excluded, tracks[1] excluded by recently-played). + if len(got) != 1 { + t.Fatalf("len = %d, want 1; got=%v", len(got), trackTitles(got)) + } + if got[0].Track.ID != f.tracks[2].ID { + t.Errorf("expected tracks[2], got %v", got[0].Track.Title) + } +} + +func TestLoadCandidates_StatJoin(t *testing.T) { + f := newFixture(t, 2) + // Seed = tracks[0]. Like tracks[1]; play it twice (one skip, one full play) 2 hours ago. + if err := f.q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: f.user, TrackID: f.tracks[1].ID}); err != nil { + t.Fatalf("like: %v", err) + } + twoH := time.Now().UTC().Add(-2 * time.Hour) + sess, _ := f.q.InsertPlaySession(context.Background(), dbq.InsertPlaySessionParams{ + UserID: f.user, StartedAt: pgtype.Timestamptz{Time: twoH, Valid: true}, + }) + // First play: completed (was_skipped=false). + pe1, _ := f.q.InsertPlayEvent(context.Background(), dbq.InsertPlayEventParams{ + UserID: f.user, TrackID: f.tracks[1].ID, SessionID: sess.ID, + StartedAt: pgtype.Timestamptz{Time: twoH, Valid: true}, + }) + dur := int32(120_000) + ratio := 1.0 + _, _ = f.q.UpdatePlayEventEnded(context.Background(), dbq.UpdatePlayEventEndedParams{ + ID: pe1.ID, EndedAt: pgtype.Timestamptz{Time: twoH.Add(2 * time.Minute), Valid: true}, + DurationPlayedMs: &dur, CompletionRatio: &ratio, WasSkipped: false, + }) + // Second play: skipped (was_skipped=true). + pe2, _ := f.q.InsertPlayEvent(context.Background(), dbq.InsertPlayEventParams{ + UserID: f.user, TrackID: f.tracks[1].ID, SessionID: sess.ID, + StartedAt: pgtype.Timestamptz{Time: twoH.Add(5 * time.Minute), Valid: true}, + }) + dur2 := int32(5_000) + ratio2 := 0.04 + _, _ = f.q.UpdatePlayEventEnded(context.Background(), dbq.UpdatePlayEventEndedParams{ + ID: pe2.ID, EndedAt: pgtype.Timestamptz{Time: twoH.Add(5*time.Minute + 5*time.Second), Valid: true}, + DurationPlayedMs: &dur2, CompletionRatio: &ratio2, WasSkipped: true, + }) + + got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1) + if err != nil { + t.Fatalf("LoadCandidates: %v", err) + } + if len(got) != 1 { + t.Fatalf("len = %d, want 1", len(got)) + } + c := got[0] + if !c.Inputs.IsGeneralLiked { + t.Errorf("IsGeneralLiked = false, want true") + } + if c.Inputs.PlayCount != 2 { + t.Errorf("PlayCount = %d, want 2", c.Inputs.PlayCount) + } + if c.Inputs.SkipCount != 1 { + t.Errorf("SkipCount = %d, want 1", c.Inputs.SkipCount) + } + if c.Inputs.LastPlayedAt == nil { + t.Errorf("LastPlayedAt = nil, want non-nil") + } +} + +func TestLoadCandidates_NeverPlayedHasNilLastPlayed(t *testing.T) { + f := newFixture(t, 2) + got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1) + if err != nil { + t.Fatalf("LoadCandidates: %v", err) + } + if len(got) != 1 { + t.Fatalf("len = %d, want 1", len(got)) + } + if got[0].Inputs.LastPlayedAt != nil { + t.Errorf("never-played track has non-nil LastPlayedAt: %v", got[0].Inputs.LastPlayedAt) + } + if got[0].Inputs.PlayCount != 0 || got[0].Inputs.SkipCount != 0 { + t.Errorf("never-played track has non-zero counts: %+v", got[0].Inputs) + } +} + +func TestLoadCandidates_CrossUserIsolation(t *testing.T) { + f := newFixture(t, 2) + bob, _ := f.q.CreateUser(context.Background(), dbq.CreateUserParams{ + Username: "bob", PasswordHash: "x", ApiToken: "y", IsAdmin: false, + }) + // Alice likes tracks[1]; Bob shouldn't see it. + _ = f.q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: f.user, TrackID: f.tracks[1].ID}) + + got, err := LoadCandidates(context.Background(), f.q, bob.ID, f.tracks[0].ID, 1) + if err != nil { + t.Fatalf("LoadCandidates: %v", err) + } + for _, c := range got { + if c.Track.ID == f.tracks[1].ID && c.Inputs.IsGeneralLiked { + t.Errorf("bob sees alice's like on track %v", c.Track.Title) + } + } +} + +func trackTitles(cs []Candidate) []string { + out := make([]string, 0, len(cs)) + for _, c := range cs { + out = append(out, c.Track.Title) + } + return out +} +``` + +- [ ] **Step 2: Run, confirm fail** + +Run: +```bash +MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \ + go test ./internal/recommendation -run TestLoadCandidates -v +``` +Expected: FAIL — `LoadCandidates` undefined. + +- [ ] **Step 3: Implement `internal/recommendation/candidates.go`** + +```go +package recommendation + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// LoadCandidates returns all candidate tracks for the given user, excluding +// the seed and any track played within the last `recentlyPlayedHours`. +// Each Candidate includes the per-(user,track) stats the scoring function +// needs. +func LoadCandidates( + ctx context.Context, + q *dbq.Queries, + userID, seedID pgtype.UUID, + recentlyPlayedHours int, +) ([]Candidate, error) { + rows, err := q.LoadRadioCandidates(ctx, dbq.LoadRadioCandidatesParams{ + UserID: userID, + ID: seedID, + Column3: float64(recentlyPlayedHours), + }) + if err != nil { + return nil, err + } + out := make([]Candidate, 0, len(rows)) + for _, r := range rows { + var lastPlayed *pgtype.Timestamptz + if r.LastPlayedAt.Valid { + ts := r.LastPlayedAt + lastPlayed = &ts + } + var lastPlayedTime *([]byte) // placeholder to satisfy structure if needed + _ = lastPlayedTime // appease unused warning + var lpt *time.Time + if r.LastPlayedAt.Valid { + t := r.LastPlayedAt.Time + lpt = &t + } + out = append(out, Candidate{ + Track: r.Track, + Inputs: ScoringInputs{ + IsGeneralLiked: r.IsLiked, + LastPlayedAt: lpt, + PlayCount: int(r.PlayCount), + SkipCount: int(r.SkipCount), + }, + }) + _ = lastPlayed + } + return out, nil +} +``` + +Note: clean up the duplicated `lastPlayed` / `lastPlayedTime` shims — they're scaffolding from drafting. Final form: + +```go +package recommendation + +import ( + "context" + "time" + + "github.com/jackc/pgx/v5/pgtype" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +func LoadCandidates( + ctx context.Context, + q *dbq.Queries, + userID, seedID pgtype.UUID, + recentlyPlayedHours int, +) ([]Candidate, error) { + rows, err := q.LoadRadioCandidates(ctx, dbq.LoadRadioCandidatesParams{ + UserID: userID, + ID: seedID, + Column3: float64(recentlyPlayedHours), + }) + if err != nil { + return nil, err + } + out := make([]Candidate, 0, len(rows)) + for _, r := range rows { + var lpt *time.Time + if r.LastPlayedAt.Valid { + t := r.LastPlayedAt.Time + lpt = &t + } + out = append(out, Candidate{ + Track: r.Track, + Inputs: ScoringInputs{ + IsGeneralLiked: r.IsLiked, + LastPlayedAt: lpt, + PlayCount: int(r.PlayCount), + SkipCount: int(r.SkipCount), + }, + }) + } + return out, nil +} +``` + +Note on the sqlc-generated `Column3` field name: sqlc names unnamed parameters `Column1`, `Column2`, etc. when there's no obvious mapping. If the generated field has a different name (e.g. `Hours`), adjust the call site to match. Run sqlc generate first and inspect `internal/db/dbq/recommendation.sql.go` to confirm. + +- [ ] **Step 4: Run, confirm pass** + +Run: +```bash +MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \ + go test ./internal/recommendation -v +``` +Expected: PASS — 16 prior tests + 5 new candidate tests = 21 total. + +- [ ] **Step 5: Lint** + +Run: `golangci-lint run ./internal/recommendation/...` +Expected: clean. + +- [ ] **Step 6: Commit** + +```bash +git add internal/recommendation/candidates.go internal/recommendation/candidates_test.go +git commit -m "feat(recommendation): add LoadCandidates DB-backed loader + +Wraps the LoadRadioCandidates sqlc query, projects rows to +[]Candidate. Live-DB tests verify seed exclusion, recently-played +exclusion, stat-join correctness (likes + plays + skips + +last_played_at), and cross-user isolation." +``` + +--- + +## Task 5: RecommendationConfig + +**Files:** +- Modify: `internal/config/config.go` +- Modify: `config.example.yaml` + +Adds operator-tunable weights to YAML / env. Defaults match the spec. + +- [ ] **Step 1: Add the struct + field + defaults** + +In `internal/config/config.go`, add `Recommendation RecommendationConfig` to the `Config` struct (alongside `Events`): + +```go +type Config struct { + Server ServerConfig `yaml:"server"` + Database DatabaseConfig `yaml:"database"` + Log LogConfig `yaml:"log"` + Auth AuthConfig `yaml:"auth"` + Library LibraryConfig `yaml:"library"` + Subsonic SubsonicConfig `yaml:"subsonic"` + Events EventsConfig `yaml:"events"` + Recommendation RecommendationConfig `yaml:"recommendation"` +} +``` + +Add the struct definition (after `EventsConfig`): + +```go +// RecommendationConfig governs the M3 weighted-shuffle scoring (spec §6). +// All weights are operator-tunable; defaults match the spec recommendations. +type RecommendationConfig struct { + BaseWeight float64 `yaml:"base_weight"` + LikeBoost float64 `yaml:"like_boost"` + RecencyWeight float64 `yaml:"recency_weight"` + SkipPenalty float64 `yaml:"skip_penalty"` + JitterMagnitude float64 `yaml:"jitter_magnitude"` + RecentlyPlayedHours int `yaml:"recently_played_hours"` + RadioSize int `yaml:"radio_size"` + RadioSizeMax int `yaml:"radio_size_max"` +} +``` + +Update `Default()` to seed the defaults: + +```go +func Default() Config { + return Config{ + Server: ServerConfig{Address: ":4533"}, + Database: DatabaseConfig{URL: ""}, + Log: LogConfig{Level: "info", Format: "text"}, + Events: EventsConfig{ + SessionTimeoutMinutes: 30, + SkipMaxCompletionRatio: 0.5, + SkipMaxDurationPlayedMs: 30000, + }, + Recommendation: RecommendationConfig{ + BaseWeight: 1.0, + LikeBoost: 2.0, + RecencyWeight: 1.0, + SkipPenalty: 1.0, + JitterMagnitude: 0.1, + RecentlyPlayedHours: 1, + RadioSize: 50, + RadioSizeMax: 200, + }, + } +} +``` + +- [ ] **Step 2: Update `config.example.yaml`** + +Append after the `events:` section: + +```yaml + +recommendation: + # Base score every candidate gets before adjustments. Spec §6. + base_weight: 1.0 + # Bonus for tracks the user has liked (general_likes). + like_boost: 2.0 + # Multiplier on recency_decay (1.0 for tracks ≥ 30 days stale, 0 for fresh). + recency_weight: 1.0 + # Penalty multiplier on skip_ratio (skips/plays); 1.0 = full penalty. + skip_penalty: 1.0 + # ± random jitter applied to every candidate; breaks ties without dominating. + jitter_magnitude: 0.1 + # Hard-suppression window: tracks played within this many hours never appear. + recently_played_hours: 1 + # Default radio size when ?limit= is not specified. + radio_size: 50 + # Maximum allowed ?limit= (server caps to this regardless). + radio_size_max: 200 +``` + +- [ ] **Step 3: Run config tests** + +Run: `go test ./internal/config -v` +Expected: PASS — existing tests still pass with the new struct defaulting correctly. + +- [ ] **Step 4: Commit** + +```bash +git add internal/config/config.go config.example.yaml +git commit -m "feat(config): add recommendation section (weighted shuffle weights) + +BaseWeight, LikeBoost, RecencyWeight, SkipPenalty, JitterMagnitude, +RecentlyPlayedHours, RadioSize, RadioSizeMax. Defaults match spec §6." +``` + +--- + +## Task 6: Radio handler rewrite + +**Files:** +- Modify: `internal/api/radio.go` (replaces stub) +- Modify: `internal/api/radio_test.go` (replaces stub tests) +- Modify: `internal/api/api.go` (Mount signature gains config arg; handlers struct gains recCfg field) +- Modify: `internal/api/auth_test.go` (testHandlers seeds a default RecommendationConfig) +- Modify: `internal/api/library_test.go` (newLibraryRouter doesn't need radio routing changes; no-op verification) +- Modify: `internal/server/server.go` + `cmd/minstrel/main.go` (pass cfg through) + +Replaces the stub with the v1 weighted-shuffle implementation. + +- [ ] **Step 1: Update `handlers` struct + Mount in `internal/api/api.go`** + +```go +package api + +import ( + "log/slog" + "math/rand" + + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" + "git.fabledsword.com/bvandeusen/minstrel/internal/config" + "git.fabledsword.com/bvandeusen/minstrel/internal/playevents" +) + +func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig) { + h := &handlers{ + pool: pool, + logger: logger, + events: events, + recCfg: recCfg, + // Seeded once at server start; per-request shuffle reads h.rng.Float64. + // Concurrent calls are fine — math/rand.Rand is safe under contention via + // a mutex internally (Go 1.21+); for stricter isolation switch to math/rand/v2. + } + rng := rand.New(rand.NewSource(rand.Int63())) + h.rng = rng.Float64 + + // ... existing route registrations unchanged ... + r.Route("/api", func(api chi.Router) { + api.Post("/auth/login", h.handleLogin) + api.Group(func(authed chi.Router) { + authed.Use(auth.RequireUser(pool)) + authed.Post("/auth/logout", h.handleLogout) + authed.Get("/me", h.handleGetMe) + + authed.Get("/artists", h.handleListArtists) + authed.Get("/artists/{id}", h.handleGetArtist) + authed.Get("/albums/{id}", h.handleGetAlbum) + authed.Get("/albums/{id}/cover", h.handleGetCover) + authed.Get("/tracks/{id}", h.handleGetTrack) + authed.Get("/tracks/{id}/stream", h.handleGetStream) + authed.Get("/search", h.handleSearch) + authed.Get("/radio", h.handleRadio) + authed.Post("/events", h.handleEvents) + authed.Post("/likes/tracks/{id}", h.handleLikeTrack) + authed.Delete("/likes/tracks/{id}", h.handleUnlikeTrack) + authed.Post("/likes/albums/{id}", h.handleLikeAlbum) + authed.Delete("/likes/albums/{id}", h.handleUnlikeAlbum) + authed.Post("/likes/artists/{id}", h.handleLikeArtist) + authed.Delete("/likes/artists/{id}", h.handleUnlikeArtist) + authed.Get("/likes/tracks", h.handleListLikedTracks) + authed.Get("/likes/albums", h.handleListLikedAlbums) + authed.Get("/likes/artists", h.handleListLikedArtists) + authed.Get("/likes/ids", h.handleGetLikedIDs) + }) + }) +} + +type handlers struct { + pool *pgxpool.Pool + logger *slog.Logger + events *playevents.Writer + recCfg config.RecommendationConfig + rng func() float64 +} +``` + +(Note: `math/rand`'s top-level `Float64` is goroutine-safe, but the new-instance pattern with explicit seed is cleaner. Stay with `func() float64` so tests can inject deterministic values.) + +- [ ] **Step 2: Replace `internal/api/radio.go`** + +```go +package api + +import ( + "errors" + "net/http" + "strconv" + "strings" + "time" + + "github.com/jackc/pgx/v5" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/recommendation" +) + +// RadioResponse is the body of GET /api/radio. +type RadioResponse struct { + Tracks []TrackRef `json:"tracks"` +} + +// handleRadio implements GET /api/radio?seed_track=&limit=. +// +// Returns the seed at index 0, followed by up to limit-1 weighted-shuffle +// picks from the user's library, scored by recommendation.Score. +func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) { + user, ok := authUserFromContext(r) + if !ok { + writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") + return + } + raw := strings.TrimSpace(r.URL.Query().Get("seed_track")) + if raw == "" { + writeErr(w, http.StatusBadRequest, "bad_request", "seed_track is required") + return + } + seedID, ok := parseUUID(raw) + if !ok { + writeErr(w, http.StatusBadRequest, "bad_request", "invalid seed_track id") + return + } + limit := h.recCfg.RadioSize + if v := r.URL.Query().Get("limit"); v != "" { + n, err := strconv.Atoi(v) + if err != nil || n < 1 { + writeErr(w, http.StatusBadRequest, "bad_request", "invalid limit") + return + } + limit = n + } + if limit > h.recCfg.RadioSizeMax { + limit = h.recCfg.RadioSizeMax + } + q := dbq.New(h.pool) + track, err := q.GetTrackByID(r.Context(), seedID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + writeErr(w, http.StatusNotFound, "not_found", "seed_track not found") + return + } + h.logger.Error("api: get radio seed track failed", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") + return + } + album, err := q.GetAlbumByID(r.Context(), track.AlbumID) + if err != nil { + h.logger.Error("api: get radio seed album failed", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") + return + } + artist, err := q.GetArtistByID(r.Context(), track.ArtistID) + if err != nil { + h.logger.Error("api: get radio seed artist failed", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") + return + } + candidates, err := recommendation.LoadCandidates(r.Context(), q, user.ID, seedID, h.recCfg.RecentlyPlayedHours) + if err != nil { + h.logger.Error("api: radio: load candidates", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "candidate load failed") + return + } + weights := recommendation.ScoringWeights{ + BaseWeight: h.recCfg.BaseWeight, + LikeBoost: h.recCfg.LikeBoost, + RecencyWeight: h.recCfg.RecencyWeight, + SkipPenalty: h.recCfg.SkipPenalty, + JitterMagnitude: h.recCfg.JitterMagnitude, + } + picks := recommendation.Shuffle(candidates, weights, time.Now().UTC(), h.rng, limit-1) + + out := make([]TrackRef, 0, len(picks)+1) + out = append(out, trackRefFrom(track, album.Title, artist.Name)) + for _, p := range picks { + // Resolve album/artist names per pick. Reuse the existing resolver. + al, err := q.GetAlbumByID(r.Context(), p.Track.AlbumID) + if err != nil { + h.logger.Error("api: radio: resolve album", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "resolve failed") + return + } + ar, err := q.GetArtistByID(r.Context(), p.Track.ArtistID) + if err != nil { + h.logger.Error("api: radio: resolve artist", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "resolve failed") + return + } + out = append(out, trackRefFrom(p.Track, al.Title, ar.Name)) + } + writeJSON(w, http.StatusOK, RadioResponse{Tracks: out}) +} + +// authUserFromContext is a thin shim around auth.UserFromContext that tests +// can override. It exists so testHandlers can inject a user via the same +// context key RequireUser populates. +func authUserFromContext(r *http.Request) (dbq.User, bool) { + return userFromContext(r) +} +``` + +`userFromContext` is the existing helper used by the events handler — reuse it; if it's named differently in your tree, follow that pattern. The shim layer (`authUserFromContext`) is unnecessary if the existing handler pattern just calls `auth.UserFromContext(r.Context())` directly. Use whatever the rest of the package uses; consistency over novelty. + +- [ ] **Step 3: Update `testHandlers` in `internal/api/auth_test.go`** + +Find the existing `testHandlers` function. Update the construction: + +```go +import ( + // ... existing imports ... + "git.fabledsword.com/bvandeusen/minstrel/internal/config" +) + +func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) { + // ... existing setup unchanged up to the return ... + w := playevents.NewWriter(pool, logger, 30*time.Minute, 0.5, 30000) + recCfg := config.RecommendationConfig{ + BaseWeight: 1.0, LikeBoost: 2.0, RecencyWeight: 1.0, + SkipPenalty: 1.0, JitterMagnitude: 0.1, + RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200, + } + h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, rng: func() float64 { return 0.5 }} + return h, pool +} +``` + +The fixed-RNG (`return 0.5`) makes tests deterministic — jitter contribution is always 0. + +- [ ] **Step 4: Replace `internal/api/radio_test.go`** + +Replace the existing 5 stub tests with new ones: + +```go +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func callRadio(h *handlers, user interface{}, query string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, "/api/radio?"+query, nil) + req = req.WithContext(context.WithValue(req.Context(), userCtxKeyForTest(), user)) + w := httptest.NewRecorder() + h.handleRadio(w, req) + return w +} + +func TestHandleRadio_ColdStart_OnlySeedReturned(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + user := seedUser(t, pool, "alice", "x", false) + artist := seedArtist(t, pool, "X") + album := seedAlbum(t, pool, artist.ID, "X", 1990) + seed := seedTrack(t, pool, album.ID, artist.ID, "Seed", 1, 100_000) + + w := callRadio(h, user, "seed_track="+uuidToString(seed.ID)) + if w.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", w.Code, w.Body.String()) + } + var resp RadioResponse + _ = json.Unmarshal(w.Body.Bytes(), &resp) + if len(resp.Tracks) != 1 { + t.Fatalf("len = %d, want 1", len(resp.Tracks)) + } + if resp.Tracks[0].Title != "Seed" { + t.Errorf("seed not first: %v", resp.Tracks[0].Title) + } +} + +func TestHandleRadio_Typical_SeedFirstPlusPicks(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + user := seedUser(t, pool, "alice", "x", false) + artist := seedArtist(t, pool, "X") + album := seedAlbum(t, pool, artist.ID, "X", 1990) + seed := seedTrack(t, pool, album.ID, artist.ID, "Seed", 1, 100_000) + for i := 2; i <= 6; i++ { + seedTrack(t, pool, album.ID, artist.ID, "T"+string(rune('0'+i)), i, 100_000) + } + + w := callRadio(h, user, "seed_track="+uuidToString(seed.ID)) + if w.Code != http.StatusOK { + t.Fatalf("status = %d", w.Code) + } + var resp RadioResponse + _ = json.Unmarshal(w.Body.Bytes(), &resp) + if len(resp.Tracks) != 6 { + t.Fatalf("len = %d, want 6 (seed + 5 picks)", len(resp.Tracks)) + } + if resp.Tracks[0].Title != "Seed" { + t.Errorf("seed not at index 0: %v", resp.Tracks[0].Title) + } +} + +func TestHandleRadio_UnknownSeedIs404(t *testing.T) { + h, pool := testHandlers(t) + user := seedUser(t, pool, "alice", "x", false) + w := callRadio(h, user, "seed_track=00000000-0000-0000-0000-000000000000") + if w.Code != http.StatusNotFound { + t.Errorf("status = %d", w.Code) + } +} + +func TestHandleRadio_BadSeedIs400(t *testing.T) { + h, pool := testHandlers(t) + user := seedUser(t, pool, "alice", "x", false) + w := callRadio(h, user, "seed_track=not-a-uuid") + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d", w.Code) + } +} + +func TestHandleRadio_MissingSeedIs400(t *testing.T) { + h, pool := testHandlers(t) + user := seedUser(t, pool, "alice", "x", false) + w := callRadio(h, user, "") + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d", w.Code) + } +} + +func TestHandleRadio_BadLimitIs400(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + user := seedUser(t, pool, "alice", "x", false) + artist := seedArtist(t, pool, "X") + album := seedAlbum(t, pool, artist.ID, "X", 1990) + seed := seedTrack(t, pool, album.ID, artist.ID, "Seed", 1, 100_000) + w := callRadio(h, user, "seed_track="+uuidToString(seed.ID)+"&limit=0") + if w.Code != http.StatusBadRequest { + t.Errorf("limit=0 status = %d", w.Code) + } + w = callRadio(h, user, "seed_track="+uuidToString(seed.ID)+"&limit=-1") + if w.Code != http.StatusBadRequest { + t.Errorf("limit=-1 status = %d", w.Code) + } +} + +func TestHandleRadio_LimitClampedToMax(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + user := seedUser(t, pool, "alice", "x", false) + artist := seedArtist(t, pool, "X") + album := seedAlbum(t, pool, artist.ID, "X", 1990) + seed := seedTrack(t, pool, album.ID, artist.ID, "Seed", 1, 100_000) + for i := 2; i <= 6; i++ { + seedTrack(t, pool, album.ID, artist.ID, "T"+string(rune('0'+i)), i, 100_000) + } + w := callRadio(h, user, "seed_track="+uuidToString(seed.ID)+"&limit=99999") + if w.Code != http.StatusOK { + t.Fatalf("status = %d", w.Code) + } + var resp RadioResponse + _ = json.Unmarshal(w.Body.Bytes(), &resp) + // We only have 6 tracks; clamped limit (max 200) returns all 6. + if len(resp.Tracks) != 6 { + t.Errorf("len = %d, want 6", len(resp.Tracks)) + } +} +``` + +- [ ] **Step 5: Update Mount call sites** + +In `internal/server/server.go`: + +```go +api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg) +``` + +The `Server` struct gains `RecommendationCfg config.RecommendationConfig`; constructor `New(...)` gains the new arg. Find `cmd/minstrel/main.go`'s `server.New(...)` call and pass `cfg.Recommendation`. + +In `internal/api/library_test.go`'s `TestRoutesRegisteredInMount`: + +```go +Mount(r, h.pool, h.logger, w, config.RecommendationConfig{ + RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1, +}) +``` + +In `internal/server/server_test.go`'s `New(...)` calls (multiple sites), append `config.RecommendationConfig{}` after the `subsonic.Config{}` arg. Use the existing `replace_all` approach if there are several: + +```go +New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}) +``` + +- [ ] **Step 6: Run tests, confirm pass** + +Run: +```bash +MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \ + go test -p 1 ./... +``` +Expected: all packages PASS. + +Run: `golangci-lint run ./...` +Expected: clean. + +- [ ] **Step 7: Commit** + +```bash +git add internal/api/radio.go internal/api/radio_test.go internal/api/api.go \ + internal/api/auth_test.go internal/api/library_test.go \ + internal/server/server.go internal/server/server_test.go \ + cmd/minstrel/main.go +git commit -m "feat(api): rewrite /api/radio with weighted shuffle v1 + +Validates seed_track + optional limit (default cfg.Recommendation.RadioSize, +clamped to RadioSizeMax). Calls recommendation.LoadCandidates + +recommendation.Shuffle. Prepends seed to result. Server seeds a +math/rand source at startup; handlers package threads that as a +func() float64 so tests inject deterministic RNGs. + +Mount + server.New gain a RecommendationConfig parameter." +``` + +--- + +## Task 7: Final verification + branch finish + +**Files:** none (verification only). + +- [ ] **Step 1: Full Go suite** + +Run: +```bash +MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \ + go test -p 1 ./... +``` +Expected: all packages PASS. + +- [ ] **Step 2: Coverage on recommendation package** + +Run: +```bash +MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \ + go test -coverprofile=/tmp/rec.cover ./internal/recommendation/... +go tool cover -func=/tmp/rec.cover | tail -3 +``` +Expected: total coverage ≥ 70%. + +- [ ] **Step 3: Lint** + +Run: `golangci-lint run ./...` +Expected: clean. + +- [ ] **Step 4: Web check + tests + build** + +Run: `cd web && npm run check && npm test && npm run build` +Expected: check 0/0; tests pass; build emits `web/build/`. + +Run: `git checkout -- web/build/index.html` +Expected: committed placeholder restored. + +- [ ] **Step 5: Docker build smoke** + +Run: `docker build -t minstrel:m3-shuffle-smoke .` +Expected: image builds. + +Run: `docker run --rm --entrypoint /bin/sh minstrel:m3-shuffle-smoke -c 'test -x /usr/local/bin/minstrel && echo ok'` +Expected: `ok`. + +- [ ] **Step 6: Local end-to-end manual check** + +```bash +docker compose up --build -d +``` + +Browser at `localhost:4533` (sign in with the dev admin creds): + +1. Play 3 tracks all the way through (build `play_count` history). +2. Skip a 4th track within 10 seconds (`skip_count = 1` for that track). +3. Like 2 tracks via the heart button. +4. Click the radio button on a 5th track (or trigger via search → click track). +5. Inspect the response in dev tools network tab — 50 tracks, seed first. +6. The 2 liked tracks appear early in the list (within first ~20). +7. The just-skipped track appears late or absent. +8. The 3 just-played tracks are absent (recently-played exclusion). +9. Trigger radio again with a different seed; ordering varies (jitter), liked tracks still rank prominently. + +Tear down: +```bash +docker compose down +``` + +- [ ] **Step 7: Finish the branch** + +Follow `superpowers:finishing-a-development-branch`. Expected path: Option 2 — push `dev`, open a PR to `main`, wait for Forgejo CI, merge once green. + +--- + +## Self-Review Notes + +**Spec coverage:** +- Pure scoring function with all 5 terms (base, like boost, recency, skip penalty, jitter) → Task 1. +- Shuffle composition (sort + truncate) → Task 2. +- Candidate loader excluding seed + recently-played → Tasks 3–4. +- Stat join (is_liked + last_played_at + play_count + skip_count) → Tasks 3–4. +- Operator-tunable weights → Task 5. +- Endpoint contract (seed first, limit default 50 / max 200, errors unchanged) → Task 6. +- Cross-user isolation → Task 4 + Task 6. +- Cold-start / never-played handling → Task 1 + Task 6. +- ≥ 70% coverage on the recommendation package → Task 7. +- End-to-end manual → Task 7. + +**Type consistency:** +- `ScoringInputs`, `ScoringWeights`, `Candidate` — same names everywhere they're consumed. +- `Score(...) → float64`, `Shuffle(...) → []Candidate`, `LoadCandidates(...) → []Candidate, error` — signatures stable. +- `recCfg config.RecommendationConfig` and `rng func() float64` — same on the handlers struct, in tests, in Mount. +- `pgtype.UUID` server-side, `string` at HTTP boundaries via `uuidToString`. + +**Filename hazards:** none — all new files under `internal/recommendation/`. The route-test handling for radio is colocated with the other api tests (no `+`-prefix concerns). + +**Placeholder scan:** no TBD/TODO/later markers. The `Column3` parameter naming caveat in Task 4 is documented inline (sqlc names unnamed positional params `Column1`, `Column2`, etc., adjust per generated code). + +**Performance note:** Task 6's per-pick album/artist resolve is N round-trips for the picks. For `RadioSize=50` that's 100 round-trips. Acceptable at v1 scale; matches the pattern used by `resolveTrackRefs` elsewhere. M4 / future task can batch via `IN (...)` + map-by-id if telemetry shows it matters. diff --git a/docs/superpowers/specs/2026-04-27-m3-shuffle-design.md b/docs/superpowers/specs/2026-04-27-m3-shuffle-design.md new file mode 100644 index 00000000..d72e0a2c --- /dev/null +++ b/docs/superpowers/specs/2026-04-27-m3-shuffle-design.md @@ -0,0 +1,230 @@ +# M3 Weighted Shuffle v1 — Design Spec + +**Status:** approved 2026-04-27 +**Slice:** M3 sub-plan #1 of 3 (recommendation engine v1 + contextual likes). Spec §13 step 7. Step 8 (session vectors + contextual_match_score) is the next sub-plan; this slice ships the scoring foundation without the contextual term. +**Fable task:** #340. + +## 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. After this slice, clicking a track in the SPA's search/track-row "play radio" surface produces a meaningful 50-track queue instead of a one-track stub. + +## Non-goals + +- Session vectors / `contextual_match_score` (sub-plan #2 / Fable #341 covers the write path; sub-plan #3 / Fable #342 folds the score into this function). +- Real "radio" candidate pools from ListenBrainz similarity / similar artists / similar tags (M4). +- Album-seed or artist-seed radio (`?seed_album=`, `?seed_artist=`). v1 is track-seeded only; the Fable task covers that scope explicitly. +- Periodic radio refresh (the spec's "regenerate at 80% consumed"). Player-side concern; lands later. +- Persistent radio queues. Each `/api/radio` call returns a fresh selection — stateless on the server. +- Performance optimization for very large libraries (>50k tracks). At v1 scale the wide-pool scan is fine; M3.5 / M4 can add sampling if telemetry shows it matters. +- `Subsonic` star-radio compatibility. Subsonic's `getRandomSongs` is a different shape; we don't try to map weighted shuffle into it for v1. + +## Architecture + +A new `internal/recommendation` package owns: + +- A pure scoring function `Score(inputs, weights, now, rng) → float64` with no DB dependency. The `rng` parameter is injectable so tests pin jitter to deterministic values. +- A candidate loader `LoadCandidates(ctx, q, userID, seedID, recentlyPlayedHours) → []Candidate` that runs ONE SQL query joining `tracks`, `general_likes`, and aggregated `play_events` to produce the per-track stats the scoring function needs. Excludes the seed and any track played within the recently-played window. +- A `Shuffle(candidates, weights, now, rng, limit)` orchestrator that scores each candidate, sorts descending, truncates to `limit`. Pure. + +The `/api/radio` handler becomes a thin shim: validate the seed → call `LoadCandidates` → call `Shuffle` → prepend the seed to the result → project to `[]TrackRef` → return JSON. + +**Why split into three layers:** +- `score.go` is the math; trivially unit-testable, no infra. +- `candidates.go` is the data access; integration-tested against a live DB. +- `shuffle.go` is the composition; pure-function tests against fake candidate sets. +- The HTTP handler is the I/O glue; lives in `api/radio.go` like the existing pattern. + +This separation also sets up sub-plan #3 (contextual scoring) cleanly — we'll add `LoadContextualSimilarity` next to `LoadCandidates`, extend `ScoringInputs` with `ContextualMatchScore`, and the rest of the chain doesn't change. + +## Schema + +No migration. All inputs are computed on demand from existing tables: + +- `general_likes` for `is_general_liked`. +- `play_events` for `last_played_at`, `play_count`, `skip_count`. Aggregations done in the candidate-loader query. +- `tracks` provides the candidate set itself. + +The recently-played-in-the-last-hour filter is applied at candidate-load time (a single `WHERE NOT EXISTS (... AND started_at > now() - interval '1 hour')` clause). Hard suppression — these tracks aren't even scored. + +## Scoring math + +```go +type ScoringInputs struct { + IsGeneralLiked bool + LastPlayedAt *time.Time // nil = never played + PlayCount int // total play_events + SkipCount int // play_events with was_skipped=true +} + +type ScoringWeights struct { + BaseWeight float64 // default 1.0 + LikeBoost float64 // default 2.0 + RecencyWeight float64 // default 1.0 + SkipPenalty float64 // default 1.0 + JitterMagnitude float64 // default 0.1 +} + +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(lastPlayed *time.Time, now time.Time) float64`** — returns `[0, 1]`: +- Never played (`lastPlayed == nil`) → `1.0`. Cold-start tracks compete favorably with stale ones; otherwise the recommendation engine never surfaces music the user hasn't tried. +- Played within last hour: doesn't reach this function (filtered earlier at the candidate-pool stage). +- Otherwise: `min(age_days / 30.0, 1.0)`. Linear ramp; tracks ≥ 30 days stale hit max recency boost. + +**`skipRatio(plays, skips int) float64`** — returns `[0, 1]`: +- `plays == 0` → `0.0`. Don't penalize never-played tracks. +- Otherwise → `float64(skips) / float64(plays)`. + +**Random jitter** — `(rng()*2 - 1) * JitterMagnitude` produces values in `[-magnitude, +magnitude]`. The `rng` parameter is `func() float64` (matches `math/rand.Float64`); tests inject a fixed-value version for deterministic ordering. + +**Score range under defaults:** +- Min (unliked, recent, all-skips): `1.0 + 0 + 0 - 1.0 - 0.1 = -0.1` +- Max (liked, ≥30d stale, never skipped): `1.0 + 2.0 + 1.0 - 0 + 0.1 = 4.1` +- Liked-track structural advantage (`LikeBoost = 2.0`) dominates the jitter band (`±0.1`), so liked tracks always rank above identical unliked tracks. + +Configurable via YAML / env. Operators can crank `LikeBoost` up if their library is dominated by stuff they don't actually like, or `JitterMagnitude` up if they want more variety. + +## API contracts + +**Request:** + +``` +GET /api/radio?seed_track=&limit= +``` + +- `seed_track` (required) — UUID of the track that seeds the radio. +- `limit` (optional, default 50, max 200) — total number of tracks to return *including* the seed. + +**Response (shape unchanged from M2 stub, only contents grew):** + +```ts +type RadioResponse = { tracks: TrackRef[] }; +``` + +- `tracks[0]` is always the seed track (so `playQueue(resp.tracks, 0)` plays the seed). +- `tracks[1..]` are the top-scored candidates from the user's library, descending. Length up to `limit - 1`. +- Cold-start (empty library beyond the seed): returns `{ tracks: [] }`. + +**Errors (unchanged from M2 stub):** +- `400 bad_request` — missing `seed_track`, malformed UUID, or `limit < 1`. +- `404 not_found` — `seed_track` doesn't exist. +- `500 server_error` — DB issue. + +**No web client changes.** The existing `playRadio(seedTrackId)` already calls this endpoint and feeds `resp.tracks` into `playQueue(tracks, 0)`. After this slice the queue is fuller; the call shape is identical. + +**No Subsonic changes.** Track-seeded radio isn't part of Subsonic's surface in our v1 scope. + +## Components & files + +### New server files + +| Path | Responsibility | +|---|---| +| `internal/recommendation/score.go` | Pure scoring function + `recencyDecay` + `skipRatio` helpers. No DB. | +| `internal/recommendation/score_test.go` | Boundary cases: every term, cold-start, jitter determinism, score ranges. | +| `internal/recommendation/candidates.go` | `LoadCandidates(...)` — single SQL query returning `[]Candidate` (`Track + ScoringInputs`). | +| `internal/recommendation/candidates_test.go` | Live-DB tests: seed exclusion, recently-played exclusion, stat-join correctness, cross-user isolation. | +| `internal/recommendation/shuffle.go` | `Shuffle(candidates, weights, now, rng, limit) []Candidate` — composes Score + sort + truncate. Pure. | +| `internal/recommendation/shuffle_test.go` | Pure-function tests: liked-rank-higher, high-skip-rejected, jitter-doesn't-reorder-structural-winners, limit. | +| `internal/db/queries/recommendation.sql` | sqlc query: `LoadRadioCandidates` — SELECT tracks WITH LEFT JOIN general_likes, LEFT JOIN aggregated play_events stats. WHERE clause excludes seed_id and last-hour plays. | +| `internal/db/dbq/recommendation.sql.go` | Generated bindings. | + +### Modified server files + +| Path | Change | +|---|---| +| `internal/api/radio.go` | Replace stub. New flow: validate `seed_track` and `limit`, call `recommendation.LoadCandidates`, call `recommendation.Shuffle`, prepend seed, project to `[]TrackRef`, return JSON. | +| `internal/api/radio_test.go` | Replace stub tests with: cold-start, typical (seed + scored picks), 404 on unknown seed, 400 on bad seed/limit, cross-user isolation. | +| `internal/config/config.go` + `config.example.yaml` | Add `RecommendationConfig` struct: `BaseWeight` (1.0), `LikeBoost` (2.0), `RecencyWeight` (1.0), `SkipPenalty` (1.0), `JitterMagnitude` (0.1), `RecentlyPlayedHours` (1), `RadioSize` (50), `RadioSizeMax` (200). YAML key `recommendation:`. | +| `internal/api/api.go` | `handlers` struct gains `recCfg config.RecommendationConfig`. `Mount` signature gains the config arg; constructs the handler with it; the radio handler builds `ScoringWeights` from it per request. | +| `internal/server/server.go` + `cmd/minstrel/main.go` | Pass `cfg.Recommendation` through `Mount`. | + +### No web changes + +The existing `playRadio(seedTrackId)` already consumes `RadioResponse`. The web slice for this PR is empty — server-only. + +## Data flow + +1. SPA calls `playRadio(seedTrackId)` (existing player-store action). It POSTs `GET /api/radio?seed_track=` (no explicit `limit`, server defaults to 50). +2. `handleRadio` validates auth + seed UUID. Looks up the track to confirm it exists (404 otherwise). +3. Reads `limit` from query (default `cfg.Recommendation.RadioSize`, clamped to `cfg.Recommendation.RadioSizeMax`). +4. Calls `recommendation.LoadCandidates(ctx, q, userID, seedID, cfg.Recommendation.RecentlyPlayedHours)`. The SQL query joins: + - `tracks t` — base set + - `LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id` → `is_liked` + - `LEFT JOIN LATERAL (SELECT max(started_at) FROM play_events WHERE user_id = $1 AND track_id = t.id) pe_last → last_played_at` + - `LEFT JOIN LATERAL (SELECT count(*), count(*) FILTER (WHERE was_skipped) FROM play_events WHERE user_id = $1 AND track_id = t.id) pe_stats → play_count, skip_count` + - `WHERE t.id <> seed_id AND NOT EXISTS (SELECT 1 FROM play_events WHERE user_id = $1 AND track_id = t.id AND started_at > now() - interval '$2 hours')` +5. Iterates the candidates, calls `Score(...)` per track, sorts descending, truncates to `limit - 1`. +6. Prepends the seed track, projects each `Candidate.Track` to `TrackRef` (existing `trackRefFrom` helper), writes `RadioResponse{ Tracks: ... }`. +7. SPA receives 50 tracks, plays from index 0 (the seed). + +**Stateless** — every call recomputes from scratch. No persisted radio queue, no cursor tracking, no "you already heard this" memory beyond the recently-played-hours window. + +## Testing + +### Server (`go test`) + +**`internal/recommendation/score_test.go`** (pure unit tests): +- Base case (never played, not liked, no skip data) with deterministic RNG `() => 0.5` → score = `BaseWeight + RecencyWeight + 0` exactly. +- Liked boost: same inputs but `IsGeneralLiked=true` → score increases by `LikeBoost`. +- Recency ramp: `lastPlayedAt = now - 15d` → `recencyDecay = 0.5`. `lastPlayedAt = now - 60d` → `1.0` (capped). +- Skip ratio: `PlayCount=4, SkipCount=2` → ratio `0.5`, score loses `0.5 * SkipPenalty`. +- Cold-start skip: `PlayCount=0, SkipCount=0` → ratio `0.0`, no penalty. +- Jitter bounds: 1000 `Score(...)` calls with `math/rand.Float64`, every result within `[mid - JitterMagnitude, mid + JitterMagnitude]` where `mid` is the deterministic-RNG score. +- Determinism: same `(inputs, weights, now, rng)` returns the same score (regression guard for hidden global state). + +**`internal/recommendation/shuffle_test.go`** (pure unit tests): +- Liked-vs-not: two otherwise-identical candidates → liked one ranks higher. +- High-skip rejected: candidate with `skipRatio=1.0` ranks last among otherwise-identical candidates. +- Limit truncates: 100 candidates, `limit=10` → result has 10. +- Jitter doesn't reorder structural winners: 100 random RNG seeds → liked track ALWAYS ranks above an equivalent unliked track. +- Empty input → empty output. + +**`internal/recommendation/candidates_test.go`** (live DB): +- Seed exclusion: 5 tracks in library, ask for radio with one as seed → returns the other 4. +- Recently-played exclusion: seed a `play_events` row with `started_at = now - 30 minutes` for track A → radio excludes A. +- Stat join: track with one play + one skip → `play_count=1, skip_count=1`. Liked track → `is_liked=true`. Never-played → `last_played_at IS NULL`. +- Cross-user: Alice's plays / likes do NOT appear in Bob's stats. + +**`internal/api/radio_test.go`** (HTTP integration, replacing existing stub tests): +- Cold-start: seed only in library → response is `{ tracks: [seed] }`. +- Typical: seed + 5 other tracks → response is `[seed, 5 ranked]`. Seed always at index 0. +- 404 on unknown seed. +- 400 on missing/malformed seed. +- 400 on `limit=0` or `limit < 0`. +- `limit > RadioSizeMax` clamped (returns at most `RadioSizeMax`, no error). +- Cross-user isolation: Alice has many plays + likes → Bob's radio uses Bob's clean stats. + +**Coverage:** `go test -coverprofile=cover.out ./internal/recommendation/...` → ≥ 70% per the M3 milestone description. The pure-function tests should hit ~100% on `score.go` + `shuffle.go`; `candidates.go` reaches similar coverage via the integration tests. + +### End-to-end manual + +Final task in the implementation plan: + +1. Sign in. Play 3 tracks all the way through (build `play_count` history). +2. Skip a 4th track within 10 seconds (creates a high `skip_ratio`). +3. Like 2 tracks via the heart button. +4. Click radio on a 5th 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. + +## Risks & mitigations + +- **Wide-pool scan performance.** For a 50k-track library: 50k rows × scoring overhead × sort ≈ a few hundred ms in Go. Below the user-perceptible threshold for a click-to-play. If telemetry later shows >1s P95 latency, M3.5 / M4 can add sampling (random-N candidate pre-filter) or a partial index. The candidate-pool function is the swap point; the scoring function and HTTP handler don't change. +- **Recently-played window edge effects.** If the user plays 50 tracks in an hour, all of them get excluded from the next radio call. Library < 100 tracks could leave the candidate pool too thin to fill `RadioSize`. Mitigation: handler returns whatever it can fit (length might be < `RadioSize`); SPA's `playQueue` works on any non-empty array. Document as acceptable degradation; users with tiny libraries get short radios. +- **Skip-ratio gaming.** A user who skips a track once (during initial library discovery) gets a `skipRatio=1.0` for that track. With `SkipPenalty=1.0` that's a `-1.0` hit — could permanently demote the track even if they like it. Mitigation: weights are tunable; user can lower `SkipPenalty`. Future: smoothing (e.g. `(skips + α) / (plays + β)`) instead of raw ratio. Out of scope for v1. +- **Cold-start RNG dominance.** A brand-new user with zero plays / likes gets every track scored at `BaseWeight + RecencyWeight + jitter`. Top-N is essentially random. That's fine — it matches user expectation ("I haven't told you anything about me, give me random music"). The recency-decay max-for-never-played avoids amplifying new tracks just because they're new. +- **Score formula evolution.** Adding `contextual_match_score` in sub-plan #3 means changing `ScoringInputs` and the `Score` function signature. Mitigation: the function is internal-package only; sub-plan #3 changes both ends of the call atomically. No external consumers. diff --git a/internal/api/api.go b/internal/api/api.go index 27ee3a2f..0ab003c5 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -6,19 +6,22 @@ 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" ) // Mount attaches /api/* handlers to r. Public endpoints (login) are outside // RequireUser; everything else is gated by the middleware. The events writer // is shared with the Subsonic mount so /rest/scrobble feeds the same store. -func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer) { - h := &handlers{pool: pool, logger: logger, events: events} +func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig) { + rng := rand.New(rand.NewSource(rand.Int63())) + h := &handlers{pool: pool, logger: logger, events: events, recCfg: recCfg, rng: rng.Float64} r.Route("/api", func(api chi.Router) { api.Post("/auth/login", h.handleLogin) @@ -54,4 +57,6 @@ type handlers struct { pool *pgxpool.Pool logger *slog.Logger events *playevents.Writer + recCfg config.RecommendationConfig + rng func() float64 } diff --git a/internal/api/auth_test.go b/internal/api/auth_test.go index d91a21e6..b6f2d5e2 100644 --- a/internal/api/auth_test.go +++ b/internal/api/auth_test.go @@ -18,6 +18,7 @@ import ( "golang.org/x/crypto/bcrypt" "git.fabledsword.com/bvandeusen/minstrel/internal/auth" + "git.fabledsword.com/bvandeusen/minstrel/internal/config" "git.fabledsword.com/bvandeusen/minstrel/internal/db" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/playevents" @@ -49,7 +50,13 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) { t.Fatalf("truncate: %v", err) } w := playevents.NewWriter(pool, logger, 30*time.Minute, 0.5, 30000) - return &handlers{pool: pool, logger: logger, events: w}, pool + 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 } func seedUser(t *testing.T, pool *pgxpool.Pool, username, password string, isAdmin bool) dbq.User { diff --git a/internal/api/library_test.go b/internal/api/library_test.go index 0c970582..9c8e7714 100644 --- a/internal/api/library_test.go +++ b/internal/api/library_test.go @@ -11,6 +11,7 @@ import ( "github.com/go-chi/chi/v5" + "git.fabledsword.com/bvandeusen/minstrel/internal/config" "git.fabledsword.com/bvandeusen/minstrel/internal/playevents" ) @@ -440,7 +441,7 @@ func TestRoutesRegisteredInMount(t *testing.T) { r := chi.NewRouter() w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)), 30*time.Minute, 0.5, 30000) - Mount(r, h.pool, h.logger, w) + Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}) paths := []string{ "/api/artists", diff --git a/internal/api/radio.go b/internal/api/radio.go index d7d70e5d..6d84994f 100644 --- a/internal/api/radio.go +++ b/internal/api/radio.go @@ -3,11 +3,15 @@ package api import ( "errors" "net/http" + "strconv" "strings" + "time" "github.com/jackc/pgx/v5" + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/recommendation" ) // RadioResponse is the body of GET /api/radio. @@ -15,24 +19,40 @@ type RadioResponse struct { Tracks []TrackRef `json:"tracks"` } -// handleRadio implements GET /api/radio?seed_track=. +// handleRadio implements GET /api/radio?seed_track=&limit=. // -// M6 stub: returns a queue containing only the seed track. The full M4 -// implementation (similarity-based candidate pool + scoring) replaces the -// body without changing the request/response shape. +// 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 := auth.UserFromContext(r.Context()) + 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 } - id, ok := parseUUID(raw) + 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(), id) + 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") @@ -54,7 +74,37 @@ func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) { writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") return } - writeJSON(w, http.StatusOK, RadioResponse{ - Tracks: []TrackRef{trackRefFrom(track, album.Title, artist.Name)}, - }) + 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 { + 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}) } diff --git a/internal/api/radio_test.go b/internal/api/radio_test.go index 58964c85..732ec5bd 100644 --- a/internal/api/radio_test.go +++ b/internal/api/radio_test.go @@ -1,90 +1,130 @@ package api import ( + "context" "encoding/json" "net/http" "net/http/httptest" "testing" - - "github.com/go-chi/chi/v5" ) -func newRadioRouter(h *handlers) chi.Router { - r := chi.NewRouter() - r.Get("/api/radio", h.handleRadio) - return r +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_Stub_ReturnsSeedOnly(t *testing.T) { +func TestHandleRadio_ColdStart_OnlySeedReturned(t *testing.T) { h, pool := testHandlers(t) truncateLibrary(t, pool) - artist := seedArtist(t, pool, "Beatles") - album := seedAlbum(t, pool, artist.ID, "Abbey Road", 1969) - track := seedTrack(t, pool, album.ID, artist.ID, "Something", 3, 183_000) - - req := httptest.NewRequest(http.MethodGet, "/api/radio?seed_track="+uuidToString(track.ID), nil) - w := httptest.NewRecorder() - newRadioRouter(h).ServeHTTP(w, req) + 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 got struct { - Tracks []TrackRef `json:"tracks"` + var resp RadioResponse + _ = json.Unmarshal(w.Body.Bytes(), &resp) + if len(resp.Tracks) != 1 { + t.Fatalf("len = %d, want 1", len(resp.Tracks)) } - if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { - t.Fatalf("decode: %v", err) - } - if len(got.Tracks) != 1 { - t.Fatalf("len=%d, want 1", len(got.Tracks)) - } - if got.Tracks[0].Title != "Something" { - t.Errorf("title=%q", got.Tracks[0].Title) - } - if got.Tracks[0].AlbumTitle != "Abbey Road" || got.Tracks[0].ArtistName != "Beatles" { - t.Errorf("ref = %+v", got.Tracks[0]) + if resp.Tracks[0].Title != "Seed" { + t.Errorf("seed not first: %v", resp.Tracks[0].Title) } } -func TestHandleRadio_NotFound(t *testing.T) { +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) + } - req := httptest.NewRequest(http.MethodGet, "/api/radio?seed_track=00000000-0000-0000-0000-000000000000", nil) - w := httptest.NewRecorder() - newRadioRouter(h).ServeHTTP(w, req) - - if w.Code != http.StatusNotFound { + w := callRadio(h, user, "seed_track="+uuidToString(seed.ID)) + if w.Code != http.StatusOK { t.Fatalf("status = %d", w.Code) } -} - -func TestHandleRadio_MissingSeed(t *testing.T) { - h, _ := testHandlers(t) - req := httptest.NewRequest(http.MethodGet, "/api/radio", nil) - w := httptest.NewRecorder() - newRadioRouter(h).ServeHTTP(w, req) - if w.Code != http.StatusBadRequest { - t.Errorf("status=%d, want 400", 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_BlankSeed(t *testing.T) { - h, _ := testHandlers(t) - req := httptest.NewRequest(http.MethodGet, "/api/radio?seed_track=%20%20", nil) - w := httptest.NewRecorder() - newRadioRouter(h).ServeHTTP(w, req) - if w.Code != http.StatusBadRequest { - t.Errorf("status=%d, want 400", w.Code) +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_BadUUID(t *testing.T) { - h, _ := testHandlers(t) - req := httptest.NewRequest(http.MethodGet, "/api/radio?seed_track=not-a-uuid", nil) - w := httptest.NewRecorder() - newRadioRouter(h).ServeHTTP(w, req) +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, want 400", w.Code) + 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)) } } diff --git a/internal/config/config.go b/internal/config/config.go index af16e97d..2a6a2a39 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -10,13 +10,14 @@ import ( ) 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"` + 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"` } type ServerConfig struct { @@ -63,6 +64,19 @@ type EventsConfig struct { SkipMaxDurationPlayedMs int `yaml:"skip_max_duration_played_ms"` } +// 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"` +} + func Default() Config { return Config{ Server: ServerConfig{Address: ":4533"}, @@ -73,6 +87,16 @@ func Default() Config { 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, + }, } } diff --git a/internal/db/dbq/recommendation.sql.go b/internal/db/dbq/recommendation.sql.go new file mode 100644 index 00000000..535b4bea --- /dev/null +++ b/internal/db/dbq/recommendation.sql.go @@ -0,0 +1,98 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: recommendation.sql + +package dbq + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const loadRadioCandidates = `-- name: LoadRadioCandidates :many +SELECT + t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, + (l.user_id IS NOT NULL)::bool AS is_liked, + pe.last_played_at::timestamptz AS 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' + ) +` + +type LoadRadioCandidatesParams struct { + UserID pgtype.UUID + ID pgtype.UUID + Column3 interface{} +} + +type LoadRadioCandidatesRow struct { + Track Track + IsLiked bool + LastPlayedAt pgtype.Timestamptz + PlayCount int64 + SkipCount int64 +} + +// 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 +func (q *Queries) LoadRadioCandidates(ctx context.Context, arg LoadRadioCandidatesParams) ([]LoadRadioCandidatesRow, error) { + rows, err := q.db.Query(ctx, loadRadioCandidates, arg.UserID, arg.ID, arg.Column3) + if err != nil { + return nil, err + } + defer rows.Close() + var items []LoadRadioCandidatesRow + for rows.Next() { + var i LoadRadioCandidatesRow + if err := rows.Scan( + &i.Track.ID, + &i.Track.Title, + &i.Track.AlbumID, + &i.Track.ArtistID, + &i.Track.TrackNumber, + &i.Track.DiscNumber, + &i.Track.DurationMs, + &i.Track.FilePath, + &i.Track.FileSize, + &i.Track.FileFormat, + &i.Track.Bitrate, + &i.Track.Mbid, + &i.Track.Genre, + &i.Track.AddedAt, + &i.Track.UpdatedAt, + &i.IsLiked, + &i.LastPlayedAt, + &i.PlayCount, + &i.SkipCount, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/db/queries/recommendation.sql b/internal/db/queries/recommendation.sql new file mode 100644 index 00000000..6eea7a75 --- /dev/null +++ b/internal/db/queries/recommendation.sql @@ -0,0 +1,29 @@ +-- 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)::bool AS is_liked, + pe.last_played_at::timestamptz AS 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' + ); diff --git a/internal/recommendation/candidates.go b/internal/recommendation/candidates.go new file mode 100644 index 00000000..8b825456 --- /dev/null +++ b/internal/recommendation/candidates.go @@ -0,0 +1,44 @@ +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 +} diff --git a/internal/recommendation/candidates_test.go b/internal/recommendation/candidates_test.go new file mode 100644 index 00000000..f8522d23 --- /dev/null +++ b/internal/recommendation/candidates_test.go @@ -0,0 +1,223 @@ +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 +} diff --git a/internal/recommendation/score.go b/internal/recommendation/score.go new file mode 100644 index 00000000..f71e193a --- /dev/null +++ b/internal/recommendation/score.go @@ -0,0 +1,79 @@ +// 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) +} diff --git a/internal/recommendation/score_test.go b/internal/recommendation/score_test.go new file mode 100644 index 00000000..bbee4f2c --- /dev/null +++ b/internal/recommendation/score_test.go @@ -0,0 +1,150 @@ +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) + } +} diff --git a/internal/recommendation/shuffle.go b/internal/recommendation/shuffle.go new file mode 100644 index 00000000..d81eedfe --- /dev/null +++ b/internal/recommendation/shuffle.go @@ -0,0 +1,48 @@ +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 +} diff --git a/internal/recommendation/shuffle_test.go b/internal/recommendation/shuffle_test.go new file mode 100644 index 00000000..ec3c2341 --- /dev/null +++ b/internal/recommendation/shuffle_test.go @@ -0,0 +1,86 @@ +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 diff --git a/internal/server/server.go b/internal/server/server.go index 09a6c331..3e449693 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -29,15 +29,16 @@ type ScanTrigger interface { } type Server struct { - Logger *slog.Logger - Pool *pgxpool.Pool - Scanner ScanTrigger - SubsonicCfg subsonic.Config - EventsCfg config.EventsConfig + Logger *slog.Logger + Pool *pgxpool.Pool + Scanner ScanTrigger + SubsonicCfg subsonic.Config + EventsCfg config.EventsConfig + RecommendationCfg config.RecommendationConfig } -func New(logger *slog.Logger, pool *pgxpool.Pool, scanner ScanTrigger, subCfg subsonic.Config, eventsCfg config.EventsConfig) *Server { - return &Server{Logger: logger, Pool: pool, Scanner: scanner, SubsonicCfg: subCfg, EventsCfg: eventsCfg} +func New(logger *slog.Logger, pool *pgxpool.Pool, scanner ScanTrigger, subCfg subsonic.Config, eventsCfg config.EventsConfig, recCfg config.RecommendationConfig) *Server { + return &Server{Logger: logger, Pool: pool, Scanner: scanner, SubsonicCfg: subCfg, EventsCfg: eventsCfg, RecommendationCfg: recCfg} } func (s *Server) Router() http.Handler { @@ -54,7 +55,7 @@ func (s *Server) Router() http.Handler { s.EventsCfg.SkipMaxCompletionRatio, s.EventsCfg.SkipMaxDurationPlayedMs, ) - api.Mount(r, s.Pool, s.Logger, writer) + api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg) r.Route("/api/admin", func(admin chi.Router) { admin.Use(auth.RequireAdmin(s.Pool)) if s.Scanner != nil { diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 66358ca4..9629ec33 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -14,7 +14,7 @@ import ( ) func TestHealthz(t *testing.T) { - s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}) + s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}) ts := httptest.NewServer(s.Router()) defer ts.Close() @@ -37,7 +37,7 @@ func TestHealthz(t *testing.T) { } func TestRouter_ServesSPAAtRoot(t *testing.T) { - s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}) + s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}) ts := httptest.NewServer(s.Router()) defer ts.Close() @@ -56,7 +56,7 @@ func TestRouter_ServesSPAAtRoot(t *testing.T) { } func TestRouter_DeepLinkFallbackReturnsSPA(t *testing.T) { - s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}) + s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}) ts := httptest.NewServer(s.Router()) defer ts.Close() @@ -75,7 +75,7 @@ func TestRouter_DeepLinkFallbackReturnsSPA(t *testing.T) { } func TestRouter_APIPathNotSwallowedBySPA(t *testing.T) { - s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}) + s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}) ts := httptest.NewServer(s.Router()) defer ts.Close() @@ -94,7 +94,7 @@ func TestRouter_APIPathNotSwallowedBySPA(t *testing.T) { } func TestRouter_RestPathNotSwallowedBySPA(t *testing.T) { - s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}) + s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}) ts := httptest.NewServer(s.Router()) defer ts.Close()