Files
minstrel/internal/api/playlists_system_shuffle.go
T
bvandeusenandClaude Opus 5.5 08a2c06b85 docs: api comments say what the endpoints do, not how they came to be (#6277)
Rewrites the internal/api comment blocks to rule 32: task, milestone and
rule ids gone, the stories behind them restated as the constraint they
left, stale references corrected (the stream secret is wired from config,
the timezone handler already refreshes the scheduler, the Android app is
the native client, the design docs cited are not in the repo). Also
rewraps comment lines over 80 columns left by the earlier batches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:23:58 -04:00

204 lines
6.4 KiB
Go

package api
import (
"errors"
"math/rand"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"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"
)
// Generic registry-driven system-playlist endpoints. {kind} is the raw
// system_variant ('for_you' | 'discover' | future singleton kinds);
// only singleton kinds (playlists.RefreshableSystemKind) are
// addressable here — songs_like_artist is multi-per-user and played
// by playlist id, so it 404s.
//
// GET /api/playlists/system/{kind}/shuffle — the caller's playlist
// for that kind with tracks in rotation-aware order: unheard
// this rotation first (shuffled), then heard; resets when exhausted.
// Same JSON shape as GET /api/playlists/{id} so clients reuse track
// parsing. Intentionally uncached — it varies per play, unlike the
// cached detail GET.
//
// POST /api/playlists/system/{kind}/refresh — rebuilds the caller's
// system playlists and returns that kind's fresh row.
func systemKindFromURL(w http.ResponseWriter, r *http.Request) (string, bool) {
kind := chi.URLParam(r, "kind")
if !playlists.RefreshableSystemKind(kind) {
writeErr(w, &apierror.Error{Status: 404, Code: "not_found", Message: "unknown system playlist"})
return "", false
}
return kind, true
}
func (h *handlers) handleSystemPlaylistShuffle(w http.ResponseWriter, r *http.Request) {
kind, ok := systemKindFromURL(w, r)
if !ok {
return
}
h.serveSystemPlaylistShuffle(w, r, kind)
}
// systemRefreshResp unifies the prior for-you/discover shapes
// (for-you carried track_ids, discover didn't); clients ignore
// extra fields. playlist_id null + empty when the build produced no
// row for that kind (empty library).
type systemRefreshResp struct {
PlaylistID *string `json:"playlist_id"`
TrackCount int32 `json:"track_count"`
TrackIDs []string `json:"track_ids"`
}
func (h *handlers) handleSystemPlaylistRefresh(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
return
}
kind, ok := systemKindFromURL(w, r)
if !ok {
return
}
if err := playlists.BuildSystemPlaylists(r.Context(), h.pool, h.logger, user.ID, time.Now(), h.dataDir); err != nil {
h.logger.Error("system refresh: build failed",
"kind", kind, "user_id", uuidToString(user.ID), "err", err)
writeErr(w, apierror.InternalMsg("build failed", err))
return
}
// Announce the rebuild so the user's other clients refresh.
h.publishSystemRebuilt(user.ID)
q := dbq.New(h.pool)
v := kind
pl, err := q.GetSystemPlaylistByVariantForUser(r.Context(),
dbq.GetSystemPlaylistByVariantForUserParams{UserID: user.ID, SystemVariant: &v})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeJSON(w, http.StatusOK, systemRefreshResp{TrackIDs: []string{}})
return
}
h.logger.Error("system refresh: lookup failed",
"kind", kind, "user_id", uuidToString(user.ID), "err", err)
writeErr(w, apierror.InternalMsg("lookup failed", err))
return
}
rows, err := q.ListPlaylistTracks(r.Context(), pl.ID)
if err != nil {
h.logger.Error("system refresh: list tracks failed",
"kind", kind, "playlist_id", uuidToString(pl.ID), "err", err)
writeErr(w, apierror.InternalMsg("list tracks failed", err))
return
}
trackIDs := make([]string, 0, len(rows))
for _, row := range rows {
if !row.LiveTrackID.Valid {
continue
}
trackIDs = append(trackIDs, uuidToString(row.LiveTrackID))
}
idStr := uuidToString(pl.ID)
writeJSON(w, http.StatusOK, systemRefreshResp{
PlaylistID: &idStr,
TrackCount: pl.TrackCount,
TrackIDs: trackIDs,
})
}
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))
}