e43281d1d0
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>
129 lines
4.1 KiB
Go
129 lines
4.1 KiB
Go
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))
|
|
}
|