diff --git a/docs/superpowers/plans/2026-04-27-m3-vectors.md b/docs/superpowers/plans/2026-04-27-m3-vectors.md new file mode 100644 index 00000000..a84b2ae7 --- /dev/null +++ b/docs/superpowers/plans/2026-04-27-m3-vectors.md @@ -0,0 +1,1491 @@ +# 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/specs/2026-04-27-m3-vectors-design.md b/docs/superpowers/specs/2026-04-27-m3-vectors-design.md new file mode 100644 index 00000000..928ab79c --- /dev/null +++ b/docs/superpowers/specs/2026-04-27-m3-vectors-design.md @@ -0,0 +1,251 @@ +# M3 Session Vectors + Contextual Likes — Design Spec + +**Status:** approved 2026-04-27 +**Slice:** M3 sub-plan #2 of 3 (recommendation engine v1 + contextual likes). Spec §6 "Session vector" + §13 step 8. +**Fable task:** #341. + +## Goal + +Add the two write paths that produce the data the recommendation engine consumes for contextual matching: + +1. **At every play_started**, compute a session vector from the prior tracks in the user's current play_session and persist it to `play_events.session_vector_at_play` (column already exists nullable since migration 0005). +2. **At every like**, if the user has an open play_event with a populated session_vector, snapshot it into a new `contextual_likes` row. + +This slice is backend-only — no UI surface. It accumulates the data; sub-plan #3 (Fable #342) reads it to add the `contextual_match_score` term to the scoring formula. + +## Non-goals + +- Adding `contextual_match_score` to the scoring function — that's sub-plan #3. +- Album/artist contextual likes. `contextual_likes` is track-only per spec §5. +- MBID-derived tags. v1 uses `tracks.genre` (denormalized text from migration 0002) as the only tag source. MBID tags are a post-v1 enrichment. +- Audio-feature tags (BPM, energy, key). Require an analysis pipeline we don't have. Post-v1. +- Cross-session vector enrichment. Each session's vector only sees prior tracks within the same play_session row. +- A purge/expiry policy on `contextual_likes`. Rows accumulate indefinitely. v1 problem only when a user has tens of thousands of likes; not a concern at v1 scale. + +## Important catch from exploration + +The `contextual_likes` table does NOT exist yet. The M2 events migration (0005) commented that "contextual_likes ships nullable now," but the actual `CREATE TABLE` was missed. This slice ships the table in a new migration (`0007`). + +## Architecture + +A new pure function `BuildSessionVector(priorTracks) → SessionVector` in `internal/recommendation/sessionvector.go`. The vector struct serializes to JSONB with the spec §6 shape: `seed` flag, `artists` set, `tags` bag-of-counts, `recent_track_ids` ordered list. + +`internal/playevents.Writer.RecordPlayStarted` extends inside its existing transaction: +1. Auto-close prior open row (existing). +2. FindOrCreate session (existing). +3. Insert play_event (existing). +4. **NEW:** Query the prior tracks in the session via a new sqlc query `ListRecentSessionTracks(sessionID, beforeTime, limit)`. +5. **NEW:** Build the vector via the pure function. +6. **NEW:** UPDATE the just-inserted play_event's `session_vector_at_play` column (new sqlc query `UpdatePlayEventVector`). + +`internal/api/likes.handleLikeTrack` extends: +1. `LikeTrack` upsert (existing) — but the sqlc query becomes `:execrows` so the handler can detect "actually inserted" vs "already exists." +2. **NEW:** if rows == 1, look up the user's open play_event. If present and its `session_vector_at_play` is non-NULL, INSERT into `contextual_likes` with the vector + session_id snapshot. + +`internal/api/likes.handleUnlikeTrack` extends: +1. `UnlikeTrack` (existing). +2. **NEW:** UPDATE all the user's `contextual_likes` rows for this track to set `deleted_at = now() WHERE deleted_at IS NULL`. Idempotent. + +Subsonic `/rest/star` and `/rest/unstar` already call into `dbq.LikeTrack` / `dbq.UnlikeTrack` through `internal/subsonic/star.go`. After this slice, those calls naturally pick up the contextual capture / soft-delete behavior — no code changes in `internal/subsonic` beyond a verification test. + +## Schema (migration `0007_contextual_likes.up.sql`) + +```sql +-- contextual_likes captures the per-session-context snapshot at the time +-- of each like. The recommendation engine in M3 sub-plan #3 uses these +-- 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 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). + +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 +); + +-- Partial index: the engine queries only active rows; this is the hot path. +CREATE INDEX contextual_likes_active_idx + ON contextual_likes (user_id, track_id) + WHERE deleted_at IS NULL; + +-- GIN index for vector similarity queries (used by sub-plan #3's +-- contextual_match_score lookup). Even though M3 sub-plan #3 doesn't yet +-- exist, this slice adds the index because the recommendation engine +-- will need it; better to migrate once. +CREATE INDEX contextual_likes_vector_idx + ON contextual_likes USING gin (session_vector); + +-- Lookup support for the soft-delete update (UPDATE ... WHERE user_id = $1 +-- AND track_id = $2 AND deleted_at IS NULL). Already covered by the +-- partial index above — no separate index needed. +``` + +`down.sql`: + +```sql +DROP TABLE IF EXISTS contextual_likes; +``` + +## Session vector shape + +```go +type SessionVector struct { + Seed bool `json:"seed"` + Artists []string `json:"artists"` // distinct, ordered by first appearance + Tags map[string]int `json:"tags"` // bag-of-counts + RecentTrackIDs []string `json:"recent_track_ids"` // newest last +} + +func BuildSessionVector(priorTracks []dbq.Track) SessionVector +``` + +Rules: +- `Seed = len(priorTracks) < 3`. The engine in #342 filters seed vectors out of the contextual-match query (they don't represent enough context to match against). +- `Artists` is the deduplicated list of `priorTracks[i].ArtistID` values, formatted as UUID strings. Order is "first appearance" (older tracks first). +- `Tags` is a `map[string]int` counting how many tracks have each genre. `priorTracks[i].Genre == nil || == ""` means no contribution. Tags are case-sensitive (we don't normalize — `"Rock"` and `"rock"` are different bins; future cleanup can add normalization). +- `RecentTrackIDs` is the ordered list, newest last (matches the spec's "ordered list of the N track ids"). +- The function does NOT truncate. The caller passes in at most N tracks; the function works with whatever it's given (extra coverage flexibility). + +## API contracts + +No HTTP shape changes. `POST /api/events`, `POST/DELETE /api/likes/...`, and Subsonic `/rest/star`, `/rest/unstar` all keep their current request/response contracts. The new behavior is internal data-write side effects. + +## Components & files + +### New server files + +| Path | Responsibility | +|---|---| +| `internal/db/migrations/0007_contextual_likes.up.sql` + `.down.sql` | Schema for `contextual_likes` + partial index + GIN index. | +| `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)` pure function. | +| `internal/recommendation/sessionvector_test.go` | Unit tests for the pure function (boundary cases for the seed flag, dedup, tag counts, ordering, JSON round-trip). | + +### Modified server files + +| Path | Change | +|---|---| +| `internal/db/queries/events.sql` | Add `ListRecentSessionTracks(sessionID, beforeTime, limit) :many` (joins play_events ↔ tracks, orders by started_at DESC). Add `UpdatePlayEventVector(id, vector) :exec`. | +| `internal/db/queries/likes.sql` | Change `LikeTrack` from `:exec` to `:execrows` so the handler detects insert vs already-exists. | +| `internal/playevents/writer.go::RecordPlayStarted` | After InsertPlayEvent: ListRecentSessionTracks → BuildSessionVector → UpdatePlayEventVector. All inside the existing transaction. | +| `internal/playevents/writer.go::RecordSyntheticCompletedPlay` | Same vector computation as RecordPlayStarted (Subsonic synthetic plays should also carry context). Single source of truth via a private helper. | +| `internal/playevents/writer_test.go` | Three new tests covering vector persistence (seed flag, populated vector, session-scope isolation). | +| `internal/api/likes.go::handleLikeTrack` | Capture `:execrows` result; if 1, GetOpenPlayEventForUser; if open + non-null vector, InsertContextualLike. | +| `internal/api/likes.go::handleUnlikeTrack` | After UnlikeTrack, SoftDeleteContextualLikesForUserTrack. | +| `internal/api/likes_test.go` | Five new tests (capture during open play, no-op without play, no duplicate on idempotent like, soft-delete on unlike, history accumulation across like/unlike/like). | +| `internal/subsonic/star_test.go` | One new test confirming Subsonic star captures contextual_likes when a now-playing event is open. | + +### No web changes + +The web client already calls `/api/events` and `/api/likes/...`. After this slice, behavior is identical from the SPA's perspective; the side-effects are server-side only. + +## Data flow + +**Play started:** + +1. SPA calls `POST /api/events` with `type: 'play_started', track_id`. +2. `handleEventPlayStarted` → `events.Writer.RecordPlayStarted(ctx, userID, trackID, clientID, at)`. +3. Inside the transaction: + - Auto-close any prior open row (existing). + - `playsessions.FindOrCreate(...)` → `sessionID` (existing). + - `q.InsertPlayEvent(...)` → `playEventID` (existing). + - **NEW:** `priorTracks := q.ListRecentSessionTracks(ctx, sessionID, at, 5)`. Returns up to 5 tracks where `started_at < at` (excluding the just-inserted row). + - **NEW:** `vec := recommendation.BuildSessionVector(priorTracks)`. + - **NEW:** `vecJSON, _ := json.Marshal(vec)`. + - **NEW:** `q.UpdatePlayEventVector(ctx, UpdatePlayEventVectorParams{ID: playEventID, SessionVectorAtPlay: vecJSON})`. +4. Returns `StartedResult{PlayEventID, SessionID}`. + +**Subsonic synthetic completed play:** `RecordSyntheticCompletedPlay` follows the same vector-computation path so Subsonic-driven plays carry context too. + +**Like a track:** + +1. `POST /api/likes/tracks/:id` (or Subsonic `/rest/star?id=X`). +2. `handleLikeTrack` validates auth + UUID + entity exists. +3. `q.LikeTrack(...)` returns rows-affected count (sqlc `:execrows`). +4. **If rows == 0** (already liked): return 204. No contextual capture. +5. **If rows == 1** (freshly inserted): + - `event, err := q.GetOpenPlayEventForUser(ctx, userID)`. + - If `errors.Is(err, pgx.ErrNoRows)` → return 204 (no open event, no contextual capture). + - If `event.SessionVectorAtPlay` is NULL → return 204 (event opened before this slice landed; no vector). + - Else: `q.InsertContextualLike(ctx, InsertContextualLikeParams{UserID, TrackID, SessionVector: event.SessionVectorAtPlay, SessionID: &event.SessionID})`. +6. Return 204. + +**Unlike a track:** + +1. `DELETE /api/likes/tracks/:id` (or Subsonic `/rest/unstar?id=X`). +2. `handleUnlikeTrack` validates UUID. +3. `q.UnlikeTrack(...)` (existing). +4. **NEW:** `q.SoftDeleteContextualLikesForUserTrack(ctx, userID, trackID)` — `UPDATE contextual_likes SET deleted_at = now() WHERE user_id = $1 AND track_id = $2 AND deleted_at IS NULL`. +5. Return 204. + +**Edge cases:** + +- **Like with no open play_event.** No contextual capture; only `general_likes`. Same outcome as today. +- **Like during cold-start session.** Vector has `seed: true`. Row is still inserted (the seed flag is informational; the engine in #342 filters on it). +- **Re-like of an already-liked track in the same session.** `LikeTrack` returns 0 affected rows; contextual capture skipped. No spam. +- **Re-like after unlike, in the same session.** Unlike soft-deleted prior rows. New like inserts a fresh row. The (user, track) pair has both rows in the table; only the fresh one is `deleted_at IS NULL`. +- **Unlike of a never-liked track.** `UnlikeTrack` is a no-op. `SoftDeleteContextualLikesForUserTrack` runs but updates 0 rows. Both safe. +- **Album / artist likes.** `contextual_likes` is track-only. Album/artist like handlers are untouched in this slice. +- **Subsonic `star?albumId=&artistId=`.** Album/artist branches don't trigger contextual capture (track-only). The track-id branch picks it up via the shared `dbq.LikeTrack` call. + +## Testing + +### Server (`go test`) + +**`internal/recommendation/sessionvector_test.go`** (pure unit tests): +- Empty input → seed=true, all collections empty. +- 1 track → seed=true, artists has 1 entry, tags has 1 entry (count=1), recent_track_ids has 1 entry. +- 2 tracks → seed=true. +- 3 tracks → seed=false. Single-artist case: artists deduplicated to 1 entry. Multi-genre case: tags accumulates counts. +- 5 tracks across 3 artists, mixed genres → all populated, dedup correct, counts correct. +- Track with empty/nil genre → tag entry not added. +- 10 tracks input → function processes all 10 (caller controls truncation; document the contract). +- JSON round-trip via `encoding/json`: marshal, unmarshal, fields equal. + +**`internal/playevents/writer_test.go`** (live-DB integration, three new): +- `TestRecordPlayStarted_PersistsSessionVector_Seed`: first play in fresh session → row's `session_vector_at_play.seed == true`, empty arrays. +- `TestRecordPlayStarted_PersistsSessionVector_Populated`: after 4 plays, the 5th's vector has `seed: false` and the 4 prior tracks' artists/tags/ids. +- `TestRecordPlayStarted_VectorScopedToSession`: open a play_event, advance > 30 minutes, start another (new session) — second play's vector is `seed: true` with nothing from the prior session. + +**`internal/api/likes_test.go`** (live-DB integration, five new): +- `TestLikeTrack_DuringOpenPlayEvent_WritesContextualLike`: user starts playing track A (creates play_event with non-null vector); likes track B; assert `contextual_likes` has one row for (user, B) with the matching `session_vector` and `session_id`. +- `TestLikeTrack_NoOpenPlayEvent_NoContextualLike`: user likes a track without playing — `general_likes` has the row, `contextual_likes` is empty. +- `TestLikeTrack_RepeatedLike_NoDuplicate`: like the same track twice — first call writes both rows; second call no-ops (LikeTrack returns 0 rows; contextual capture skipped). +- `TestUnlikeTrack_SoftDeletesContextualLikes`: like → unlike. `general_likes` row gone, `contextual_likes` row's `deleted_at` is set, row count unchanged. +- `TestLikeUnlikeRelike_HistoryAccumulates`: like → unlike → like. Two `contextual_likes` rows for that (user, track): one with `deleted_at` set, one without. + +**`internal/subsonic/star_test.go`** (live-DB, one new): +- `TestHandleStar_DuringNowPlaying_WritesContextualLike`: send `submission=false` for track A (creates open play_event with vector), then `star?id=B` — `contextual_likes` row exists for (user, B). + +**Migration smoke**: existing `internal/db` test runs the new 0007 migration; verify no errors. + +### End-to-end manual + +Final task in the implementation plan: + +1. Sign in. 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 are 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` (different artists/tags from the new session). +7. From Feishin: send a now-playing for track A, then star track B. Confirm contextual capture worked through the Subsonic path. + +## Risks & mitigations + +- **Vector size bloat**: each play_event row carries up to 5 artist UUIDs + a tag map + 5 track UUIDs. ~500 bytes per play_event in JSON. For a user with 50,000 plays, that's 25 MB of JSONB. Acceptable; PostgreSQL handles it. If telemetry shows the column dominates row width, future work can extract to a separate table or hash older vectors. +- **Tag normalization**: `tracks.genre` is denormalized free-text. `"Rock"` and `"rock"` end up as different bins. v1 doesn't normalize. Mitigation: documented; M3.5/M4 cleanup can `lower()` consistently; the recommendation engine in #342 can do its similarity calculation on lowercased tags as a workaround. +- **Eager column read**: extending `RecordPlayStarted` adds 1 SELECT (recent tracks) + 1 UPDATE (vector) per play. Single-digit ms overhead at v1 scale. Reasonable cost for the data we get. +- **NULL vector after this slice ships**: existing play_events from before the migration have NULL `session_vector_at_play`. The like-handler's contextual-capture path skips when the vector is NULL — those old plays simply don't generate contextual likes. No backfill; new plays going forward populate the vector. +- **Subsonic synthetic plays carry vector**: synthetic plays from `submission=true` use the same vector logic. Subsonic clients that scrobble in bulk after-the-fact (e.g. submit 10 plays in 5 seconds) generate vectors quickly across each new session/track — vectors may show artificial 5-track sliding windows during the catch-up. Acceptable for v1; treats Subsonic clients as first-class citizens of the recommendation pipeline. +- **`contextual_likes` table forgotten in 0005**: this slice adds it. Existing migration history is unchanged; the new 0007 migration brings the schema up to spec. +- **Soft-delete query without explicit unique constraint**: `SoftDeleteContextualLikesForUserTrack` updates ALL active rows for (user, track), which may be more than one if a user liked the same track in multiple sessions. That's the correct behavior — unliking means "all my historical likes for this track no longer count." Documented. diff --git a/internal/api/likes.go b/internal/api/likes.go index 3cb1c698..98470203 100644 --- a/internal/api/likes.go +++ b/internal/api/likes.go @@ -11,6 +11,7 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/auth" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/playevents" ) type likedIDsResponse struct { @@ -40,11 +41,15 @@ func (h *handlers) handleLikeTrack(w http.ResponseWriter, r *http.Request) { writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") return } - if err := q.LikeTrack(r.Context(), dbq.LikeTrackParams{UserID: user.ID, TrackID: id}); err != nil { + 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) } @@ -59,11 +64,16 @@ func (h *handlers) handleUnlikeTrack(w http.ResponseWriter, r *http.Request) { writeErr(w, http.StatusBadRequest, "bad_request", "invalid track id") return } - if err := dbq.New(h.pool).UnlikeTrack(r.Context(), dbq.UnlikeTrackParams{UserID: user.ID, TrackID: id}); err != nil { + 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) } diff --git a/internal/api/likes_test.go b/internal/api/likes_test.go index e97a6a08..c47e9a63 100644 --- a/internal/api/likes_test.go +++ b/internal/api/likes_test.go @@ -199,3 +199,134 @@ func TestLikeAlbumAndArtist_HappyPath(t *testing.T) { t.Errorf("album total = %d", resp.Total) } } + +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) + } +} diff --git a/internal/db/dbq/contextual_likes.sql.go b/internal/db/dbq/contextual_likes.sql.go new file mode 100644 index 00000000..7543cc6e --- /dev/null +++ b/internal/db/dbq/contextual_likes.sql.go @@ -0,0 +1,52 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: contextual_likes.sql + +package dbq + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const insertContextualLike = `-- name: InsertContextualLike :exec +INSERT INTO contextual_likes (user_id, track_id, session_vector, session_id) +VALUES ($1, $2, $3, $4) +` + +type InsertContextualLikeParams struct { + UserID pgtype.UUID + TrackID pgtype.UUID + SessionVector []byte + SessionID pgtype.UUID +} + +func (q *Queries) InsertContextualLike(ctx context.Context, arg InsertContextualLikeParams) error { + _, err := q.db.Exec(ctx, insertContextualLike, + arg.UserID, + arg.TrackID, + arg.SessionVector, + arg.SessionID, + ) + return err +} + +const softDeleteContextualLikesForUserTrack = `-- name: SoftDeleteContextualLikesForUserTrack :exec +UPDATE contextual_likes +SET deleted_at = now() +WHERE user_id = $1 AND track_id = $2 AND deleted_at IS NULL +` + +type SoftDeleteContextualLikesForUserTrackParams struct { + UserID pgtype.UUID + TrackID pgtype.UUID +} + +// Marks all currently-active rows for (user, track) as deleted. Idempotent — +// already-deleted rows aren't re-touched. +func (q *Queries) SoftDeleteContextualLikesForUserTrack(ctx context.Context, arg SoftDeleteContextualLikesForUserTrackParams) error { + _, err := q.db.Exec(ctx, softDeleteContextualLikesForUserTrack, arg.UserID, arg.TrackID) + return err +} diff --git a/internal/db/dbq/events.sql.go b/internal/db/dbq/events.sql.go index 15f94543..44183e7d 100644 --- a/internal/db/dbq/events.sql.go +++ b/internal/db/dbq/events.sql.go @@ -188,6 +188,60 @@ func (q *Queries) InsertSkipEvent(ctx context.Context, arg InsertSkipEventParams return i, err } +const listRecentSessionTracks = `-- name: ListRecentSessionTracks :many +SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at 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 +` + +type ListRecentSessionTracksParams struct { + SessionID pgtype.UUID + StartedAt pgtype.Timestamptz + Limit int32 +} + +// 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. +func (q *Queries) ListRecentSessionTracks(ctx context.Context, arg ListRecentSessionTracksParams) ([]Track, error) { + rows, err := q.db.Query(ctx, listRecentSessionTracks, arg.SessionID, arg.StartedAt, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Track + for rows.Next() { + var i Track + if err := rows.Scan( + &i.ID, + &i.Title, + &i.AlbumID, + &i.ArtistID, + &i.TrackNumber, + &i.DiscNumber, + &i.DurationMs, + &i.FilePath, + &i.FileSize, + &i.FileFormat, + &i.Bitrate, + &i.Mbid, + &i.Genre, + &i.AddedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const touchPlaySessionLastEvent = `-- name: TouchPlaySessionLastEvent :exec UPDATE play_sessions SET last_event_at = $2, @@ -251,3 +305,21 @@ func (q *Queries) UpdatePlayEventEnded(ctx context.Context, arg UpdatePlayEventE ) return i, err } + +const updatePlayEventVector = `-- name: UpdatePlayEventVector :exec +UPDATE play_events +SET session_vector_at_play = $2 +WHERE id = $1 +` + +type UpdatePlayEventVectorParams struct { + ID pgtype.UUID + SessionVectorAtPlay []byte +} + +// Used right after InsertPlayEvent to populate session_vector_at_play +// once the vector has been computed. +func (q *Queries) UpdatePlayEventVector(ctx context.Context, arg UpdatePlayEventVectorParams) error { + _, err := q.db.Exec(ctx, updatePlayEventVector, arg.ID, arg.SessionVectorAtPlay) + return err +} diff --git a/internal/db/dbq/likes.sql.go b/internal/db/dbq/likes.sql.go index 3ded9371..5510829b 100644 --- a/internal/db/dbq/likes.sql.go +++ b/internal/db/dbq/likes.sql.go @@ -76,7 +76,7 @@ func (q *Queries) LikeArtist(ctx context.Context, arg LikeArtistParams) error { return err } -const likeTrack = `-- name: LikeTrack :exec +const likeTrack = `-- name: LikeTrack :execrows INSERT INTO general_likes (user_id, track_id) VALUES ($1, $2) ON CONFLICT (user_id, track_id) DO NOTHING @@ -87,9 +87,12 @@ type LikeTrackParams struct { TrackID pgtype.UUID } -func (q *Queries) LikeTrack(ctx context.Context, arg LikeTrackParams) error { - _, err := q.db.Exec(ctx, likeTrack, arg.UserID, arg.TrackID) - return err +func (q *Queries) LikeTrack(ctx context.Context, arg LikeTrackParams) (int64, error) { + result, err := q.db.Exec(ctx, likeTrack, arg.UserID, arg.TrackID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil } const listLikedAlbumIDs = `-- name: ListLikedAlbumIDs :many diff --git a/internal/db/dbq/models.go b/internal/db/dbq/models.go index 4d6c4199..4de19781 100644 --- a/internal/db/dbq/models.go +++ b/internal/db/dbq/models.go @@ -29,6 +29,16 @@ type Artist struct { UpdatedAt pgtype.Timestamptz } +type ContextualLike struct { + ID pgtype.UUID + UserID pgtype.UUID + TrackID pgtype.UUID + LikedAt pgtype.Timestamptz + DeletedAt pgtype.Timestamptz + SessionVector []byte + SessionID pgtype.UUID +} + type GeneralLike struct { UserID pgtype.UUID TrackID pgtype.UUID diff --git a/internal/db/migrations/0007_contextual_likes.down.sql b/internal/db/migrations/0007_contextual_likes.down.sql new file mode 100644 index 00000000..942b4de2 --- /dev/null +++ b/internal/db/migrations/0007_contextual_likes.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS contextual_likes; diff --git a/internal/db/migrations/0007_contextual_likes.up.sql b/internal/db/migrations/0007_contextual_likes.up.sql new file mode 100644 index 00000000..93882b5d --- /dev/null +++ b/internal/db/migrations/0007_contextual_likes.up.sql @@ -0,0 +1,32 @@ +-- 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); diff --git a/internal/db/queries/contextual_likes.sql b/internal/db/queries/contextual_likes.sql new file mode 100644 index 00000000..2a99793a --- /dev/null +++ b/internal/db/queries/contextual_likes.sql @@ -0,0 +1,10 @@ +-- 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; diff --git a/internal/db/queries/events.sql b/internal/db/queries/events.sql index aa5fe2ed..3a7ec1b7 100644 --- a/internal/db/queries/events.sql +++ b/internal/db/queries/events.sql @@ -48,3 +48,21 @@ SELECT * FROM play_events WHERE id = $1; INSERT INTO skip_events (user_id, track_id, session_id, skipped_at, position_ms) VALUES ($1, $2, $3, $4, $5) RETURNING *; + +-- 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; diff --git a/internal/db/queries/likes.sql b/internal/db/queries/likes.sql index b4a38473..5a8b76f5 100644 --- a/internal/db/queries/likes.sql +++ b/internal/db/queries/likes.sql @@ -1,4 +1,4 @@ --- name: LikeTrack :exec +-- name: LikeTrack :execrows INSERT INTO general_likes (user_id, track_id) VALUES ($1, $2) ON CONFLICT (user_id, track_id) DO NOTHING; diff --git a/internal/playevents/contextual_likes.go b/internal/playevents/contextual_likes.go new file mode 100644 index 00000000..4cb36732 --- /dev/null +++ b/internal/playevents/contextual_likes.go @@ -0,0 +1,59 @@ +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, + }) +} diff --git a/internal/playevents/contextual_likes_test.go b/internal/playevents/contextual_likes_test.go new file mode 100644 index 00000000..83c53a23 --- /dev/null +++ b/internal/playevents/contextual_likes_test.go @@ -0,0 +1,77 @@ +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" +) + +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") + } +} diff --git a/internal/playevents/writer.go b/internal/playevents/writer.go index e8b73bb8..14350f48 100644 --- a/internal/playevents/writer.go +++ b/internal/playevents/writer.go @@ -9,6 +9,7 @@ package playevents import ( "context" + "encoding/json" "errors" "log/slog" "time" @@ -19,6 +20,7 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/playsessions" + "git.fabledsword.com/bvandeusen/minstrel/internal/recommendation" ) type Writer struct { @@ -87,6 +89,11 @@ func (w *Writer) RecordPlayStarted( } 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 }) return out, err @@ -223,6 +230,9 @@ func (w *Writer) RecordSyntheticCompletedPlay( if err != nil { return err } + if err := w.captureSessionVector(ctx, q, ev.ID, sessionID, at); err != nil { + return err + } ratio := 1.0 dur := track.DurationMs _, err = q.UpdatePlayEventEnded(ctx, dbq.UpdatePlayEventEndedParams{ @@ -236,6 +246,36 @@ func (w *Writer) RecordSyntheticCompletedPlay( }) } +// 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, + }) +} + // autoClosePriorOpen closes any open (ended_at IS NULL) play_event for the // user. Sets ended_at = at, duration_played_ms = min(at - started_at, track // duration), was_skipped = true. Skip rule is NOT applied — auto-closed diff --git a/internal/playevents/writer_test.go b/internal/playevents/writer_test.go index 4d300a5c..5786e826 100644 --- a/internal/playevents/writer_test.go +++ b/internal/playevents/writer_test.go @@ -2,6 +2,7 @@ package playevents import ( "context" + "encoding/json" "io" "log/slog" "os" @@ -234,3 +235,85 @@ func TestRecordSyntheticCompletedPlay_WritesBothRows(t *testing.T) { t.Errorf("closed play_events count = %d, want 1", count) } } + +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)) + } +} diff --git a/internal/recommendation/candidates_test.go b/internal/recommendation/candidates_test.go index f8522d23..5ed2bdc4 100644 --- a/internal/recommendation/candidates_test.go +++ b/internal/recommendation/candidates_test.go @@ -126,7 +126,7 @@ func TestLoadCandidates_ExcludesRecentlyPlayed(t *testing.T) { 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 { + 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) @@ -201,7 +201,7 @@ func TestLoadCandidates_CrossUserIsolation(t *testing.T) { 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}) + _, _ = 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 { diff --git a/internal/recommendation/sessionvector.go b/internal/recommendation/sessionvector.go new file mode 100644 index 00000000..94cd3154 --- /dev/null +++ b/internal/recommendation/sessionvector.go @@ -0,0 +1,43 @@ +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() +} diff --git a/internal/recommendation/sessionvector_test.go b/internal/recommendation/sessionvector_test.go new file mode 100644 index 00000000..745b3bf9 --- /dev/null +++ b/internal/recommendation/sessionvector_test.go @@ -0,0 +1,151 @@ +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)) + } +} diff --git a/internal/subsonic/scrobble_test.go b/internal/subsonic/scrobble_test.go index 2c2e1876..36079a56 100644 --- a/internal/subsonic/scrobble_test.go +++ b/internal/subsonic/scrobble_test.go @@ -57,7 +57,7 @@ func testScrobblePool(t *testing.T) (*pgxpool.Pool, dbq.User, dbq.Track) { func newScrobbleHandlers(pool *pgxpool.Pool) *mediaHandlers { logger := slog.New(slog.NewTextHandler(io.Discard, nil)) w := playevents.NewWriter(pool, logger, 30*time.Minute, 0.5, 30000) - return newMediaHandlers(pool, w) + return newMediaHandlers(pool, w, logger) } func TestHandleScrobble_SubmissionFalseInsertsOpenPlayEvent(t *testing.T) { diff --git a/internal/subsonic/star.go b/internal/subsonic/star.go index 7dea5cb4..b2b333f5 100644 --- a/internal/subsonic/star.go +++ b/internal/subsonic/star.go @@ -9,6 +9,7 @@ import ( "github.com/jackc/pgx/v5/pgtype" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/playevents" ) // handleStar implements /rest/star. Accepts any combination of id (track), @@ -38,10 +39,14 @@ func (m *mediaHandlers) handleStar(w http.ResponseWriter, r *http.Request) { return } if trackID.Valid { - if err := q.LikeTrack(r.Context(), dbq.LikeTrackParams{UserID: user.ID, TrackID: trackID}); err != nil { + 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) + } } if albumID.Valid { if err := q.LikeAlbum(r.Context(), dbq.LikeAlbumParams{UserID: user.ID, AlbumID: albumID}); err != nil { @@ -71,6 +76,7 @@ func (m *mediaHandlers) handleUnstar(w http.ResponseWriter, r *http.Request) { q := dbq.New(m.pool) 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) } if a, ok := parseUUID(params.Get("albumId")); ok { _ = q.UnlikeAlbum(r.Context(), dbq.UnlikeAlbumParams{UserID: user.ID, AlbumID: a}) diff --git a/internal/subsonic/star_test.go b/internal/subsonic/star_test.go index be27e876..06634b5a 100644 --- a/internal/subsonic/star_test.go +++ b/internal/subsonic/star_test.go @@ -55,7 +55,7 @@ func testStarPool(t *testing.T) (*pgxpool.Pool, dbq.User, dbq.Track, dbq.Album, func newStarHandlers(pool *pgxpool.Pool) *mediaHandlers { logger := slog.New(slog.NewTextHandler(io.Discard, nil)) w := playevents.NewWriter(pool, logger, 30*time.Minute, 0.5, 30000) - return newMediaHandlers(pool, w) + return newMediaHandlers(pool, w, logger) } func TestHandleStar_TrackIDInsertsRow(t *testing.T) { @@ -198,7 +198,7 @@ func TestHandleGetStarred2_ReturnsAllThreeArrays(t *testing.T) { q := dbq.New(pool) // Star one of each. - _ = q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: user.ID, TrackID: track.ID}) + _, _ = q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: user.ID, TrackID: track.ID}) _ = q.LikeAlbum(context.Background(), dbq.LikeAlbumParams{UserID: user.ID, AlbumID: album.ID}) _ = q.LikeArtist(context.Background(), dbq.LikeArtistParams{UserID: user.ID, ArtistID: artist.ID}) @@ -232,7 +232,7 @@ func TestHandleGetStarred2_CrossUserIsolation(t *testing.T) { bob, _ := q.CreateUser(context.Background(), dbq.CreateUserParams{ Username: "bob", PasswordHash: "x", ApiToken: "y", IsAdmin: false, }) - _ = q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: alice.ID, TrackID: track.ID}) + _, _ = q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: alice.ID, TrackID: track.ID}) m := newStarHandlers(pool) req := httptest.NewRequest(http.MethodGet, "/rest/getStarred2?f=json", nil) @@ -249,3 +249,49 @@ func TestHandleGetStarred2_CrossUserIsolation(t *testing.T) { t.Errorf("bob's starred songs should be empty, got %d", len(songs)) } } + +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) + } +} diff --git a/internal/subsonic/stream.go b/internal/subsonic/stream.go index e05d73df..8aad9342 100644 --- a/internal/subsonic/stream.go +++ b/internal/subsonic/stream.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "log/slog" "net/http" "os" "path/filepath" @@ -24,10 +25,11 @@ import ( type mediaHandlers struct { pool *pgxpool.Pool events *playevents.Writer + logger *slog.Logger } -func newMediaHandlers(pool *pgxpool.Pool, events *playevents.Writer) *mediaHandlers { - return &mediaHandlers{pool: pool, events: events} +func newMediaHandlers(pool *pgxpool.Pool, events *playevents.Writer, logger *slog.Logger) *mediaHandlers { + return &mediaHandlers{pool: pool, events: events, logger: logger} } // handleStream serves the raw track bytes. http.ServeContent gives us Range, diff --git a/internal/subsonic/subsonic.go b/internal/subsonic/subsonic.go index c885bbf1..75260ff2 100644 --- a/internal/subsonic/subsonic.go +++ b/internal/subsonic/subsonic.go @@ -21,7 +21,7 @@ import ( // either way. func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, cfg Config, events *playevents.Writer) { b := &browseHandlers{pool: pool} - m := newMediaHandlers(pool, events) + m := newMediaHandlers(pool, events, logger) r.Route("/rest", func(sub chi.Router) { sub.Use(Middleware(pool, cfg)) register(sub, "/ping", handlePing) @@ -52,7 +52,6 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, cfg Config, ev sub.NotFound(func(w http.ResponseWriter, r *http.Request) { WriteFail(w, r, ErrGeneric, "Method not implemented") }) - _ = logger }) }