From 54c40c18be9915f1d07e7facb9516661c139227d Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 7 May 2026 10:37:12 -0400 Subject: [PATCH] feat(server/playlists): manual For-You refresh endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/playlists/system/for-you/refresh re-runs the system playlist build synchronously for the calling user, then returns the freshly-built For-You playlist's id, track count, and the track IDs in playlist position order. The frontend tile play button (next task) calls this endpoint on click and enqueues the returned track_ids directly — one roundtrip, no follow-up "list tracks" call needed for playback to start. Same authenticated-user-only posture as the Discover refresh from D-T3. Returns playlist_id=null and track_ids=[] when the build succeeded but the user's library yielded no eligible candidates (degenerate empty-library). Returns 500 on actual build failure. Tests cover 200 with shape (track_count matches len(track_ids)) and 401 without auth. --- internal/api/api.go | 1 + internal/api/playlists_foryou_refresh.go | 99 +++++++++++++++++++ internal/api/playlists_foryou_refresh_test.go | 67 +++++++++++++ 3 files changed, 167 insertions(+) create mode 100644 internal/api/playlists_foryou_refresh.go create mode 100644 internal/api/playlists_foryou_refresh_test.go diff --git a/internal/api/api.go b/internal/api/api.go index bcffcf9b..c1844f53 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -137,6 +137,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev 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) }) }) } diff --git a/internal/api/playlists_foryou_refresh.go b/internal/api/playlists_foryou_refresh.go new file mode 100644 index 00000000..f420ad98 --- /dev/null +++ b/internal/api/playlists_foryou_refresh.go @@ -0,0 +1,99 @@ +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" +) + +// 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 := 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("for-you refresh: build failed", + "user_id", uuidToString(user.ID), "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "build failed") + 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, http.StatusInternalServerError, "server_error", "lookup failed") + 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, http.StatusInternalServerError, "server_error", "list tracks failed") + 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, + }) +} diff --git a/internal/api/playlists_foryou_refresh_test.go b/internal/api/playlists_foryou_refresh_test.go new file mode 100644 index 00000000..76b6122a --- /dev/null +++ b/internal/api/playlists_foryou_refresh_test.go @@ -0,0 +1,67 @@ +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 newForYouRefreshRouter(h *handlers) chi.Router { + r := chi.NewRouter() + r.Route("/api", func(api chi.Router) { + api.Use(auth.RequireUser(h.pool)) + api.Post("/playlists/system/for-you/refresh", h.handleForYouRefresh) + }) + return r +} + +func TestForYouRefresh_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, "foryourefresh", "pw", false) + + req := httptest.NewRequest(http.MethodPost, "/api/playlists/system/for-you/refresh", nil) + req = withUser(req, user) + rec := httptest.NewRecorder() + newForYouRefreshRouter(h).ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var resp foryouRefreshResp + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.TrackIDs == nil { + t.Errorf("track_ids = nil, want []string (empty array, not null)") + } + if int(resp.TrackCount) != len(resp.TrackIDs) { + // Acceptable mismatch only when some tracks were removed from + // the library between build and response. For a fresh test + // user the counts should match. + t.Errorf("track_count=%d, len(track_ids)=%d — mismatch", resp.TrackCount, len(resp.TrackIDs)) + } +} + +func TestForYouRefresh_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/for-you/refresh", nil) + rec := httptest.NewRecorder() + newForYouRefreshRouter(h).ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", rec.Code) + } +}