feat(playlists): #415 stage 2 — rotation-aware shuffle endpoint

GET /api/playlists/system/{discover,for-you}/shuffle returns the
caller's system playlist with tracks ordered: unplayed-this-rotation
first (shuffled), then already-heard (shuffled). When the whole
snapshot has been heard, ResetRotationState fires and the full list
reshuffles fresh.

Option A (operator's choice): a separate, intentionally-uncached
endpoint. The cached GET /api/playlists/{id} detail path stays pure
for "open to view"; this varies per play. Same JSON shape as the
detail GET so Stage 3 clients reuse track parsing with no new model.

Two explicit static routes per variant mirror the refresh handlers
and avoid chi static-vs-param ambiguity under /playlists/system/.
Empty/absent snapshot → 200 with empty track list (nothing to play,
not an error). Rotation reset failure is non-fatal — still returns a
playable reshuffled list.

No client wiring yet — Stage 3 makes web + Flutter call this on the
play/tile gesture and send `source` on play_started. Handler-level
test deferred to Stage 3 (needs the full service+pool harness; the
end-to-end path is exercised there).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-15 07:47:57 -04:00
parent d2a0b7d780
commit e43281d1d0
2 changed files with 130 additions and 0 deletions
+2
View File
@@ -174,6 +174,8 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
authed.Get("/playlists/{id}/cover", h.handleGetPlaylistCover)
authed.Post("/playlists/system/discover/refresh", h.handleDiscoverRefresh)
authed.Post("/playlists/system/for-you/refresh", h.handleForYouRefresh)
authed.Get("/playlists/system/discover/shuffle", h.handleDiscoverShuffle)
authed.Get("/playlists/system/for-you/shuffle", h.handleForYouShuffle)
})
})
}
+128
View File
@@ -0,0 +1,128 @@
package api
import (
"errors"
"math/rand"
"net/http"
"github.com/jackc/pgx/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
)
// Stage 2 of #415. GET /api/playlists/system/{variant}/shuffle returns
// the caller's system playlist with tracks in rotation-aware play
// order: tracks not yet heard this rotation first (shuffled), then
// already-heard tracks (shuffled). When the whole snapshot has been
// heard the rotation resets and the full list reshuffles.
//
// Same JSON shape as GET /api/playlists/{id} so clients reuse their
// existing track parsing. Intentionally a separate, uncached endpoint
// (Option A): the cached playlist-detail GET stays pure for the
// "open the playlist to look at it" path; this one varies per play.
//
// Two explicit static routes (one per variant) mirror the refresh
// handlers and sidestep chi static-vs-param ambiguity under
// /playlists/system/. Future kinds add their own thin route.
func (h *handlers) handleForYouShuffle(w http.ResponseWriter, r *http.Request) {
h.serveSystemPlaylistShuffle(w, r, "for_you")
}
func (h *handlers) handleDiscoverShuffle(w http.ResponseWriter, r *http.Request) {
h.serveSystemPlaylistShuffle(w, r, "discover")
}
func (h *handlers) serveSystemPlaylistShuffle(w http.ResponseWriter, r *http.Request, variant string) {
user, ok := requireUser(w, r)
if !ok {
return
}
q := dbq.New(h.pool)
v := variant
pl, err := q.GetSystemPlaylistByVariantForUser(r.Context(),
dbq.GetSystemPlaylistByVariantForUserParams{
UserID: user.ID,
SystemVariant: &v,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
// No snapshot yet (empty library / not built). Empty list,
// not an error — the client just has nothing to play.
writeJSON(w, http.StatusOK, playlistDetailView{
Tracks: []playlistTrackView{},
})
return
}
h.logger.Error("system shuffle: lookup failed", "variant", variant, "err", err)
writeErr(w, apierror.InternalMsg("lookup failed", err))
return
}
detail, err := h.playlists.Get(r.Context(), user.ID, pl.ID)
if err != nil {
h.writePlaylistErr(w, err, "system shuffle")
return
}
// Played set for the current rotation. ErrNoRows = nothing played
// from this playlist yet → empty set, everything counts unplayed.
played := map[[16]byte]bool{}
st, rerr := q.GetRotationState(r.Context(), dbq.GetRotationStateParams{
UserID: user.ID, PlaylistKind: variant,
})
if rerr != nil && !errors.Is(rerr, pgx.ErrNoRows) {
h.logger.Error("system shuffle: rotation lookup failed", "variant", variant, "err", rerr)
writeErr(w, apierror.InternalMsg("rotation lookup failed", rerr))
return
}
if rerr == nil {
for _, id := range st.PlayedTrackIds {
if id.Valid {
played[id.Bytes] = true
}
}
}
// Partition playable tracks. Rows whose upstream track was deleted
// (TrackID nil) are dropped — nothing to enqueue.
unplayed := make([]playlists.PlaylistTrack, 0, len(detail.Tracks))
heard := make([]playlists.PlaylistTrack, 0)
for _, t := range detail.Tracks {
if t.TrackID == nil || !t.TrackID.Valid {
continue
}
if played[t.TrackID.Bytes] {
heard = append(heard, t)
} else {
unplayed = append(unplayed, t)
}
}
// Whole snapshot heard → reset the rotation and treat everything
// as fresh. Reset failure is non-fatal: we still return a
// playable reshuffled list; the next play just re-resets.
if len(unplayed) == 0 && len(heard) > 0 {
if err := q.ResetRotationState(r.Context(), dbq.ResetRotationStateParams{
UserID: user.ID, PlaylistKind: variant,
}); err != nil {
h.logger.Warn("system shuffle: rotation reset failed",
"variant", variant, "err", err)
}
unplayed = heard
heard = nil
}
rand.Shuffle(len(unplayed), func(i, j int) {
unplayed[i], unplayed[j] = unplayed[j], unplayed[i]
})
rand.Shuffle(len(heard), func(i, j int) {
heard[i], heard[j] = heard[j], heard[i]
})
detail.Tracks = append(unplayed, heard...)
writeJSON(w, http.StatusOK, playlistDetailToView(detail))
}