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:
@@ -40,7 +40,7 @@ func (h *handlers) handleLikeTrack(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
|
writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
|
||||||
return
|
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)
|
h.logger.Error("api: like track insert", "err", err)
|
||||||
writeErr(w, http.StatusInternalServerError, "server_error", "insert failed")
|
writeErr(w, http.StatusInternalServerError, "server_error", "insert failed")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -188,6 +188,60 @@ func (q *Queries) InsertSkipEvent(ctx context.Context, arg InsertSkipEventParams
|
|||||||
return i, err
|
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
|
const touchPlaySessionLastEvent = `-- name: TouchPlaySessionLastEvent :exec
|
||||||
UPDATE play_sessions
|
UPDATE play_sessions
|
||||||
SET last_event_at = $2,
|
SET last_event_at = $2,
|
||||||
@@ -251,3 +305,21 @@ func (q *Queries) UpdatePlayEventEnded(ctx context.Context, arg UpdatePlayEventE
|
|||||||
)
|
)
|
||||||
return i, err
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ func (q *Queries) LikeArtist(ctx context.Context, arg LikeArtistParams) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
const likeTrack = `-- name: LikeTrack :exec
|
const likeTrack = `-- name: LikeTrack :execrows
|
||||||
INSERT INTO general_likes (user_id, track_id)
|
INSERT INTO general_likes (user_id, track_id)
|
||||||
VALUES ($1, $2)
|
VALUES ($1, $2)
|
||||||
ON CONFLICT (user_id, track_id) DO NOTHING
|
ON CONFLICT (user_id, track_id) DO NOTHING
|
||||||
@@ -87,9 +87,12 @@ type LikeTrackParams struct {
|
|||||||
TrackID pgtype.UUID
|
TrackID pgtype.UUID
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) LikeTrack(ctx context.Context, arg LikeTrackParams) error {
|
func (q *Queries) LikeTrack(ctx context.Context, arg LikeTrackParams) (int64, error) {
|
||||||
_, err := q.db.Exec(ctx, likeTrack, arg.UserID, arg.TrackID)
|
result, err := q.db.Exec(ctx, likeTrack, arg.UserID, arg.TrackID)
|
||||||
return err
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const listLikedAlbumIDs = `-- name: ListLikedAlbumIDs :many
|
const listLikedAlbumIDs = `-- name: ListLikedAlbumIDs :many
|
||||||
|
|||||||
@@ -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)
|
INSERT INTO skip_events (user_id, track_id, session_id, skipped_at, position_ms)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
RETURNING *;
|
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,4 +1,4 @@
|
|||||||
-- name: LikeTrack :exec
|
-- name: LikeTrack :execrows
|
||||||
INSERT INTO general_likes (user_id, track_id)
|
INSERT INTO general_likes (user_id, track_id)
|
||||||
VALUES ($1, $2)
|
VALUES ($1, $2)
|
||||||
ON CONFLICT (user_id, track_id) DO NOTHING;
|
ON CONFLICT (user_id, track_id) DO NOTHING;
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ func TestLoadCandidates_ExcludesRecentlyPlayed(t *testing.T) {
|
|||||||
func TestLoadCandidates_StatJoin(t *testing.T) {
|
func TestLoadCandidates_StatJoin(t *testing.T) {
|
||||||
f := newFixture(t, 2)
|
f := newFixture(t, 2)
|
||||||
// Seed = tracks[0]. Like tracks[1]; play it twice (one skip, one full play) 2 hours ago.
|
// 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)
|
t.Fatalf("like: %v", err)
|
||||||
}
|
}
|
||||||
twoH := time.Now().UTC().Add(-2 * time.Hour)
|
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,
|
Username: "bob", PasswordHash: "x", ApiToken: "y", IsAdmin: false,
|
||||||
})
|
})
|
||||||
// Alice likes tracks[1]; Bob shouldn't see it.
|
// 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)
|
got, err := LoadCandidates(context.Background(), f.q, bob.ID, f.tracks[0].ID, 1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ func (m *mediaHandlers) handleStar(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if trackID.Valid {
|
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")
|
WriteFail(w, r, ErrGeneric, "Could not star track")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ func TestHandleGetStarred2_ReturnsAllThreeArrays(t *testing.T) {
|
|||||||
q := dbq.New(pool)
|
q := dbq.New(pool)
|
||||||
|
|
||||||
// Star one of each.
|
// 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.LikeAlbum(context.Background(), dbq.LikeAlbumParams{UserID: user.ID, AlbumID: album.ID})
|
||||||
_ = q.LikeArtist(context.Background(), dbq.LikeArtistParams{UserID: user.ID, ArtistID: artist.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{
|
bob, _ := q.CreateUser(context.Background(), dbq.CreateUserParams{
|
||||||
Username: "bob", PasswordHash: "x", ApiToken: "y", IsAdmin: false,
|
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)
|
m := newStarHandlers(pool)
|
||||||
req := httptest.NewRequest(http.MethodGet, "/rest/getStarred2?f=json", nil)
|
req := httptest.NewRequest(http.MethodGet, "/rest/getStarred2?f=json", nil)
|
||||||
|
|||||||
Reference in New Issue
Block a user