From 46c8edfa82ff96491f3e3323ad54b9b64bd16931 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 13 May 2026 11:57:13 -0400 Subject: [PATCH] feat(playlists): gocron-based per-user scheduler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds the per-user daily-at-03:00-local scheduler for #392 Half B. Uses github.com/go-co-op/gocron/v2 with WithLocation(userTZ) for each user's job. Hourly reconciliation pass keeps the in-memory job set in sync with the active-users query. Start sequence: clear stale in_flight rows; register a daily job for every active user at their stored timezone; fire a one-shot runOnce to catch up missed schedules during downtime; start gocron. Stop drains and shuts down the gocron loop. Refresh(ctx, userID) removes the user's existing job (if any) and registers a fresh one at their current timezone — wired into the PUT /api/me/timezone handler and new-user registration in the next commit. Server struct gains PlaylistScheduler; main.go constructs it after the eventbus and threads it through to api.Mount. The existing StartSystemPlaylistCron call stays for one more commit so we don't have a window where no scheduler is running; Task 3 deletes it. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/minstrel/main.go | 14 ++ go.mod | 6 +- go.sum | 14 +- internal/api/api.go | 4 +- internal/api/library_test.go | 2 +- internal/playlists/scheduler.go | 246 +++++++++++++++++++++++++++ internal/playlists/scheduler_test.go | 51 ++++++ internal/server/server.go | 8 +- 8 files changed, 339 insertions(+), 6 deletions(-) create mode 100644 internal/playlists/scheduler.go create mode 100644 internal/playlists/scheduler_test.go diff --git a/cmd/minstrel/main.go b/cmd/minstrel/main.go index f0edb417..159a7250 100644 --- a/cmd/minstrel/main.go +++ b/cmd/minstrel/main.go @@ -159,6 +159,19 @@ func run() error { lidarrReconciler := lidarrrequests.NewReconciler(pool, lidarrCfg, logger.With("component", "lidarr"), bus) go lidarrReconciler.Run(ctx) + // Per-user system-playlist scheduler (#392 Half B). Fires each + // active user's daily build at 03:00 in their stored timezone. + // Replaces the 24h-anchored cron loop (removed in the next commit + // of this arc). + playlistScheduler, err := playlists.NewScheduler(pool, logger.With("component", "playlist_scheduler"), cfg.Storage.DataDir) + if err != nil { + return fmt.Errorf("init playlist scheduler: %w", err) + } + if err := playlistScheduler.Start(ctx); err != nil { + return fmt.Errorf("start playlist scheduler: %w", err) + } + defer playlistScheduler.Stop() + // Ensure DataDir exists before any service tries to write into it. // Fatal on failure: a non-writable data_dir silently breaks every // downstream cache (playlist covers, artist art, album-cover fallback) @@ -184,6 +197,7 @@ func run() error { AllowPlaintextPassword: cfg.Subsonic.AllowPlaintextPassword, }, 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, diff --git a/go.mod b/go.mod index f0b33727..78ceac27 100644 --- a/go.mod +++ b/go.mod @@ -5,10 +5,12 @@ go 1.23.0 require ( github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 github.com/go-chi/chi/v5 v5.2.5 + github.com/go-co-op/gocron/v2 v2.21.2 github.com/golang-migrate/migrate/v4 v4.18.2 + github.com/google/uuid v1.6.0 github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa github.com/jackc/pgx/v5 v5.7.4 - github.com/stretchr/testify v1.9.0 + github.com/stretchr/testify v1.11.1 golang.org/x/crypto v0.35.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -20,7 +22,9 @@ require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jonboulle/clockwork v0.5.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect go.uber.org/atomic v1.7.0 // indirect golang.org/x/sync v0.11.0 // indirect diff --git a/go.sum b/go.sum index d305e049..d3b61c32 100644 --- a/go.sum +++ b/go.sum @@ -21,6 +21,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-co-op/gocron/v2 v2.21.2 h1:bD8/YwkojYHgXFr3iEulL148KBdTbKVxUZzFKpXcdbY= +github.com/go-co-op/gocron/v2 v2.21.2/go.mod h1:5lEiCKk1oVJV39Zg7/YG10OnaVrDAV5GGR6O0663k6U= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -29,6 +31,8 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8= github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -44,6 +48,8 @@ github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg= github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= +github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -64,13 +70,15 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= @@ -81,6 +89,8 @@ go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt3 go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= diff --git a/internal/api/api.go b/internal/api/api.go index dafbea68..e85579a8 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -28,7 +28,7 @@ import ( // Mount attaches /api/* handlers to r. Public endpoints (login) are outside // RequireUser; everything else is gated by the middleware. The events writer // is shared with the Subsonic mount so /rest/scrobble feeds the same store. -func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverBackfillCap int, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, scheduler *library.Scheduler, dataDir string, sender mailer.Sender, bus *eventbus.Bus) { +func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverBackfillCap int, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, scheduler *library.Scheduler, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler) { rng := rand.New(rand.NewSource(rand.Int63())) h := &handlers{ pool: pool, logger: logger, events: events, recCfg: recCfg, @@ -47,6 +47,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev dataDir: dataDir, mailer: sender, eventbus: bus, + playlistScheduler: playlistScheduler, } r.Route("/api", func(api chi.Router) { @@ -196,4 +197,5 @@ type handlers struct { dataDir string mailer mailer.Sender eventbus *eventbus.Bus + playlistScheduler *playlists.Scheduler } diff --git a/internal/api/library_test.go b/internal/api/library_test.go index 7f61ae6d..4cc8a3c5 100644 --- a/internal/api/library_test.go +++ b/internal/api/library_test.go @@ -444,7 +444,7 @@ func TestRoutesRegisteredInMount(t *testing.T) { r := chi.NewRouter() w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)), 30*time.Minute, 0.5, 30000) - Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverArtBackfillCap, h.coverSettings, h.scanner, h.scanCfg, nil, h.dataDir, nil, eventbus.New()) + Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverArtBackfillCap, h.coverSettings, h.scanner, h.scanCfg, nil, h.dataDir, nil, eventbus.New(), nil) paths := []string{ "/api/artists", diff --git a/internal/playlists/scheduler.go b/internal/playlists/scheduler.go new file mode 100644 index 00000000..ccf4139e --- /dev/null +++ b/internal/playlists/scheduler.go @@ -0,0 +1,246 @@ +// Per-user system-playlist scheduler (#392 Half B). Replaces the +// 24h-anchored cron loop in system_cron.go (deleted in the same arc). +// +// Each active user gets one gocron daily job at 03:00 in their stored +// timezone. The scheduler reconciles its in-memory job set with the +// "active users" query once per hour so newly-active / no-longer- +// active users are picked up without restarts. New-user registration +// and PUT /api/me/timezone both call Refresh() so changes take effect +// synchronously without waiting for the hourly pass. +// +// Scheduler ownership: cmd/minstrel/main.go constructs one instance +// at startup, calls Start, and stops it on graceful shutdown. The +// server.Server struct holds a pointer so the API layer can call +// Refresh from handlers. + +package playlists + +import ( + "context" + "fmt" + "log/slog" + "sync" + "time" + + "github.com/go-co-op/gocron/v2" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// dailyRebuildHour is the local-time hour at which each user's system +// playlists rebuild. 03:00 is well past most "today" listening and +// before morning use; matches the operator framing in the design doc. +const dailyRebuildHour = 3 + +// reconciliationInterval drives the hourly pass that brings the +// in-memory job set in sync with the live "active users" query. +const reconciliationInterval = time.Hour + +// Scheduler owns one gocron daily job per active user. Refresh and +// the hourly reconciliation pass mutate s.jobs under s.mu. +type Scheduler struct { + gocron gocron.Scheduler + pool *pgxpool.Pool + logger *slog.Logger + dataDir string + + mu sync.Mutex + jobs map[pgtype.UUID]uuid.UUID // user_id → gocron job id +} + +// NewScheduler builds an idle scheduler. Caller must invoke Start +// before any builds fire. +func NewScheduler(pool *pgxpool.Pool, logger *slog.Logger, dataDir string) (*Scheduler, error) { + g, err := gocron.NewScheduler() + if err != nil { + return nil, fmt.Errorf("init gocron: %w", err) + } + return &Scheduler{ + gocron: g, + pool: pool, + logger: logger, + dataDir: dataDir, + jobs: map[pgtype.UUID]uuid.UUID{}, + }, nil +} + +// Start clears any stale in_flight rows from a previous crash, +// registers a daily-3am job for every active user at their stored +// timezone, fires a one-shot runOnce to catch up missed schedules, +// and starts the gocron loop. +// +// ctx scopes BuildSystemPlaylists calls fired by the jobs. Cancel +// the parent context to drain in-progress builds on shutdown. +func (s *Scheduler) Start(ctx context.Context) error { + q := dbq.New(s.pool) + if err := q.ClearStaleSystemPlaylistInFlight(ctx); err != nil { + s.logger.Warn("scheduler: clear stale in_flight failed", "err", err) + // Non-fatal — TryClaimSystemPlaylistRun will block any + // concurrent build attempts; stale rows just stay stuck. + } + + users, err := q.ListActiveUsersWithTimezones(ctx) + if err != nil { + return fmt.Errorf("list active users: %w", err) + } + + s.mu.Lock() + for _, u := range users { + if err := s.registerLocked(ctx, u.ID, u.Timezone); err != nil { + s.logger.Warn("scheduler: register at startup failed", + "user_id", uuidStringPL(u.ID), "err", err) + } + } + s.mu.Unlock() + + // One-shot startup catch-up: build for every active user right + // now, regardless of their scheduled time. Mirrors the existing + // system_cron.go startup runOnce so users who missed yesterday's + // schedule (process down) get caught up. + go s.runStartupCatchUp(ctx, users) + + // Hourly reconciliation pass. + if _, err := s.gocron.NewJob( + gocron.DurationJob(reconciliationInterval), + gocron.NewTask(func() { + s.reconcile(ctx) + }), + ); err != nil { + return fmt.Errorf("register reconciliation job: %w", err) + } + + s.gocron.Start() + s.logger.Info("scheduler: started", "users", len(users)) + return nil +} + +// Refresh removes the user's existing job (if any) and registers a +// fresh one at their current timezone. Safe to call from any +// goroutine. Returns an error only on DB lookup failure; gocron-side +// errors are logged but not propagated since they don't represent +// recoverable conditions for the caller. +func (s *Scheduler) Refresh(ctx context.Context, userID pgtype.UUID) error { + q := dbq.New(s.pool) + user, err := q.GetUserByID(ctx, userID) + if err != nil { + return fmt.Errorf("get user: %w", err) + } + s.mu.Lock() + defer s.mu.Unlock() + return s.registerLocked(ctx, userID, user.Timezone) +} + +// registerLocked replaces the user's job entry. Caller must hold s.mu. +func (s *Scheduler) registerLocked(ctx context.Context, userID pgtype.UUID, tz string) error { + if existing, ok := s.jobs[userID]; ok { + if err := s.gocron.RemoveJob(existing); err != nil { + s.logger.Warn("scheduler: remove existing job failed", + "user_id", uuidStringPL(userID), "err", err) + } + delete(s.jobs, userID) + } + loc := validateTimezoneOrUTC(tz) + job, err := s.gocron.NewJob( + gocron.DailyJob(1, gocron.NewAtTimes( + gocron.NewAtTime(dailyRebuildHour, 0, 0), + )), + gocron.NewTask(func() { + if err := BuildSystemPlaylists(ctx, s.pool, s.logger, userID, time.Now(), s.dataDir); err != nil { + s.logger.Warn("scheduler: build failed", + "user_id", uuidStringPL(userID), "err", err) + } + }), + gocron.WithLocation(loc), + ) + if err != nil { + return fmt.Errorf("register daily job: %w", err) + } + s.jobs[userID] = job.ID() + return nil +} + +// reconcile syncs the in-memory job set with the live active-users +// query. Newly-active users get jobs; no-longer-active users have +// their jobs removed. Runs hourly. +func (s *Scheduler) reconcile(ctx context.Context) { + q := dbq.New(s.pool) + users, err := q.ListActiveUsersWithTimezones(ctx) + if err != nil { + s.logger.Warn("scheduler: reconcile list failed", "err", err) + return + } + + active := make(map[pgtype.UUID]string, len(users)) + for _, u := range users { + active[u.ID] = u.Timezone + } + + s.mu.Lock() + defer s.mu.Unlock() + + // Add users newly active. + for id, tz := range active { + if _, ok := s.jobs[id]; !ok { + if err := s.registerLocked(ctx, id, tz); err != nil { + s.logger.Warn("scheduler: reconcile add failed", + "user_id", uuidStringPL(id), "err", err) + } + } + } + + // Remove users no longer active. + for id := range s.jobs { + if _, ok := active[id]; !ok { + if jobID, exists := s.jobs[id]; exists { + if err := s.gocron.RemoveJob(jobID); err != nil { + s.logger.Warn("scheduler: reconcile remove failed", + "user_id", uuidStringPL(id), "err", err) + } + delete(s.jobs, id) + } + } + } +} + +// runStartupCatchUp fires BuildSystemPlaylists once for every active +// user at startup, regardless of scheduled time. Matches the +// behavior of the previous system_cron.go startup runOnce so users +// whose scheduled fire was missed during downtime get caught up. +func (s *Scheduler) runStartupCatchUp(ctx context.Context, users []dbq.ListActiveUsersWithTimezonesRow) { + for _, u := range users { + if err := BuildSystemPlaylists(ctx, s.pool, s.logger, u.ID, time.Now(), s.dataDir); err != nil { + s.logger.Warn("scheduler: startup catch-up build failed", + "user_id", uuidStringPL(u.ID), "err", err) + } + } +} + +// Stop drains gocron and stops the scheduler loop. +func (s *Scheduler) Stop() { + if err := s.gocron.Shutdown(); err != nil { + s.logger.Warn("scheduler: shutdown failed", "err", err) + } +} + +// validateTimezone returns the parsed Location for tz, or an error. +// Callers should use this when accepting input from clients. +func validateTimezone(tz string) (*time.Location, error) { + if tz == "" { + return nil, fmt.Errorf("timezone is empty") + } + return time.LoadLocation(tz) +} + +// validateTimezoneOrUTC returns the parsed Location for tz, or +// time.UTC if parsing fails. Used internally by the scheduler so +// corrupted DB values don't crash the process. +func validateTimezoneOrUTC(tz string) *time.Location { + loc, err := validateTimezone(tz) + if err != nil { + return time.UTC + } + return loc +} diff --git a/internal/playlists/scheduler_test.go b/internal/playlists/scheduler_test.go new file mode 100644 index 00000000..9ec232b3 --- /dev/null +++ b/internal/playlists/scheduler_test.go @@ -0,0 +1,51 @@ +package playlists + +import ( + "testing" +) + +func TestValidateTimezone_Valid(t *testing.T) { + cases := []string{"UTC", "America/New_York", "Europe/London", "Asia/Tokyo"} + for _, tz := range cases { + t.Run(tz, func(t *testing.T) { + loc, err := validateTimezone(tz) + if err != nil { + t.Fatalf("validateTimezone(%q): %v", tz, err) + } + if loc == nil { + t.Fatalf("validateTimezone(%q): nil location", tz) + } + }) + } +} + +func TestValidateTimezone_Invalid(t *testing.T) { + cases := []string{"", "Not/A/Zone", "GMT+5", "EST5EDT-not-iana"} + for _, tz := range cases { + t.Run(tz, func(t *testing.T) { + _, err := validateTimezone(tz) + if err == nil { + t.Fatalf("validateTimezone(%q): expected error, got nil", tz) + } + }) + } +} + +func TestValidateTimezoneOrUTC_FallbackToUTC(t *testing.T) { + // validateTimezoneOrUTC returns UTC for any unparseable input. + // Used by the scheduler when reading stored timezones — corrupted + // DB values should not crash; they should fall back to UTC. + loc := validateTimezoneOrUTC("Not/A/Zone") + if loc.String() != "UTC" { + t.Errorf("expected UTC fallback for invalid input, got %s", loc) + } + + // Valid input returns the named location, not UTC. + loc = validateTimezoneOrUTC("America/New_York") + if loc.String() == "UTC" { + t.Errorf("valid input incorrectly fell back to UTC") + } + if loc.String() != "America/New_York" { + t.Errorf("expected America/New_York, got %s", loc) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 8a49630a..8e6f5730 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -84,6 +84,12 @@ type Server struct { // lidarr reconciler, scan scheduler) constructed in cmd/minstrel/main.go. // When nil, Router() constructs a local fallback (test contexts). Bus *eventbus.Bus + // PlaylistScheduler fires per-user daily system-playlist builds at + // 03:00 in each user's stored timezone (#392 Half B). Constructed + // in cmd/minstrel/main.go and threaded into the API handlers so + // PUT /api/me/timezone and POST /api/auth/register can call + // Refresh synchronously. + PlaylistScheduler *playlists.Scheduler } func New(logger *slog.Logger, pool *pgxpool.Pool, scanner ScanTrigger, subCfg subsonic.Config, eventsCfg config.EventsConfig, recCfg config.RecommendationConfig, dataDir string, brandingCfg config.BrandingConfig, coverEnricher *coverart.Enricher, coverArtBackfillCap int, coverSettings *coverart.SettingsService, libraryScanner *library.Scanner, scanCfg library.RunScanConfig, scheduler *library.Scheduler) *Server { @@ -133,7 +139,7 @@ func (s *Server) Router() http.Handler { if bus == nil { bus = eventbus.New() } - api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverArtBackfillCap, s.CoverSettings, s.LibraryScanner, s.ScanCfg, s.Scheduler, s.DataDir, smtpSender, bus) + api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverArtBackfillCap, s.CoverSettings, s.LibraryScanner, s.ScanCfg, s.Scheduler, s.DataDir, smtpSender, bus, s.PlaylistScheduler) // /api/admin/scan is the only admin route owned by the server package // (it needs the Scanner). Register it as a single inline-middleware // route — using r.Route("/api/admin", ...) here would create a second