diff --git a/internal/api/api.go b/internal/api/api.go index 6e039e5d..3cbf2dbf 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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) }) }) } diff --git a/internal/api/playlists_system_shuffle.go b/internal/api/playlists_system_shuffle.go new file mode 100644 index 00000000..e531f3f0 --- /dev/null +++ b/internal/api/playlists_system_shuffle.go @@ -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)) +}