From d43d8df6d5065ffa773bc482c40ab59cb217c557 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 27 Apr 2026 11:16:05 -0400 Subject: [PATCH] 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. --- internal/api/likes.go | 2 +- internal/db/dbq/events.sql.go | 72 ++++++++++++++++++++++ internal/db/dbq/likes.sql.go | 11 ++-- internal/db/queries/events.sql | 18 ++++++ internal/db/queries/likes.sql | 2 +- internal/recommendation/candidates_test.go | 4 +- internal/subsonic/star.go | 2 +- internal/subsonic/star_test.go | 4 +- 8 files changed, 104 insertions(+), 11 deletions(-) diff --git a/internal/api/likes.go b/internal/api/likes.go index 3cb1c698..d415953e 100644 --- a/internal/api/likes.go +++ b/internal/api/likes.go @@ -40,7 +40,7 @@ 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 { + if _, err := q.LikeTrack(r.Context(), dbq.LikeTrackParams{UserID: user.ID, TrackID: id}); err != nil { h.logger.Error("api: like track insert", "err", err) writeErr(w, http.StatusInternalServerError, "server_error", "insert failed") return diff --git a/internal/db/dbq/events.sql.go b/internal/db/dbq/events.sql.go index 15f94543..44183e7d 100644 --- a/internal/db/dbq/events.sql.go +++ b/internal/db/dbq/events.sql.go @@ -188,6 +188,60 @@ func (q *Queries) InsertSkipEvent(ctx context.Context, arg InsertSkipEventParams return i, err } +const listRecentSessionTracks = `-- name: ListRecentSessionTracks :many +SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at FROM tracks t +JOIN play_events pe ON pe.track_id = t.id +WHERE pe.session_id = $1 + AND pe.started_at < $2 +ORDER BY pe.started_at DESC +LIMIT $3 +` + +type ListRecentSessionTracksParams struct { + SessionID pgtype.UUID + StartedAt pgtype.Timestamptz + Limit int32 +} + +// Returns up to $3 tracks in session $1 whose play_event started before +// $2, ordered newest-first. Used by playevents.RecordPlayStarted to +// build the session_vector for the just-inserted play_event. +func (q *Queries) ListRecentSessionTracks(ctx context.Context, arg ListRecentSessionTracksParams) ([]Track, error) { + rows, err := q.db.Query(ctx, listRecentSessionTracks, arg.SessionID, arg.StartedAt, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Track + for rows.Next() { + var i Track + if err := rows.Scan( + &i.ID, + &i.Title, + &i.AlbumID, + &i.ArtistID, + &i.TrackNumber, + &i.DiscNumber, + &i.DurationMs, + &i.FilePath, + &i.FileSize, + &i.FileFormat, + &i.Bitrate, + &i.Mbid, + &i.Genre, + &i.AddedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const touchPlaySessionLastEvent = `-- name: TouchPlaySessionLastEvent :exec UPDATE play_sessions SET last_event_at = $2, @@ -251,3 +305,21 @@ func (q *Queries) UpdatePlayEventEnded(ctx context.Context, arg UpdatePlayEventE ) return i, err } + +const updatePlayEventVector = `-- name: UpdatePlayEventVector :exec +UPDATE play_events +SET session_vector_at_play = $2 +WHERE id = $1 +` + +type UpdatePlayEventVectorParams struct { + ID pgtype.UUID + SessionVectorAtPlay []byte +} + +// Used right after InsertPlayEvent to populate session_vector_at_play +// once the vector has been computed. +func (q *Queries) UpdatePlayEventVector(ctx context.Context, arg UpdatePlayEventVectorParams) error { + _, err := q.db.Exec(ctx, updatePlayEventVector, arg.ID, arg.SessionVectorAtPlay) + return err +} diff --git a/internal/db/dbq/likes.sql.go b/internal/db/dbq/likes.sql.go index 3ded9371..5510829b 100644 --- a/internal/db/dbq/likes.sql.go +++ b/internal/db/dbq/likes.sql.go @@ -76,7 +76,7 @@ func (q *Queries) LikeArtist(ctx context.Context, arg LikeArtistParams) error { return err } -const likeTrack = `-- name: LikeTrack :exec +const likeTrack = `-- name: LikeTrack :execrows INSERT INTO general_likes (user_id, track_id) VALUES ($1, $2) ON CONFLICT (user_id, track_id) DO NOTHING @@ -87,9 +87,12 @@ type LikeTrackParams struct { TrackID pgtype.UUID } -func (q *Queries) LikeTrack(ctx context.Context, arg LikeTrackParams) error { - _, err := q.db.Exec(ctx, likeTrack, arg.UserID, arg.TrackID) - return err +func (q *Queries) LikeTrack(ctx context.Context, arg LikeTrackParams) (int64, error) { + result, err := q.db.Exec(ctx, likeTrack, arg.UserID, arg.TrackID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil } const listLikedAlbumIDs = `-- name: ListLikedAlbumIDs :many diff --git a/internal/db/queries/events.sql b/internal/db/queries/events.sql index aa5fe2ed..3a7ec1b7 100644 --- a/internal/db/queries/events.sql +++ b/internal/db/queries/events.sql @@ -48,3 +48,21 @@ SELECT * FROM play_events WHERE id = $1; INSERT INTO skip_events (user_id, track_id, session_id, skipped_at, position_ms) VALUES ($1, $2, $3, $4, $5) RETURNING *; + +-- name: ListRecentSessionTracks :many +-- Returns up to $3 tracks in session $1 whose play_event started before +-- $2, ordered newest-first. Used by playevents.RecordPlayStarted to +-- build the session_vector for the just-inserted play_event. +SELECT t.* FROM tracks t +JOIN play_events pe ON pe.track_id = t.id +WHERE pe.session_id = $1 + AND pe.started_at < $2 +ORDER BY pe.started_at DESC +LIMIT $3; + +-- name: UpdatePlayEventVector :exec +-- Used right after InsertPlayEvent to populate session_vector_at_play +-- once the vector has been computed. +UPDATE play_events +SET session_vector_at_play = $2 +WHERE id = $1; diff --git a/internal/db/queries/likes.sql b/internal/db/queries/likes.sql index b4a38473..5a8b76f5 100644 --- a/internal/db/queries/likes.sql +++ b/internal/db/queries/likes.sql @@ -1,4 +1,4 @@ --- name: LikeTrack :exec +-- name: LikeTrack :execrows INSERT INTO general_likes (user_id, track_id) VALUES ($1, $2) ON CONFLICT (user_id, track_id) DO NOTHING; diff --git a/internal/recommendation/candidates_test.go b/internal/recommendation/candidates_test.go index f8522d23..5ed2bdc4 100644 --- a/internal/recommendation/candidates_test.go +++ b/internal/recommendation/candidates_test.go @@ -126,7 +126,7 @@ func TestLoadCandidates_ExcludesRecentlyPlayed(t *testing.T) { func TestLoadCandidates_StatJoin(t *testing.T) { f := newFixture(t, 2) // Seed = tracks[0]. Like tracks[1]; play it twice (one skip, one full play) 2 hours ago. - if err := f.q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: f.user, TrackID: f.tracks[1].ID}); err != nil { + if _, err := f.q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: f.user, TrackID: f.tracks[1].ID}); err != nil { t.Fatalf("like: %v", err) } twoH := time.Now().UTC().Add(-2 * time.Hour) @@ -201,7 +201,7 @@ func TestLoadCandidates_CrossUserIsolation(t *testing.T) { Username: "bob", PasswordHash: "x", ApiToken: "y", IsAdmin: false, }) // Alice likes tracks[1]; Bob shouldn't see it. - _ = f.q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: f.user, TrackID: f.tracks[1].ID}) + _, _ = f.q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: f.user, TrackID: f.tracks[1].ID}) got, err := LoadCandidates(context.Background(), f.q, bob.ID, f.tracks[0].ID, 1) if err != nil { diff --git a/internal/subsonic/star.go b/internal/subsonic/star.go index 7dea5cb4..67ccc9ac 100644 --- a/internal/subsonic/star.go +++ b/internal/subsonic/star.go @@ -38,7 +38,7 @@ 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 { + if _, err := q.LikeTrack(r.Context(), dbq.LikeTrackParams{UserID: user.ID, TrackID: trackID}); err != nil { WriteFail(w, r, ErrGeneric, "Could not star track") return } diff --git a/internal/subsonic/star_test.go b/internal/subsonic/star_test.go index be27e876..a2ff17e5 100644 --- a/internal/subsonic/star_test.go +++ b/internal/subsonic/star_test.go @@ -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)