` with positioned overlay link is a surface change. The existing artist tests (in route pages — `src/routes/+page.svelte` etc.) use `screen.getByRole('link')` and `getByText` which both still work. Tests re-run as part of Task 7 Step 7.
diff --git a/docs/superpowers/plans/2026-04-27-m3-shuffle.md b/docs/superpowers/plans/2026-04-27-m3-shuffle.md
deleted file mode 100644
index 652bb836..00000000
--- a/docs/superpowers/plans/2026-04-27-m3-shuffle.md
+++ /dev/null
@@ -1,1588 +0,0 @@
-# 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/plans/2026-04-27-m3-similarity.md b/docs/superpowers/plans/2026-04-27-m3-similarity.md
deleted file mode 100644
index b892be85..00000000
--- a/docs/superpowers/plans/2026-04-27-m3-similarity.md
+++ /dev/null
@@ -1,1414 +0,0 @@
-# M3 Session Similarity + contextual_match_score 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:** Add the `contextual_match_score` term to the recommendation scoring formula by computing weighted-Jaccard similarity between the user's current session vector and each candidate track's stored `contextual_likes` session vectors. Closes M3.
-
-**Architecture:** New pure `Similarity` + `ContextualMatchScore` functions in `internal/recommendation/similarity.go` — set Jaccard on tags + artists, weighted 0.7/0.3, hardcoded for v1. `Score()` gains `ContextualMatchScore` input + `ContextWeight` weight. `LoadCandidates` accepts a `currentVector` and bulk-fetches the user's active `contextual_likes` once, mapping by `track_id` and computing per-candidate max similarity. `internal/api/radio.go` reads the user's most recent open session's `session_vector_at_play` via a new `GetCurrentSessionVectorForUser` query and threads it through.
-
-**Tech Stack:** Go 1.23 + sqlc + pgx/v5. No web changes.
-
-**Reference:** design spec at `docs/superpowers/specs/2026-04-27-m3-similarity-design.md`.
-
----
-
-## File Structure
-
-**New server files:**
-
-| File | Responsibility |
-|---|---|
-| `internal/recommendation/similarity.go` | Pure: `SimilarityWeights` struct, `DefaultSimilarityWeights`, `Similarity(a, b SessionVector, w SimilarityWeights) float64`, `ContextualMatchScore(current SessionVector, likes []SessionVector, w SimilarityWeights) float64`. |
-| `internal/recommendation/similarity_test.go` | Pure unit tests (table-driven). |
-
-**Modified server files:**
-
-| File | Change |
-|---|---|
-| `internal/recommendation/score.go` | `ScoringInputs` gains `ContextualMatchScore float64`. `ScoringWeights` gains `ContextWeight float64`. `Score()` adds `+ in.ContextualMatchScore * w.ContextWeight`. |
-| `internal/recommendation/score_test.go` | Three new tests for the new term. |
-| `internal/recommendation/candidates.go` | `LoadCandidates` signature gains `currentVector SessionVector` parameter. Body bulk-fetches user's contextual_likes, groups by track_id, populates per-candidate `ContextualMatchScore`. |
-| `internal/recommendation/candidates_test.go` | Six new tests for contextual scoring. |
-| `internal/db/queries/contextual_likes.sql` | Add `ListActiveContextualLikesForUser :many`. |
-| `internal/db/queries/events.sql` | Add `GetCurrentSessionVectorForUser :one`. |
-| `internal/db/dbq/contextual_likes.sql.go` | Generated bindings. |
-| `internal/db/dbq/events.sql.go` | Generated bindings. |
-| `internal/config/config.go` | `RecommendationConfig` gains `ContextWeight float64` (yaml `context_weight`, default `2.0`). |
-| `internal/api/radio.go` | Fetch current session vector before `LoadCandidates`, build `ContextWeight` into `ScoringWeights`, thread `currentVector` into `LoadCandidates`. |
-| `internal/api/auth_test.go` | `recCfg` test-helper builds `ContextWeight: 2.0`. |
-| `internal/api/radio_test.go` | One new end-to-end test: contextual ranking. |
-
-**No web changes.**
-
----
-
-## Task 1: Pure `Similarity` function (Jaccard + axis weights)
-
-**Files:**
-- Create: `internal/recommendation/similarity.go`
-- Create: `internal/recommendation/similarity_test.go`
-
-- [ ] **Step 1: Write the failing tests for `Similarity`**
-
-Create `internal/recommendation/similarity_test.go`:
-
-```go
-package recommendation
-
-import (
- "math"
- "testing"
-)
-
-func approxEq(a, b float64) bool { return math.Abs(a-b) < 1e-9 }
-
-func TestSimilarity_IdenticalVectors_Returns1(t *testing.T) {
- v := SessionVector{
- Artists: []string{"a1", "a2"},
- Tags: map[string]int{"rock": 2, "indie": 1},
- }
- got := Similarity(v, v, DefaultSimilarityWeights)
- if !approxEq(got, 1.0) {
- t.Errorf("Similarity(v,v) = %v, want 1.0", got)
- }
-}
-
-func TestSimilarity_FullyDisjoint_Returns0(t *testing.T) {
- a := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}
- b := SessionVector{Artists: []string{"a2"}, Tags: map[string]int{"jazz": 1}}
- got := Similarity(a, b, DefaultSimilarityWeights)
- if !approxEq(got, 0.0) {
- t.Errorf("disjoint = %v, want 0.0", got)
- }
-}
-
-func TestSimilarity_TagsOnlyShared_AppliesTagsWeight(t *testing.T) {
- // Shared tags fully (Jaccard=1), no shared artists (Jaccard=0).
- a := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}
- b := SessionVector{Artists: []string{"a2"}, Tags: map[string]int{"rock": 5}}
- got := Similarity(a, b, DefaultSimilarityWeights)
- // Expected: 0.7 * 1.0 + 0.3 * 0.0 = 0.7
- if !approxEq(got, 0.7) {
- t.Errorf("tags-only = %v, want 0.7", got)
- }
-}
-
-func TestSimilarity_ArtistsOnlyShared_AppliesArtistsWeight(t *testing.T) {
- a := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}
- b := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"jazz": 1}}
- got := Similarity(a, b, DefaultSimilarityWeights)
- // Expected: 0.7 * 0.0 + 0.3 * 1.0 = 0.3
- if !approxEq(got, 0.3) {
- t.Errorf("artists-only = %v, want 0.3", got)
- }
-}
-
-func TestSimilarity_EitherSeed_Returns0(t *testing.T) {
- v := SessionVector{Artists: []string{"a"}, Tags: map[string]int{"rock": 1}}
- seed := SessionVector{Seed: true, Artists: []string{"a"}, Tags: map[string]int{"rock": 1}}
- if got := Similarity(v, seed, DefaultSimilarityWeights); !approxEq(got, 0.0) {
- t.Errorf("v vs seed = %v, want 0.0", got)
- }
- if got := Similarity(seed, v, DefaultSimilarityWeights); !approxEq(got, 0.0) {
- t.Errorf("seed vs v = %v, want 0.0", got)
- }
-}
-
-func TestSimilarity_BothEmpty_Returns0NotNaN(t *testing.T) {
- a := SessionVector{}
- b := SessionVector{}
- got := Similarity(a, b, DefaultSimilarityWeights)
- if math.IsNaN(got) || !approxEq(got, 0.0) {
- t.Errorf("empty = %v, want 0.0 (not NaN)", got)
- }
-}
-
-func TestSimilarity_OneAxisEmptyOneSide_AxisContributesZero(t *testing.T) {
- // a has tags but no artists; b has artists but no tags.
- a := SessionVector{Tags: map[string]int{"rock": 1}}
- b := SessionVector{Artists: []string{"a1"}}
- got := Similarity(a, b, DefaultSimilarityWeights)
- // tags axis: A={rock}, B={} → union={rock}, intersect={} → 0
- // artists axis: A={}, B={a1} → union={a1}, intersect={} → 0
- if !approxEq(got, 0.0) {
- t.Errorf("one-axis-each = %v, want 0.0", got)
- }
-}
-
-func TestSimilarity_PartialTagsOverlap(t *testing.T) {
- // Tags A={rock,indie}, B={rock,jazz}: intersect=1, union=3, J=1/3
- // Artists fully shared: J=1
- a := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1, "indie": 1}}
- b := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1, "jazz": 1}}
- got := Similarity(a, b, DefaultSimilarityWeights)
- want := 0.7*(1.0/3.0) + 0.3*1.0
- if !approxEq(got, want) {
- t.Errorf("partial = %v, want %v", got, want)
- }
-}
-
-func TestSimilarity_BagOfCountsCollapsesToSet(t *testing.T) {
- // Same tag keysets, different counts → set-Jaccard collapses to 1.0.
- a := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 2, "indie": 1}}
- b := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 5, "indie": 3}}
- got := Similarity(a, b, DefaultSimilarityWeights)
- if !approxEq(got, 1.0) {
- t.Errorf("set-collapse = %v, want 1.0", got)
- }
-}
-```
-
-- [ ] **Step 2: Run tests to verify they fail**
-
-Run: `go test ./internal/recommendation/ -run Similarity -v`
-
-Expected: FAIL with "undefined: Similarity" / "undefined: DefaultSimilarityWeights".
-
-- [ ] **Step 3: Write minimal `Similarity` implementation**
-
-Create `internal/recommendation/similarity.go`:
-
-```go
-package recommendation
-
-// SimilarityWeights balances the per-axis contribution to the weighted Jaccard
-// score. v1 hardcodes the defaults — operators cannot tune via YAML. If
-// telemetry justifies it, expose under recommendation.similarity.* later.
-type SimilarityWeights struct {
- TagsWeight float64
- ArtistsWeight float64
-}
-
-// DefaultSimilarityWeights is the v1 axis balance per the M3 design.
-// Tags carry more signal than artists because a session's "vibe" tracks
-// genre more directly than artist identity (a session can mix artists
-// within a genre but rarely mixes genres).
-var DefaultSimilarityWeights = SimilarityWeights{
- TagsWeight: 0.7,
- ArtistsWeight: 0.3,
-}
-
-// Similarity returns weighted-Jaccard similarity in [0, 1] between two
-// session vectors. Returns 0 if either input is Seed=true (low-confidence
-// vectors don't contribute to scoring).
-func Similarity(a, b SessionVector, w SimilarityWeights) float64 {
- if a.Seed || b.Seed {
- return 0.0
- }
- tagJ := setJaccardKeys(a.Tags, b.Tags)
- artistJ := setJaccardSlice(a.Artists, b.Artists)
- return tagJ*w.TagsWeight + artistJ*w.ArtistsWeight
-}
-
-// setJaccardKeys collapses two map keysets to sets and returns
-// |A ∩ B| / |A ∪ B|. Both empty → 0 (not NaN).
-func setJaccardKeys(a, b map[string]int) float64 {
- if len(a) == 0 && len(b) == 0 {
- return 0.0
- }
- intersect := 0
- for k := range a {
- if _, ok := b[k]; ok {
- intersect++
- }
- }
- union := len(a) + len(b) - intersect
- if union == 0 {
- return 0.0
- }
- return float64(intersect) / float64(union)
-}
-
-// setJaccardSlice deduplicates each input slice into a set and returns
-// |A ∩ B| / |A ∪ B|. Both empty → 0 (not NaN).
-func setJaccardSlice(a, b []string) float64 {
- if len(a) == 0 && len(b) == 0 {
- return 0.0
- }
- aset := make(map[string]struct{}, len(a))
- for _, x := range a {
- aset[x] = struct{}{}
- }
- bset := make(map[string]struct{}, len(b))
- for _, x := range b {
- bset[x] = struct{}{}
- }
- intersect := 0
- for k := range aset {
- if _, ok := bset[k]; ok {
- intersect++
- }
- }
- union := len(aset) + len(bset) - intersect
- if union == 0 {
- return 0.0
- }
- return float64(intersect) / float64(union)
-}
-```
-
-- [ ] **Step 4: Run tests to verify they pass**
-
-Run: `go test ./internal/recommendation/ -run Similarity -v`
-
-Expected: PASS for all 9 tests.
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add internal/recommendation/similarity.go internal/recommendation/similarity_test.go
-git commit -m "feat(recommendation): add pure Similarity function with weighted Jaccard"
-```
-
----
-
-## Task 2: `ContextualMatchScore` convenience function
-
-**Files:**
-- Modify: `internal/recommendation/similarity.go` (append)
-- Modify: `internal/recommendation/similarity_test.go` (append)
-
-- [ ] **Step 1: Write the failing tests**
-
-Append to `internal/recommendation/similarity_test.go`:
-
-```go
-func TestContextualMatchScore_NoLikes_Returns0(t *testing.T) {
- current := SessionVector{Artists: []string{"a"}, Tags: map[string]int{"rock": 1}}
- got := ContextualMatchScore(current, nil, DefaultSimilarityWeights)
- if !approxEq(got, 0.0) {
- t.Errorf("no likes = %v, want 0.0", got)
- }
- got = ContextualMatchScore(current, []SessionVector{}, DefaultSimilarityWeights)
- if !approxEq(got, 0.0) {
- t.Errorf("empty likes = %v, want 0.0", got)
- }
-}
-
-func TestContextualMatchScore_CurrentSeed_Returns0(t *testing.T) {
- current := SessionVector{Seed: true}
- likes := []SessionVector{
- {Artists: []string{"a"}, Tags: map[string]int{"rock": 1}},
- }
- got := ContextualMatchScore(current, likes, DefaultSimilarityWeights)
- if !approxEq(got, 0.0) {
- t.Errorf("current seed = %v, want 0.0", got)
- }
-}
-
-func TestContextualMatchScore_AllLikesSeed_Returns0(t *testing.T) {
- current := SessionVector{Artists: []string{"a"}, Tags: map[string]int{"rock": 1}}
- likes := []SessionVector{
- {Seed: true, Artists: []string{"a"}, Tags: map[string]int{"rock": 1}},
- {Seed: true, Artists: []string{"a"}, Tags: map[string]int{"rock": 1}},
- }
- got := ContextualMatchScore(current, likes, DefaultSimilarityWeights)
- if !approxEq(got, 0.0) {
- t.Errorf("all-seed likes = %v, want 0.0", got)
- }
-}
-
-func TestContextualMatchScore_TakesMax(t *testing.T) {
- // Three likes: full match, partial match, mismatch. Expect full match (1.0).
- current := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}
- likes := []SessionVector{
- {Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}, // 1.0
- {Artists: []string{"a2"}, Tags: map[string]int{"rock": 1}}, // 0.7
- {Artists: []string{"a99"}, Tags: map[string]int{"jazz": 1}}, // 0.0
- }
- got := ContextualMatchScore(current, likes, DefaultSimilarityWeights)
- if !approxEq(got, 1.0) {
- t.Errorf("takes-max = %v, want 1.0", got)
- }
-}
-
-func TestContextualMatchScore_FiltersSeedThenMaxes(t *testing.T) {
- // One Seed=true match (would be 1.0 if not filtered) + one partial match.
- current := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}
- likes := []SessionVector{
- {Seed: true, Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}},
- {Artists: []string{"a2"}, Tags: map[string]int{"rock": 1}},
- }
- got := ContextualMatchScore(current, likes, DefaultSimilarityWeights)
- // Seed=true filtered out → only partial match counts → 0.7
- if !approxEq(got, 0.7) {
- t.Errorf("filter-then-max = %v, want 0.7", got)
- }
-}
-```
-
-- [ ] **Step 2: Run tests to verify they fail**
-
-Run: `go test ./internal/recommendation/ -run ContextualMatchScore -v`
-
-Expected: FAIL with "undefined: ContextualMatchScore".
-
-- [ ] **Step 3: Append `ContextualMatchScore` to `similarity.go`**
-
-Append to `internal/recommendation/similarity.go`:
-
-```go
-// ContextualMatchScore returns the maximum Similarity between the current
-// session vector and any non-seed entry in likes. Returns 0 when:
-// - current.Seed is true (no meaningful current context)
-// - likes is empty after filtering out Seed=true entries
-//
-// The "max" semantics means a single strong contextual match dominates
-// over many weak ones — we want to surface the track because it was liked
-// in *some* matching context, not because it was vaguely-liked in many.
-func ContextualMatchScore(current SessionVector, likes []SessionVector, w SimilarityWeights) float64 {
- if current.Seed {
- return 0.0
- }
- best := 0.0
- for _, l := range likes {
- if l.Seed {
- continue
- }
- s := Similarity(current, l, w)
- if s > best {
- best = s
- }
- }
- return best
-}
-```
-
-- [ ] **Step 4: Run tests to verify they pass**
-
-Run: `go test ./internal/recommendation/ -run ContextualMatchScore -v`
-
-Expected: PASS for all 5 tests.
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add internal/recommendation/similarity.go internal/recommendation/similarity_test.go
-git commit -m "feat(recommendation): add ContextualMatchScore (max over non-seed likes)"
-```
-
----
-
-## Task 3: Extend `Score` with `ContextualMatchScore` + `ContextWeight`
-
-**Files:**
-- Modify: `internal/recommendation/score.go`
-- Modify: `internal/recommendation/score_test.go`
-
-- [ ] **Step 1: Write the failing tests**
-
-Append to `internal/recommendation/score_test.go`:
-
-```go
-func TestScore_ContextualMatch_PerfectMatchAtWeight2(t *testing.T) {
- w := defaultWeights()
- w.ContextWeight = 2.0
- in := ScoringInputs{ContextualMatchScore: 1.0}
- got := Score(in, w, time.Now(), fixedRNG(0.5))
- // base 1.0 + recency 1.0 (never played) + contextual 2.0 = 4.0
- want := 4.0
- if math.Abs(got-want) > 1e-9 {
- t.Errorf("score = %v, want %v", got, want)
- }
-}
-
-func TestScore_ContextualMatch_HalfMatchAtWeight2(t *testing.T) {
- w := defaultWeights()
- w.ContextWeight = 2.0
- in := ScoringInputs{ContextualMatchScore: 0.5}
- got := Score(in, w, time.Now(), fixedRNG(0.5))
- // base 1.0 + recency 1.0 + contextual 1.0 = 3.0
- want := 3.0
- if math.Abs(got-want) > 1e-9 {
- t.Errorf("score = %v, want %v", got, want)
- }
-}
-
-func TestScore_ContextualMatch_ZeroNoEffect(t *testing.T) {
- wWithCtx := defaultWeights()
- wWithCtx.ContextWeight = 2.0
- withCtx := Score(ScoringInputs{ContextualMatchScore: 0}, wWithCtx, time.Now(), fixedRNG(0.5))
- withoutCtx := Score(ScoringInputs{}, defaultWeights(), time.Now(), fixedRNG(0.5))
- if math.Abs(withCtx-withoutCtx) > 1e-9 {
- t.Errorf("score-with-zero-ctx = %v, score-without = %v; should be equal", withCtx, withoutCtx)
- }
-}
-```
-
-- [ ] **Step 2: Run tests to verify they fail**
-
-Run: `go test ./internal/recommendation/ -run Score -v`
-
-Expected: FAIL — `ScoringInputs` has no `ContextualMatchScore`, `ScoringWeights` has no `ContextWeight`.
-
-- [ ] **Step 3: Extend `score.go`**
-
-Modify `internal/recommendation/score.go`. Replace the file contents with:
-
-```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.
-// ContextualMatchScore is in [0, 1] — max similarity between the user's
-// current session vector and any non-seed contextual_like row for this
-// track. Set by LoadCandidates after a bulk fetch.
-type ScoringInputs struct {
- IsGeneralLiked bool
- LastPlayedAt *time.Time // nil = never played
- PlayCount int // total play_events
- SkipCount int // play_events with was_skipped=true
- ContextualMatchScore float64 // [0, 1]; 0 when no signal
-}
-
-// 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
- ContextWeight float64
-}
-
-// Score computes the weighted-shuffle score per spec §6:
-//
-// score = base
-// + (is_general_liked ? LikeBoost : 0)
-// + recency_decay * RecencyWeight
-// - skip_ratio * SkipPenalty
-// + contextual_match_score * ContextWeight
-// + 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 += in.ContextualMatchScore * w.ContextWeight
- 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 to verify they pass**
-
-Run: `go test ./internal/recommendation/ -run Score -v`
-
-Expected: PASS for all existing Score tests + 3 new contextual tests.
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add internal/recommendation/score.go internal/recommendation/score_test.go
-git commit -m "feat(recommendation): extend Score with ContextualMatchScore + ContextWeight"
-```
-
----
-
-## Task 4: New sqlc queries for current vector + contextual likes lookup
-
-**Files:**
-- Modify: `internal/db/queries/contextual_likes.sql`
-- Modify: `internal/db/queries/events.sql`
-- Generated: `internal/db/dbq/contextual_likes.sql.go`
-- Generated: `internal/db/dbq/events.sql.go`
-
-- [ ] **Step 1: Add `ListActiveContextualLikesForUser` query**
-
-Append to `internal/db/queries/contextual_likes.sql`:
-
-```sql
--- name: ListActiveContextualLikesForUser :many
--- Returns all the user's active (non-soft-deleted) contextual_likes with
--- non-null vectors. Cardinality is bounded by the user's actual like-while-
--- playing history — typically tens to low hundreds. Used by the engine to
--- compute contextual_match_score for the candidate pool.
-SELECT track_id, session_vector
-FROM contextual_likes
-WHERE user_id = $1
- AND deleted_at IS NULL
- AND session_vector IS NOT NULL;
-```
-
-- [ ] **Step 2: Add `GetCurrentSessionVectorForUser` query**
-
-Append to `internal/db/queries/events.sql`:
-
-```sql
--- name: GetCurrentSessionVectorForUser :one
--- Returns the session_vector_at_play of the user's most recent play_event
--- in a still-active (un-timed-out) session. NoRows means no current vector.
--- Joined with play_sessions so closed sessions don't leak stale vectors.
-SELECT pe.session_vector_at_play
-FROM play_events pe
-JOIN play_sessions s ON s.id = pe.session_id
-WHERE pe.user_id = $1
- AND s.ended_at IS NULL
-ORDER BY pe.started_at DESC
-LIMIT 1;
-```
-
-- [ ] **Step 3: Run sqlc generate**
-
-Run: `make generate` (or `cd internal/db && sqlc generate` if the Makefile target differs).
-
-Expected: `internal/db/dbq/contextual_likes.sql.go` gains `ListActiveContextualLikesForUser` method; `internal/db/dbq/events.sql.go` gains `GetCurrentSessionVectorForUser`.
-
-- [ ] **Step 4: Verify compile**
-
-Run: `go build ./...`
-
-Expected: clean compile (no test-side changes yet, just generated bindings).
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add internal/db/queries/contextual_likes.sql internal/db/queries/events.sql internal/db/dbq/
-git commit -m "feat(db): add similarity lookup queries (ListActiveContextualLikesForUser, GetCurrentSessionVectorForUser)"
-```
-
----
-
-## Task 5: Extend `LoadCandidates` to compute per-candidate contextual scores
-
-**Files:**
-- Modify: `internal/recommendation/candidates.go`
-- Modify: `internal/recommendation/candidates_test.go`
-
-- [ ] **Step 1: Write failing tests for `LoadCandidates` contextual behavior**
-
-Append to `internal/recommendation/candidates_test.go`:
-
-```go
-import (
- "context"
- "encoding/json"
- "testing"
- "time"
-)
-
-// helperInsertContextualLike inserts a contextual_like row with the given
-// session_vector marshaled to JSON. Returns nothing — the test asserts via
-// LoadCandidates output. Bypasses playevents.CaptureContextualLikeIfPlaying
-// because we want full control over the stored vector for these unit tests.
-func helperInsertContextualLike(t *testing.T, f fixture, trackID pgtype.UUID, vec SessionVector) {
- t.Helper()
- raw, err := json.Marshal(vec)
- if err != nil {
- t.Fatalf("marshal: %v", err)
- }
- if _, err := f.pool.Exec(context.Background(),
- `INSERT INTO contextual_likes (user_id, track_id, session_vector) VALUES ($1, $2, $3)`,
- f.user, trackID, raw); err != nil {
- t.Fatalf("insert: %v", err)
- }
-}
-
-func TestLoadCandidates_NoContextualLikes_AllZero(t *testing.T) {
- f := newFixture(t, 5)
- current := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}
- got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, current)
- if err != nil {
- t.Fatalf("load: %v", err)
- }
- for _, c := range got {
- if c.Inputs.ContextualMatchScore != 0 {
- t.Errorf("track %s ContextualMatchScore = %v, want 0", c.Track.Title, c.Inputs.ContextualMatchScore)
- }
- }
-}
-
-func TestLoadCandidates_OneMatchingLike_ScoresPositive(t *testing.T) {
- f := newFixture(t, 3)
- target := f.tracks[1]
- likeVec := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}
- helperInsertContextualLike(t, f, target.ID, likeVec)
-
- current := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}
- got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, current)
- if err != nil {
- t.Fatalf("load: %v", err)
- }
- var found bool
- for _, c := range got {
- if c.Track.ID == target.ID {
- found = true
- if c.Inputs.ContextualMatchScore < 0.99 {
- t.Errorf("target ContextualMatchScore = %v, want ~1.0", c.Inputs.ContextualMatchScore)
- }
- } else {
- if c.Inputs.ContextualMatchScore != 0 {
- t.Errorf("non-target track has ContextualMatchScore = %v", c.Inputs.ContextualMatchScore)
- }
- }
- }
- if !found {
- t.Error("target track missing from candidate list")
- }
-}
-
-func TestLoadCandidates_MultipleMatchingLikes_TakesMax(t *testing.T) {
- f := newFixture(t, 3)
- target := f.tracks[1]
- // Three likes on the same track: weak, strong, medium. Expect strong.
- helperInsertContextualLike(t, f, target.ID, SessionVector{
- Artists: []string{"a99"}, Tags: map[string]int{"jazz": 1},
- })
- helperInsertContextualLike(t, f, target.ID, SessionVector{
- Artists: []string{"a1"}, Tags: map[string]int{"rock": 1},
- })
- helperInsertContextualLike(t, f, target.ID, SessionVector{
- Artists: []string{"a1"}, Tags: map[string]int{"jazz": 1},
- })
-
- current := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}
- got, _ := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, current)
- for _, c := range got {
- if c.Track.ID == target.ID && c.Inputs.ContextualMatchScore < 0.99 {
- t.Errorf("target = %v, want ~1.0 (max)", c.Inputs.ContextualMatchScore)
- }
- }
-}
-
-func TestLoadCandidates_SoftDeletedLikes_Ignored(t *testing.T) {
- f := newFixture(t, 3)
- target := f.tracks[1]
- likeVec := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}
- helperInsertContextualLike(t, f, target.ID, likeVec)
- // Soft-delete the row.
- if _, err := f.pool.Exec(context.Background(),
- `UPDATE contextual_likes SET deleted_at = now() WHERE user_id = $1`, f.user); err != nil {
- t.Fatalf("soft-delete: %v", err)
- }
-
- current := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}
- got, _ := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, current)
- for _, c := range got {
- if c.Inputs.ContextualMatchScore != 0 {
- t.Errorf("soft-deleted track %s ContextualMatchScore = %v", c.Track.Title, c.Inputs.ContextualMatchScore)
- }
- }
-}
-
-func TestLoadCandidates_OnlySeedLikes_ScoresZero(t *testing.T) {
- f := newFixture(t, 3)
- target := f.tracks[1]
- // Only a Seed=true contextual_like exists.
- helperInsertContextualLike(t, f, target.ID, SessionVector{
- Seed: true, Artists: []string{"a1"}, Tags: map[string]int{"rock": 1},
- })
-
- current := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}
- got, _ := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, current)
- for _, c := range got {
- if c.Inputs.ContextualMatchScore != 0 {
- t.Errorf("seed-only track %s ContextualMatchScore = %v", c.Track.Title, c.Inputs.ContextualMatchScore)
- }
- }
-}
-
-func TestLoadCandidates_CurrentSeed_ScoresZero(t *testing.T) {
- f := newFixture(t, 3)
- target := f.tracks[1]
- likeVec := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}
- helperInsertContextualLike(t, f, target.ID, likeVec)
-
- currentSeed := SessionVector{Seed: true}
- got, _ := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, currentSeed)
- for _, c := range got {
- if c.Inputs.ContextualMatchScore != 0 {
- t.Errorf("seed-current track %s ContextualMatchScore = %v", c.Track.Title, c.Inputs.ContextualMatchScore)
- }
- }
-}
-```
-
-Then update existing test call sites (`TestLoadCandidates_ExcludesSeed` and any other extant calls in this file): each `LoadCandidates(...)` call gets a sixth argument. For tests that don't care about contextual scoring, pass `SessionVector{Seed: true}` — that short-circuits the term to 0 and matches v1 behavior.
-
-Replace existing call patterns:
-
-```go
-got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1)
-```
-
-with:
-
-```go
-got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, SessionVector{Seed: true})
-```
-
-(There are 5 such call sites in the existing `candidates_test.go`. Update them all.)
-
-Update the `import` block at the top to add `"encoding/json"` if not present.
-
-- [ ] **Step 2: Run tests to verify the new ones fail and existing ones still compile**
-
-Run: `go test ./internal/recommendation/ -run LoadCandidates -v`
-
-Expected: existing tests fail to compile because `LoadCandidates` only takes 5 args. After updating call sites, they pass; new tests fail with "too few arguments" or "ContextualMatchScore not set".
-
-- [ ] **Step 3: Update `LoadCandidates` signature + body**
-
-Replace `internal/recommendation/candidates.go` contents:
-
-```go
-package recommendation
-
-import (
- "context"
- "encoding/json"
- "time"
-
- "github.com/jackc/pgx/v5/pgtype"
-
- "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
-)
-
-// LoadCandidates fetches the candidate pool for radio scoring. Combines
-// the existing track+stats query with a one-shot bulk fetch of the user's
-// active contextual_likes, mapping each candidate to its max similarity
-// against currentVector. Pass currentVector with Seed=true to short-circuit
-// the contextual term to 0 (cold-start path).
-func LoadCandidates(
- ctx context.Context,
- q *dbq.Queries,
- userID, seedID pgtype.UUID,
- recentlyPlayedHours int,
- currentVector SessionVector,
-) ([]Candidate, error) {
- rows, err := q.LoadRadioCandidates(ctx, dbq.LoadRadioCandidatesParams{
- UserID: userID,
- ID: seedID,
- Column3: float64(recentlyPlayedHours),
- })
- if err != nil {
- return nil, err
- }
-
- likes, err := loadContextualLikesByTrack(ctx, q, userID)
- 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
- }
- ctxScore := ContextualMatchScore(currentVector, likes[r.Track.ID], DefaultSimilarityWeights)
- out = append(out, Candidate{
- Track: r.Track,
- Inputs: ScoringInputs{
- IsGeneralLiked: r.IsLiked,
- LastPlayedAt: lpt,
- PlayCount: int(r.PlayCount),
- SkipCount: int(r.SkipCount),
- ContextualMatchScore: ctxScore,
- },
- })
- }
- return out, nil
-}
-
-// loadContextualLikesByTrack fetches the user's active contextual_likes in
-// one query and groups them by track_id. Rows whose session_vector fails
-// to unmarshal are skipped with no error (don't poison scoring over one
-// bad row); the SQL query already filters NULL vectors.
-func loadContextualLikesByTrack(
- ctx context.Context,
- q *dbq.Queries,
- userID pgtype.UUID,
-) (map[pgtype.UUID][]SessionVector, error) {
- rows, err := q.ListActiveContextualLikesForUser(ctx, userID)
- if err != nil {
- return nil, err
- }
- out := make(map[pgtype.UUID][]SessionVector, len(rows))
- for _, r := range rows {
- var v SessionVector
- if err := json.Unmarshal(r.SessionVector, &v); err != nil {
- continue
- }
- out[r.TrackID] = append(out[r.TrackID], v)
- }
- return out, nil
-}
-```
-
-- [ ] **Step 4: Run tests to verify all pass**
-
-Run: `go test ./internal/recommendation/ -v`
-
-Expected: PASS for all existing tests + 6 new contextual tests.
-
-- [ ] **Step 5: Verify other callers compile**
-
-Run: `go build ./...`
-
-Expected: `internal/api/radio.go` fails — it still calls `LoadCandidates` with 5 args. We fix that in Task 6.
-
-- [ ] **Step 6: Commit (allow temporary build break — fixed in Task 6)**
-
-```bash
-git add internal/recommendation/candidates.go internal/recommendation/candidates_test.go
-git commit -m "feat(recommendation): LoadCandidates computes per-candidate ContextualMatchScore"
-```
-
-Note: this commit leaves `internal/api/radio.go` non-compiling. Task 6 restores green. Don't push the branch in this state — finish Task 6 first.
-
----
-
-## Task 6: Config + radio handler wiring
-
-**Files:**
-- Modify: `internal/config/config.go`
-- Modify: `internal/api/radio.go`
-- Modify: `internal/api/auth_test.go`
-
-- [ ] **Step 1: Add `ContextWeight` to `RecommendationConfig`**
-
-In `internal/config/config.go`, modify the `RecommendationConfig` struct:
-
-```go
-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"`
- ContextWeight float64 `yaml:"context_weight"`
- RecentlyPlayedHours int `yaml:"recently_played_hours"`
- RadioSize int `yaml:"radio_size"`
- RadioSizeMax int `yaml:"radio_size_max"`
-}
-```
-
-In the same file, modify `Default()`'s `Recommendation` block to include the new field:
-
-```go
-Recommendation: RecommendationConfig{
- BaseWeight: 1.0,
- LikeBoost: 2.0,
- RecencyWeight: 1.0,
- SkipPenalty: 1.0,
- JitterMagnitude: 0.1,
- ContextWeight: 2.0,
- RecentlyPlayedHours: 1,
- RadioSize: 50,
- RadioSizeMax: 200,
-},
-```
-
-- [ ] **Step 2: Update `radio.go` to fetch current vector and pass it through**
-
-Replace `internal/api/radio.go` contents:
-
-```go
-package api
-
-import (
- "encoding/json"
- "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.
-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. The
-// scoring formula folds in contextual_match_score using the user's current
-// session vector (read from the most recent open play_event).
-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
- }
- 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
- }
-
- currentVec := loadCurrentSessionVector(r, q, user.ID, h.logger)
-
- candidates, err := recommendation.LoadCandidates(r.Context(), q, user.ID, seedID, h.recCfg.RecentlyPlayedHours, currentVec)
- 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,
- ContextWeight: h.recCfg.ContextWeight,
- }
- 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})
-}
-
-// loadCurrentSessionVector returns the user's most recent active session
-// vector, or a Seed=true sentinel if none exists / the column is NULL /
-// the JSON fails to unmarshal. Sentinel short-circuits ContextualMatchScore
-// to 0 so the contextual term contributes nothing in cold-start cases.
-func loadCurrentSessionVector(r *http.Request, q *dbq.Queries, userID pgtype.UUID, logger *slog.Logger) recommendation.SessionVector {
- raw, err := q.GetCurrentSessionVectorForUser(r.Context(), userID)
- if err != nil {
- // pgx.ErrNoRows is the common path: no active session yet.
- if !errors.Is(err, pgx.ErrNoRows) {
- logger.Warn("api: radio: load current session vector", "err", err)
- }
- return recommendation.SessionVector{Seed: true}
- }
- if len(raw) == 0 {
- return recommendation.SessionVector{Seed: true}
- }
- var v recommendation.SessionVector
- if jerr := json.Unmarshal(raw, &v); jerr != nil {
- logger.Warn("api: radio: bad session_vector_at_play json", "err", jerr)
- return recommendation.SessionVector{Seed: true}
- }
- return v
-}
-```
-
-Add the missing imports at the top: `"log/slog"` and `"github.com/jackc/pgx/v5/pgtype"`.
-
-- [ ] **Step 3: Update `auth_test.go` test helper to include `ContextWeight`**
-
-In `internal/api/auth_test.go`, modify the `recCfg` definition inside `testHandlers`:
-
-```go
-recCfg := config.RecommendationConfig{
- BaseWeight: 1.0, LikeBoost: 2.0, RecencyWeight: 1.0,
- SkipPenalty: 1.0, JitterMagnitude: 0.1, ContextWeight: 2.0,
- RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200,
-}
-```
-
-- [ ] **Step 4: Run full build + existing tests**
-
-Run: `go build ./...`
-
-Expected: clean compile.
-
-Run: `go test ./internal/api/ ./internal/recommendation/ ./internal/config/ -v -short`
-
-Expected: PASS. The `-short` flag lets unit tests run; integration tests need `MINSTREL_TEST_DATABASE_URL`.
-
-If `MINSTREL_TEST_DATABASE_URL` is set, drop `-short` and verify integration tests pass:
-
-Run: `go test ./internal/api/ ./internal/recommendation/ -v`
-
-Expected: PASS. All existing radio tests still pass — `currentVec` defaults to `Seed=true` for users with no plays, so behavior is identical to v1.
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add internal/config/config.go internal/api/radio.go internal/api/auth_test.go
-git commit -m "feat(api): radio handler reads current session vector + threads ContextWeight"
-```
-
----
-
-## Task 7: End-to-end test — contextual match boosts ranking
-
-**Files:**
-- Modify: `internal/api/radio_test.go`
-
-- [ ] **Step 1: Write the failing end-to-end test**
-
-Append to `internal/api/radio_test.go`:
-
-```go
-func TestHandleRadio_ContextualMatch_BoostsRankingOverControl(t *testing.T) {
- h, pool := testHandlers(t)
- truncateLibrary(t, pool)
- user := seedUser(t, pool, "alice", "x", false)
-
- // Two artists in distinct genres, so we can construct a "rock vibe" session
- // and a contextual match.
- rockArtist := seedArtist(t, pool, "RockArtist")
- rockAlbum := seedAlbum(t, pool, rockArtist.ID, "RockAlbum", 2020)
- jazzArtist := seedArtist(t, pool, "JazzArtist")
- jazzAlbum := seedAlbum(t, pool, jazzArtist.ID, "JazzAlbum", 2020)
-
- // Seed track is unrelated to both (don't want it to dominate scoring).
- popArtist := seedArtist(t, pool, "PopArtist")
- popAlbum := seedAlbum(t, pool, popArtist.ID, "PopAlbum", 2020)
- seed := seedTrack(t, pool, popAlbum.ID, popArtist.ID, "Seed", 1, 100_000)
-
- // Target: a rock track. Control: a jazz track. Both will be scored.
- target := seedTrackWithGenre(t, pool, rockAlbum.ID, rockArtist.ID, "Target", 1, 100_000, "rock")
- control := seedTrackWithGenre(t, pool, jazzAlbum.ID, jazzArtist.ID, "Control", 1, 100_000, "jazz")
-
- // Build the user's "rock vibe" current context: insert an open play_session
- // with a play_event whose session_vector_at_play matches the rock vibe.
- rockVec := recommendation.SessionVector{
- Artists: []string{toUUIDString(rockArtist.ID)},
- Tags: map[string]int{"rock": 3},
- }
- rockVecJSON, _ := json.Marshal(rockVec)
- insertOpenSessionWithVector(t, pool, user.ID, rockArtist.ID, rockVecJSON)
-
- // Insert a contextual_like on the target track whose stored vector matches
- // the rock vibe. (Direct DB insert — we want full control over the vector
- // for this test, not whatever playevents.CaptureContextualLikeIfPlaying
- // would produce.)
- if _, err := pool.Exec(context.Background(),
- `INSERT INTO contextual_likes (user_id, track_id, session_vector) VALUES ($1, $2, $3)`,
- user.ID, target.ID, rockVecJSON); err != nil {
- t.Fatalf("insert contextual_like: %v", err)
- }
-
- // Request radio. The deterministic RNG (rng=0.5 → jitter contribution = 0)
- // means rankings are reproducible for this test.
- 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
- if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("decode: %v", err)
- }
-
- // Find the indexes of target and control in the response.
- targetIdx, controlIdx := -1, -1
- for i, tr := range resp.Tracks {
- if tr.Title == "Target" {
- targetIdx = i
- }
- if tr.Title == "Control" {
- controlIdx = i
- }
- }
- if targetIdx == -1 || controlIdx == -1 {
- t.Fatalf("target=%d control=%d, expected both present (resp.Tracks=%v)", targetIdx, controlIdx, resp.Tracks)
- }
- if targetIdx >= controlIdx {
- t.Errorf("target ranked at %d, control at %d: contextual match should put target above control", targetIdx, controlIdx)
- }
-}
-```
-
-- [ ] **Step 2: Add the helper `seedTrackWithGenre` and `insertOpenSessionWithVector`**
-
-These don't exist yet. Add them to `internal/api/radio_test.go` (or to whichever helpers file the existing `seedTrack` lives in — keep them adjacent):
-
-```go
-func seedTrackWithGenre(t *testing.T, pool *pgxpool.Pool, albumID, artistID pgtype.UUID, title string, trackNo, durationMs int, genre string) dbq.Track {
- t.Helper()
- q := dbq.New(pool)
- tr, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
- Title: title,
- AlbumID: albumID,
- ArtistID: artistID,
- FilePath: "/tmp/" + title + ".flac",
- DurationMs: int32(durationMs),
- Genre: &genre,
- })
- if err != nil {
- t.Fatalf("UpsertTrack: %v", err)
- }
- return tr
-}
-
-// insertOpenSessionWithVector creates a play_session with ended_at NULL and
-// inserts a play_event in it whose session_vector_at_play is the given JSON.
-// Used to simulate "user is mid-listen" for the radio handler's current-vector
-// lookup. The play_event itself doesn't reference any of the test tracks.
-func insertOpenSessionWithVector(t *testing.T, pool *pgxpool.Pool, userID, anyArtistID pgtype.UUID, vectorJSON []byte) {
- t.Helper()
- // Create a placeholder track so the play_event has a valid track_id.
- q := dbq.New(pool)
- al, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{
- Title: "PlaceholderAlbum", SortTitle: "PlaceholderAlbum", ArtistID: anyArtistID,
- })
- ph, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
- Title: "Placeholder", AlbumID: al.ID, ArtistID: anyArtistID,
- FilePath: "/tmp/placeholder.flac", DurationMs: 100_000,
- })
- if err != nil {
- t.Fatalf("placeholder track: %v", err)
- }
- // Insert open session.
- var sessionID pgtype.UUID
- if err := pool.QueryRow(context.Background(),
- `INSERT INTO play_sessions (user_id, started_at, last_event_at, client_id)
- VALUES ($1, now() - interval '5 minutes', now(), 'test') RETURNING id`,
- userID).Scan(&sessionID); err != nil {
- t.Fatalf("insert session: %v", err)
- }
- // Insert play_event with the given session_vector_at_play.
- if _, err := pool.Exec(context.Background(),
- `INSERT INTO play_events (user_id, track_id, session_id, started_at, session_vector_at_play)
- VALUES ($1, $2, $3, now() - interval '1 minute', $4)`,
- userID, ph.ID, sessionID, vectorJSON); err != nil {
- t.Fatalf("insert play_event: %v", err)
- }
-}
-
-// toUUIDString converts a pgtype.UUID to its canonical hex string. Mirrors
-// what BuildSessionVector emits, so the test's session_vector matches what
-// the engine would produce in production.
-func toUUIDString(u pgtype.UUID) string {
- if !u.Valid {
- return ""
- }
- return u.String()
-}
-```
-
-Add the missing imports at the top of `radio_test.go`: `"github.com/jackc/pgx/v5/pgtype"`, `"github.com/jackc/pgx/v5/pgxpool"`, `"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"`, `"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"`. (Some may already exist.)
-
-Also: the `truncateLibrary` helper used by other tests probably truncates `play_events, play_sessions, sessions, users, tracks, albums, artists, general_likes`. We need it to also include `contextual_likes`. Find the helper (probably in `auth_test.go` or `radio_test.go`) and add `contextual_likes` to its TRUNCATE list:
-
-```go
-func truncateLibrary(t *testing.T, pool *pgxpool.Pool) {
- t.Helper()
- if _, err := pool.Exec(context.Background(),
- "TRUNCATE contextual_likes, 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)
- }
-}
-```
-
-(If `truncateLibrary` already exists, add `contextual_likes` to its TRUNCATE list. If it doesn't exist as a named helper, find the inline TRUNCATE in `testHandlers` and update that.)
-
-- [ ] **Step 3: Run the new test**
-
-Run: `MINSTREL_TEST_DATABASE_URL= go test ./internal/api/ -run TestHandleRadio_ContextualMatch_BoostsRankingOverControl -v`
-
-Expected: PASS. Target track appears at a lower index than Control in the response.
-
-- [ ] **Step 4: Run the full integration suite**
-
-Run: `MINSTREL_TEST_DATABASE_URL= go test ./internal/api/ ./internal/recommendation/ -v`
-
-Expected: PASS. Existing tests unaffected; new contextual test passes.
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add internal/api/radio_test.go internal/api/auth_test.go
-git commit -m "test(api): end-to-end contextual ranking test for /api/radio"
-```
-
-(Only commit `auth_test.go` here if you didn't already in Task 6 — if `truncateLibrary` lives there and got modified, include it.)
-
----
-
-## Task 8: Final verification + branch finish
-
-**Files:** none (verification only)
-
-- [ ] **Step 1: Full Go test suite**
-
-Run: `go test -short -race ./...`
-
-Expected: PASS. All packages, no race conditions.
-
-- [ ] **Step 2: Full integration suite (with DB)**
-
-Run: `MINSTREL_TEST_DATABASE_URL= go test -race ./...`
-
-Expected: PASS.
-
-- [ ] **Step 3: Lint**
-
-Run: `golangci-lint run ./...`
-
-Expected: clean.
-
-- [ ] **Step 4: Coverage check**
-
-Run: `go test -short -coverprofile=cover.out ./internal/recommendation/ ./internal/playevents/ && go tool cover -func=cover.out | tail -1`
-
-Expected: combined coverage ≥ 70% (target). With pure functions in similarity.go heavily tested, we expect to land at 80%+.
-
-- [ ] **Step 5: Web verification (sanity)**
-
-Run: `cd web && npm run check && npm test -- --run && npm run build`
-
-Expected: svelte-check 0/0, all vitest tests pass, build succeeds. (No web changes in this slice, so this should be clean.)
-
-- [ ] **Step 6: Docker smoke (if present locally)**
-
-Run: `make docker-build && make docker-smoke` (or whatever the project's smoke-test target is).
-
-Expected: container builds; smoke check (`/healthz`, `/api/auth/login`) returns expected codes.
-
-- [ ] **Step 7: Manual end-to-end verification**
-
-Start the server. As an authenticated user:
-
-1. Play a few tracks of one genre (e.g., 3+ rock tracks) via the web UI to populate a session vector.
-2. Like one of those tracks (creates a contextual_like with the rock-vibe vector).
-3. Wait for the recently-played-hours window to clear, OR pick tracks not yet recently played.
-4. Click "Play radio" from a different track.
-5. Inspect: the previously-liked rock track should appear in the radio queue with a noticeably-likely-to-rank-high position. Compare against a jazz track that the user has NEVER liked — it should be ranked lower.
-
-Verification is qualitative for v1; the integration test in Task 7 is the deterministic guarantee.
-
-- [ ] **Step 8: Update Fable task #342**
-
-Set status to `in_progress` (after PR opens). After PR merges, mark `done` with the closing summary in the body. The task tracking should mention:
-
-- Closes the M3 milestone
-- All three v1 components shipped: weighted shuffle (#340), session vectors (#341), contextual matching (this slice)
-- Coverage targets met
-- Backwards-compat: `/api/radio` API shape unchanged
-
-- [ ] **Step 9: Finishing the branch**
-
-**REQUIRED SUB-SKILL:** Use `superpowers:finishing-a-development-branch` to verify tests, present completion options (merge locally / push + PR / keep / discard), and execute the user's choice.
-
-Per established cadence: this slice will land as a single-purpose PR (no bundling with future M3.5 / M4 work). Once merged, M3 is closed.
-
----
-
-## Self-Review
-
-**Spec coverage:**
-
-- §3.1 (similarity.go) → Tasks 1, 2 ✓
-- §3.2 (ListActiveContextualLikesForUser) → Task 4 ✓
-- §3.3 (GetCurrentSessionVectorForUser) → Task 4 ✓
-- §3.4 (Score extension) → Task 3 ✓
-- §3.5 (LoadCandidates extension) → Task 5 ✓
-- §3.6 (radio.go wiring) → Task 6 ✓
-- §3.7 (config) → Task 6 ✓
-- §4 (request flow) → Tasks 5+6 (composition) ✓
-- §5 (cold-start) → Task 5 covers Seed/empty paths; Task 6 covers no-session/NULL paths ✓
-- §6.1 (similarity_test) → Task 1 ✓
-- §6.2 (score_test) → Task 3 ✓
-- §6.3 (candidates_test) → Task 5 ✓
-- §6.4 (radio_test) → Task 7 ✓
-- §6.5 (coverage gate) → Task 8 ✓
-- §7 (backwards compat) → preserved by zero-value semantics in Task 3 + cold-start handling in Tasks 5-6 ✓
-
-No gaps.
-
-**Placeholder scan:** No "TBD"/"TODO" content. All steps have explicit code or commands.
-
-**Type consistency:** Verified — `SessionVector`, `Similarity`, `ContextualMatchScore`, `ScoringInputs.ContextualMatchScore`, `ScoringWeights.ContextWeight`, `RecommendationConfig.ContextWeight`, `LoadCandidates`'s sixth parameter `currentVector SessionVector`, `loadContextualLikesByTrack` return type `map[pgtype.UUID][]SessionVector`, `loadCurrentSessionVector` return type `recommendation.SessionVector` — all consistent across tasks.
diff --git a/docs/superpowers/plans/2026-04-27-m3-vectors.md b/docs/superpowers/plans/2026-04-27-m3-vectors.md
deleted file mode 100644
index a84b2ae7..00000000
--- a/docs/superpowers/plans/2026-04-27-m3-vectors.md
+++ /dev/null
@@ -1,1491 +0,0 @@
-# M3 Session Vectors + Contextual Likes 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:** Add the two write paths that produce contextual data for the recommendation engine: (1) compute and persist a session vector at every play_started; (2) snapshot that vector into `contextual_likes` whenever a user likes a track during an active play_event. Soft-delete contextual_likes on unlike. Backend-only — no UI surface.
-
-**Architecture:** New migration `0007_contextual_likes` creates the missing table (it was referenced in 0005's comments but never CREATE'd). New pure function `BuildSessionVector(priorTracks)` produces the JSONB shape from spec §6. `internal/playevents/writer.go::RecordPlayStarted` and `RecordSyntheticCompletedPlay` extend in-transaction to compute and UPDATE the vector after inserting the play_event. `internal/api/likes.go` and `internal/subsonic/star.go` extend their like/unlike paths via two shared helpers in `internal/playevents` (so both surfaces capture/soft-delete uniformly).
-
-**Tech Stack:** Go 1.23 + chi + sqlc + pgx/v5. No web changes.
-
-**Reference:** design spec at `docs/superpowers/specs/2026-04-27-m3-vectors-design.md`.
-
----
-
-## File Structure
-
-**New server files:**
-
-| File | Responsibility |
-|---|---|
-| `internal/db/migrations/0007_contextual_likes.up.sql` + `.down.sql` | `contextual_likes` table + partial index (active rows) + GIN index (session_vector). |
-| `internal/db/queries/contextual_likes.sql` | `InsertContextualLike`, `SoftDeleteContextualLikesForUserTrack`. |
-| `internal/db/dbq/contextual_likes.sql.go` | Generated bindings. |
-| `internal/recommendation/sessionvector.go` | `SessionVector` struct + `BuildSessionVector(priorTracks []dbq.Track) SessionVector` pure function. |
-| `internal/recommendation/sessionvector_test.go` | Pure unit tests for the function (boundary cases). |
-| `internal/playevents/contextual_likes.go` | Two shared helpers: `CaptureContextualLikeIfPlaying(ctx, q, userID, trackID, logger)` and `SoftDeleteContextualLikes(ctx, q, userID, trackID)`. Imported by both `internal/api` and `internal/subsonic`. |
-| `internal/playevents/contextual_likes_test.go` | Live-DB tests for the helpers. |
-
-**Modified server files:**
-
-| File | Change |
-|---|---|
-| `internal/db/queries/events.sql` | Add `ListRecentSessionTracks(sessionID, beforeTime, limit) :many` and `UpdatePlayEventVector(id, vector) :exec`. |
-| `internal/db/queries/likes.sql` | Change `LikeTrack` from `:exec` to `:execrows` (returns int64 affected count). |
-| `internal/playevents/writer.go::RecordPlayStarted` and `RecordSyntheticCompletedPlay` | After InsertPlayEvent: ListRecentSessionTracks → BuildSessionVector → marshal JSON → UpdatePlayEventVector. Extracted into a shared private helper. |
-| `internal/playevents/writer_test.go` | Three new tests covering vector persistence (seed flag, populated, session-scope isolation). |
-| `internal/api/likes.go::handleLikeTrack` | Capture `:execrows` result; if 1, call `playevents.CaptureContextualLikeIfPlaying`. |
-| `internal/api/likes.go::handleUnlikeTrack` | After `UnlikeTrack`, call `playevents.SoftDeleteContextualLikes`. |
-| `internal/api/likes_test.go` | Five new tests (capture during open play, no capture without play, no duplicate on idempotent re-like, soft-delete on unlike, history accumulation). |
-| `internal/subsonic/star.go::handleStar` (track branch) | After `LikeTrack`, capture rows count and call `playevents.CaptureContextualLikeIfPlaying`. |
-| `internal/subsonic/star.go::handleUnstar` (track branch) | After `UnlikeTrack`, call `playevents.SoftDeleteContextualLikes`. |
-| `internal/subsonic/star_test.go` | One new test verifying contextual capture through the Subsonic path. |
-
-**No web changes.**
-
----
-
-## Task 1: Migration 0007 + contextual_likes sqlc queries
-
-**Files:**
-- Create: `internal/db/migrations/0007_contextual_likes.up.sql`
-- Create: `internal/db/migrations/0007_contextual_likes.down.sql`
-- Create: `internal/db/queries/contextual_likes.sql`
-- Generated: `internal/db/dbq/contextual_likes.sql.go`
-
-- [ ] **Step 1: Write the up migration**
-
-Create `internal/db/migrations/0007_contextual_likes.up.sql`:
-
-```sql
--- contextual_likes captures a per-session-context snapshot at the time of
--- each like. The recommendation engine in M3 sub-plan #3 uses these rows
--- to compute contextual_match_score per (current session, candidate track).
---
--- Multiple rows per (user, track) are allowed and expected — each row
--- represents a like in a specific session context. Soft-deleted rows
--- remain in the table (deleted_at populated) but are filtered out of the
--- engine's queries via the partial index. Re-liking a previously-unliked
--- track adds a NEW row (does not undelete the old one).
---
--- Note: the M2 events migration (0005) commented that contextual_likes
--- "ships nullable now" but the actual CREATE TABLE was missed. This slice
--- ships the table for the first time.
-
-CREATE TABLE contextual_likes (
- id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
- user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
- track_id uuid NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
- liked_at timestamptz NOT NULL DEFAULT now(),
- deleted_at timestamptz,
- session_vector jsonb,
- session_id uuid REFERENCES play_sessions(id) ON DELETE SET NULL
-);
-
--- Hot path for both the engine's lookups and the soft-delete UPDATE.
-CREATE INDEX contextual_likes_active_idx
- ON contextual_likes (user_id, track_id)
- WHERE deleted_at IS NULL;
-
--- Vector similarity queries from M3 sub-plan #3 will use this.
-CREATE INDEX contextual_likes_vector_idx
- ON contextual_likes USING gin (session_vector);
-```
-
-- [ ] **Step 2: Write the down migration**
-
-Create `internal/db/migrations/0007_contextual_likes.down.sql`:
-
-```sql
-DROP TABLE IF EXISTS contextual_likes;
-```
-
-- [ ] **Step 3: Write sqlc queries**
-
-Create `internal/db/queries/contextual_likes.sql`:
-
-```sql
--- name: InsertContextualLike :exec
-INSERT INTO contextual_likes (user_id, track_id, session_vector, session_id)
-VALUES ($1, $2, $3, $4);
-
--- name: SoftDeleteContextualLikesForUserTrack :exec
--- Marks all currently-active rows for (user, track) as deleted. Idempotent —
--- already-deleted rows aren't re-touched.
-UPDATE contextual_likes
-SET deleted_at = now()
-WHERE user_id = $1 AND track_id = $2 AND deleted_at IS NULL;
-```
-
-- [ ] **Step 4: Generate sqlc bindings**
-
-Run: `$(go env GOPATH)/bin/sqlc generate`
-Expected: writes `internal/db/dbq/contextual_likes.sql.go` with `InsertContextualLikeParams { UserID pgtype.UUID; TrackID pgtype.UUID; SessionVector []byte; SessionID pgtype.UUID }` and `SoftDeleteContextualLikesForUserTrackParams { UserID pgtype.UUID; TrackID pgtype.UUID }`. Also adds a `ContextualLike` model type to `models.go`.
-
-- [ ] **Step 5: Verify build**
-
-Run: `go build ./...`
-Expected: clean.
-
-- [ ] **Step 6: Migration smoke**
-
-Run:
-```bash
-MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \
- go test ./internal/db -run TestMigrate -v
-```
-Expected: PASS.
-
-- [ ] **Step 7: Commit**
-
-```bash
-git add internal/db/migrations/0007_contextual_likes.up.sql \
- internal/db/migrations/0007_contextual_likes.down.sql \
- internal/db/queries/contextual_likes.sql \
- internal/db/dbq/contextual_likes.sql.go \
- internal/db/dbq/models.go
-git commit -m "feat(db): add contextual_likes table (M3 session-context capture)
-
-Table was referenced in migration 0005's comment but never created —
-this slice fills the gap. Soft-delete via deleted_at column; hot-path
-partial index on active rows; GIN index on session_vector for
-M3 sub-plan #3's similarity queries."
-```
-
----
-
-## Task 2: Vector capture queries in events.sql
-
-**Files:**
-- Modify: `internal/db/queries/events.sql` (append two new queries)
-- Generated: `internal/db/dbq/events.sql.go` (regenerated)
-- Modify: `internal/db/queries/likes.sql` (`LikeTrack` becomes `:execrows`)
-
-The vector capture in `RecordPlayStarted` needs (a) a query that lists the prior tracks in a session and (b) a query that updates `play_events.session_vector_at_play`. Bundle the `LikeTrack` `:execrows` change here too — same generated-bindings cycle.
-
-- [ ] **Step 1: Append `ListRecentSessionTracks` and `UpdatePlayEventVector` to `internal/db/queries/events.sql`**
-
-```sql
--- name: ListRecentSessionTracks :many
--- Returns up to $3 tracks in session $1 whose play_event started before
--- $2, ordered newest-first. Used by playevents.RecordPlayStarted to
--- build the session_vector for the just-inserted play_event.
-SELECT t.* FROM tracks t
-JOIN play_events pe ON pe.track_id = t.id
-WHERE pe.session_id = $1
- AND pe.started_at < $2
-ORDER BY pe.started_at DESC
-LIMIT $3;
-
--- name: UpdatePlayEventVector :exec
--- Used right after InsertPlayEvent to populate session_vector_at_play
--- once the vector has been computed.
-UPDATE play_events
-SET session_vector_at_play = $2
-WHERE id = $1;
-```
-
-- [ ] **Step 2: Change `LikeTrack` to `:execrows` in `internal/db/queries/likes.sql`**
-
-Find the existing `-- name: LikeTrack :exec` block. Replace the annotation:
-
-```sql
--- name: LikeTrack :execrows
-INSERT INTO general_likes (user_id, track_id)
-VALUES ($1, $2)
-ON CONFLICT (user_id, track_id) DO NOTHING;
-```
-
-(The SQL body is unchanged; only the `:exec` → `:execrows` annotation changes.)
-
-- [ ] **Step 3: Generate sqlc bindings**
-
-Run: `$(go env GOPATH)/bin/sqlc generate`
-Expected:
-- `internal/db/dbq/events.sql.go` gains `ListRecentSessionTracks` and `UpdatePlayEventVector` functions, plus their param structs.
-- `internal/db/dbq/likes.sql.go` — `LikeTrack` signature changes from `(ctx, params) error` to `(ctx, params) (int64, error)`. Build will break at every existing call site.
-
-- [ ] **Step 4: Update existing `LikeTrack` callers to ignore the count**
-
-Two sites need updating right now to keep the build green; the next tasks will use the count meaningfully.
-
-In `internal/api/likes.go`:
-```go
-// Before:
-if err := q.LikeTrack(r.Context(), dbq.LikeTrackParams{UserID: user.ID, TrackID: id}); err != nil {
-// After:
-if _, err := q.LikeTrack(r.Context(), dbq.LikeTrackParams{UserID: user.ID, TrackID: id}); err != nil {
-```
-
-In `internal/subsonic/star.go`:
-```go
-// Before:
-if err := q.LikeTrack(r.Context(), dbq.LikeTrackParams{UserID: user.ID, TrackID: trackID}); err != nil {
-// After:
-if _, err := q.LikeTrack(r.Context(), dbq.LikeTrackParams{UserID: user.ID, TrackID: trackID}); err != nil {
-```
-
-- [ ] **Step 5: Verify build**
-
-Run: `go build ./...`
-Expected: clean.
-
-- [ ] **Step 6: Run existing test 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. Existing like tests still work because the behavior is unchanged at this stage; only the function signature evolved.
-
-- [ ] **Step 7: Commit**
-
-```bash
-git add internal/db/queries/events.sql internal/db/queries/likes.sql \
- internal/db/dbq/events.sql.go internal/db/dbq/likes.sql.go \
- internal/api/likes.go internal/subsonic/star.go
-git commit -m "feat(db): add session-vector capture queries; LikeTrack returns row count
-
-ListRecentSessionTracks + UpdatePlayEventVector for the vector capture
-path inside RecordPlayStarted. LikeTrack switches to :execrows so the
-contextual-likes capture can detect insert vs already-exists. Existing
-callers updated to ignore the count for now; later tasks consume it."
-```
-
----
-
-## Task 3: Pure `BuildSessionVector` function
-
-**Files:**
-- Create: `internal/recommendation/sessionvector.go`
-- Create: `internal/recommendation/sessionvector_test.go`
-
-Pure function, no DB. Produces the JSONB-serializable struct from a slice of `dbq.Track`.
-
-- [ ] **Step 1: Write the failing tests**
-
-Create `internal/recommendation/sessionvector_test.go`:
-
-```go
-package recommendation
-
-import (
- "encoding/json"
- "testing"
-
- "github.com/jackc/pgx/v5/pgtype"
-
- "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
-)
-
-func track(artistID pgtype.UUID, genre string) dbq.Track {
- t := dbq.Track{
- ArtistID: artistID,
- }
- if genre != "" {
- g := genre
- t.Genre = &g
- }
- return t
-}
-
-func uuid(s string) pgtype.UUID {
- var u pgtype.UUID
- _ = u.Scan(s)
- return u
-}
-
-func TestBuildSessionVector_Empty_IsSeed(t *testing.T) {
- v := BuildSessionVector(nil)
- if !v.Seed {
- t.Errorf("Seed = false, want true for empty input")
- }
- if len(v.Artists) != 0 || len(v.Tags) != 0 || len(v.RecentTrackIDs) != 0 {
- t.Errorf("non-empty collections: %+v", v)
- }
-}
-
-func TestBuildSessionVector_OneTrack_IsSeed(t *testing.T) {
- a1 := uuid("11111111-1111-1111-1111-111111111111")
- tr := track(a1, "rock")
- tr.ID = uuid("22222222-2222-2222-2222-222222222222")
- v := BuildSessionVector([]dbq.Track{tr})
- if !v.Seed {
- t.Errorf("1 track: Seed=false, want true (< 3)")
- }
- if len(v.Artists) != 1 {
- t.Errorf("Artists: %+v", v.Artists)
- }
- if v.Tags["rock"] != 1 {
- t.Errorf("Tags: %+v", v.Tags)
- }
- if len(v.RecentTrackIDs) != 1 {
- t.Errorf("RecentTrackIDs: %+v", v.RecentTrackIDs)
- }
-}
-
-func TestBuildSessionVector_TwoTracks_IsSeed(t *testing.T) {
- a1 := uuid("11111111-1111-1111-1111-111111111111")
- v := BuildSessionVector([]dbq.Track{track(a1, "rock"), track(a1, "jazz")})
- if !v.Seed {
- t.Errorf("2 tracks: Seed=false, want true (< 3)")
- }
-}
-
-func TestBuildSessionVector_ThreeTracks_NotSeed(t *testing.T) {
- a1 := uuid("11111111-1111-1111-1111-111111111111")
- v := BuildSessionVector([]dbq.Track{
- track(a1, "rock"), track(a1, "rock"), track(a1, "rock"),
- })
- if v.Seed {
- t.Errorf("3 tracks: Seed=true, want false")
- }
- // All same artist → 1 entry.
- if len(v.Artists) != 1 {
- t.Errorf("Artists len = %d, want 1 (dedup)", len(v.Artists))
- }
- // All same genre → count 3.
- if v.Tags["rock"] != 3 {
- t.Errorf("Tags['rock'] = %d, want 3", v.Tags["rock"])
- }
-}
-
-func TestBuildSessionVector_DistinctArtistsAndGenres(t *testing.T) {
- a1 := uuid("11111111-1111-1111-1111-111111111111")
- a2 := uuid("22222222-2222-2222-2222-222222222222")
- a3 := uuid("33333333-3333-3333-3333-333333333333")
- v := BuildSessionVector([]dbq.Track{
- track(a1, "rock"),
- track(a2, "jazz"),
- track(a3, "rock"),
- track(a1, "experimental"),
- })
- // 4 tracks → not seed.
- if v.Seed {
- t.Errorf("Seed=true, want false")
- }
- // Distinct artists: 3 (a1 dedup'd).
- if len(v.Artists) != 3 {
- t.Errorf("Artists len = %d, want 3", len(v.Artists))
- }
- // Tag counts.
- if v.Tags["rock"] != 2 || v.Tags["jazz"] != 1 || v.Tags["experimental"] != 1 {
- t.Errorf("Tags = %+v", v.Tags)
- }
- if len(v.RecentTrackIDs) != 4 {
- t.Errorf("RecentTrackIDs len = %d", len(v.RecentTrackIDs))
- }
-}
-
-func TestBuildSessionVector_NilGenre_NotIndexed(t *testing.T) {
- a1 := uuid("11111111-1111-1111-1111-111111111111")
- v := BuildSessionVector([]dbq.Track{
- track(a1, ""), // empty genre via the helper (nil pointer)
- track(a1, "rock"),
- track(a1, ""),
- })
- if v.Tags["rock"] != 1 {
- t.Errorf("Tags['rock'] = %d, want 1", v.Tags["rock"])
- }
- if _, exists := v.Tags[""]; exists {
- t.Errorf("Tags has empty-string entry: %+v", v.Tags)
- }
-}
-
-func TestBuildSessionVector_JSONRoundTrip(t *testing.T) {
- a1 := uuid("11111111-1111-1111-1111-111111111111")
- v := BuildSessionVector([]dbq.Track{
- track(a1, "rock"), track(a1, "jazz"), track(a1, "rock"),
- })
- data, err := json.Marshal(v)
- if err != nil {
- t.Fatalf("marshal: %v", err)
- }
- var got SessionVector
- if err := json.Unmarshal(data, &got); err != nil {
- t.Fatalf("unmarshal: %v", err)
- }
- if got.Seed != v.Seed {
- t.Errorf("Seed: %v != %v", got.Seed, v.Seed)
- }
- if got.Tags["rock"] != v.Tags["rock"] {
- t.Errorf("Tags: %+v != %+v", got.Tags, v.Tags)
- }
- if len(got.Artists) != len(v.Artists) {
- t.Errorf("Artists: len %d != %d", len(got.Artists), len(v.Artists))
- }
- if len(got.RecentTrackIDs) != len(v.RecentTrackIDs) {
- t.Errorf("RecentTrackIDs: len %d != %d", len(got.RecentTrackIDs), len(v.RecentTrackIDs))
- }
-}
-```
-
-- [ ] **Step 2: Run, confirm fail**
-
-Run: `go test ./internal/recommendation -run TestBuildSessionVector -v`
-Expected: FAIL — `SessionVector`, `BuildSessionVector` undefined.
-
-- [ ] **Step 3: Implement `internal/recommendation/sessionvector.go`**
-
-```go
-package recommendation
-
-import (
- "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
-)
-
-// SessionVector is the JSONB-serializable shape of a play_event's
-// session_vector_at_play column. Spec §6 "Session vector". Sub-plan #3
-// will read these to compute contextual_match_score in the radio scoring.
-type SessionVector struct {
- Seed bool `json:"seed"`
- Artists []string `json:"artists"`
- Tags map[string]int `json:"tags"`
- RecentTrackIDs []string `json:"recent_track_ids"`
-}
-
-// BuildSessionVector aggregates the prior tracks into a session vector.
-// Pure: no DB, no time, no randomness. Caller is responsible for ordering
-// (oldest first / newest last) and for limiting to the desired N.
-//
-// Rules:
-// - Seed = len(priorTracks) < 3.
-// - Artists deduplicated, ordered by first appearance.
-// - Tags is a bag-of-counts over tracks.genre. Empty/nil genres do not contribute.
-// - RecentTrackIDs preserves input order (newest last).
-func BuildSessionVector(priorTracks []dbq.Track) SessionVector {
- v := SessionVector{
- Seed: len(priorTracks) < 3,
- Artists: []string{},
- Tags: map[string]int{},
- RecentTrackIDs: []string{},
- }
- seen := map[string]bool{}
- for _, t := range priorTracks {
- artistID := uuidString(t.ArtistID)
- if !seen[artistID] {
- seen[artistID] = true
- v.Artists = append(v.Artists, artistID)
- }
- if t.Genre != nil && *t.Genre != "" {
- v.Tags[*t.Genre]++
- }
- v.RecentTrackIDs = append(v.RecentTrackIDs, uuidString(t.ID))
- }
- return v
-}
-
-// uuidString formats a pgtype.UUID as the canonical hex form.
-func uuidString(u pgtype.UUID) string {
- if !u.Valid {
- return ""
- }
- b := u.Bytes
- return formatUUIDBytes(b)
-}
-
-func formatUUIDBytes(b [16]byte) string {
- const hexdigits = "0123456789abcdef"
- out := make([]byte, 36)
- pos := 0
- for i, by := range b {
- switch i {
- case 4, 6, 8, 10:
- out[pos] = '-'
- pos++
- }
- out[pos] = hexdigits[by>>4]
- out[pos+1] = hexdigits[by&0x0f]
- pos += 2
- }
- return string(out)
-}
-```
-
-The `pgtype.UUID` import is needed:
-
-```go
-import (
- "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
- "github.com/jackc/pgx/v5/pgtype"
-)
-```
-
-(Adjust the imports to actually compile — the formatter import is `pgtype`. Note: `pgtype.UUID` already has a `String()` method on pgx/v5; we could call `u.String()` directly. The hand-rolled formatter is shown for clarity; using `u.String()` is preferred — replace the body of `uuidString` with `return u.String()`.)
-
-Final clean form (pick this):
-
-```go
-package recommendation
-
-import (
- "github.com/jackc/pgx/v5/pgtype"
-
- "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
-)
-
-type SessionVector struct {
- Seed bool `json:"seed"`
- Artists []string `json:"artists"`
- Tags map[string]int `json:"tags"`
- RecentTrackIDs []string `json:"recent_track_ids"`
-}
-
-func BuildSessionVector(priorTracks []dbq.Track) SessionVector {
- v := SessionVector{
- Seed: len(priorTracks) < 3,
- Artists: []string{},
- Tags: map[string]int{},
- RecentTrackIDs: []string{},
- }
- seen := map[string]bool{}
- for _, t := range priorTracks {
- artistID := uuidString(t.ArtistID)
- if !seen[artistID] {
- seen[artistID] = true
- v.Artists = append(v.Artists, artistID)
- }
- if t.Genre != nil && *t.Genre != "" {
- v.Tags[*t.Genre]++
- }
- v.RecentTrackIDs = append(v.RecentTrackIDs, uuidString(t.ID))
- }
- return v
-}
-
-func uuidString(u pgtype.UUID) string {
- if !u.Valid {
- return ""
- }
- return u.String()
-}
-```
-
-- [ ] **Step 4: Run, confirm pass**
-
-Run: `go test ./internal/recommendation -v`
-Expected: PASS — 16 prior tests + 7 new = 23 total.
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add internal/recommendation/sessionvector.go internal/recommendation/sessionvector_test.go
-git commit -m "feat(recommendation): add BuildSessionVector pure function
-
-Pure aggregation per spec §6: Seed flag (true when prior < 3),
-deduplicated Artists ordered by first appearance, Tags bag-of-counts
-from tracks.genre (empty genres skipped), RecentTrackIDs preserving
-input order. JSON round-trip verified."
-```
-
----
-
-## Task 4: Vector capture in playevents.Writer
-
-**Files:**
-- Modify: `internal/playevents/writer.go::RecordPlayStarted`
-- Modify: `internal/playevents/writer.go::RecordSyntheticCompletedPlay`
-- Modify: `internal/playevents/writer_test.go`
-
-Both record paths share the same vector logic — extract a private helper. Both run inside an existing `pgx.BeginFunc` transaction, so the new SELECT + UPDATE go in the same atomic operation.
-
-- [ ] **Step 1: Write three failing tests**
-
-Append to `internal/playevents/writer_test.go`:
-
-```go
-import (
- // existing imports
- "encoding/json"
-)
-
-func TestRecordPlayStarted_PersistsSessionVector_Seed(t *testing.T) {
- f := newFixture(t, 200_000)
- now := time.Now().UTC()
- res, err := f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", now)
- if err != nil {
- t.Fatalf("RecordPlayStarted: %v", err)
- }
- got, err := f.q.GetPlayEventByID(context.Background(), res.PlayEventID)
- if err != nil {
- t.Fatalf("get: %v", err)
- }
- if got.SessionVectorAtPlay == nil {
- t.Fatalf("session_vector_at_play is NULL")
- }
- var vec map[string]any
- if err := json.Unmarshal(got.SessionVectorAtPlay, &vec); err != nil {
- t.Fatalf("unmarshal: %v", err)
- }
- if seed, _ := vec["seed"].(bool); !seed {
- t.Errorf("seed = %v, want true (first play in session)", vec["seed"])
- }
- if artists, _ := vec["artists"].([]any); len(artists) != 0 {
- t.Errorf("artists not empty: %v", artists)
- }
-}
-
-func TestRecordPlayStarted_PersistsSessionVector_Populated(t *testing.T) {
- f := newFixture(t, 200_000)
- // Seed 4 prior plays with the same track (reusing the fixture's single
- // track is fine — vector dedup means we'll see 1 artist regardless).
- t0 := time.Now().UTC().Add(-1 * time.Hour)
- for i := 0; i < 4; i++ {
- at := t0.Add(time.Duration(i) * time.Minute)
- _, _ = f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", at)
- }
- // 5th play — should see vector with 4 prior tracks.
- at := t0.Add(10 * time.Minute)
- res, err := f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", at)
- if err != nil {
- t.Fatalf("RecordPlayStarted: %v", err)
- }
- got, _ := f.q.GetPlayEventByID(context.Background(), res.PlayEventID)
- if got.SessionVectorAtPlay == nil {
- t.Fatal("session_vector_at_play is NULL")
- }
- var vec map[string]any
- _ = json.Unmarshal(got.SessionVectorAtPlay, &vec)
- if seed, _ := vec["seed"].(bool); seed {
- t.Errorf("seed = true after 4 prior tracks, want false")
- }
- recentIDs, _ := vec["recent_track_ids"].([]any)
- if len(recentIDs) != 4 {
- t.Errorf("recent_track_ids len = %d, want 4", len(recentIDs))
- }
-}
-
-func TestRecordPlayStarted_VectorScopedToSession(t *testing.T) {
- f := newFixture(t, 200_000)
- // Play 1: at t=0.
- t0 := time.Now().UTC().Add(-2 * time.Hour)
- _, _ = f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", t0)
- // Play 2: t = t0 + 31 minutes (exceeds 30-min session timeout).
- t1 := t0.Add(31 * time.Minute)
- res, err := f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", t1)
- if err != nil {
- t.Fatalf("RecordPlayStarted: %v", err)
- }
- got, _ := f.q.GetPlayEventByID(context.Background(), res.PlayEventID)
- if got.SessionVectorAtPlay == nil {
- t.Fatal("vector NULL")
- }
- var vec map[string]any
- _ = json.Unmarshal(got.SessionVectorAtPlay, &vec)
- if seed, _ := vec["seed"].(bool); !seed {
- t.Errorf("seed = false in fresh session, want true")
- }
- recentIDs, _ := vec["recent_track_ids"].([]any)
- if len(recentIDs) != 0 {
- t.Errorf("recent_track_ids has %d entries from prior session, want 0", len(recentIDs))
- }
-}
-```
-
-- [ ] **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/playevents -run TestRecordPlayStarted_PersistsSessionVector -v
-```
-Expected: FAIL — `session_vector_at_play` is NULL because vector capture is not yet wired.
-
-- [ ] **Step 3: Add the private helper + extend `RecordPlayStarted`**
-
-Open `internal/playevents/writer.go`. Add an import block addition for `encoding/json` and `git.fabledsword.com/bvandeusen/minstrel/internal/recommendation`. Add a private method:
-
-```go
-// captureSessionVector queries the user's prior plays in the given session
-// (before `at`), builds the session vector, and UPDATEs the just-inserted
-// play_event with it. Runs inside the caller's transaction (q is the
-// transaction-bound *dbq.Queries). Errors here propagate — vector capture
-// is best-effort, but DB errors should fail the transaction.
-func (w *Writer) captureSessionVector(
- ctx context.Context,
- q *dbq.Queries,
- playEventID, sessionID pgtype.UUID,
- at time.Time,
-) error {
- priorTracks, err := q.ListRecentSessionTracks(ctx, dbq.ListRecentSessionTracksParams{
- SessionID: sessionID,
- StartedAt: pgtype.Timestamptz{Time: at, Valid: true},
- Limit: 5,
- })
- if err != nil {
- return err
- }
- vec := recommendation.BuildSessionVector(priorTracks)
- vecJSON, err := json.Marshal(vec)
- if err != nil {
- return err
- }
- return q.UpdatePlayEventVector(ctx, dbq.UpdatePlayEventVectorParams{
- ID: playEventID,
- SessionVectorAtPlay: vecJSON,
- })
-}
-```
-
-The `ListRecentSessionTracksParams` field names are sqlc-generated from the SQL. Check the actual generated names — the SQL uses `$1`, `$2`, `$3` so sqlc may use `Column1/2/3` OR may infer better names from the WHERE clauses. If the generated struct uses `Column1`/`Column2`/`Column3`, adjust the field names accordingly:
-
-```go
-priorTracks, err := q.ListRecentSessionTracks(ctx, dbq.ListRecentSessionTracksParams{
- SessionID: sessionID, // or Column1
- StartedAt: pgtype.Timestamptz{Time: at, Valid: true}, // or Column2
- Limit: 5, // or Column3
-})
-```
-
-(Run sqlc generate first; inspect the actual struct.)
-
-- [ ] **Step 4: Call the helper from `RecordPlayStarted`**
-
-In `RecordPlayStarted`, after the `q.InsertPlayEvent` call (around line 80), add:
-
-```go
-ev, err := q.InsertPlayEvent(ctx, dbq.InsertPlayEventParams{...})
-if err != nil {
- return err
-}
-out.PlayEventID = ev.ID
-out.SessionID = sessionID
-
-// Capture session vector for the just-inserted row.
-if err := w.captureSessionVector(ctx, q, ev.ID, sessionID, at); err != nil {
- return err
-}
-return nil
-```
-
-- [ ] **Step 5: Call the helper from `RecordSyntheticCompletedPlay`**
-
-In `RecordSyntheticCompletedPlay`, find the `q.InsertPlayEvent(...)` call. Right before the subsequent `q.UpdatePlayEventEnded(...)`, add a `captureSessionVector` call:
-
-```go
-ev, err := q.InsertPlayEvent(ctx, dbq.InsertPlayEventParams{...})
-if err != nil {
- return err
-}
-if err := w.captureSessionVector(ctx, q, ev.ID, sessionID, at); err != nil {
- return err
-}
-// existing UpdatePlayEventEnded follows...
-```
-
-- [ ] **Step 6: Run, confirm pass**
-
-Run:
-```bash
-MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \
- go test ./internal/playevents -v
-```
-Expected: PASS — 8 existing + 3 new = 11 tests.
-
-- [ ] **Step 7: Lint**
-
-Run: `golangci-lint run ./internal/playevents/...`
-Expected: clean.
-
-- [ ] **Step 8: Commit**
-
-```bash
-git add internal/playevents/writer.go internal/playevents/writer_test.go
-git commit -m "feat(playevents): persist session_vector_at_play on every record path
-
-RecordPlayStarted and RecordSyntheticCompletedPlay both capture the
-session vector inside their existing transactions. Single private
-helper queries the prior 5 tracks, builds the vector, UPDATEs the
-just-inserted play_event. Tests verify the seed flag boundary and
-session-scope isolation."
-```
-
----
-
-## Task 5: Contextual-likes helpers + api handler updates
-
-**Files:**
-- Create: `internal/playevents/contextual_likes.go`
-- Create: `internal/playevents/contextual_likes_test.go`
-- Modify: `internal/api/likes.go::handleLikeTrack` and `handleUnlikeTrack`
-- Modify: `internal/api/likes_test.go`
-
-Two helpers in `internal/playevents` so both `internal/api` and `internal/subsonic` use the same code path. The api handlers wire them in here; the subsonic handlers in Task 6.
-
-- [ ] **Step 1: Write the helper tests**
-
-Create `internal/playevents/contextual_likes_test.go`:
-
-```go
-package playevents
-
-import (
- "context"
- "encoding/json"
- "io"
- "log/slog"
- "testing"
- "time"
-
- "github.com/jackc/pgx/v5/pgtype"
-
- "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
-)
-
-// fixtureWithSecondTrack extends the existing fixture with a second track
-// the test uses as the "track being liked while track 1 plays".
-type fixtureWithSecondTrack struct {
- fixture
- track2 pgtype.UUID
-}
-
-func newFixtureWithSecondTrack(t *testing.T, durationMs int32) fixtureWithSecondTrack {
- f := newFixture(t, durationMs)
- q := dbq.New(f.pool)
- tr2, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
- Title: "Other", AlbumID: f.q.GetAlbumByID, // placeholder — use the same album
- FilePath: "/tmp/track-2.flac", DurationMs: durationMs,
- })
- _ = err // for sketching; the real implementation should look up an existing album/artist from f
- _ = tr2
- return fixtureWithSecondTrack{fixture: f}
-}
-
-func TestCaptureContextualLikeIfPlaying_NoOpenEvent_NoOp(t *testing.T) {
- f := newFixture(t, 200_000)
- logger := slog.New(slog.NewTextHandler(io.Discard, nil))
- q := dbq.New(f.pool)
- if err := CaptureContextualLikeIfPlaying(context.Background(), q, f.user, f.track, logger); err != nil {
- t.Fatalf("err: %v", err)
- }
- var count int
- _ = f.pool.QueryRow(context.Background(), "SELECT count(*) FROM contextual_likes WHERE user_id=$1", f.user).Scan(&count)
- if count != 0 {
- t.Errorf("count = %d, want 0 (no open play_event)", count)
- }
-}
-
-func TestCaptureContextualLikeIfPlaying_OpenEventWithVector_Inserts(t *testing.T) {
- f := newFixture(t, 200_000)
- logger := slog.New(slog.NewTextHandler(io.Discard, nil))
- // Start a play (creates an open play_event with a populated vector).
- at := time.Now().UTC()
- _, err := f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", at)
- if err != nil {
- t.Fatalf("RecordPlayStarted: %v", err)
- }
- // Like the same track — captures contextual signal.
- q := dbq.New(f.pool)
- if err := CaptureContextualLikeIfPlaying(context.Background(), q, f.user, f.track, logger); err != nil {
- t.Fatalf("Capture: %v", err)
- }
- var rawVec []byte
- if err := f.pool.QueryRow(context.Background(),
- "SELECT session_vector FROM contextual_likes WHERE user_id=$1 AND track_id=$2",
- f.user, f.track).Scan(&rawVec); err != nil {
- t.Fatalf("query: %v", err)
- }
- var vec map[string]any
- _ = json.Unmarshal(rawVec, &vec)
- if vec == nil {
- t.Errorf("vector empty: %s", rawVec)
- }
-}
-
-func TestSoftDeleteContextualLikes_MarksActiveRowsDeleted(t *testing.T) {
- f := newFixture(t, 200_000)
- logger := slog.New(slog.NewTextHandler(io.Discard, nil))
- at := time.Now().UTC()
- _, _ = f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", at)
- q := dbq.New(f.pool)
- _ = CaptureContextualLikeIfPlaying(context.Background(), q, f.user, f.track, logger)
- // Now soft-delete.
- if err := SoftDeleteContextualLikes(context.Background(), q, f.user, f.track); err != nil {
- t.Fatalf("SoftDelete: %v", err)
- }
- var deletedAt pgtype.Timestamptz
- if err := f.pool.QueryRow(context.Background(),
- "SELECT deleted_at FROM contextual_likes WHERE user_id=$1 AND track_id=$2",
- f.user, f.track).Scan(&deletedAt); err != nil {
- t.Fatalf("query: %v", err)
- }
- if !deletedAt.Valid {
- t.Errorf("deleted_at not set after SoftDelete")
- }
-}
-```
-
-The fixture-extension code above has a placeholder (`fixtureWithSecondTrack` is sketched). For these tests we don't actually need a second track — using `f.track` for both the "playing" and "liked" sides is fine because contextual_likes is keyed on (user, track) and the test is verifying capture, not cross-track behavior. Drop the `fixtureWithSecondTrack` helper; use `f.track` directly.
-
-- [ ] **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/playevents -run TestCaptureContextualLikeIfPlaying -v
-```
-Expected: FAIL — `CaptureContextualLikeIfPlaying`, `SoftDeleteContextualLikes` undefined.
-
-- [ ] **Step 3: Implement `internal/playevents/contextual_likes.go`**
-
-```go
-package playevents
-
-import (
- "context"
- "errors"
- "log/slog"
-
- "github.com/jackc/pgx/v5"
- "github.com/jackc/pgx/v5/pgtype"
-
- "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
-)
-
-// CaptureContextualLikeIfPlaying snapshots the user's currently-playing
-// session context into a new contextual_likes row. No-op if no open
-// play_event exists or its session_vector is NULL. Failures are logged
-// but never returned to the caller — contextual capture is best-effort
-// and must not break the like response.
-func CaptureContextualLikeIfPlaying(
- ctx context.Context,
- q *dbq.Queries,
- userID, trackID pgtype.UUID,
- logger *slog.Logger,
-) error {
- event, err := q.GetOpenPlayEventForUser(ctx, userID)
- if err != nil {
- if errors.Is(err, pgx.ErrNoRows) {
- return nil // no play in progress; nothing to capture
- }
- logger.Error("playevents: get open play_event", "err", err)
- return nil // best-effort
- }
- if event.SessionVectorAtPlay == nil {
- return nil // event predates this slice; no vector to snapshot
- }
- if err := q.InsertContextualLike(ctx, dbq.InsertContextualLikeParams{
- UserID: userID,
- TrackID: trackID,
- SessionVector: event.SessionVectorAtPlay,
- SessionID: event.SessionID,
- }); err != nil {
- logger.Error("playevents: insert contextual_like", "err", err)
- return nil
- }
- return nil
-}
-
-// SoftDeleteContextualLikes marks all currently-active contextual_likes
-// rows for (user, track) as deleted. Idempotent.
-func SoftDeleteContextualLikes(
- ctx context.Context,
- q *dbq.Queries,
- userID, trackID pgtype.UUID,
-) error {
- return q.SoftDeleteContextualLikesForUserTrack(ctx, dbq.SoftDeleteContextualLikesForUserTrackParams{
- UserID: userID,
- TrackID: trackID,
- })
-}
-```
-
-Note: the `InsertContextualLikeParams` field names follow sqlc's naming — `UserID`, `TrackID`, `SessionVector`, `SessionID`. If sqlc generates different names (e.g. positional `Column1`...), adjust per the actual generated code.
-
-- [ ] **Step 4: Run helper tests, confirm pass**
-
-Run:
-```bash
-MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \
- go test ./internal/playevents -v
-```
-Expected: PASS — 11 prior tests + 3 helper tests = 14 total.
-
-- [ ] **Step 5: Update `internal/api/likes.go::handleLikeTrack`**
-
-```go
-func (h *handlers) handleLikeTrack(w http.ResponseWriter, r *http.Request) {
- user, ok := auth.UserFromContext(r.Context())
- if !ok {
- writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required")
- return
- }
- id, ok := parseUUID(chi.URLParam(r, "id"))
- if !ok {
- writeErr(w, http.StatusBadRequest, "bad_request", "invalid track id")
- return
- }
- q := dbq.New(h.pool)
- if _, err := q.GetTrackByID(r.Context(), id); err != nil {
- if errors.Is(err, pgx.ErrNoRows) {
- writeErr(w, http.StatusNotFound, "not_found", "track not found")
- return
- }
- h.logger.Error("api: like track lookup", "err", err)
- writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
- return
- }
- rows, err := q.LikeTrack(r.Context(), dbq.LikeTrackParams{UserID: user.ID, TrackID: id})
- if err != nil {
- h.logger.Error("api: like track insert", "err", err)
- writeErr(w, http.StatusInternalServerError, "server_error", "insert failed")
- return
- }
- if rows == 1 {
- _ = playevents.CaptureContextualLikeIfPlaying(r.Context(), q, user.ID, id, h.logger)
- }
- w.WriteHeader(http.StatusNoContent)
-}
-```
-
-Add the `playevents` import: `"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"`.
-
-- [ ] **Step 6: Update `internal/api/likes.go::handleUnlikeTrack`**
-
-```go
-func (h *handlers) handleUnlikeTrack(w http.ResponseWriter, r *http.Request) {
- user, ok := auth.UserFromContext(r.Context())
- if !ok {
- writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required")
- return
- }
- id, ok := parseUUID(chi.URLParam(r, "id"))
- if !ok {
- writeErr(w, http.StatusBadRequest, "bad_request", "invalid track id")
- return
- }
- q := dbq.New(h.pool)
- if err := q.UnlikeTrack(r.Context(), dbq.UnlikeTrackParams{UserID: user.ID, TrackID: id}); err != nil {
- h.logger.Error("api: unlike track", "err", err)
- writeErr(w, http.StatusInternalServerError, "server_error", "delete failed")
- return
- }
- if err := playevents.SoftDeleteContextualLikes(r.Context(), q, user.ID, id); err != nil {
- h.logger.Error("api: soft-delete contextual_likes", "err", err)
- // Don't fail the response — soft-delete is best-effort.
- }
- w.WriteHeader(http.StatusNoContent)
-}
-```
-
-- [ ] **Step 7: Add five new tests to `internal/api/likes_test.go`**
-
-Append:
-
-```go
-func TestLikeTrack_DuringOpenPlayEvent_WritesContextualLike(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)
- playing := seedTrack(t, pool, album.ID, artist.ID, "Now Playing", 1, 100_000)
- other := seedTrack(t, pool, album.ID, artist.ID, "Other", 2, 100_000)
-
- // Simulate a play_started: insert via the writer.
- w := callEvents(h, user, []byte(`{"type":"play_started","track_id":"`+uuidToString(playing.ID)+`"}`))
- if w.Code != http.StatusOK {
- t.Fatalf("play_started failed: %d %s", w.Code, w.Body.String())
- }
- // Like the OTHER track. With an open play_event for `playing`,
- // the contextual_likes row should reference `other` and the playing
- // track's session vector.
- if r := callLike(h, user, http.MethodPost, "/api/likes/tracks/"+uuidToString(other.ID)); r.Code != http.StatusNoContent {
- t.Fatalf("like: %d", r.Code)
- }
- var count int
- _ = pool.QueryRow(context.Background(),
- "SELECT count(*) FROM contextual_likes WHERE user_id=$1 AND track_id=$2", user.ID, other.ID).Scan(&count)
- if count != 1 {
- t.Errorf("contextual_likes count = %d, want 1", count)
- }
-}
-
-func TestLikeTrack_NoOpenPlayEvent_NoContextualLike(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)
- track := seedTrack(t, pool, album.ID, artist.ID, "T", 1, 100_000)
-
- if r := callLike(h, user, http.MethodPost, "/api/likes/tracks/"+uuidToString(track.ID)); r.Code != http.StatusNoContent {
- t.Fatalf("like: %d", r.Code)
- }
- var count int
- _ = pool.QueryRow(context.Background(),
- "SELECT count(*) FROM contextual_likes WHERE user_id=$1", user.ID).Scan(&count)
- if count != 0 {
- t.Errorf("contextual_likes count = %d, want 0 (no open play)", count)
- }
-}
-
-func TestLikeTrack_RepeatedLike_NoDuplicate(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)
- playing := seedTrack(t, pool, album.ID, artist.ID, "Playing", 1, 100_000)
- other := seedTrack(t, pool, album.ID, artist.ID, "Other", 2, 100_000)
-
- // Open play.
- _ = callEvents(h, user, []byte(`{"type":"play_started","track_id":"`+uuidToString(playing.ID)+`"}`))
- // First like — captures.
- _ = callLike(h, user, http.MethodPost, "/api/likes/tracks/"+uuidToString(other.ID))
- // Second like — :execrows=0, no contextual write.
- _ = callLike(h, user, http.MethodPost, "/api/likes/tracks/"+uuidToString(other.ID))
-
- var count int
- _ = pool.QueryRow(context.Background(),
- "SELECT count(*) FROM contextual_likes WHERE user_id=$1 AND track_id=$2", user.ID, other.ID).Scan(&count)
- if count != 1 {
- t.Errorf("contextual_likes count = %d, want 1 (no duplicate from idempotent re-like)", count)
- }
-}
-
-func TestUnlikeTrack_SoftDeletesContextualLikes(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)
- playing := seedTrack(t, pool, album.ID, artist.ID, "P", 1, 100_000)
- other := seedTrack(t, pool, album.ID, artist.ID, "O", 2, 100_000)
-
- _ = callEvents(h, user, []byte(`{"type":"play_started","track_id":"`+uuidToString(playing.ID)+`"}`))
- _ = callLike(h, user, http.MethodPost, "/api/likes/tracks/"+uuidToString(other.ID))
- if r := callLike(h, user, http.MethodDelete, "/api/likes/tracks/"+uuidToString(other.ID)); r.Code != http.StatusNoContent {
- t.Fatalf("unlike: %d", r.Code)
- }
- // general_likes deleted.
- var glCount int
- _ = pool.QueryRow(context.Background(),
- "SELECT count(*) FROM general_likes WHERE user_id=$1 AND track_id=$2", user.ID, other.ID).Scan(&glCount)
- if glCount != 0 {
- t.Errorf("general_likes count = %d, want 0", glCount)
- }
- // contextual_likes row exists but deleted_at is set.
- var deletedAtValid bool
- _ = pool.QueryRow(context.Background(),
- "SELECT deleted_at IS NOT NULL FROM contextual_likes WHERE user_id=$1 AND track_id=$2 LIMIT 1",
- user.ID, other.ID).Scan(&deletedAtValid)
- if !deletedAtValid {
- t.Errorf("deleted_at not set after unlike")
- }
-}
-
-func TestLikeUnlikeRelike_HistoryAccumulates(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)
- playing := seedTrack(t, pool, album.ID, artist.ID, "P", 1, 100_000)
- other := seedTrack(t, pool, album.ID, artist.ID, "O", 2, 100_000)
-
- _ = callEvents(h, user, []byte(`{"type":"play_started","track_id":"`+uuidToString(playing.ID)+`"}`))
- _ = callLike(h, user, http.MethodPost, "/api/likes/tracks/"+uuidToString(other.ID))
- _ = callLike(h, user, http.MethodDelete, "/api/likes/tracks/"+uuidToString(other.ID))
- // Re-like in same session — new row should be inserted.
- _ = callLike(h, user, http.MethodPost, "/api/likes/tracks/"+uuidToString(other.ID))
-
- var total, active int
- _ = pool.QueryRow(context.Background(),
- "SELECT count(*) FROM contextual_likes WHERE user_id=$1 AND track_id=$2", user.ID, other.ID).Scan(&total)
- _ = pool.QueryRow(context.Background(),
- "SELECT count(*) FROM contextual_likes WHERE user_id=$1 AND track_id=$2 AND deleted_at IS NULL",
- user.ID, other.ID).Scan(&active)
- if total != 2 {
- t.Errorf("total = %d, want 2 (history accumulates)", total)
- }
- if active != 1 {
- t.Errorf("active = %d, want 1", active)
- }
-}
-```
-
-- [ ] **Step 8: Run, confirm pass**
-
-Run:
-```bash
-MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \
- go test ./internal/api -v
-```
-Expected: PASS, including the 5 new tests.
-
-- [ ] **Step 9: Lint**
-
-Run: `golangci-lint run ./...`
-Expected: clean.
-
-- [ ] **Step 10: Commit**
-
-```bash
-git add internal/playevents/contextual_likes.go internal/playevents/contextual_likes_test.go \
- internal/api/likes.go internal/api/likes_test.go
-git commit -m "feat(api): capture contextual_likes on like during open play_event
-
-Two new helpers in internal/playevents (CaptureContextualLikeIfPlaying,
-SoftDeleteContextualLikes) called by both api and subsonic surfaces.
-Like inserts a new contextual_likes row when LikeTrack actually
-inserted (rows=1) AND there's an open play_event with a vector.
-Unlike soft-deletes via deleted_at. Idempotent in both directions.
-
-Subsonic wiring lands in the next commit."
-```
-
----
-
-## Task 6: Subsonic handler updates + verification test
-
-**Files:**
-- Modify: `internal/subsonic/star.go::handleStar` (track branch)
-- Modify: `internal/subsonic/star.go::handleUnstar` (track branch)
-- Modify: `internal/subsonic/star_test.go`
-
-- [ ] **Step 1: Update `handleStar` track branch**
-
-In `internal/subsonic/star.go`, find the section that calls `q.LikeTrack(...)`. Replace:
-
-```go
-if trackID.Valid {
- if _, err := q.LikeTrack(r.Context(), dbq.LikeTrackParams{UserID: user.ID, TrackID: trackID}); err != nil {
- WriteFail(w, r, ErrGeneric, "Could not star track")
- return
- }
-}
-```
-
-with:
-
-```go
-if trackID.Valid {
- rows, err := q.LikeTrack(r.Context(), dbq.LikeTrackParams{UserID: user.ID, TrackID: trackID})
- if err != nil {
- WriteFail(w, r, ErrGeneric, "Could not star track")
- return
- }
- if rows == 1 {
- _ = playevents.CaptureContextualLikeIfPlaying(r.Context(), q, user.ID, trackID, m.logger)
- }
-}
-```
-
-The `mediaHandlers` struct currently has fields `pool *pgxpool.Pool` and `events *playevents.Writer`. It does NOT have a `logger`. Two ways to handle:
-- Option A: add a `logger *slog.Logger` field to `mediaHandlers`. Update `newMediaHandlers(pool, events, logger)` and the `subsonic.Mount` call site (`internal/subsonic/subsonic.go`) to pass it through.
-- Option B: pass a no-op logger inline: `slog.New(slog.NewTextHandler(io.Discard, nil))`.
-
-Use Option A (cleanest). Add the field; thread the logger through `Mount`. The `subsonic.Mount` signature already takes a `*slog.Logger` per existing code.
-
-Add the import: `"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"`.
-
-- [ ] **Step 2: Update `handleUnstar` track branch**
-
-```go
-if t, ok := parseUUID(params.Get("id")); ok {
- _ = q.UnlikeTrack(r.Context(), dbq.UnlikeTrackParams{UserID: user.ID, TrackID: t})
- _ = playevents.SoftDeleteContextualLikes(r.Context(), q, user.ID, t)
-}
-```
-
-- [ ] **Step 3: Update `mediaHandlers` struct + factory + caller**
-
-Add `logger *slog.Logger` field to `mediaHandlers`. Update `newMediaHandlers(pool, events, logger)` constructor. Update `internal/subsonic/subsonic.go::Mount`'s call site:
-
-```go
-m := newMediaHandlers(pool, events, logger)
-```
-
-(`logger` is already a parameter to `Mount`.)
-
-- [ ] **Step 4: Add verification test in `star_test.go`**
-
-Append:
-
-```go
-func TestHandleStar_DuringNowPlaying_WritesContextualLike(t *testing.T) {
- pool, user, track1, _, _ := testStarPool(t)
- // Add a second track so star refers to a different track than the one playing.
- q := dbq.New(pool)
- a, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "Y", SortName: "Y"})
- al, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{Title: "Y", SortTitle: "Y", ArtistID: a.ID})
- track2, _ := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
- Title: "Star Me", AlbumID: al.ID, ArtistID: a.ID, FilePath: "/tmp/t2.flac", DurationMs: 100_000,
- })
-
- m := newStarHandlers(pool)
- logger := slog.New(slog.NewTextHandler(io.Discard, nil))
- m.logger = logger
-
- // Start now-playing for track1 (creates open play_event with vector).
- q1 := url.Values{}
- q1.Set("id", uuidToID(track1.ID))
- q1.Set("submission", "false")
- scrobReq := httptest.NewRequest(http.MethodGet, "/rest/scrobble?"+q1.Encode(), nil)
- scrobCtx := context.WithValue(scrobReq.Context(), userCtxKey, user)
- scrobResp := httptest.NewRecorder()
- m.handleScrobble(scrobResp, scrobReq.WithContext(scrobCtx))
- if scrobResp.Code != http.StatusOK {
- t.Fatalf("scrobble: %d", scrobResp.Code)
- }
-
- // Star track2.
- q2 := url.Values{}
- q2.Set("id", uuidToID(track2.ID))
- starReq := httptest.NewRequest(http.MethodGet, "/rest/star?"+q2.Encode(), nil)
- starCtx := context.WithValue(starReq.Context(), userCtxKey, user)
- starResp := httptest.NewRecorder()
- m.handleStar(starResp, starReq.WithContext(starCtx))
- if starResp.Code != http.StatusOK {
- t.Fatalf("star: %d", starResp.Code)
- }
-
- // Verify contextual_likes row.
- var count int
- _ = pool.QueryRow(context.Background(),
- "SELECT count(*) FROM contextual_likes WHERE user_id=$1 AND track_id=$2", user.ID, track2.ID).Scan(&count)
- if count != 1 {
- t.Errorf("contextual_likes count = %d, want 1", count)
- }
-}
-```
-
-The `newStarHandlers` helper currently doesn't set a logger; the `mediaHandlers` factory needs to be updated so the test passes the logger. Adjust the test fixture to construct `mediaHandlers` with a real (or io.Discard) logger.
-
-- [ ] **Step 5: Run, confirm pass**
-
-Run:
-```bash
-MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \
- go test ./internal/subsonic -v
-```
-Expected: PASS — existing scrobble + star tests + 1 new test.
-
-- [ ] **Step 6: Full Go suite + lint**
-
-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/subsonic/star.go internal/subsonic/star_test.go internal/subsonic/subsonic.go
-git commit -m "feat(subsonic): wire contextual_likes capture/soft-delete into star/unstar
-
-handleStar's track branch calls playevents.CaptureContextualLikeIfPlaying
-when the underlying LikeTrack actually inserted a row. handleUnstar
-calls SoftDeleteContextualLikes after every track-id unstar. Same
-helpers as the api surface — single source of truth.
-
-mediaHandlers struct gains a logger field threaded through Mount."
-```
-
----
-
-## Task 7: Final verification + branch finish
-
-**Files:** none (verification only).
-
-- [ ] **Step 1: Full Go suite (with DB)**
-
-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 + playevents**
-
-Run:
-```bash
-MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \
- go test -coverprofile=/tmp/m3v.cover ./internal/recommendation/... ./internal/playevents/...
-go tool cover -func=/tmp/m3v.cover | tail -3
-```
-Expected: total ≥ 70% per the M3 milestone description (sub-plan #1 already pushed `recommendation` to 95.5%; this slice keeps it high while bringing in `playevents` improvements).
-
-- [ ] **Step 3: Go 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/`. (No web changes in this slice; this is a regression check.)
-
-Run: `git checkout -- web/build/index.html`
-Expected: committed placeholder restored.
-
-- [ ] **Step 5: Docker build smoke**
-
-Run: `docker build -t minstrel:m3-vectors-smoke .`
-Expected: image builds.
-
-Run: `docker run --rm --entrypoint /bin/sh minstrel:m3-vectors-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 4 tracks all the way through.
-2. `psql ... SELECT id, session_vector_at_play FROM play_events ORDER BY started_at DESC LIMIT 5` — vectors populated, first 3 have `seed: true`, 4th onward `seed: false`.
-3. While the 4th track is playing, like it via the heart button. `SELECT * FROM contextual_likes` shows a row with that track's session_vector.
-4. Like a different track NOT currently playing. `general_likes` row appears; no `contextual_likes` row.
-5. Unlike the first track. `general_likes` deleted; `contextual_likes` row still exists but `deleted_at` is set.
-6. Re-like that track in a new session. New `contextual_likes` row with `deleted_at IS NULL` and a different `session_vector`.
-7. From Feishin: send a scrobble with `submission=false` for track A, then star track B. Verify contextual_likes captured the Subsonic-driven event.
-
-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:**
-- New `contextual_likes` table + indexes → Task 1.
-- Pure session-vector function → Task 3.
-- Vector capture inside RecordPlayStarted + RecordSyntheticCompletedPlay → Task 4.
-- LikeTrack `:execrows` change → Task 2.
-- Contextual capture on like (api) → Task 5.
-- Soft-delete on unlike (api) → Task 5.
-- Same on subsonic → Task 6.
-- ListRecentSessionTracks + UpdatePlayEventVector queries → Task 2.
-- Cross-session isolation (vector scoped to current session) → Task 4 third test.
-- Edge cases (no-op without play, no duplicate on idempotent re-like, history on relike) → Task 5 tests.
-- Subsonic verification → Task 6 test.
-- ≥70% coverage maintained → Task 7 step 2.
-
-**Type consistency:**
-- `SessionVector { Seed, Artists, Tags, RecentTrackIDs }` — same struct used in `BuildSessionVector` (Task 3) + serialized as JSON in `play_events.session_vector_at_play` (Task 4) + read back from `contextual_likes.session_vector` (Task 5).
-- `CaptureContextualLikeIfPlaying(ctx, q, userID, trackID, logger)` — same signature in helpers (Task 5) + api callers (Task 5) + subsonic callers (Task 6).
-- `SoftDeleteContextualLikes(ctx, q, userID, trackID)` — same signature in helpers + both call sites.
-- `pgtype.UUID` server-side throughout; JSON serialization via `u.String()`.
-
-**Filename hazards:** none. Only Go files; no SvelteKit route walker concerns.
-
-**Placeholder scan:** no TBD/TODO/later markers. Each step has complete code; the sqlc-generated-name caveats (Column1/Column2 vs UserID/TrackID) are documented inline so engineers know to verify after `sqlc generate`.
-
-**Performance note:** the new SELECT + UPDATE inside `RecordPlayStarted` adds ~1-2 ms per play event. Negligible at v1 scale. The `contextual_likes` writes happen at human click rates — also negligible.
diff --git a/docs/superpowers/plans/2026-04-28-m4a-scrobble.md b/docs/superpowers/plans/2026-04-28-m4a-scrobble.md
deleted file mode 100644
index 15d6d92a..00000000
--- a/docs/superpowers/plans/2026-04-28-m4a-scrobble.md
+++ /dev/null
@@ -1,2523 +0,0 @@
-# M4a — ListenBrainz Outbound Scrobble Worker 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:** Send the user's qualifying plays (≥240s OR ≥50% completion) to ListenBrainz with batching + exponential-backoff retry. Per-user token + enabled flag in the SPA's new `/settings` page. Backed by a `scrobble_queue` table that the worker drains every 30s.
-
-**Architecture:** New `internal/scrobble/` package tree: `listenbrainz/` for the pure HTTP client (typed errors for auth/permanent/transient/retry-after), and the parent package for `Qualifies`, `MaybeEnqueue`, and the `Worker` goroutine. `playevents.Writer.RecordPlayEnded` and `RecordSyntheticCompletedPlay` gain a post-commit best-effort enqueue call. New API endpoints `GET/PUT /api/me/listenbrainz` and a minimal `/settings` page in the SPA.
-
-**Tech Stack:** Go 1.23 + chi + sqlc + pgx/v5. SvelteKit 2 + Svelte 5 + TanStack Query. New SQL migration; no breaking changes to existing schema.
-
-**Reference:** design spec at `docs/superpowers/specs/2026-04-28-m4a-scrobble-design.md`.
-
----
-
-## File Structure
-
-**New server files:**
-
-| File | Responsibility |
-|---|---|
-| `internal/db/migrations/0008_scrobble.up.sql` + `.down.sql` | `users.listenbrainz_token`, `users.listenbrainz_enabled`, `scrobble_queue` table + partial index. |
-| `internal/db/queries/users.sql` (modify) | Add `SetListenBrainzToken`, `SetListenBrainzEnabled`, `GetListenBrainzConfig`. |
-| `internal/db/queries/scrobble.sql` | New: `EnqueueScrobble :execrows`, `ListPendingScrobbles`, `MarkScrobbleSent`, `MarkScrobbleFailed`, `RescheduleScrobble`, `MarkScrobbleRetryAfter`, `GetLastScrobbledForUser`. |
-| `internal/db/dbq/scrobble.sql.go` | Generated bindings. |
-| `internal/scrobble/listenbrainz/client.go` | Pure HTTP client. `Client`, `Listen`, `Track` types; `SubmitListens` method; typed errors `ErrAuth`, `ErrPermanent`, `ErrTransient`, `*RetryAfterError`. |
-| `internal/scrobble/listenbrainz/client_test.go` | `httptest.Server`-driven tests for each error mapping + payload shape. |
-| `internal/scrobble/threshold.go` | Pure `Qualifies(durationPlayedMs int, completionRatio float64) bool`. |
-| `internal/scrobble/threshold_test.go` | Boundary tests. |
-| `internal/scrobble/queue.go` | `MaybeEnqueue(ctx, q, playEventID)`. Reads user config + threshold; idempotent INSERT. |
-| `internal/scrobble/queue_test.go` | Live-DB integration tests. |
-| `internal/scrobble/worker.go` | `Worker` struct, `Run` loop, `tickOnce`, `backoffDelay` helper. |
-| `internal/scrobble/worker_test.go` | Pure tests for `backoffDelay`. |
-| `internal/scrobble/worker_integration_test.go` | Live-DB integration: full ticker pipeline against an httptest LB. |
-| `internal/api/listenbrainz.go` | `handleGetListenBrainz`, `handlePutListenBrainz`. |
-| `internal/api/listenbrainz_test.go` | Live-DB HTTP tests. |
-
-**Modified server files:**
-
-| File | Change |
-|---|---|
-| `internal/db/dbq/users.sql.go` + `models.go` | Generated additions for the new user columns. |
-| `internal/playevents/writer.go::RecordPlayEnded` | After `pgx.BeginFunc` returns nil, call `scrobble.MaybeEnqueue(ctx, dbq.New(w.pool), playEventID)`; log + swallow errors. |
-| `internal/playevents/writer.go::RecordSyntheticCompletedPlay` | Same pattern. |
-| `internal/playevents/writer_test.go` | Three new tests covering enqueue: qualifying play → row inserted, sub-threshold → no row, user-disabled → no row. |
-| `internal/api/api.go::Mount` | Add `authed.Get("/me/listenbrainz", h.handleGetListenBrainz)` and `authed.Put("/me/listenbrainz", h.handlePutListenBrainz)`. |
-| `internal/api/auth_test.go` | testHandlers helper unchanged (no new struct fields). |
-| `cmd/minstrel/main.go` | Construct `scrobble.Worker` after pool open; `go worker.Run(ctx)` after server starts. |
-
-**Frontend files:**
-
-| File | Responsibility |
-|---|---|
-| `web/src/lib/api/client.ts` (modify) | Add `api.put(path, body)` helper. |
-| `web/src/lib/api/listenbrainz.ts` | New: `getListenBrainzStatus()`, `setListenBrainzToken(token)`, `setListenBrainzEnabled(enabled)` + `LBStatus` type. |
-| `web/src/routes/settings/+page.svelte` | The Settings page, single ListenBrainz section. |
-| `web/src/routes/settings/settings.test.ts` | Vitest coverage for the page. |
-| `web/src/lib/components/Shell.svelte` | Add `{ href: '/settings', label: 'Settings' }` to `navItems`. |
-| `web/src/lib/components/Shell.test.ts` (modify) | Update nav assertions if the test enumerates items. |
-
----
-
-## Task 1: Migration 0008 — schema
-
-**Files:**
-- Create: `internal/db/migrations/0008_scrobble.up.sql`
-- Create: `internal/db/migrations/0008_scrobble.down.sql`
-
-- [ ] **Step 1: Write the up migration**
-
-Create `internal/db/migrations/0008_scrobble.up.sql`:
-
-```sql
--- M4a: outbound ListenBrainz scrobble worker.
--- Per-user LB config (plaintext token; users rotate via /settings if leaked).
--- scrobble_queue is a work list, not a log: successfully-sent rows are
--- DELETEd by the worker (the canonical record stays in play_events.scrobbled_at
--- via M2's column). Only `pending` and `failed` states exist.
-
-ALTER TABLE users
- ADD COLUMN listenbrainz_token TEXT NULL,
- ADD COLUMN listenbrainz_enabled BOOLEAN NOT NULL DEFAULT FALSE;
-
-CREATE TABLE scrobble_queue (
- id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
- user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
- play_event_id uuid NOT NULL REFERENCES play_events(id) ON DELETE CASCADE,
- status TEXT NOT NULL CHECK (status IN ('pending', 'failed')),
- attempts INTEGER NOT NULL DEFAULT 0,
- next_attempt_at timestamptz NOT NULL DEFAULT now(),
- last_error TEXT NULL,
- enqueued_at timestamptz NOT NULL DEFAULT now(),
- UNIQUE (play_event_id)
-);
-
--- Hot path for the worker's per-tick query.
-CREATE INDEX scrobble_queue_pending_idx
- ON scrobble_queue (next_attempt_at)
- WHERE status = 'pending';
-```
-
-- [ ] **Step 2: Write the down migration**
-
-Create `internal/db/migrations/0008_scrobble.down.sql`:
-
-```sql
-DROP TABLE IF EXISTS scrobble_queue;
-ALTER TABLE users DROP COLUMN IF EXISTS listenbrainz_enabled;
-ALTER TABLE users DROP COLUMN IF EXISTS listenbrainz_token;
-```
-
-- [ ] **Step 3: Verify the migration applies cleanly**
-
-Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test -short -race ./internal/db/`
-
-Expected: PASS. The migration test (which applies all migrations against a temp schema) finds version 8.
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add internal/db/migrations/0008_scrobble.up.sql internal/db/migrations/0008_scrobble.down.sql
-git commit -m "feat(db): add migration 0008 for scrobble (users LB columns + scrobble_queue table)"
-```
-
----
-
-## Task 2: sqlc queries — users LB config + scrobble_queue
-
-**Files:**
-- Modify: `internal/db/queries/users.sql`
-- Create: `internal/db/queries/scrobble.sql`
-- Generated: `internal/db/dbq/users.sql.go`, `internal/db/dbq/scrobble.sql.go`, `internal/db/dbq/models.go`
-
-- [ ] **Step 1: Add user-LB-config queries**
-
-Append to `internal/db/queries/users.sql`:
-
-```sql
--- name: SetListenBrainzToken :exec
-UPDATE users
-SET listenbrainz_token = $2,
- listenbrainz_enabled = CASE WHEN COALESCE($2, '') = '' THEN FALSE ELSE listenbrainz_enabled END
-WHERE id = $1;
-
--- name: SetListenBrainzEnabled :exec
-UPDATE users
-SET listenbrainz_enabled = $2
-WHERE id = $1;
-
--- name: GetListenBrainzConfig :one
--- Returns the user's LB token + enabled flag and the most recent
--- play_events.scrobbled_at for last-scrobbled-at status.
-SELECT
- u.listenbrainz_token,
- u.listenbrainz_enabled,
- (SELECT MAX(pe.scrobbled_at)
- FROM play_events pe
- WHERE pe.user_id = u.id) AS last_scrobbled_at
-FROM users u
-WHERE u.id = $1;
-```
-
-- [ ] **Step 2: Add scrobble_queue queries**
-
-Create `internal/db/queries/scrobble.sql`:
-
-```sql
--- name: EnqueueScrobble :execrows
--- Idempotent: if a row exists for this play_event_id, ON CONFLICT does nothing
--- and the affected-rows count is 0. Caller (MaybeEnqueue) doesn't treat 0 as
--- an error.
-INSERT INTO scrobble_queue (user_id, play_event_id, status)
-VALUES ($1, $2, 'pending')
-ON CONFLICT (play_event_id) DO NOTHING;
-
--- name: ListPendingScrobbles :many
--- Worker pulls up to N pending rows whose next_attempt_at has passed.
--- Joins play_events + tracks/albums/artists + users so the worker can
--- assemble the LB Listen payload in one round-trip.
-SELECT
- sq.id AS queue_id,
- sq.user_id AS user_id,
- sq.play_event_id AS play_event_id,
- sq.attempts AS attempts,
- u.listenbrainz_token AS lb_token,
- u.listenbrainz_enabled AS lb_enabled,
- pe.started_at AS started_at,
- t.title AS track_title,
- t.duration_ms AS track_duration_ms,
- t.mbid AS track_mbid,
- al.title AS album_title,
- al.mbid AS album_mbid,
- ar.name AS artist_name,
- ar.mbid AS artist_mbid
-FROM scrobble_queue sq
-JOIN users u ON u.id = sq.user_id
-JOIN play_events pe ON pe.id = sq.play_event_id
-JOIN tracks t ON t.id = pe.track_id
-JOIN albums al ON al.id = t.album_id
-JOIN artists ar ON ar.id = t.artist_id
-WHERE sq.status = 'pending'
- AND sq.next_attempt_at <= now()
-ORDER BY sq.next_attempt_at
-LIMIT $1;
-
--- name: MarkScrobbleSent :exec
--- Successful submit. Delete the queue row AND stamp play_events.scrobbled_at
--- in one statement via a CTE so partial failure is impossible.
-WITH del AS (
- DELETE FROM scrobble_queue
- WHERE id = $1
- RETURNING play_event_id
-)
-UPDATE play_events
-SET scrobbled_at = now()
-WHERE id = (SELECT play_event_id FROM del);
-
--- name: RescheduleScrobble :exec
--- After a transient failure: increment attempts, schedule next attempt,
--- record the error.
-UPDATE scrobble_queue
-SET attempts = attempts + 1,
- next_attempt_at = $2,
- last_error = $3
-WHERE id = $1;
-
--- name: MarkScrobbleRetryAfter :exec
--- After a 429: schedule next attempt per LB's Retry-After header, but do NOT
--- increment attempts (the server told us to wait, not that we failed).
-UPDATE scrobble_queue
-SET next_attempt_at = $2
-WHERE id = $1;
-
--- name: MarkScrobbleFailed :exec
--- After a permanent failure (4xx, exhausted retries, auth) — mark failed
--- and keep the row for diagnostics.
-UPDATE scrobble_queue
-SET status = 'failed',
- last_error = $2
-WHERE id = $1;
-```
-
-- [ ] **Step 3: Run sqlc generate**
-
-Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && make generate`
-
-Expected: docker pulls sqlc 1.27.0, regenerates `internal/db/dbq/`. New methods appear on `*Queries`:
-- `SetListenBrainzToken`, `SetListenBrainzEnabled`, `GetListenBrainzConfig`
-- `EnqueueScrobble`, `ListPendingScrobbles`, `MarkScrobbleSent`, `RescheduleScrobble`, `MarkScrobbleRetryAfter`, `MarkScrobbleFailed`
-
-`models.go` gains the two new `User` fields: `ListenbrainzToken *string` and `ListenbrainzEnabled bool`.
-
-- [ ] **Step 4: Verify compile**
-
-Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go build ./...`
-
-Expected: clean. No callers yet — generated bindings just need to compile.
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add internal/db/queries/ internal/db/dbq/
-git commit -m "feat(db): add ListenBrainz user-config and scrobble_queue queries"
-```
-
----
-
-## Task 3: ListenBrainz HTTP client (pure)
-
-**Files:**
-- Create: `internal/scrobble/listenbrainz/client.go`
-- Create: `internal/scrobble/listenbrainz/client_test.go`
-
-- [ ] **Step 1: Write the failing tests**
-
-Create `internal/scrobble/listenbrainz/client_test.go`:
-
-```go
-package listenbrainz
-
-import (
- "context"
- "errors"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
- "time"
-)
-
-func newTestClient(handler http.HandlerFunc) (*Client, *httptest.Server) {
- srv := httptest.NewServer(handler)
- return &Client{BaseURL: srv.URL, HTTP: srv.Client()}, srv
-}
-
-func TestClient_SubmitListens_Success(t *testing.T) {
- var seenPath, seenAuth, seenBody string
- c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
- seenPath = r.URL.Path
- seenAuth = r.Header.Get("Authorization")
- buf := make([]byte, 4096)
- n, _ := r.Body.Read(buf)
- seenBody = string(buf[:n])
- w.WriteHeader(http.StatusOK)
- _, _ = w.Write([]byte(`{"status":"ok"}`))
- })
- defer srv.Close()
-
- listens := []Listen{{
- ListenedAt: 1700000000,
- Track: Track{
- ArtistName: "Miles Davis",
- TrackName: "So What",
- ReleaseName: "Kind of Blue",
- DurationMs: 545000,
- },
- }}
- if err := c.SubmitListens(context.Background(), "tk", listens); err != nil {
- t.Fatalf("err: %v", err)
- }
- if seenPath != "/1/submit-listens" {
- t.Errorf("path = %q, want /1/submit-listens", seenPath)
- }
- if seenAuth != "Token tk" {
- t.Errorf("auth = %q, want %q", seenAuth, "Token tk")
- }
- if !strings.Contains(seenBody, `"track_name":"So What"`) {
- t.Errorf("body missing track_name: %s", seenBody)
- }
- if !strings.Contains(seenBody, `"artist_name":"Miles Davis"`) {
- t.Errorf("body missing artist_name: %s", seenBody)
- }
-}
-
-func TestClient_SubmitListens_401_ReturnsErrAuth(t *testing.T) {
- c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(http.StatusUnauthorized)
- })
- defer srv.Close()
- err := c.SubmitListens(context.Background(), "bad", []Listen{{}})
- if !errors.Is(err, ErrAuth) {
- t.Errorf("err = %v, want ErrAuth", err)
- }
-}
-
-func TestClient_SubmitListens_400_ReturnsErrPermanent(t *testing.T) {
- c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(http.StatusBadRequest)
- })
- defer srv.Close()
- err := c.SubmitListens(context.Background(), "tk", []Listen{{}})
- if !errors.Is(err, ErrPermanent) {
- t.Errorf("err = %v, want ErrPermanent", err)
- }
-}
-
-func TestClient_SubmitListens_503_ReturnsErrTransient(t *testing.T) {
- c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(http.StatusServiceUnavailable)
- })
- defer srv.Close()
- err := c.SubmitListens(context.Background(), "tk", []Listen{{}})
- if !errors.Is(err, ErrTransient) {
- t.Errorf("err = %v, want ErrTransient", err)
- }
-}
-
-func TestClient_SubmitListens_429_ReturnsRetryAfter(t *testing.T) {
- c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Retry-After", "60")
- w.WriteHeader(http.StatusTooManyRequests)
- })
- defer srv.Close()
- err := c.SubmitListens(context.Background(), "tk", []Listen{{}})
- var ra *RetryAfterError
- if !errors.As(err, &ra) {
- t.Fatalf("err = %v, want *RetryAfterError", err)
- }
- if ra.Wait != 60*time.Second {
- t.Errorf("Wait = %v, want 60s", ra.Wait)
- }
-}
-
-func TestClient_SubmitListens_NetworkFailure_ReturnsErrTransient(t *testing.T) {
- c := &Client{BaseURL: "http://127.0.0.1:1", HTTP: &http.Client{Timeout: 100 * time.Millisecond}}
- err := c.SubmitListens(context.Background(), "tk", []Listen{{}})
- if !errors.Is(err, ErrTransient) {
- t.Errorf("err = %v, want ErrTransient", err)
- }
-}
-
-func TestClient_SubmitListens_PayloadTypeImportForBatch(t *testing.T) {
- var seenBody string
- c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
- buf := make([]byte, 4096)
- n, _ := r.Body.Read(buf)
- seenBody = string(buf[:n])
- w.WriteHeader(http.StatusOK)
- })
- defer srv.Close()
- _ = c.SubmitListens(context.Background(), "tk", []Listen{
- {ListenedAt: 1, Track: Track{ArtistName: "A", TrackName: "X"}},
- {ListenedAt: 2, Track: Track{ArtistName: "B", TrackName: "Y"}},
- })
- if !strings.Contains(seenBody, `"listen_type":"import"`) {
- t.Errorf("batch body missing listen_type=import: %s", seenBody)
- }
-}
-
-func TestClient_SubmitListens_PayloadTypeSingleForOne(t *testing.T) {
- var seenBody string
- c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
- buf := make([]byte, 4096)
- n, _ := r.Body.Read(buf)
- seenBody = string(buf[:n])
- w.WriteHeader(http.StatusOK)
- })
- defer srv.Close()
- _ = c.SubmitListens(context.Background(), "tk", []Listen{
- {ListenedAt: 1, Track: Track{ArtistName: "A", TrackName: "X"}},
- })
- if !strings.Contains(seenBody, `"listen_type":"single"`) {
- t.Errorf("single body missing listen_type=single: %s", seenBody)
- }
-}
-
-func TestClient_SubmitListens_OmitsEmptyMBIDs(t *testing.T) {
- var seenBody string
- c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
- buf := make([]byte, 4096)
- n, _ := r.Body.Read(buf)
- seenBody = string(buf[:n])
- w.WriteHeader(http.StatusOK)
- })
- defer srv.Close()
- _ = c.SubmitListens(context.Background(), "tk", []Listen{
- {ListenedAt: 1, Track: Track{ArtistName: "A", TrackName: "X"}},
- })
- if strings.Contains(seenBody, "recording_mbid") {
- t.Errorf("body should omit empty recording_mbid: %s", seenBody)
- }
-}
-```
-
-- [ ] **Step 2: Run tests to verify they fail**
-
-Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go test ./internal/scrobble/listenbrainz/ -v`
-
-Expected: FAIL with "no Go files" or "undefined: Client".
-
-- [ ] **Step 3: Write the client**
-
-Create `internal/scrobble/listenbrainz/client.go`:
-
-```go
-// Package listenbrainz is the pure HTTP client for the ListenBrainz
-// submit-listens endpoint. No DB, no worker plumbing — just types and one
-// method. Errors are typed (ErrAuth, ErrPermanent, ErrTransient,
-// *RetryAfterError) so callers can branch on response semantics.
-package listenbrainz
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "net/http"
- "strconv"
- "time"
-)
-
-const defaultBaseURL = "https://api.listenbrainz.org"
-
-// Listen is one row sent to ListenBrainz.
-type Listen struct {
- ListenedAt int64 // unix seconds
- Track Track
-}
-
-// Track is the per-listen metadata. Optional fields are omitted from the
-// payload when zero-valued.
-type Track struct {
- ArtistName string
- TrackName string
- ReleaseName string
- DurationMs int
- RecordingMBID string
- ArtistMBIDs []string
- ReleaseMBID string
-}
-
-// Client posts to the LB submit-listens endpoint.
-type Client struct {
- BaseURL string
- HTTP *http.Client
-}
-
-// NewClient returns a default-configured client (LB production base URL,
-// 30s timeout).
-func NewClient() *Client {
- return &Client{
- BaseURL: defaultBaseURL,
- HTTP: &http.Client{Timeout: 30 * time.Second},
- }
-}
-
-// Sentinel errors. The worker branches on these to decide retry vs fail.
-var (
- ErrAuth = errors.New("listenbrainz: auth rejected")
- ErrPermanent = errors.New("listenbrainz: permanent error")
- ErrTransient = errors.New("listenbrainz: transient error")
-)
-
-// RetryAfterError carries the LB-supplied wait duration for 429 responses.
-type RetryAfterError struct{ Wait time.Duration }
-
-func (e *RetryAfterError) Error() string {
- return fmt.Sprintf("listenbrainz: retry after %v", e.Wait)
-}
-
-// SubmitListens posts the given listens. payload_type is "single" for one
-// listen, "import" for batches.
-func (c *Client) SubmitListens(ctx context.Context, token string, listens []Listen) error {
- if len(listens) == 0 {
- return nil
- }
-
- listenType := "import"
- if len(listens) == 1 {
- listenType = "single"
- }
-
- body, err := json.Marshal(buildPayload(listenType, listens))
- if err != nil {
- return fmt.Errorf("listenbrainz: marshal: %w", err)
- }
-
- base := c.BaseURL
- if base == "" {
- base = defaultBaseURL
- }
- req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/1/submit-listens", bytes.NewReader(body))
- if err != nil {
- return fmt.Errorf("listenbrainz: build request: %w", err)
- }
- req.Header.Set("Content-Type", "application/json")
- req.Header.Set("Authorization", "Token "+token)
-
- httpClient := c.HTTP
- if httpClient == nil {
- httpClient = http.DefaultClient
- }
- resp, err := httpClient.Do(req)
- if err != nil {
- return fmt.Errorf("%w: %v", ErrTransient, err)
- }
- defer func() { _ = resp.Body.Close() }()
-
- switch {
- case resp.StatusCode >= 200 && resp.StatusCode < 300:
- return nil
- case resp.StatusCode == http.StatusUnauthorized:
- return ErrAuth
- case resp.StatusCode == http.StatusTooManyRequests:
- wait := parseRetryAfter(resp.Header.Get("Retry-After"))
- return &RetryAfterError{Wait: wait}
- case resp.StatusCode >= 500:
- return fmt.Errorf("%w: status %d", ErrTransient, resp.StatusCode)
- default:
- return fmt.Errorf("%w: status %d", ErrPermanent, resp.StatusCode)
- }
-}
-
-func parseRetryAfter(header string) time.Duration {
- if header == "" {
- return 60 * time.Second // sensible default
- }
- if secs, err := strconv.Atoi(header); err == nil {
- return time.Duration(secs) * time.Second
- }
- if t, err := http.ParseTime(header); err == nil {
- d := time.Until(t)
- if d < 0 {
- return 60 * time.Second
- }
- return d
- }
- return 60 * time.Second
-}
-
-// payload mirrors the LB submit-listens body. Optional MBID fields use
-// `omitempty` so empty strings/slices don't appear in the JSON.
-type payload struct {
- ListenType string `json:"listen_type"`
- Payload []payloadEntry `json:"payload"`
-}
-
-type payloadEntry struct {
- ListenedAt int64 `json:"listened_at"`
- TrackMetadata trackMetadata `json:"track_metadata"`
-}
-
-type trackMetadata struct {
- ArtistName string `json:"artist_name"`
- TrackName string `json:"track_name"`
- ReleaseName string `json:"release_name,omitempty"`
- AdditionalInfo additionalInfo `json:"additional_info,omitempty"`
-}
-
-type additionalInfo struct {
- DurationMs int `json:"duration_ms,omitempty"`
- RecordingMBID string `json:"recording_mbid,omitempty"`
- ArtistMBIDs []string `json:"artist_mbids,omitempty"`
- ReleaseMBID string `json:"release_mbid,omitempty"`
-}
-
-func buildPayload(listenType string, listens []Listen) payload {
- entries := make([]payloadEntry, 0, len(listens))
- for _, l := range listens {
- entries = append(entries, payloadEntry{
- ListenedAt: l.ListenedAt,
- TrackMetadata: trackMetadata{
- ArtistName: l.Track.ArtistName,
- TrackName: l.Track.TrackName,
- ReleaseName: l.Track.ReleaseName,
- AdditionalInfo: additionalInfo{
- DurationMs: l.Track.DurationMs,
- RecordingMBID: l.Track.RecordingMBID,
- ArtistMBIDs: l.Track.ArtistMBIDs,
- ReleaseMBID: l.Track.ReleaseMBID,
- },
- },
- })
- }
- return payload{ListenType: listenType, Payload: entries}
-}
-```
-
-- [ ] **Step 4: Run tests to verify they pass**
-
-Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go test ./internal/scrobble/listenbrainz/ -v`
-
-Expected: PASS for all 9 tests.
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add internal/scrobble/listenbrainz/
-git commit -m "feat(scrobble): add pure ListenBrainz HTTP client with typed errors"
-```
-
----
-
-## Task 4: Threshold (pure)
-
-**Files:**
-- Create: `internal/scrobble/threshold.go`
-- Create: `internal/scrobble/threshold_test.go`
-
-- [ ] **Step 1: Write the failing tests**
-
-Create `internal/scrobble/threshold_test.go`:
-
-```go
-package scrobble
-
-import "testing"
-
-func TestQualifies_NeverPlayed(t *testing.T) {
- if Qualifies(0, 0) {
- t.Error("0/0 should not qualify")
- }
-}
-
-func TestQualifies_AtLeast240Seconds(t *testing.T) {
- if !Qualifies(240_000, 0.0) {
- t.Error("240_000 ms played should qualify regardless of ratio")
- }
-}
-
-func TestQualifies_AtLeastHalfRatio(t *testing.T) {
- if !Qualifies(0, 0.5) {
- t.Error("ratio=0.5 should qualify regardless of duration")
- }
-}
-
-func TestQualifies_BelowBoth(t *testing.T) {
- if Qualifies(120_000, 0.49) {
- t.Error("120s + 49% should not qualify")
- }
-}
-
-func TestQualifies_BoundaryJustUnder(t *testing.T) {
- if Qualifies(239_999, 0.4999) {
- t.Error("just-under both thresholds should not qualify")
- }
-}
-
-func TestQualifies_BoundaryExactly(t *testing.T) {
- if !Qualifies(240_000, 0.4999) {
- t.Error("exactly 240s should qualify")
- }
- if !Qualifies(239_999, 0.5) {
- t.Error("exactly 50% should qualify")
- }
-}
-```
-
-- [ ] **Step 2: Run tests to verify they fail**
-
-Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go test ./internal/scrobble/ -run Qualifies -v`
-
-Expected: FAIL with "no Go files" or "undefined: Qualifies".
-
-- [ ] **Step 3: Write the threshold function**
-
-Create `internal/scrobble/threshold.go`:
-
-```go
-// Package scrobble owns the outbound-scrobble pipeline: threshold detection,
-// the queue write path, and the worker goroutine. The HTTP client is in
-// internal/scrobble/listenbrainz; this package consumes it.
-package scrobble
-
-// Qualifies returns true if a closed play_event meets ListenBrainz's
-// recommended scrobble threshold: ≥240 seconds played OR ≥50% of the
-// track length played.
-//
-// The two thresholds are deliberately separate from M2's skip-detection
-// thresholds (which serve a different purpose — internal engine signal
-// for the scoring formula's skip penalty).
-func Qualifies(durationPlayedMs int, completionRatio float64) bool {
- return durationPlayedMs >= 240_000 || completionRatio >= 0.5
-}
-```
-
-- [ ] **Step 4: Run tests to verify they pass**
-
-Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go test ./internal/scrobble/ -run Qualifies -v`
-
-Expected: PASS for all 6 tests.
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add internal/scrobble/threshold.go internal/scrobble/threshold_test.go
-git commit -m "feat(scrobble): add pure Qualifies threshold function"
-```
-
----
-
-## Task 5: MaybeEnqueue (DB-backed)
-
-**Files:**
-- Create: `internal/scrobble/queue.go`
-- Create: `internal/scrobble/queue_test.go`
-
-- [ ] **Step 1: Write the failing integration tests**
-
-Create `internal/scrobble/queue_test.go`:
-
-```go
-package scrobble
-
-import (
- "context"
- "io"
- "log/slog"
- "os"
- "testing"
-
- "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, *dbq.Queries) {
- t.Helper()
- if testing.Short() {
- t.Skip("skipping 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 scrobble_queue, play_events, skip_events, play_sessions, sessions, users, tracks, albums, artists RESTART IDENTITY CASCADE"); err != nil {
- t.Fatalf("truncate: %v", err)
- }
- return pool, dbq.New(pool)
-}
-
-type setup struct {
- pool *pgxpool.Pool
- q *dbq.Queries
- user pgtype.UUID
- playEvent pgtype.UUID
-}
-
-// seed creates: user (with optional LB token + enabled), one artist/album/track,
-// one closed play_event with the given duration_played_ms and completion_ratio.
-func seed(t *testing.T, opts seedOpts) setup {
- t.Helper()
- pool, q := testPool(t)
- ctx := context.Background()
- u, err := q.CreateUser(ctx, dbq.CreateUserParams{
- Username: "tester", PasswordHash: "x", ApiToken: "x", IsAdmin: false,
- })
- if err != nil {
- t.Fatalf("user: %v", err)
- }
- if opts.lbToken != "" {
- if err := q.SetListenBrainzToken(ctx, dbq.SetListenBrainzTokenParams{ID: u.ID, ListenbrainzToken: &opts.lbToken}); err != nil {
- t.Fatalf("token: %v", err)
- }
- }
- if opts.lbEnabled {
- if err := q.SetListenBrainzEnabled(ctx, dbq.SetListenBrainzEnabledParams{ID: u.ID, ListenbrainzEnabled: true}); err != nil {
- t.Fatalf("enabled: %v", err)
- }
- }
- a, _ := q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: "X", SortName: "X"})
- al, _ := q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: "X", SortTitle: "X", ArtistID: a.ID})
- tr, _ := q.UpsertTrack(ctx, dbq.UpsertTrackParams{
- Title: "Y", AlbumID: al.ID, ArtistID: a.ID, FilePath: "/tmp/y.flac", DurationMs: 300_000,
- })
- // Insert play_session + play_event directly with the provided duration/ratio.
- var sessionID pgtype.UUID
- if err := pool.QueryRow(ctx,
- `INSERT INTO play_sessions (user_id, started_at, last_event_at, client_id)
- VALUES ($1, now() - interval '5 minutes', now(), 'test') RETURNING id`,
- u.ID).Scan(&sessionID); err != nil {
- t.Fatalf("session: %v", err)
- }
- var peID pgtype.UUID
- if err := pool.QueryRow(ctx,
- `INSERT INTO play_events (user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped)
- VALUES ($1, $2, $3, now() - interval '1 minute', now(), $4, $5, false) RETURNING id`,
- u.ID, tr.ID, sessionID, opts.durationMs, opts.ratio).Scan(&peID); err != nil {
- t.Fatalf("play_event: %v", err)
- }
- return setup{pool: pool, q: q, user: u.ID, playEvent: peID}
-}
-
-type seedOpts struct {
- lbToken string
- lbEnabled bool
- durationMs int32
- ratio float64
-}
-
-func countQueueRows(t *testing.T, s setup) int {
- t.Helper()
- var n int
- if err := s.pool.QueryRow(context.Background(),
- `SELECT count(*) FROM scrobble_queue WHERE play_event_id = $1`, s.playEvent).Scan(&n); err != nil {
- t.Fatalf("count: %v", err)
- }
- return n
-}
-
-func TestMaybeEnqueue_QualifyingPlayWithEnabledUser_InsertsRow(t *testing.T) {
- s := seed(t, seedOpts{lbToken: "tk", lbEnabled: true, durationMs: 250_000, ratio: 0.83})
- if err := MaybeEnqueue(context.Background(), s.q, s.playEvent); err != nil {
- t.Fatalf("MaybeEnqueue: %v", err)
- }
- if got := countQueueRows(t, s); got != 1 {
- t.Errorf("rows = %d, want 1", got)
- }
-}
-
-func TestMaybeEnqueue_SubThreshold_NoRow(t *testing.T) {
- // 100s, 33% — neither threshold met.
- s := seed(t, seedOpts{lbToken: "tk", lbEnabled: true, durationMs: 100_000, ratio: 0.33})
- if err := MaybeEnqueue(context.Background(), s.q, s.playEvent); err != nil {
- t.Fatalf("MaybeEnqueue: %v", err)
- }
- if got := countQueueRows(t, s); got != 0 {
- t.Errorf("rows = %d, want 0 (sub-threshold)", got)
- }
-}
-
-func TestMaybeEnqueue_DisabledUser_NoRow(t *testing.T) {
- s := seed(t, seedOpts{lbToken: "tk", lbEnabled: false, durationMs: 250_000, ratio: 0.83})
- if err := MaybeEnqueue(context.Background(), s.q, s.playEvent); err != nil {
- t.Fatalf("MaybeEnqueue: %v", err)
- }
- if got := countQueueRows(t, s); got != 0 {
- t.Errorf("rows = %d, want 0 (disabled)", got)
- }
-}
-
-func TestMaybeEnqueue_NoToken_NoRow(t *testing.T) {
- // Even with enabled=true, an empty token shouldn't enqueue.
- s := seed(t, seedOpts{lbToken: "", lbEnabled: true, durationMs: 250_000, ratio: 0.83})
- if err := MaybeEnqueue(context.Background(), s.q, s.playEvent); err != nil {
- t.Fatalf("MaybeEnqueue: %v", err)
- }
- if got := countQueueRows(t, s); got != 0 {
- t.Errorf("rows = %d, want 0 (no token)", got)
- }
-}
-
-func TestMaybeEnqueue_Idempotent_TwoCallsOneRow(t *testing.T) {
- s := seed(t, seedOpts{lbToken: "tk", lbEnabled: true, durationMs: 250_000, ratio: 0.83})
- for i := 0; i < 2; i++ {
- if err := MaybeEnqueue(context.Background(), s.q, s.playEvent); err != nil {
- t.Fatalf("MaybeEnqueue #%d: %v", i, err)
- }
- }
- if got := countQueueRows(t, s); got != 1 {
- t.Errorf("rows = %d, want 1 (idempotent)", got)
- }
-}
-```
-
-- [ ] **Step 2: Run tests to verify they fail**
-
-Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test ./internal/scrobble/ -run MaybeEnqueue -v`
-
-Expected: FAIL with "undefined: MaybeEnqueue".
-
-- [ ] **Step 3: Write the implementation**
-
-Create `internal/scrobble/queue.go`:
-
-```go
-package scrobble
-
-import (
- "context"
- "errors"
-
- "github.com/jackc/pgx/v5"
- "github.com/jackc/pgx/v5/pgtype"
-
- "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
-)
-
-// MaybeEnqueue inserts a scrobble_queue row for the given play_event if:
-// 1. The user has listenbrainz_enabled = true with a non-empty token.
-// 2. The play_event passes Qualifies().
-// Idempotent via UNIQUE(play_event_id) — re-runs are no-ops.
-//
-// Best-effort: callers (the playevents.Writer hooks) should log returned
-// errors but not propagate them, since scrobble delivery is enrichment
-// and must not block the canonical play history.
-func MaybeEnqueue(ctx context.Context, q *dbq.Queries, playEventID pgtype.UUID) error {
- pe, err := q.GetPlayEventByID(ctx, playEventID)
- if err != nil {
- if errors.Is(err, pgx.ErrNoRows) {
- return nil
- }
- return err
- }
- // Only closed events qualify (DurationPlayedMs/CompletionRatio populated).
- if pe.DurationPlayedMs == nil || pe.CompletionRatio == nil {
- return nil
- }
- if !Qualifies(int(*pe.DurationPlayedMs), *pe.CompletionRatio) {
- return nil
- }
- cfg, err := q.GetListenBrainzConfig(ctx, pe.UserID)
- if err != nil {
- return err
- }
- if !cfg.ListenbrainzEnabled || cfg.ListenbrainzToken == nil || *cfg.ListenbrainzToken == "" {
- return nil
- }
- _, err = q.EnqueueScrobble(ctx, dbq.EnqueueScrobbleParams{
- UserID: pe.UserID,
- PlayEventID: pe.ID,
- })
- return err
-}
-```
-
-- [ ] **Step 4: Run tests to verify they pass**
-
-Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test ./internal/scrobble/ -run MaybeEnqueue -v`
-
-Expected: PASS for all 5 tests.
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add internal/scrobble/queue.go internal/scrobble/queue_test.go
-git commit -m "feat(scrobble): add MaybeEnqueue with idempotent insert + threshold gate"
-```
-
----
-
-## Task 6: Backoff schedule (pure)
-
-**Files:**
-- Create: `internal/scrobble/worker.go` (skeleton with backoffDelay only)
-- Create: `internal/scrobble/worker_test.go`
-
-- [ ] **Step 1: Write the failing tests**
-
-Create `internal/scrobble/worker_test.go`:
-
-```go
-package scrobble
-
-import (
- "testing"
- "time"
-)
-
-func TestBackoffDelay(t *testing.T) {
- cases := []struct {
- attempts int
- want time.Duration
- ok bool
- }{
- {1, 1 * time.Minute, true},
- {2, 5 * time.Minute, true},
- {3, 30 * time.Minute, true},
- {4, 2 * time.Hour, true},
- {5, 6 * time.Hour, true},
- {6, 0, false}, // exhausted
- {99, 0, false}, // way past max
- }
- for _, c := range cases {
- got, ok := backoffDelay(c.attempts)
- if ok != c.ok {
- t.Errorf("backoffDelay(%d) ok = %v, want %v", c.attempts, ok, c.ok)
- }
- if got != c.want {
- t.Errorf("backoffDelay(%d) = %v, want %v", c.attempts, got, c.want)
- }
- }
-}
-```
-
-- [ ] **Step 2: Run tests to verify they fail**
-
-Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go test ./internal/scrobble/ -run BackoffDelay -v`
-
-Expected: FAIL with "undefined: backoffDelay".
-
-- [ ] **Step 3: Write the worker skeleton with backoffDelay**
-
-Create `internal/scrobble/worker.go`:
-
-```go
-package scrobble
-
-import (
- "time"
-)
-
-// backoffSchedule maps the failure count (1-indexed: 1 = first failure
-// just happened) to the delay before the next attempt. Per spec §9.6:
-// 1m → 5m → 30m → 2h → 6h, then give up.
-var backoffSchedule = []time.Duration{
- 1 * time.Minute,
- 5 * time.Minute,
- 30 * time.Minute,
- 2 * time.Hour,
- 6 * time.Hour,
-}
-
-const maxAttempts = 5
-
-// backoffDelay returns the delay before the next attempt given the value
-// of the `attempts` column AFTER the failure has been recorded (1-indexed).
-// Returns (_, false) when attempts > maxAttempts (give up — the row should
-// be marked failed).
-func backoffDelay(attempts int) (time.Duration, bool) {
- if attempts < 1 || attempts > maxAttempts {
- return 0, false
- }
- return backoffSchedule[attempts-1], true
-}
-```
-
-- [ ] **Step 4: Run tests to verify they pass**
-
-Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go test ./internal/scrobble/ -run BackoffDelay -v`
-
-Expected: PASS for all cases.
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add internal/scrobble/worker.go internal/scrobble/worker_test.go
-git commit -m "feat(scrobble): add backoffDelay schedule (1m, 5m, 30m, 2h, 6h, give up)"
-```
-
----
-
-## Task 7: Worker tickOnce (DB-backed)
-
-**Files:**
-- Modify: `internal/scrobble/worker.go` (add Worker struct, NewWorker, tickOnce, Run)
-- Create: `internal/scrobble/worker_integration_test.go`
-
-- [ ] **Step 1: Write the failing integration tests**
-
-Create `internal/scrobble/worker_integration_test.go`:
-
-```go
-package scrobble
-
-import (
- "context"
- "errors"
- "net/http"
- "net/http/httptest"
- "strings"
- "sync/atomic"
- "testing"
- "time"
-
- "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
- "git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz"
-)
-
-// helperEnqueue inserts a queue row directly so we don't go through the threshold path.
-func helperEnqueue(t *testing.T, s setup) {
- t.Helper()
- if _, err := s.pool.Exec(context.Background(),
- `INSERT INTO scrobble_queue (user_id, play_event_id, status) VALUES ($1, $2, 'pending')`,
- s.user, s.playEvent); err != nil {
- t.Fatalf("enqueue: %v", err)
- }
-}
-
-func helperPendingCount(t *testing.T, s setup) int {
- t.Helper()
- var n int
- if err := s.pool.QueryRow(context.Background(),
- `SELECT count(*) FROM scrobble_queue WHERE status = 'pending' AND play_event_id = $1`, s.playEvent).Scan(&n); err != nil {
- t.Fatalf("pending: %v", err)
- }
- return n
-}
-
-func helperFailedCount(t *testing.T, s setup) int {
- t.Helper()
- var n int
- if err := s.pool.QueryRow(context.Background(),
- `SELECT count(*) FROM scrobble_queue WHERE status = 'failed' AND play_event_id = $1`, s.playEvent).Scan(&n); err != nil {
- t.Fatalf("failed: %v", err)
- }
- return n
-}
-
-func helperPlayEventScrobbledAt(t *testing.T, s setup) bool {
- t.Helper()
- var v *time.Time
- if err := s.pool.QueryRow(context.Background(),
- `SELECT scrobbled_at FROM play_events WHERE id = $1`, s.playEvent).Scan(&v); err != nil {
- t.Fatalf("scrobbled_at: %v", err)
- }
- return v != nil
-}
-
-func newTestWorker(s setup, lb *listenbrainz.Client) *Worker {
- return NewWorker(s.pool, lb, helperLogger())
-}
-
-func helperLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
-
-func TestWorker_Success_DeletesQueueRowAndStampsScrobbledAt(t *testing.T) {
- s := seed(t, seedOpts{lbToken: "tk", lbEnabled: true, durationMs: 250_000, ratio: 0.83})
- helperEnqueue(t, s)
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(http.StatusOK)
- }))
- defer srv.Close()
- lb := &listenbrainz.Client{BaseURL: srv.URL, HTTP: srv.Client()}
- w := newTestWorker(s, lb)
-
- if err := w.tickOnce(context.Background()); err != nil {
- t.Fatalf("tickOnce: %v", err)
- }
- if helperPendingCount(t, s) != 0 {
- t.Error("queue row should be deleted on success")
- }
- if helperFailedCount(t, s) != 0 {
- t.Error("no failed row expected on success")
- }
- if !helperPlayEventScrobbledAt(t, s) {
- t.Error("play_events.scrobbled_at should be populated on success")
- }
-}
-
-func TestWorker_503Once_RowRemainsAttempts1(t *testing.T) {
- s := seed(t, seedOpts{lbToken: "tk", lbEnabled: true, durationMs: 250_000, ratio: 0.83})
- helperEnqueue(t, s)
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(http.StatusServiceUnavailable)
- }))
- defer srv.Close()
- lb := &listenbrainz.Client{BaseURL: srv.URL, HTTP: srv.Client()}
- w := newTestWorker(s, lb)
- _ = w.tickOnce(context.Background())
-
- var attempts int
- if err := s.pool.QueryRow(context.Background(),
- `SELECT attempts FROM scrobble_queue WHERE play_event_id = $1`, s.playEvent).Scan(&attempts); err != nil {
- t.Fatalf("attempts: %v", err)
- }
- if attempts != 1 {
- t.Errorf("attempts = %d, want 1 after 503", attempts)
- }
- if helperPendingCount(t, s) != 1 {
- t.Error("row should remain pending after transient failure")
- }
-}
-
-func TestWorker_503FiveTimes_MarksFailed(t *testing.T) {
- s := seed(t, seedOpts{lbToken: "tk", lbEnabled: true, durationMs: 250_000, ratio: 0.83})
- helperEnqueue(t, s)
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(http.StatusServiceUnavailable)
- }))
- defer srv.Close()
- lb := &listenbrainz.Client{BaseURL: srv.URL, HTTP: srv.Client()}
- w := newTestWorker(s, lb)
- // Five failures: each tick marches next_attempt_at forward, so we need to
- // reset it between ticks to simulate the time passing.
- for i := 0; i < 5; i++ {
- if _, err := s.pool.Exec(context.Background(),
- `UPDATE scrobble_queue SET next_attempt_at = now() WHERE play_event_id = $1`, s.playEvent); err != nil {
- t.Fatalf("reset: %v", err)
- }
- _ = w.tickOnce(context.Background())
- }
- if helperFailedCount(t, s) != 1 {
- t.Errorf("expected row marked failed after 5 attempts; pending=%d failed=%d",
- helperPendingCount(t, s), helperFailedCount(t, s))
- }
- var attempts int
- var lastErr *string
- _ = s.pool.QueryRow(context.Background(),
- `SELECT attempts, last_error FROM scrobble_queue WHERE play_event_id = $1`, s.playEvent).Scan(&attempts, &lastErr)
- if attempts != 5 {
- t.Errorf("attempts = %d, want 5", attempts)
- }
- if lastErr == nil || *lastErr == "" {
- t.Error("last_error should be populated")
- }
-}
-
-func TestWorker_401_MarksFailedAndDisablesUser(t *testing.T) {
- s := seed(t, seedOpts{lbToken: "tk", lbEnabled: true, durationMs: 250_000, ratio: 0.83})
- helperEnqueue(t, s)
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(http.StatusUnauthorized)
- }))
- defer srv.Close()
- lb := &listenbrainz.Client{BaseURL: srv.URL, HTTP: srv.Client()}
- w := newTestWorker(s, lb)
- _ = w.tickOnce(context.Background())
-
- if helperFailedCount(t, s) != 1 {
- t.Error("401 should mark failed immediately")
- }
- cfg, _ := s.q.GetListenBrainzConfig(context.Background(), s.user)
- if cfg.ListenbrainzEnabled {
- t.Error("user listenbrainz_enabled should be set to false on auth failure")
- }
-}
-
-func TestWorker_429_RespectsRetryAfterWithoutIncrementingAttempts(t *testing.T) {
- s := seed(t, seedOpts{lbToken: "tk", lbEnabled: true, durationMs: 250_000, ratio: 0.83})
- helperEnqueue(t, s)
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Retry-After", "300")
- w.WriteHeader(http.StatusTooManyRequests)
- }))
- defer srv.Close()
- lb := &listenbrainz.Client{BaseURL: srv.URL, HTTP: srv.Client()}
- w := newTestWorker(s, lb)
- _ = w.tickOnce(context.Background())
-
- var attempts int
- var nextAttemptAt time.Time
- _ = s.pool.QueryRow(context.Background(),
- `SELECT attempts, next_attempt_at FROM scrobble_queue WHERE play_event_id = $1`, s.playEvent).
- Scan(&attempts, &nextAttemptAt)
- if attempts != 0 {
- t.Errorf("attempts = %d, want 0 (429 should not increment)", attempts)
- }
- if d := time.Until(nextAttemptAt); d < 250*time.Second || d > 350*time.Second {
- t.Errorf("next_attempt_at = +%v, want ~+5m (Retry-After 300)", d)
- }
-}
-
-func TestWorker_BatchOfTen_OnePost(t *testing.T) {
- s := seed(t, seedOpts{lbToken: "tk", lbEnabled: true, durationMs: 250_000, ratio: 0.83})
- // Enqueue 10 distinct play_events to test batching.
- for i := 0; i < 10; i++ {
- var newPE pgtype.UUID
- if err := s.pool.QueryRow(context.Background(),
- `INSERT INTO play_events (user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped)
- SELECT user_id, track_id, session_id, now(), now(), duration_played_ms, completion_ratio, was_skipped FROM play_events WHERE id = $1
- RETURNING id`, s.playEvent).Scan(&newPE); err != nil {
- t.Fatalf("dup play_event: %v", err)
- }
- if _, err := s.pool.Exec(context.Background(),
- `INSERT INTO scrobble_queue (user_id, play_event_id, status) VALUES ($1, $2, 'pending')`,
- s.user, newPE); err != nil {
- t.Fatalf("enqueue: %v", err)
- }
- }
- var posts atomic.Int32
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- posts.Add(1)
- w.WriteHeader(http.StatusOK)
- }))
- defer srv.Close()
- lb := &listenbrainz.Client{BaseURL: srv.URL, HTTP: srv.Client()}
- w := newTestWorker(s, lb)
- _ = w.tickOnce(context.Background())
-
- if posts.Load() != 1 {
- t.Errorf("LB POSTs = %d, want 1 (batch)", posts.Load())
- }
-}
-```
-
-Note: this test file imports `io`, `log/slog`, `pgtype` — make sure to add those imports at the top of the file. The exact import block:
-
-```go
-import (
- "context"
- "errors"
- "io"
- "log/slog"
- "net/http"
- "net/http/httptest"
- "strings"
- "sync/atomic"
- "testing"
- "time"
-
- "github.com/jackc/pgx/v5/pgtype"
-
- "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
- "git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz"
-)
-```
-
-(The `errors` and `strings` imports may be unused — remove if Go compiler complains.)
-
-- [ ] **Step 2: Run tests to verify they fail**
-
-Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test ./internal/scrobble/ -run Worker -v`
-
-Expected: FAIL with "undefined: NewWorker" and "undefined: tickOnce".
-
-- [ ] **Step 3: Implement Worker, NewWorker, tickOnce, Run**
-
-Replace `internal/scrobble/worker.go` contents:
-
-```go
-package scrobble
-
-import (
- "context"
- "errors"
- "log/slog"
- "time"
-
- "github.com/jackc/pgx/v5/pgtype"
- "github.com/jackc/pgx/v5/pgxpool"
-
- "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
- "git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz"
-)
-
-// backoffSchedule maps the failure count (1-indexed) to the delay before
-// the next attempt. Per spec §9.6: 1m → 5m → 30m → 2h → 6h, then give up.
-var backoffSchedule = []time.Duration{
- 1 * time.Minute,
- 5 * time.Minute,
- 30 * time.Minute,
- 2 * time.Hour,
- 6 * time.Hour,
-}
-
-const maxAttempts = 5
-
-// backoffDelay returns the delay before the next attempt given the value
-// of the `attempts` column AFTER the failure has been recorded (1-indexed).
-func backoffDelay(attempts int) (time.Duration, bool) {
- if attempts < 1 || attempts > maxAttempts {
- return 0, false
- }
- return backoffSchedule[attempts-1], true
-}
-
-// Worker drains scrobble_queue rows and POSTs them to ListenBrainz.
-type Worker struct {
- pool *pgxpool.Pool
- client *listenbrainz.Client
- logger *slog.Logger
- tick time.Duration
- batch int32
-}
-
-// NewWorker constructs a worker with production defaults: 30s tick, 50-row
-// batch.
-func NewWorker(pool *pgxpool.Pool, client *listenbrainz.Client, logger *slog.Logger) *Worker {
- return &Worker{pool: pool, client: client, logger: logger, tick: 30 * time.Second, batch: 50}
-}
-
-// Run blocks until ctx is cancelled, ticking every w.tick.
-func (w *Worker) Run(ctx context.Context) {
- t := time.NewTicker(w.tick)
- defer t.Stop()
- for {
- select {
- case <-ctx.Done():
- return
- case <-t.C:
- if err := w.tickOnce(ctx); err != nil {
- w.logger.Error("scrobble: tick failed", "err", err)
- }
- }
- }
-}
-
-// tickOnce drains up to w.batch pending rows. Returns the first error
-// encountered loading rows; per-row errors are logged and don't abort
-// the batch.
-func (w *Worker) tickOnce(ctx context.Context) error {
- q := dbq.New(w.pool)
- rows, err := q.ListPendingScrobbles(ctx, w.batch)
- if err != nil {
- return err
- }
- if len(rows) == 0 {
- return nil
- }
-
- // Group rows by user (each user has their own token + may produce its
- // own batch).
- type userBatch struct {
- token string
- queueIDs []pgtype.UUID
- listens []listenbrainz.Listen
- userID pgtype.UUID
- playIDs []pgtype.UUID
- }
- batches := map[string]*userBatch{}
- for _, r := range rows {
- if r.LbToken == nil || *r.LbToken == "" || !r.LbEnabled {
- // Defensive: row got into queue but user since disabled. Skip.
- continue
- }
- key := r.UserID.String()
- ub := batches[key]
- if ub == nil {
- ub = &userBatch{token: *r.LbToken, userID: r.UserID}
- batches[key] = ub
- }
- ub.queueIDs = append(ub.queueIDs, r.QueueID)
- ub.playIDs = append(ub.playIDs, r.PlayEventID)
- l := listenbrainz.Listen{
- ListenedAt: r.StartedAt.Time.Unix(),
- Track: listenbrainz.Track{
- ArtistName: r.ArtistName,
- TrackName: r.TrackTitle,
- ReleaseName: r.AlbumTitle,
- DurationMs: int(r.TrackDurationMs),
- },
- }
- if r.TrackMbid != nil {
- l.Track.RecordingMBID = *r.TrackMbid
- }
- if r.AlbumMbid != nil {
- l.Track.ReleaseMBID = *r.AlbumMbid
- }
- if r.ArtistMbid != nil {
- l.Track.ArtistMBIDs = []string{*r.ArtistMbid}
- }
- ub.listens = append(ub.listens, l)
- }
-
- for _, ub := range batches {
- w.processBatch(ctx, q, ub.userID, ub.token, ub.queueIDs, ub.listens)
- }
- return nil
-}
-
-// processBatch submits one user's pending batch and updates queue rows
-// according to the response.
-func (w *Worker) processBatch(
- ctx context.Context,
- q *dbq.Queries,
- userID pgtype.UUID,
- token string,
- queueIDs []pgtype.UUID,
- listens []listenbrainz.Listen,
-) {
- err := w.client.SubmitListens(ctx, token, listens)
- now := time.Now().UTC()
-
- if err == nil {
- for _, id := range queueIDs {
- if uerr := q.MarkScrobbleSent(ctx, id); uerr != nil {
- w.logger.Error("scrobble: MarkScrobbleSent", "queue_id", id, "err", uerr)
- }
- }
- return
- }
-
- // 429: schedule retry per Retry-After, do NOT increment attempts.
- var ra *listenbrainz.RetryAfterError
- if errors.As(err, &ra) {
- next := pgtype.Timestamptz{Time: now.Add(ra.Wait), Valid: true}
- for _, id := range queueIDs {
- if uerr := q.MarkScrobbleRetryAfter(ctx, dbq.MarkScrobbleRetryAfterParams{
- ID: id, NextAttemptAt: next,
- }); uerr != nil {
- w.logger.Error("scrobble: MarkScrobbleRetryAfter", "err", uerr)
- }
- }
- return
- }
-
- // 401: mark failed AND disable user (defensive — bad token shouldn't keep
- // retrying or feed future rows).
- if errors.Is(err, listenbrainz.ErrAuth) {
- w.logger.Warn("scrobble: auth rejected; disabling user listenbrainz", "user_id", userID)
- if uerr := q.SetListenBrainzEnabled(ctx, dbq.SetListenBrainzEnabledParams{
- ID: userID, ListenbrainzEnabled: false,
- }); uerr != nil {
- w.logger.Error("scrobble: SetListenBrainzEnabled", "err", uerr)
- }
- errStr := err.Error()
- for _, id := range queueIDs {
- if uerr := q.MarkScrobbleFailed(ctx, dbq.MarkScrobbleFailedParams{
- ID: id, LastError: &errStr,
- }); uerr != nil {
- w.logger.Error("scrobble: MarkScrobbleFailed", "err", uerr)
- }
- }
- return
- }
-
- // 4xx: permanent. Mark failed, no retry.
- if errors.Is(err, listenbrainz.ErrPermanent) {
- errStr := err.Error()
- for _, id := range queueIDs {
- if uerr := q.MarkScrobbleFailed(ctx, dbq.MarkScrobbleFailedParams{
- ID: id, LastError: &errStr,
- }); uerr != nil {
- w.logger.Error("scrobble: MarkScrobbleFailed", "err", uerr)
- }
- }
- return
- }
-
- // Transient (5xx, network): increment attempts, schedule next or give up.
- // Each row in the batch shares the same fate since they were submitted
- // together — we update each independently using its current attempts+1.
- errStr := err.Error()
- for _, id := range queueIDs {
- // Read current attempts to compute next state.
- var attempts int32
- if rerr := w.pool.QueryRow(ctx,
- `SELECT attempts FROM scrobble_queue WHERE id = $1`, id).Scan(&attempts); rerr != nil {
- w.logger.Error("scrobble: read attempts", "err", rerr)
- continue
- }
- next := attempts + 1
- if delay, ok := backoffDelay(int(next)); ok {
- nextAt := pgtype.Timestamptz{Time: now.Add(delay), Valid: true}
- if uerr := q.RescheduleScrobble(ctx, dbq.RescheduleScrobbleParams{
- ID: id, NextAttemptAt: nextAt, LastError: &errStr,
- }); uerr != nil {
- w.logger.Error("scrobble: RescheduleScrobble", "err", uerr)
- }
- } else {
- if uerr := q.MarkScrobbleFailed(ctx, dbq.MarkScrobbleFailedParams{
- ID: id, LastError: &errStr,
- }); uerr != nil {
- w.logger.Error("scrobble: MarkScrobbleFailed", "err", uerr)
- }
- }
- }
-}
-```
-
-- [ ] **Step 4: Run all scrobble tests**
-
-Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test ./internal/scrobble/... -v`
-
-Expected: PASS for all worker tests + previously-passing tests in `listenbrainz/`, `threshold_test.go`, `queue_test.go`, `worker_test.go`.
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add internal/scrobble/worker.go internal/scrobble/worker_integration_test.go
-git commit -m "feat(scrobble): add Worker with tickOnce, batched submit, retry/fail/auth handling"
-```
-
----
-
-## Task 8: Hook MaybeEnqueue into playevents.Writer
-
-**Files:**
-- Modify: `internal/playevents/writer.go`
-- Modify: `internal/playevents/writer_test.go` (or a sibling file, depending on existing structure)
-
-- [ ] **Step 1: Write the failing test**
-
-Append to `internal/playevents/writer_test.go` (or create one if missing):
-
-```go
-func TestRecordPlayEnded_QualifyingPlay_EnqueuesScrobble(t *testing.T) {
- if testing.Short() {
- t.Skip("integration test")
- }
- dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
- if dsn == "" {
- t.Skip("MINSTREL_TEST_DATABASE_URL not set")
- }
- pool, q := newTestPool(t, dsn) // assume helper from existing writer_test.go
- defer pool.Close()
- logger := slog.New(slog.NewTextHandler(io.Discard, nil))
- w := NewWriter(pool, logger, 30*time.Minute, 0.5, 30000)
-
- user, track := seedUserAndTrack(t, q, "tester", 300_000) // helper, set duration=300s
-
- // Enable LB.
- tk := "tk"
- if err := q.SetListenBrainzToken(context.Background(), dbq.SetListenBrainzTokenParams{ID: user.ID, ListenbrainzToken: &tk}); err != nil {
- t.Fatalf("token: %v", err)
- }
- if err := q.SetListenBrainzEnabled(context.Background(), dbq.SetListenBrainzEnabledParams{ID: user.ID, ListenbrainzEnabled: true}); err != nil {
- t.Fatalf("enable: %v", err)
- }
-
- // Start + end with 250s played (qualifies).
- res, err := w.RecordPlayStarted(context.Background(), user.ID, track.ID, "test", time.Now().Add(-1*time.Minute))
- if err != nil {
- t.Fatalf("start: %v", err)
- }
- if err := w.RecordPlayEnded(context.Background(), res.PlayEventID, 250_000, time.Now()); err != nil {
- t.Fatalf("end: %v", err)
- }
-
- // Allow the post-commit goroutine (if any) to settle. Currently MaybeEnqueue
- // is called synchronously after BeginFunc returns, so a sleep isn't needed —
- // but if we ever switch to a goroutine, this gives it time.
- time.Sleep(50 * time.Millisecond)
-
- var n int
- if err := pool.QueryRow(context.Background(),
- `SELECT count(*) FROM scrobble_queue WHERE play_event_id = $1`, res.PlayEventID).Scan(&n); err != nil {
- t.Fatalf("count: %v", err)
- }
- if n != 1 {
- t.Errorf("scrobble_queue rows = %d, want 1", n)
- }
-}
-
-func TestRecordPlayEnded_SubThreshold_NoEnqueue(t *testing.T) {
- if testing.Short() {
- t.Skip("integration test")
- }
- dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
- if dsn == "" {
- t.Skip("MINSTREL_TEST_DATABASE_URL not set")
- }
- pool, q := newTestPool(t, dsn)
- defer pool.Close()
- logger := slog.New(slog.NewTextHandler(io.Discard, nil))
- w := NewWriter(pool, logger, 30*time.Minute, 0.5, 30000)
- user, track := seedUserAndTrack(t, q, "tester", 300_000)
-
- tk := "tk"
- _ = q.SetListenBrainzToken(context.Background(), dbq.SetListenBrainzTokenParams{ID: user.ID, ListenbrainzToken: &tk})
- _ = q.SetListenBrainzEnabled(context.Background(), dbq.SetListenBrainzEnabledParams{ID: user.ID, ListenbrainzEnabled: true})
-
- res, _ := w.RecordPlayStarted(context.Background(), user.ID, track.ID, "test", time.Now().Add(-1*time.Minute))
- _ = w.RecordPlayEnded(context.Background(), res.PlayEventID, 60_000, time.Now()) // 20%, well below threshold
-
- var n int
- _ = pool.QueryRow(context.Background(),
- `SELECT count(*) FROM scrobble_queue WHERE play_event_id = $1`, res.PlayEventID).Scan(&n)
- if n != 0 {
- t.Errorf("expected 0 queue rows for sub-threshold play, got %d", n)
- }
-}
-```
-
-If `seedUserAndTrack` and `newTestPool` aren't already in `writer_test.go`, look for the existing fixture functions (the existing tests must use something equivalent) and reuse those. If they're truly absent, write minimal versions following the pattern from `internal/scrobble/queue_test.go::seed`.
-
-- [ ] **Step 2: Run tests to verify they fail**
-
-Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test ./internal/playevents/ -run RecordPlayEnded.Scrobble -v`
-
-Expected: tests fail because no enqueue hook exists yet.
-
-- [ ] **Step 3: Add the post-commit enqueue hook**
-
-In `internal/playevents/writer.go`, modify `RecordPlayEnded` to add the post-commit `MaybeEnqueue` call:
-
-```go
-import (
- // existing imports …
- "git.fabledsword.com/bvandeusen/minstrel/internal/scrobble"
-)
-
-func (w *Writer) RecordPlayEnded(
- ctx context.Context,
- playEventID pgtype.UUID,
- durationPlayedMs int32,
- at time.Time,
-) error {
- if err := pgx.BeginFunc(ctx, w.pool, func(tx pgx.Tx) error {
- // … existing close logic …
- }); err != nil {
- return err
- }
-
- // Post-commit best-effort scrobble enqueue. Errors logged + swallowed:
- // scrobble delivery is enrichment, not a precondition for canonical play
- // history.
- if err := scrobble.MaybeEnqueue(ctx, dbq.New(w.pool), playEventID); err != nil {
- w.logger.Warn("playevents: scrobble enqueue failed", "play_event_id", playEventID, "err", err)
- }
- return nil
-}
-```
-
-Apply the **same pattern** to `RecordSyntheticCompletedPlay`: keep the existing `pgx.BeginFunc`, capture the inserted play_event ID, and after the commit call `scrobble.MaybeEnqueue(ctx, dbq.New(w.pool), evID)`. The existing function already creates and ends the play_event in one transaction; you'll need to surface the ID up out of the closure into a local variable before the post-commit call.
-
-The exact diff for `RecordSyntheticCompletedPlay`:
-
-```go
-func (w *Writer) RecordSyntheticCompletedPlay(
- ctx context.Context,
- userID, trackID pgtype.UUID,
- clientID string,
- at time.Time,
-) error {
- var playEventID pgtype.UUID
- if err := pgx.BeginFunc(ctx, w.pool, func(tx pgx.Tx) error {
- // … existing logic, but assign ev.ID to playEventID before returning …
- // e.g. after `ev, err := q.InsertPlayEvent(...)` succeeds:
- // playEventID = ev.ID
- }); err != nil {
- return err
- }
- if err := scrobble.MaybeEnqueue(ctx, dbq.New(w.pool), playEventID); err != nil {
- w.logger.Warn("playevents: scrobble enqueue failed", "play_event_id", playEventID, "err", err)
- }
- return nil
-}
-```
-
-(Inspect the current `RecordSyntheticCompletedPlay` body and add `playEventID = ev.ID` immediately after `q.InsertPlayEvent` returns. The function ends the play_event in the same transaction; a single playEventID variable captured pre-commit is correct.)
-
-- [ ] **Step 4: Run tests to verify they pass**
-
-Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test ./internal/playevents/ -v`
-
-Expected: PASS for all existing tests + 2 new scrobble-enqueue tests.
-
-- [ ] **Step 5: Verify Subsonic scrobble path also passes**
-
-Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test ./internal/subsonic/ -v`
-
-Expected: PASS. Pre-existing scrobble integration tests still pass; the post-commit enqueue is a no-op for users without LB enabled.
-
-- [ ] **Step 6: Commit**
-
-```bash
-git add internal/playevents/writer.go internal/playevents/writer_test.go
-git commit -m "feat(playevents): post-commit MaybeEnqueue in RecordPlayEnded + Synthetic"
-```
-
----
-
-## Task 9: API endpoints — GET/PUT /api/me/listenbrainz
-
-**Files:**
-- Create: `internal/api/listenbrainz.go`
-- Create: `internal/api/listenbrainz_test.go`
-- Modify: `internal/api/api.go::Mount`
-
-- [ ] **Step 1: Write the failing tests**
-
-Create `internal/api/listenbrainz_test.go`:
-
-```go
-package api
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "net/http"
- "net/http/httptest"
- "testing"
-
- "git.fabledsword.com/bvandeusen/minstrel/internal/auth"
- "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
-)
-
-type lbStatusJSON struct {
- Enabled bool `json:"enabled"`
- TokenSet bool `json:"token_set"`
- LastScrobbledAt *string `json:"last_scrobbled_at"`
-}
-
-func callGetLB(h *handlers, user dbq.User) *httptest.ResponseRecorder {
- req := httptest.NewRequest(http.MethodGet, "/api/me/listenbrainz", nil)
- req = req.WithContext(auth.WithUser(req.Context(), user))
- w := httptest.NewRecorder()
- h.handleGetListenBrainz(w, req)
- return w
-}
-
-func callPutLB(h *handlers, user dbq.User, body any) *httptest.ResponseRecorder {
- buf, _ := json.Marshal(body)
- req := httptest.NewRequest(http.MethodPut, "/api/me/listenbrainz", bytes.NewReader(buf))
- req.Header.Set("Content-Type", "application/json")
- req = req.WithContext(auth.WithUser(req.Context(), user))
- w := httptest.NewRecorder()
- h.handlePutListenBrainz(w, req)
- return w
-}
-
-func TestHandleGetListenBrainz_NoTokenInitial(t *testing.T) {
- h, pool := testHandlers(t)
- truncateLibrary(t, pool)
- u := seedUser(t, pool, "alice", "x", false)
- w := callGetLB(h, u)
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d", w.Code)
- }
- var resp lbStatusJSON
- _ = json.Unmarshal(w.Body.Bytes(), &resp)
- if resp.Enabled || resp.TokenSet || resp.LastScrobbledAt != nil {
- t.Errorf("initial state wrong: %+v", resp)
- }
-}
-
-func TestHandlePutListenBrainz_SetTokenThenGet(t *testing.T) {
- h, pool := testHandlers(t)
- truncateLibrary(t, pool)
- u := seedUser(t, pool, "alice", "x", false)
-
- put := callPutLB(h, u, map[string]any{"token": "abc"})
- if put.Code != http.StatusOK {
- t.Fatalf("PUT status = %d body = %s", put.Code, put.Body.String())
- }
-
- get := callGetLB(h, u)
- var resp lbStatusJSON
- _ = json.Unmarshal(get.Body.Bytes(), &resp)
- if !resp.TokenSet {
- t.Error("TokenSet = false after PUT, want true")
- }
-}
-
-func TestHandlePutListenBrainz_ClearTokenForcesEnabledFalse(t *testing.T) {
- h, pool := testHandlers(t)
- truncateLibrary(t, pool)
- u := seedUser(t, pool, "alice", "x", false)
-
- _ = callPutLB(h, u, map[string]any{"token": "abc"})
- _ = callPutLB(h, u, map[string]any{"enabled": true})
- clear := callPutLB(h, u, map[string]any{"token": ""})
- if clear.Code != http.StatusOK {
- t.Fatalf("clear status = %d", clear.Code)
- }
-
- get := callGetLB(h, u)
- var resp lbStatusJSON
- _ = json.Unmarshal(get.Body.Bytes(), &resp)
- if resp.TokenSet {
- t.Error("TokenSet = true after clearing")
- }
- if resp.Enabled {
- t.Error("Enabled = true after clearing token, want false (force-cleared)")
- }
-}
-
-func TestHandlePutListenBrainz_EnableWithoutToken_400(t *testing.T) {
- h, pool := testHandlers(t)
- truncateLibrary(t, pool)
- u := seedUser(t, pool, "alice", "x", false)
- resp := callPutLB(h, u, map[string]any{"enabled": true})
- if resp.Code != http.StatusBadRequest {
- t.Errorf("status = %d, want 400 (cannot enable without token)", resp.Code)
- }
-}
-```
-
-- [ ] **Step 2: Run tests to verify they fail**
-
-Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test ./internal/api/ -run ListenBrainz -v`
-
-Expected: FAIL — handlers undefined.
-
-- [ ] **Step 3: Implement the handlers**
-
-Create `internal/api/listenbrainz.go`:
-
-```go
-package api
-
-import (
- "encoding/json"
- "errors"
- "net/http"
-
- "github.com/jackc/pgx/v5"
-
- "git.fabledsword.com/bvandeusen/minstrel/internal/auth"
- "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
-)
-
-// LBStatus is the GET response body and a subset of the PUT response.
-type LBStatus struct {
- Enabled bool `json:"enabled"`
- TokenSet bool `json:"token_set"`
- LastScrobbledAt *string `json:"last_scrobbled_at"`
-}
-
-func (h *handlers) handleGetListenBrainz(w http.ResponseWriter, r *http.Request) {
- user, ok := auth.UserFromContext(r.Context())
- if !ok {
- writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required")
- return
- }
- resp, err := buildLBStatus(r, h, user.ID)
- if err != nil {
- h.logger.Error("api: get listenbrainz", "err", err)
- writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
- return
- }
- writeJSON(w, http.StatusOK, resp)
-}
-
-type putLBBody struct {
- Token *string `json:"token,omitempty"`
- Enabled *bool `json:"enabled,omitempty"`
-}
-
-func (h *handlers) handlePutListenBrainz(w http.ResponseWriter, r *http.Request) {
- user, ok := auth.UserFromContext(r.Context())
- if !ok {
- writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required")
- return
- }
- var body putLBBody
- if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
- writeErr(w, http.StatusBadRequest, "bad_request", "invalid json")
- return
- }
-
- q := dbq.New(h.pool)
- if body.Token != nil {
- t := *body.Token
- var tokenPtr *string
- if t != "" {
- tokenPtr = &t
- } // empty string clears via SetListenBrainzToken's COALESCE handling
- if err := q.SetListenBrainzToken(r.Context(), dbq.SetListenBrainzTokenParams{
- ID: user.ID, ListenbrainzToken: tokenPtr,
- }); err != nil {
- h.logger.Error("api: set listenbrainz token", "err", err)
- writeErr(w, http.StatusInternalServerError, "server_error", "update failed")
- return
- }
- }
- if body.Enabled != nil && *body.Enabled {
- // Enabling requires a non-empty token.
- cfg, err := q.GetListenBrainzConfig(r.Context(), user.ID)
- if err != nil {
- h.logger.Error("api: get listenbrainz config", "err", err)
- writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
- return
- }
- if cfg.ListenbrainzToken == nil || *cfg.ListenbrainzToken == "" {
- writeErr(w, http.StatusBadRequest, "bad_request", "cannot enable without a token")
- return
- }
- }
- if body.Enabled != nil {
- if err := q.SetListenBrainzEnabled(r.Context(), dbq.SetListenBrainzEnabledParams{
- ID: user.ID, ListenbrainzEnabled: *body.Enabled,
- }); err != nil {
- h.logger.Error("api: set listenbrainz enabled", "err", err)
- writeErr(w, http.StatusInternalServerError, "server_error", "update failed")
- return
- }
- }
-
- resp, err := buildLBStatus(r, h, user.ID)
- if err != nil {
- h.logger.Error("api: put listenbrainz refresh status", "err", err)
- writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
- return
- }
- writeJSON(w, http.StatusOK, resp)
-}
-
-func buildLBStatus(r *http.Request, h *handlers, userID pgtypeUUID) (LBStatus, error) {
- q := dbq.New(h.pool)
- cfg, err := q.GetListenBrainzConfig(r.Context(), userID)
- if err != nil {
- if errors.Is(err, pgx.ErrNoRows) {
- return LBStatus{}, nil
- }
- return LBStatus{}, err
- }
- out := LBStatus{
- Enabled: cfg.ListenbrainzEnabled,
- TokenSet: cfg.ListenbrainzToken != nil && *cfg.ListenbrainzToken != "",
- }
- if cfg.LastScrobbledAt.Valid {
- s := cfg.LastScrobbledAt.Time.UTC().Format(time.RFC3339)
- out.LastScrobbledAt = &s
- }
- return out, nil
-}
-```
-
-Note: `pgtypeUUID` is shorthand here — replace with the actual `pgtype.UUID` import. Add `"github.com/jackc/pgx/v5/pgtype"` and `"time"` to the imports. Update the function signature: `func buildLBStatus(r *http.Request, h *handlers, userID pgtype.UUID) (LBStatus, error)`.
-
-- [ ] **Step 4: Mount the routes**
-
-In `internal/api/api.go::Mount`, add inside the `authed.Group(...)` block (after the existing `/me` route):
-
-```go
-authed.Get("/me/listenbrainz", h.handleGetListenBrainz)
-authed.Put("/me/listenbrainz", h.handlePutListenBrainz)
-```
-
-- [ ] **Step 5: Run tests to verify they pass**
-
-Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' go test ./internal/api/ -run ListenBrainz -v`
-
-Expected: PASS for all 4 tests.
-
-- [ ] **Step 6: Commit**
-
-```bash
-git add internal/api/listenbrainz.go internal/api/listenbrainz_test.go internal/api/api.go
-git commit -m "feat(api): add GET/PUT /api/me/listenbrainz endpoints"
-```
-
----
-
-## Task 10: Boot the worker in main.go
-
-**Files:**
-- Modify: `cmd/minstrel/main.go`
-
-- [ ] **Step 1: Wire the worker startup**
-
-In `cmd/minstrel/main.go`, add the imports:
-
-```go
-"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble"
-"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz"
-```
-
-After the `pool` is opened (after `pool, err := db.Open(...)` and the `defer pool.Close()`), and before the HTTP server starts, add:
-
-```go
-// Start the ListenBrainz scrobble worker. Per spec §M4a, runs every 30s,
-// drains up to 50 pending rows per tick. Per-user gating happens inside the
-// worker (rows from disabled users are skipped).
-scrobbleWorker := scrobble.NewWorker(pool, listenbrainz.NewClient(), logger.With("component", "scrobble"))
-go scrobbleWorker.Run(ctx)
-```
-
-- [ ] **Step 2: Verify compile**
-
-Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && go build ./...`
-
-Expected: clean.
-
-- [ ] **Step 3: Verify the worker actually starts**
-
-Run the binary briefly and check the logs for the scrobble worker:
-
-Run: `cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel && SMARTMUSIC_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' SMARTMUSIC_LIBRARY_SCAN_PATHS='' SMARTMUSIC_LIBRARY_SCAN_ON_STARTUP=false timeout 5 go run ./cmd/minstrel/ 2>&1 | head -20`
-
-Expected: server starts, no panic, worker tick logs (or absence of error logs) demonstrate Run is alive.
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add cmd/minstrel/main.go
-git commit -m "feat(cmd): start scrobble worker alongside HTTP server"
-```
-
----
-
-## Task 11: Frontend — apiPut + listenbrainz API helpers + /settings page
-
-**Files:**
-- Modify: `web/src/lib/api/client.ts`
-- Create: `web/src/lib/api/listenbrainz.ts`
-- Create: `web/src/routes/settings/+page.svelte`
-- Create: `web/src/routes/settings/settings.test.ts`
-- Modify: `web/src/lib/components/Shell.svelte`
-
-- [ ] **Step 1: Add `api.put` helper**
-
-In `web/src/lib/api/client.ts`, add a `put` method to the `api` object. The existing object looks like:
-
-```ts
-export const api = {
- get: (path: string): Promise => apiFetch(path) as Promise,
- post: (path: string, body: unknown): Promise =>
- apiFetch(path, { method: 'POST', body: JSON.stringify(body) }) as Promise,
- del: (path: string): Promise =>
- apiFetch(path, { method: 'DELETE' }) as Promise
-};
-```
-
-Replace with:
-
-```ts
-export const api = {
- get: (path: string): Promise => apiFetch(path) as Promise,
- post: (path: string, body: unknown): Promise =>
- apiFetch(path, { method: 'POST', body: JSON.stringify(body) }) as Promise,
- put: (path: string, body: unknown): Promise =>
- apiFetch(path, { method: 'PUT', body: JSON.stringify(body) }) as Promise,
- del: (path: string): Promise =>
- apiFetch(path, { method: 'DELETE' }) as Promise
-};
-```
-
-- [ ] **Step 2: Add ListenBrainz API helpers**
-
-Create `web/src/lib/api/listenbrainz.ts`:
-
-```ts
-import { api } from './client';
-
-export type LBStatus = {
- enabled: boolean;
- token_set: boolean;
- last_scrobbled_at: string | null;
-};
-
-export function getListenBrainzStatus(): Promise {
- return api.get('/api/me/listenbrainz');
-}
-
-export function setListenBrainzToken(token: string): Promise {
- return api.put('/api/me/listenbrainz', { token });
-}
-
-export function setListenBrainzEnabled(enabled: boolean): Promise {
- return api.put('/api/me/listenbrainz', { enabled });
-}
-```
-
-- [ ] **Step 3: Create the Settings page**
-
-Create `web/src/routes/settings/+page.svelte`:
-
-```svelte
-
-
-
-
Settings
-
-
- ListenBrainz
-
- {#if $status.isPending}
- Loading…
- {:else if $status.isError}
- Couldn't load status.
- {:else if $status.data}
-
-