feat(server/playlists): manual Discover refresh endpoint

POST /api/playlists/system/discover/refresh re-runs the system
playlist build synchronously for the calling user, then returns
their newly-built Discover playlist's UUID and track count.

Used by the frontend's "Refresh" affordances (next task) on the
Discover detail page header and the home Playlists row tile kebab.
Operator's escape hatch when they want a different randomness
without waiting for tomorrow's cron tick (e.g., after pasting a
Last.fm key, after the daily cover-art enrichment expanded their
playable library, or just for the heck of it).

Authenticated user only; the authed sub-router's RequireUser
middleware handles 401. Each user refreshes only their own
Discover — no admin route, no cross-user impersonation.

Adds GetSystemPlaylistByVariantForUser sqlc query for the response
lookup. Returns playlist_id=null and track_count=0 if the build
succeeded but the user's library yielded no eligible tracks
(degenerate empty-library).
This commit is contained in:
2026-05-07 08:39:26 -04:00
parent f77a0699ec
commit 0ba31c7816
5 changed files with 200 additions and 0 deletions
+1
View File
@@ -136,6 +136,7 @@ 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)
})
})
}
@@ -0,0 +1,70 @@
package api
import (
"errors"
"net/http"
"time"
"github.com/jackc/pgx/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"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 := auth.UserFromContext(r.Context())
if !ok {
writeErr(w, http.StatusUnauthorized, "auth_required", "")
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, http.StatusInternalServerError, "server_error", "build failed")
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, http.StatusInternalServerError, "server_error", "lookup failed")
return
}
idStr := uuidToString(pl.ID)
writeJSON(w, http.StatusOK, discoverRefreshResp{
PlaylistID: &idStr,
TrackCount: pl.TrackCount,
})
}
@@ -0,0 +1,64 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
)
func newDiscoverRefreshRouter(h *handlers) chi.Router {
r := chi.NewRouter()
r.Route("/api", func(api chi.Router) {
api.Use(auth.RequireUser(h.pool))
api.Post("/playlists/system/discover/refresh", h.handleDiscoverRefresh)
})
return r
}
func TestDiscoverRefresh_AuthenticatedUser_Returns200(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
h, pool := testHandlers(t)
user := seedUser(t, pool, "discrefresh", "pw", false)
req := httptest.NewRequest(http.MethodPost, "/api/playlists/system/discover/refresh", nil)
req = withUser(req, user)
rec := httptest.NewRecorder()
newDiscoverRefreshRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var resp discoverRefreshResp
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
// On a fresh user with no library, the build runs and emits a
// Discover playlist (or no row if library is truly empty).
// Either case is OK; we only assert the response shape decoded.
if resp.TrackCount < 0 {
t.Errorf("track_count = %d, want >= 0", resp.TrackCount)
}
}
func TestDiscoverRefresh_NoAuth_Returns401(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
h, _ := testHandlers(t)
req := httptest.NewRequest(http.MethodPost, "/api/playlists/system/discover/refresh", nil)
rec := httptest.NewRecorder()
newDiscoverRefreshRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", rec.Code)
}
}