38beadf22e
7 tasks: contextual_likes migration + sqlc, events.sql vector queries + LikeTrack :execrows change, BuildSessionVector pure function, vector capture in playevents.Writer, contextual_likes helpers + api handler updates, subsonic handler updates, final verification.
1492 lines
53 KiB
Markdown
1492 lines
53 KiB
Markdown
# 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.
|