refactor(playlists): #411 R2 — generic registry-driven system endpoints
Replaces the per-kind refresh/shuffle handlers with one generic
pair driven off the kind registry, in lockstep across both clients.
Server:
- systemPlaylistKind gains Singleton; RefreshableSystemKind(key)
exported. for_you/discover singleton; songs_like_artist not.
- New generic POST /api/playlists/system/{kind}/refresh and
GET /api/playlists/system/{kind}/shuffle ({kind} = raw
system_variant). Non-singleton/unknown kind → 404. Deleted
playlists_{foryou,discover}_refresh.go and the per-kind shuffle
wrappers; serveSystemPlaylistShuffle core kept.
- playlistRowView.refreshable: server-derived flag so clients show
the refresh affordance generically without hardcoding kinds.
Web (not drift-cached → uses the server flag):
- refreshSystem(variant) replaces refreshForYou/refreshDiscover;
systemShuffle drops the for_you→for-you mapping (raw variant).
- PlaylistCard + detail page gate the kebab/Refresh button on
playlist.refreshable; label is "Refresh {name}". Tests reworked;
obsolete refresh-foryou/discover api tests deleted.
Flutter (list tiles are drift-cache-sourced → derive, no migration):
- Playlist.refreshable getter = isSystem && variant !=
songs_like_artist (holds for all current + planned kinds).
- refreshSystem/systemShuffle use the raw variant; PlaylistCard +
detail screen gate kebab/Regenerate/source-tagging on refreshable
so songs_like_artist plays via get() (no by-kind endpoint).
Pure-plumbing refactor; CI verifies parity. Next (R3): the five
discovery mixes — each a candidate query + one registry entry.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,9 @@ 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"
|
||||
@@ -12,27 +14,99 @@ import (
|
||||
"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.
|
||||
// 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.
|
||||
//
|
||||
// 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.
|
||||
// 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.
|
||||
//
|
||||
// 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.
|
||||
// POST /api/playlists/system/{kind}/refresh — rebuilds the caller's
|
||||
// system playlists and returns that kind's fresh row.
|
||||
|
||||
func (h *handlers) handleForYouShuffle(w http.ResponseWriter, r *http.Request) {
|
||||
h.serveSystemPlaylistShuffle(w, r, "for_you")
|
||||
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) handleDiscoverShuffle(w http.ResponseWriter, r *http.Request) {
|
||||
h.serveSystemPlaylistShuffle(w, r, "discover")
|
||||
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
|
||||
}
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user