feat(server/m7-352): kind filter on GET /api/playlists + lazy fallback

Adds ?kind= filter (user|system|all, default user) to GET /api/playlists
so system mixes don't leak into slice-1 SPA views. Lazy background build
fires when kind=system|all and the caller's run row is stale (>24h).
Propagates Kind/SystemVariant/SeedArtistID through PlaylistRow and
playlistRowView wire types.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-04 09:24:27 -04:00
parent 391f5f16a1
commit a90ff11f0f
3 changed files with 212 additions and 11 deletions
+88 -11
View File
@@ -1,17 +1,22 @@
package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
)
@@ -20,17 +25,20 @@ import (
// derives the cover_url from cover_path so clients don't have to know
// the on-disk layout.
type playlistRowView struct {
ID string `json:"id"`
UserID string `json:"user_id"`
OwnerUsername string `json:"owner_username"`
Name string `json:"name"`
Description string `json:"description"`
IsPublic bool `json:"is_public"`
CoverURL string `json:"cover_url,omitempty"`
TrackCount int32 `json:"track_count"`
DurationSec int32 `json:"duration_sec"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
ID string `json:"id"`
UserID string `json:"user_id"`
OwnerUsername string `json:"owner_username"`
Name string `json:"name"`
Description string `json:"description"`
IsPublic bool `json:"is_public"`
Kind string `json:"kind"`
SystemVariant *string `json:"system_variant,omitempty"`
SeedArtistID *string `json:"seed_artist_id,omitempty"`
CoverURL string `json:"cover_url,omitempty"`
TrackCount int32 `json:"track_count"`
DurationSec int32 `json:"duration_sec"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// playlistTrackView is one row of a playlist's track list on the wire.
@@ -70,12 +78,39 @@ type listPlaylistsResponse struct {
// every playlist visible to the caller (own + others' public); we split
// the result here so the wire shape carries the distinction the UI cares
// about without a second DB query.
//
// An optional ?kind= query parameter filters the Owned bucket:
// - "user" (default): only user-created playlists
// - "system": only system-generated mixes
// - "all": no filter
//
// The Public bucket (other users' public playlists) is always unfiltered
// since system mixes are always private.
func (h *handlers) handleListPlaylists(w http.ResponseWriter, r *http.Request) {
caller, ok := auth.UserFromContext(r.Context())
if !ok {
writeErr(w, http.StatusUnauthorized, "unauthenticated", "no session")
return
}
kind := strings.TrimSpace(r.URL.Query().Get("kind"))
if kind == "" {
kind = "user"
}
switch kind {
case "user", "system", "all":
// valid
default:
writeErr(w, http.StatusBadRequest, "bad_kind", "kind must be one of: user, system, all")
return
}
// Lazy fallback: when the caller wants system mixes and they're stale,
// kick a background build but serve current state.
if kind == "system" || kind == "all" {
h.maybeKickBackgroundBuild(r.Context(), caller.ID)
}
rows, err := h.playlists.List(r.Context(), caller.ID)
if err != nil {
h.logger.Error("api: list playlists failed", "err", err)
@@ -86,14 +121,50 @@ func (h *handlers) handleListPlaylists(w http.ResponseWriter, r *http.Request) {
for i := range rows {
v := playlistRowToView(&rows[i])
if uuidEqual(rows[i].UserID, caller.ID) {
// Filter own playlists by the kind selector.
if kind != "all" && rows[i].Kind != kind {
continue
}
resp.Owned = append(resp.Owned, v)
} else {
// Public-from-others: kind filter doesn't apply (system mixes
// are always private; this slot is for public-share feature).
resp.Public = append(resp.Public, v)
}
}
writeJSON(w, http.StatusOK, resp)
}
// maybeKickBackgroundBuild fires a background BuildSystemPlaylists when the
// run is stale (>24h) AND not currently in_flight. Best-effort: any error
// fetching the run row is silently swallowed. A real cron tick will catch
// up the user within 24h regardless.
func (h *handlers) maybeKickBackgroundBuild(ctx context.Context, userID pgtype.UUID) {
run, err := dbq.New(h.pool).GetSystemPlaylistRun(ctx, userID)
var stale bool
switch {
case errors.Is(err, pgx.ErrNoRows):
stale = true // no run yet — first request — definitely stale
case err != nil:
h.logger.Warn("lazy system build: get run row failed", "err", err)
return
case !run.LastRunAt.Valid:
stale = true
case run.LastRunAt.Time.Before(time.Now().Add(-24 * time.Hour)):
stale = true
}
if !stale || run.InFlight {
return
}
go func() {
bgCtx := context.Background()
if err := playlists.BuildSystemPlaylists(bgCtx, h.pool, h.logger, userID, time.Now()); err != nil {
h.logger.Warn("lazy system playlist build failed",
"user_id", "redacted", "err", err)
}
}()
}
type createPlaylistBody struct {
Name string `json:"name"`
Description string `json:"description"`
@@ -370,11 +441,17 @@ func playlistRowToView(r *playlists.PlaylistRow) playlistRowView {
Name: r.Name,
Description: r.Description,
IsPublic: r.IsPublic,
Kind: r.Kind,
SystemVariant: r.SystemVariant,
TrackCount: r.TrackCount,
DurationSec: r.DurationSec,
CreatedAt: formatTimestamp(r.CreatedAt),
UpdatedAt: formatTimestamp(r.UpdatedAt),
}
if r.SeedArtistID.Valid {
s := uuidToString(r.SeedArtistID)
v.SeedArtistID = &s
}
if r.CoverPath != nil && *r.CoverPath != "" {
v.CoverURL = "/api/playlists/" + v.ID + "/cover"
}