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;