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:
+88
-11
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user