Merge pull request 'feat: M3 session vectors + contextual_likes capture' (#22) from dev into main

This commit was merged in pull request #22.
This commit is contained in:
2026-04-27 23:56:23 +00:00
25 changed files with 2605 additions and 18 deletions
File diff suppressed because it is too large Load Diff
@@ -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.
+12 -2
View File
@@ -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)
}
+131
View File
@@ -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)
}
}
+52
View File
@@ -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
}
+72
View File
@@ -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
}
+7 -4
View File
@@ -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
+10
View File
@@ -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
@@ -0,0 +1 @@
DROP TABLE IF EXISTS contextual_likes;
@@ -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);
+10
View File
@@ -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;
+18
View File
@@ -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;
+1 -1
View File
@@ -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;
+59
View File
@@ -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,
})
}
@@ -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")
}
}
+40
View File
@@ -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
+83
View File
@@ -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))
}
}
+2 -2
View File
@@ -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 {
+43
View File
@@ -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()
}
@@ -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))
}
}
+1 -1
View File
@@ -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) {
+7 -1
View File
@@ -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})
+49 -3
View File
@@ -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)
}
}
+4 -2
View File
@@ -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,
+1 -2
View File
@@ -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
})
}