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.
This commit is contained in:
2026-04-27 11:16:05 -04:00
parent 92ef53c04d
commit d43d8df6d5
8 changed files with 104 additions and 11 deletions
+1 -1
View File
@@ -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
+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
+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;
+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 {
+1 -1
View File
@@ -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
}
+2 -2
View File
@@ -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)