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
+2 -4
View File
@@ -172,10 +172,8 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
authed.Delete("/playlists/{id}/tracks/{position}", h.handleRemovePlaylistTrack)
authed.Put("/playlists/{id}/tracks", h.handleReorderPlaylist)
authed.Get("/playlists/{id}/cover", h.handleGetPlaylistCover)
authed.Post("/playlists/system/discover/refresh", h.handleDiscoverRefresh)
authed.Post("/playlists/system/for-you/refresh", h.handleForYouRefresh)
authed.Get("/playlists/system/discover/shuffle", h.handleDiscoverShuffle)
authed.Get("/playlists/system/for-you/shuffle", h.handleForYouShuffle)
authed.Post("/playlists/system/{kind}/refresh", h.handleSystemPlaylistRefresh)
authed.Get("/playlists/system/{kind}/shuffle", h.handleSystemPlaylistShuffle)
})
})
}
+7 -1
View File
@@ -32,7 +32,12 @@ type playlistRowView struct {
IsPublic bool `json:"is_public"`
Kind string `json:"kind"`
SystemVariant *string `json:"system_variant,omitempty"`
SeedArtistID *string `json:"seed_artist_id,omitempty"`
// Refreshable: a singleton system kind addressable by the generic
// /system/{kind}/{refresh,shuffle} endpoints. Lets clients show
// the per-tile refresh affordance generically without hardcoding
// which kinds support it (false for user + songs_like_artist).
Refreshable bool `json:"refreshable"`
SeedArtistID *string `json:"seed_artist_id,omitempty"`
CoverURL string `json:"cover_url,omitempty"`
TrackCount int32 `json:"track_count"`
DurationSec int32 `json:"duration_sec"`
@@ -435,6 +440,7 @@ func playlistRowToView(r *playlists.PlaylistRow) playlistRowView {
IsPublic: r.IsPublic,
Kind: r.Kind,
SystemVariant: r.SystemVariant,
Refreshable: r.SystemVariant != nil && playlists.RefreshableSystemKind(*r.SystemVariant),
TrackCount: r.TrackCount,
DurationSec: r.DurationSec,
CreatedAt: formatTimestamp(r.CreatedAt),
@@ -1,69 +0,0 @@
package api
import (
"errors"
"net/http"
"time"
"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"
)
// discoverRefreshResp is the wire shape for POST
// /api/playlists/system/discover/refresh. playlist_id is null when
// the build succeeded but the user's library yielded no eligible
// tracks (degenerate empty-library); track_count is 0 in that case.
type discoverRefreshResp struct {
PlaylistID *string `json:"playlist_id"`
TrackCount int32 `json:"track_count"`
}
// handleDiscoverRefresh re-runs BuildSystemPlaylists synchronously
// for the calling user, then returns the resulting Discover row's
// id + track_count. Used by the frontend's refresh affordances on
// the detail page header and the home tile kebab.
//
// Authenticated user only (gated by the authed sub-router's
// RequireUser middleware). Each user only ever refreshes their own
// Discover — no admin route, no cross-user impersonation.
func (h *handlers) handleDiscoverRefresh(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(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("discover refresh: build failed",
"user_id", uuidToString(user.ID), "err", err)
writeErr(w, apierror.InternalMsg("build failed", err))
return
}
sysVariant := "discover"
pl, err := dbq.New(h.pool).GetSystemPlaylistByVariantForUser(r.Context(),
dbq.GetSystemPlaylistByVariantForUserParams{
UserID: user.ID,
SystemVariant: &sysVariant,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
// Build succeeded but no Discover row produced — empty library.
writeJSON(w, http.StatusOK, discoverRefreshResp{
PlaylistID: nil, TrackCount: 0,
})
return
}
h.logger.Error("discover refresh: lookup failed",
"user_id", uuidToString(user.ID), "err", err)
writeErr(w, apierror.InternalMsg("lookup failed", err))
return
}
idStr := uuidToString(pl.ID)
writeJSON(w, http.StatusOK, discoverRefreshResp{
PlaylistID: &idStr,
TrackCount: pl.TrackCount,
})
}
-98
View File
@@ -1,98 +0,0 @@
package api
import (
"errors"
"net/http"
"time"
"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"
)
// foryouRefreshResp is the wire shape for POST
// /api/playlists/system/for-you/refresh. track_ids is the playlist's
// tracks in playlist position order — the frontend enqueues this
// list directly to start playback without a second roundtrip.
//
// playlist_id is null + track_ids empty when the build succeeded but
// the user's library yielded no eligible candidates (degenerate
// empty-library case).
type foryouRefreshResp struct {
PlaylistID *string `json:"playlist_id"`
TrackCount int32 `json:"track_count"`
TrackIDs []string `json:"track_ids"`
}
// handleForYouRefresh re-runs BuildSystemPlaylists synchronously for
// the calling user and returns their freshly-built For-You playlist.
// Used by the home Playlists row's For-You tile play button: click
// triggers refresh, the response carries the track IDs, the
// frontend enqueues + auto-plays.
//
// Authenticated user only — each user refreshes only their own
// For-You.
func (h *handlers) handleForYouRefresh(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(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("for-you refresh: build failed",
"user_id", uuidToString(user.ID), "err", err)
writeErr(w, apierror.InternalMsg("build failed", err))
return
}
q := dbq.New(h.pool)
sysVariant := "for_you"
pl, err := q.GetSystemPlaylistByVariantForUser(r.Context(),
dbq.GetSystemPlaylistByVariantForUserParams{
UserID: user.ID,
SystemVariant: &sysVariant,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
// Build succeeded but no For-You row — empty library.
writeJSON(w, http.StatusOK, foryouRefreshResp{
PlaylistID: nil,
TrackCount: 0,
TrackIDs: []string{},
})
return
}
h.logger.Error("for-you refresh: lookup failed",
"user_id", uuidToString(user.ID), "err", err)
writeErr(w, apierror.InternalMsg("lookup failed", err))
return
}
// Fetch tracks in playlist position order so the frontend can
// enqueue them directly.
rows, err := q.ListPlaylistTracks(r.Context(), pl.ID)
if err != nil {
h.logger.Error("for-you refresh: list tracks failed",
"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 {
// Skip rows where the track was removed from the library
// (LiveTrackID won't be Valid). The snapshot row still
// exists in playlist_tracks, but there's nothing to enqueue.
if !row.LiveTrackID.Valid {
continue
}
trackIDs = append(trackIDs, uuidToString(row.LiveTrackID))
}
idStr := uuidToString(pl.ID)
writeJSON(w, http.StatusOK, foryouRefreshResp{
PlaylistID: &idStr,
TrackCount: pl.TrackCount,
TrackIDs: trackIDs,
})
}
+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) {
+26 -5
View File
@@ -246,8 +246,29 @@ type systemPlaylistProducer func(
) ([]builtPlaylist, error)
type systemPlaylistKind struct {
Key string
Produce systemPlaylistProducer
Key string
// Singleton kinds produce exactly one playlist per user (For-You,
// Discover, the discovery mixes) and so can be addressed by kind:
// the generic /system/{kind}/{refresh,shuffle} endpoints + the
// per-tile refresh affordance only apply to these. Non-singleton
// kinds (songs_like_artist → up to 3 per user) are played by
// playlist id and have no by-kind endpoint.
Singleton bool
Produce systemPlaylistProducer
}
// RefreshableSystemKind reports whether `key` is a known singleton
// system-playlist kind — i.e. addressable by the generic by-kind
// refresh/shuffle endpoints and eligible for the per-tile refresh
// affordance. The API layer + clients use this so neither hardcodes
// the kind list.
func RefreshableSystemKind(key string) bool {
for _, k := range systemPlaylistRegistry {
if k.Key == key {
return k.Singleton
}
}
return false
}
// systemPlaylistRegistry is the single source of truth for which
@@ -258,9 +279,9 @@ type systemPlaylistKind struct {
// it has no functional effect (atomic replace + per-playlist
// collage are order-independent).
var systemPlaylistRegistry = []systemPlaylistKind{
{Key: "for_you", Produce: produceForYou},
{Key: "songs_like_artist", Produce: produceSeedMixes},
{Key: "discover", Produce: produceDiscover},
{Key: "for_you", Singleton: true, Produce: produceForYou},
{Key: "songs_like_artist", Singleton: false, Produce: produceSeedMixes},
{Key: "discover", Singleton: true, Produce: produceDiscover},
}
// produceForYou: today's seed from the user's top-5 played tracks