From fe1cc4675243ef4c72a0b1a4cb650f995f96dba7 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 27 Apr 2026 11:22:35 -0400 Subject: [PATCH] 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. --- internal/api/likes.go | 14 +- internal/api/likes_test.go | 131 +++++++++++++++++++ internal/playevents/contextual_likes.go | 59 +++++++++ internal/playevents/contextual_likes_test.go | 77 +++++++++++ 4 files changed, 279 insertions(+), 2 deletions(-) create mode 100644 internal/playevents/contextual_likes.go create mode 100644 internal/playevents/contextual_likes_test.go diff --git a/internal/api/likes.go b/internal/api/likes.go index d415953e..98470203 100644 --- a/internal/api/likes.go +++ b/internal/api/likes.go @@ -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) } diff --git a/internal/api/likes_test.go b/internal/api/likes_test.go index e97a6a08..c47e9a63 100644 --- a/internal/api/likes_test.go +++ b/internal/api/likes_test.go @@ -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) + } +} diff --git a/internal/playevents/contextual_likes.go b/internal/playevents/contextual_likes.go new file mode 100644 index 00000000..4cb36732 --- /dev/null +++ b/internal/playevents/contextual_likes.go @@ -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, + }) +} diff --git a/internal/playevents/contextual_likes_test.go b/internal/playevents/contextual_likes_test.go new file mode 100644 index 00000000..83c53a23 --- /dev/null +++ b/internal/playevents/contextual_likes_test.go @@ -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") + } +}