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 (#411 R2), // replacing the former per-kind handlers. {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 (#415): 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 } // #968: 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)) }