16f76ea707
The daily 03:00 scheduler rebuild (and the manual refresh endpoint) replace a user's system playlists + You-might-like rows but published no event, so a client left open across the rebuild served yesterday's snapshot until a manual reload — the stale-tab case behind #968. Add a user-scoped playlist.system_rebuilt event (envelope {kind,user_id,data:{}}) from both the scheduler (bus threaded into NewScheduler) and handleSystemPlaylistRefresh. Clients consume it to invalidate home / system-playlist views and proactively re-pull a stale active queue. Issue #968. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
205 lines
6.4 KiB
Go
205 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 (#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))
|
|
}
|