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:
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user