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:
2026-04-27 11:22:35 -04:00
parent d6acb3b63c
commit fe1cc46752
4 changed files with 279 additions and 2 deletions
+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)
}
}
+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")
}
}