From bf7c5ad47d6ad71573b7de5d694b04868ffaf7c9 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 27 Apr 2026 11:25:10 -0400 Subject: [PATCH] feat(subsonic): wire contextual_likes capture/soft-delete into star/unstar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handleStar's track branch calls playevents.CaptureContextualLikeIfPlaying when the underlying LikeTrack actually inserted a row. handleUnstar calls SoftDeleteContextualLikes after every track-id unstar. Same helpers as the api surface — single source of truth. mediaHandlers struct gains a logger field threaded through Mount. --- internal/subsonic/scrobble_test.go | 2 +- internal/subsonic/star.go | 8 ++++- internal/subsonic/star_test.go | 48 +++++++++++++++++++++++++++++- internal/subsonic/stream.go | 6 ++-- internal/subsonic/subsonic.go | 3 +- 5 files changed, 60 insertions(+), 7 deletions(-) diff --git a/internal/subsonic/scrobble_test.go b/internal/subsonic/scrobble_test.go index 2c2e1876..36079a56 100644 --- a/internal/subsonic/scrobble_test.go +++ b/internal/subsonic/scrobble_test.go @@ -57,7 +57,7 @@ func testScrobblePool(t *testing.T) (*pgxpool.Pool, dbq.User, dbq.Track) { func newScrobbleHandlers(pool *pgxpool.Pool) *mediaHandlers { logger := slog.New(slog.NewTextHandler(io.Discard, nil)) w := playevents.NewWriter(pool, logger, 30*time.Minute, 0.5, 30000) - return newMediaHandlers(pool, w) + return newMediaHandlers(pool, w, logger) } func TestHandleScrobble_SubmissionFalseInsertsOpenPlayEvent(t *testing.T) { diff --git a/internal/subsonic/star.go b/internal/subsonic/star.go index 67ccc9ac..b2b333f5 100644 --- a/internal/subsonic/star.go +++ b/internal/subsonic/star.go @@ -9,6 +9,7 @@ import ( "github.com/jackc/pgx/v5/pgtype" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/playevents" ) // handleStar implements /rest/star. Accepts any combination of id (track), @@ -38,10 +39,14 @@ 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 { + rows, err := q.LikeTrack(r.Context(), dbq.LikeTrackParams{UserID: user.ID, TrackID: trackID}) + if err != nil { WriteFail(w, r, ErrGeneric, "Could not star track") return } + if rows == 1 { + _ = playevents.CaptureContextualLikeIfPlaying(r.Context(), q, user.ID, trackID, m.logger) + } } if albumID.Valid { if err := q.LikeAlbum(r.Context(), dbq.LikeAlbumParams{UserID: user.ID, AlbumID: albumID}); err != nil { @@ -71,6 +76,7 @@ func (m *mediaHandlers) handleUnstar(w http.ResponseWriter, r *http.Request) { q := dbq.New(m.pool) if t, ok := parseUUID(params.Get("id")); ok { _ = q.UnlikeTrack(r.Context(), dbq.UnlikeTrackParams{UserID: user.ID, TrackID: t}) + _ = playevents.SoftDeleteContextualLikes(r.Context(), q, user.ID, t) } if a, ok := parseUUID(params.Get("albumId")); ok { _ = q.UnlikeAlbum(r.Context(), dbq.UnlikeAlbumParams{UserID: user.ID, AlbumID: a}) diff --git a/internal/subsonic/star_test.go b/internal/subsonic/star_test.go index a2ff17e5..06634b5a 100644 --- a/internal/subsonic/star_test.go +++ b/internal/subsonic/star_test.go @@ -55,7 +55,7 @@ func testStarPool(t *testing.T) (*pgxpool.Pool, dbq.User, dbq.Track, dbq.Album, func newStarHandlers(pool *pgxpool.Pool) *mediaHandlers { logger := slog.New(slog.NewTextHandler(io.Discard, nil)) w := playevents.NewWriter(pool, logger, 30*time.Minute, 0.5, 30000) - return newMediaHandlers(pool, w) + return newMediaHandlers(pool, w, logger) } func TestHandleStar_TrackIDInsertsRow(t *testing.T) { @@ -249,3 +249,49 @@ func TestHandleGetStarred2_CrossUserIsolation(t *testing.T) { t.Errorf("bob's starred songs should be empty, got %d", len(songs)) } } + +func TestHandleStar_DuringNowPlaying_WritesContextualLike(t *testing.T) { + pool, user, track1, _, _ := testStarPool(t) + // Add a second track so star refers to a different track than the one playing. + q := dbq.New(pool) + a, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "Y", SortName: "Y"}) + al, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{Title: "Y", SortTitle: "Y", ArtistID: a.ID}) + track2, _ := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ + Title: "Star Me", AlbumID: al.ID, ArtistID: a.ID, FilePath: "/tmp/t2.flac", DurationMs: 100_000, + }) + + m := newStarHandlers(pool) + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + m.logger = logger + + // Start now-playing for track1 (creates open play_event with vector). + q1 := url.Values{} + q1.Set("id", uuidToID(track1.ID)) + q1.Set("submission", "false") + scrobReq := httptest.NewRequest(http.MethodGet, "/rest/scrobble?"+q1.Encode(), nil) + scrobCtx := context.WithValue(scrobReq.Context(), userCtxKey, user) + scrobResp := httptest.NewRecorder() + m.handleScrobble(scrobResp, scrobReq.WithContext(scrobCtx)) + if scrobResp.Code != http.StatusOK { + t.Fatalf("scrobble: %d", scrobResp.Code) + } + + // Star track2. + q2 := url.Values{} + q2.Set("id", uuidToID(track2.ID)) + starReq := httptest.NewRequest(http.MethodGet, "/rest/star?"+q2.Encode(), nil) + starCtx := context.WithValue(starReq.Context(), userCtxKey, user) + starResp := httptest.NewRecorder() + m.handleStar(starResp, starReq.WithContext(starCtx)) + if starResp.Code != http.StatusOK { + t.Fatalf("star: %d", starResp.Code) + } + + // Verify contextual_likes row. + var count int + _ = pool.QueryRow(context.Background(), + "SELECT count(*) FROM contextual_likes WHERE user_id=$1 AND track_id=$2", user.ID, track2.ID).Scan(&count) + if count != 1 { + t.Errorf("contextual_likes count = %d, want 1", count) + } +} diff --git a/internal/subsonic/stream.go b/internal/subsonic/stream.go index e05d73df..8aad9342 100644 --- a/internal/subsonic/stream.go +++ b/internal/subsonic/stream.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "log/slog" "net/http" "os" "path/filepath" @@ -24,10 +25,11 @@ import ( type mediaHandlers struct { pool *pgxpool.Pool events *playevents.Writer + logger *slog.Logger } -func newMediaHandlers(pool *pgxpool.Pool, events *playevents.Writer) *mediaHandlers { - return &mediaHandlers{pool: pool, events: events} +func newMediaHandlers(pool *pgxpool.Pool, events *playevents.Writer, logger *slog.Logger) *mediaHandlers { + return &mediaHandlers{pool: pool, events: events, logger: logger} } // handleStream serves the raw track bytes. http.ServeContent gives us Range, diff --git a/internal/subsonic/subsonic.go b/internal/subsonic/subsonic.go index c885bbf1..75260ff2 100644 --- a/internal/subsonic/subsonic.go +++ b/internal/subsonic/subsonic.go @@ -21,7 +21,7 @@ import ( // either way. func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, cfg Config, events *playevents.Writer) { b := &browseHandlers{pool: pool} - m := newMediaHandlers(pool, events) + m := newMediaHandlers(pool, events, logger) r.Route("/rest", func(sub chi.Router) { sub.Use(Middleware(pool, cfg)) register(sub, "/ping", handlePing) @@ -52,7 +52,6 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, cfg Config, ev sub.NotFound(func(w http.ResponseWriter, r *http.Request) { WriteFail(w, r, ErrGeneric, "Method not implemented") }) - _ = logger }) }