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.
This commit is contained in:
+12
-2
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user