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:
2026-05-15 13:21:09 -04:00
parent e3957b8eed
commit d67c0de596
21 changed files with 277 additions and 428 deletions
+90 -16
View File
@@ -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) {