diff --git a/internal/playlists/scheduler.go b/internal/playlists/scheduler.go index ccf4139e..96376584 100644 --- a/internal/playlists/scheduler.go +++ b/internal/playlists/scheduler.go @@ -134,6 +134,12 @@ func (s *Scheduler) Refresh(ctx context.Context, userID pgtype.UUID) error { } // registerLocked replaces the user's job entry. Caller must hold s.mu. +// +// gocron v2 doesn't expose a per-job location option — WithLocation is +// a SchedulerOption that affects every job. To get per-user timezones +// we use a cron expression with the CRON_TZ= prefix, which the +// underlying robfig/cron parser honors. The expression "CRON_TZ=$tz +// 0 3 * * *" fires at 03:00 in the named zone every day. 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 { @@ -142,18 +148,21 @@ func (s *Scheduler) registerLocked(ctx context.Context, userID pgtype.UUID, tz s } delete(s.jobs, userID) } - loc := validateTimezoneOrUTC(tz) + // Fall back to UTC if the stored tz is unparseable so a corrupted + // DB row doesn't error out registration. + resolvedTZ := "UTC" + if _, err := validateTimezone(tz); err == nil { + resolvedTZ = tz + } + cronExpr := fmt.Sprintf("CRON_TZ=%s 0 %d * * *", resolvedTZ, dailyRebuildHour) job, err := s.gocron.NewJob( - gocron.DailyJob(1, gocron.NewAtTimes( - gocron.NewAtTime(dailyRebuildHour, 0, 0), - )), + gocron.CronJob(cronExpr, false /* withSeconds */), 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)