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)) }