feat(playlists): wire Refresh into PUT /api/me/timezone + registration
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) <noreply@anthropic.com>
This commit is contained in:
@@ -198,7 +198,6 @@ func run() error {
|
|||||||
}, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, cfg.Library.CoverArtBackfillCap, coverSettings, scanner, scanCfg, scheduler)
|
}, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, cfg.Library.CoverArtBackfillCap, coverSettings, scanner, scanCfg, scheduler)
|
||||||
srv.Bus = bus
|
srv.Bus = bus
|
||||||
srv.PlaylistScheduler = playlistScheduler
|
srv.PlaylistScheduler = playlistScheduler
|
||||||
playlists.StartSystemPlaylistCron(ctx, pool, logger.With("component", "system_playlist_cron"), cfg.Storage.DataDir)
|
|
||||||
httpServer := &http.Server{
|
httpServer := &http.Server{
|
||||||
Addr: cfg.Server.Address,
|
Addr: cfg.Server.Address,
|
||||||
Handler: srv.Router(),
|
Handler: srv.Router(),
|
||||||
|
|||||||
@@ -202,6 +202,17 @@ func (h *handlers) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|||||||
map[string]any{"token": usedInviteToken})
|
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{
|
writeJSON(w, http.StatusOK, LoginResponse{
|
||||||
Token: sessionToken,
|
Token: sessionToken,
|
||||||
User: UserView{
|
User: UserView{
|
||||||
|
|||||||
@@ -56,5 +56,15 @@ func (h *handlers) handlePutTimezone(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user