From 0ba31c78168ae298e26c195ae2edbed9df00d8f1 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 7 May 2026 08:39:26 -0400 Subject: [PATCH] feat(server/playlists): manual Discover refresh endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- internal/api/api.go | 1 + internal/api/playlists_discover_refresh.go | 70 +++++++++++++++++++ .../api/playlists_discover_refresh_test.go | 64 +++++++++++++++++ internal/db/dbq/system_playlists.sql.go | 49 +++++++++++++ internal/db/queries/system_playlists.sql | 16 +++++ 5 files changed, 200 insertions(+) create mode 100644 internal/api/playlists_discover_refresh.go create mode 100644 internal/api/playlists_discover_refresh_test.go diff --git a/internal/api/api.go b/internal/api/api.go index 0435d301..bcffcf9b 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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) }) }) } diff --git a/internal/api/playlists_discover_refresh.go b/internal/api/playlists_discover_refresh.go new file mode 100644 index 00000000..71f47ef5 --- /dev/null +++ b/internal/api/playlists_discover_refresh.go @@ -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, + }) +} diff --git a/internal/api/playlists_discover_refresh_test.go b/internal/api/playlists_discover_refresh_test.go new file mode 100644 index 00000000..0134c622 --- /dev/null +++ b/internal/api/playlists_discover_refresh_test.go @@ -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) + } +} diff --git a/internal/db/dbq/system_playlists.sql.go b/internal/db/dbq/system_playlists.sql.go index 90be73f1..97a8fde8 100644 --- a/internal/db/dbq/system_playlists.sql.go +++ b/internal/db/dbq/system_playlists.sql.go @@ -119,6 +119,55 @@ func (q *Queries) FinishSystemPlaylistRun(ctx context.Context, arg FinishSystemP return err } +const getSystemPlaylistByVariantForUser = `-- name: GetSystemPlaylistByVariantForUser :one +SELECT p.id, p.user_id, p.name, p.kind, p.system_variant, + p.seed_artist_id, p.cover_path, p.track_count + FROM playlists p + WHERE p.user_id = $1 + AND p.kind = 'system' + AND p.system_variant = $2 + ORDER BY p.created_at DESC + LIMIT 1 +` + +type GetSystemPlaylistByVariantForUserParams struct { + UserID pgtype.UUID + SystemVariant *string +} + +type GetSystemPlaylistByVariantForUserRow struct { + ID pgtype.UUID + UserID pgtype.UUID + Name string + Kind string + SystemVariant *string + SeedArtistID pgtype.UUID + CoverPath *string + TrackCount int32 +} + +// Returns the user's system playlist for the given variant +// ('for_you' / 'songs_like_artist' / 'discover'), or pgx.ErrNoRows +// when the user hasn't built that variant yet. For +// 'songs_like_artist' (which can have multiple rows per user, one +// per seed), this returns whichever LIMIT 1 picks — callers that +// need all instances should use a different query. +func (q *Queries) GetSystemPlaylistByVariantForUser(ctx context.Context, arg GetSystemPlaylistByVariantForUserParams) (GetSystemPlaylistByVariantForUserRow, error) { + row := q.db.QueryRow(ctx, getSystemPlaylistByVariantForUser, arg.UserID, arg.SystemVariant) + var i GetSystemPlaylistByVariantForUserRow + err := row.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.Kind, + &i.SystemVariant, + &i.SeedArtistID, + &i.CoverPath, + &i.TrackCount, + ) + return i, err +} + const getSystemPlaylistRun = `-- name: GetSystemPlaylistRun :one SELECT user_id, last_run_at, last_run_date, in_flight, last_error FROM system_playlist_runs diff --git a/internal/db/queries/system_playlists.sql b/internal/db/queries/system_playlists.sql index 63580593..fa7443bd 100644 --- a/internal/db/queries/system_playlists.sql +++ b/internal/db/queries/system_playlists.sql @@ -154,3 +154,19 @@ INSERT INTO playlists ( kind, system_variant, seed_artist_id, cover_path ) VALUES ($1, $2, '', false, 'system', $3, $4, $5) RETURNING *; + +-- name: GetSystemPlaylistByVariantForUser :one +-- Returns the user's system playlist for the given variant +-- ('for_you' / 'songs_like_artist' / 'discover'), or pgx.ErrNoRows +-- when the user hasn't built that variant yet. For +-- 'songs_like_artist' (which can have multiple rows per user, one +-- per seed), this returns whichever LIMIT 1 picks — callers that +-- need all instances should use a different query. +SELECT p.id, p.user_id, p.name, p.kind, p.system_variant, + p.seed_artist_id, p.cover_path, p.track_count + FROM playlists p + WHERE p.user_id = $1 + AND p.kind = 'system' + AND p.system_variant = $2 + ORDER BY p.created_at DESC + LIMIT 1;