From 7fac264c731e1d79c80e7adcb88efbc5d98a5eca Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 13 May 2026 11:58:24 -0400 Subject: [PATCH] feat(playlists): wire Refresh into PUT /api/me/timezone + registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the server side of #392 Half B. PUT /api/me/timezone now calls scheduler.Refresh(ctx, userID) after the DB write so the rescheduled daily-at-03:00-local job takes effect synchronously. Failure to refresh is logged but doesn't undo the DB write — the hourly reconciliation pass would pick it up within an hour regardless. POST /api/auth/register calls Refresh after successful user insert so brand-new users get scheduled immediately rather than waiting for the hourly pass to discover them. system_cron.go deleted: the new scheduler subsumes its responsibilities. The StartSystemPlaylistCron call in main.go is also removed. Server restart now runs the new scheduler's startup recovery + catch-up instead. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/minstrel/main.go | 1 - internal/api/auth_register.go | 11 ++++++ internal/api/me_timezone.go | 10 +++++ internal/playlists/system_cron.go | 61 ------------------------------- 4 files changed, 21 insertions(+), 62 deletions(-) delete mode 100644 internal/playlists/system_cron.go diff --git a/cmd/minstrel/main.go b/cmd/minstrel/main.go index 159a7250..b5288985 100644 --- a/cmd/minstrel/main.go +++ b/cmd/minstrel/main.go @@ -198,7 +198,6 @@ func run() error { }, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, cfg.Library.CoverArtBackfillCap, coverSettings, scanner, scanCfg, scheduler) srv.Bus = bus srv.PlaylistScheduler = playlistScheduler - playlists.StartSystemPlaylistCron(ctx, pool, logger.With("component", "system_playlist_cron"), cfg.Storage.DataDir) httpServer := &http.Server{ Addr: cfg.Server.Address, Handler: srv.Router(), diff --git a/internal/api/auth_register.go b/internal/api/auth_register.go index cc98ee2b..849573c1 100644 --- a/internal/api/auth_register.go +++ b/internal/api/auth_register.go @@ -202,6 +202,17 @@ func (h *handlers) handleRegister(w http.ResponseWriter, r *http.Request) { map[string]any{"token": usedInviteToken}) } + // Register the new user with the playlist scheduler so their daily + // builds are scheduled without waiting for the hourly reconciliation + // pass. Failure here is logged but doesn't fail registration — the + // next hourly pass will pick them up. + if h.playlistScheduler != nil { + if err := h.playlistScheduler.Refresh(r.Context(), user.ID); err != nil { + h.logger.Warn("api: scheduler refresh after registration", + "user_id", uuidToString(user.ID), "err", err) + } + } + writeJSON(w, http.StatusOK, LoginResponse{ Token: sessionToken, User: UserView{ diff --git a/internal/api/me_timezone.go b/internal/api/me_timezone.go index 8f5cc5fa..60cfe06e 100644 --- a/internal/api/me_timezone.go +++ b/internal/api/me_timezone.go @@ -56,5 +56,15 @@ func (h *handlers) handlePutTimezone(w http.ResponseWriter, r *http.Request) { return } + // Reschedule the user's daily build at their new timezone. Failure + // here doesn't undo the DB write — the hourly reconciliation pass + // would pick up the new tz within an hour at worst. + if h.playlistScheduler != nil { + if err := h.playlistScheduler.Refresh(r.Context(), user.ID); err != nil { + h.logger.Warn("api: scheduler refresh after timezone update", + "user_id", uuidToString(user.ID), "err", err) + } + } + w.WriteHeader(http.StatusNoContent) } diff --git a/internal/playlists/system_cron.go b/internal/playlists/system_cron.go deleted file mode 100644 index 8ac11426..00000000 --- a/internal/playlists/system_cron.go +++ /dev/null @@ -1,61 +0,0 @@ -package playlists - -import ( - "context" - "log/slog" - "time" - - "github.com/jackc/pgx/v5/pgxpool" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -// StartSystemPlaylistCron launches the in-process daily build loop in a -// goroutine. Caller passes a context that's cancelled on graceful shutdown. -// -// Lifecycle: -// 1. Clear any stale in_flight=true rows from a previously crashed process -// (safe at startup: by definition, nothing is running yet). -// 2. Run once at startup so users catch up after a server restart without -// waiting up to 24h. -// 3. Tick every 24h; runOnce on each tick. -// 4. Exit on context cancellation. -// -// Per-user errors are logged but don't halt the loop. Per-cycle errors (e.g., -// "list active users" failed) are logged but the next tick still fires. -func StartSystemPlaylistCron(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, dataDir string) { - go func() { - q := dbq.New(pool) - if err := q.ClearStaleSystemPlaylistInFlight(ctx); err != nil { - logger.Warn("system playlist cron: startup recovery failed", "err", err) - } - - runOnce(ctx, pool, logger, dataDir) - - ticker := time.NewTicker(24 * time.Hour) - defer ticker.Stop() - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - runOnce(ctx, pool, logger, dataDir) - } - } - }() -} - -func runOnce(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, dataDir string) { - q := dbq.New(pool) - users, err := q.ListActiveUsersForSystemPlaylists(ctx) - if err != nil { - logger.Error("system playlist cron: list active users failed", "err", err) - return - } - for _, userID := range users { - if err := BuildSystemPlaylists(ctx, pool, logger, userID, time.Now(), dataDir); err != nil { - logger.Warn("system playlist build failed in cron", - "user_id", uuidStringPL(userID), "err", err) - } - } -}