From 0514931140841c98e7f75ee9f4c958b65f1dfde8 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Sun, 21 Jun 2026 11:41:34 -0700 Subject: [PATCH] Auto-wishlist airing: run at a fixed daily 1am, not a rolling 24h interval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The job shipped as a 24h 'schedule' because the system-automation seeder only armed next_run for interval specs — a 'daily_time' spec sat idle and never fired. The interval fired reliably but drifted with every restart (5min after startup, then +24h) instead of a fixed wall-clock time, which is worse for 'today's airings' (you want it queued overnight). Fix, the robust way: - Seeder now arms timed system triggers (daily/weekly/monthly) via next_run_at, not just interval ones. Event-based triggers still return None and are left alone. - Spec -> daily_time {time:'01:00'} for fresh installs. - _fix_airing_automation_schedule migrates the existing 24h-interval row to daily 01:00 (the seeder only creates rows, never updates a drifted trigger). Idempotent. _finish_run already reschedules daily_time to the next 1am, so it stays pinned. --- core/automation_engine.py | 53 ++++++++++++++++--- .../test_engine_schedule_integration.py | 37 +++++++++++++ 2 files changed, 83 insertions(+), 7 deletions(-) diff --git a/core/automation_engine.py b/core/automation_engine.py index d1c8e0b6..01c3f473 100644 --- a/core/automation_engine.py +++ b/core/automation_engine.py @@ -170,15 +170,16 @@ SYSTEM_AUTOMATIONS = [ 'action_config': {'mode': 'incremental'}, 'owned_by': 'video', }, - # Sonarr-style: once a day, wishlist every episode airing today for the shows you - # follow (skipping ones already owned) so they queue up to be grabbed. Uses a 24h - # 'schedule' (not 'daily_time', which the seeder doesn't arm) so it actually fires. + # Sonarr-style: once a day at 1am (server-local), wishlist every episode airing + # today for the shows you follow (skipping ones already owned) so the day's episodes + # are queued overnight. A fixed wall-clock 'daily_time' (not a rolling 24h interval + # that drifts with restarts) — the seeder now arms timed system triggers, and + # _fix_airing_automation_schedule migrates the old 24h-interval row. { 'name': 'Auto-Wishlist Episodes Airing Today', - 'trigger_type': 'schedule', - 'trigger_config': {'interval': 24, 'unit': 'hours'}, + 'trigger_type': 'daily_time', + 'trigger_config': {'time': '01:00'}, 'action_type': 'video_add_airing_episodes', - 'initial_delay': 300, # 5 minutes after startup, then daily 'owned_by': 'video', }, ] @@ -323,8 +324,21 @@ class AutomationEngine: self.db.update_automation(existing['id'], next_run=nr) logger.info(f"System automation '{spec['name']}' next_run set to {initial_delay}s from now") else: - logger.info(f"System automation '{spec['name']}' ready (event-based)") + # No initial_delay. A timer-based timed trigger (daily/weekly/ + # monthly at a fixed wall-clock time) still needs its next_run + # armed — compute it from the schedule. Genuinely event-based + # triggers (batch_complete, scan_done) have no next_run, so + # next_run_at returns None and we leave them alone. Don't clobber + # an existing future next_run (manual edit / restart-resume). + nr_dt = next_run_at(spec['trigger_type'], spec['trigger_config'], + now_utc=_utcnow(), default_tz=self._default_tz) + if nr_dt is not None and not existing.get('next_run'): + self.db.update_automation(existing['id'], next_run=_dt_to_db_str(nr_dt)) + logger.info(f"System automation '{spec['name']}' next_run armed for {_dt_to_db_str(nr_dt)} (timed)") + else: + logger.info(f"System automation '{spec['name']}' ready (event-based)") self._fix_video_scan_default() + self._fix_airing_automation_schedule() def _fix_video_scan_default(self): """Remove the obsolete standalone 'Scan Video Library' SYSTEM automation — it's @@ -345,6 +359,31 @@ class AutomationEngine: except Exception: logger.exception("video scan cleanup failed") + def _fix_airing_automation_schedule(self): + """Migrate 'Auto-Wishlist Episodes Airing Today' from the old rolling 24h + interval to a fixed daily 1am run. + + It originally shipped as a 'schedule'/24h interval because the seeder only + armed interval specs — a 'daily_time' spec sat idle and never fired. The 24h + interval fires reliably but at a time that drifts with every restart (5 min + after startup, then +24h). Now that the seeder arms timed triggers, rewrite + the live row to run at a fixed 1am (better for 'today's airings' — queues the + day overnight). Matches only the is_system row; idempotent (no-op once the row + is already daily_time).""" + try: + auto = self.db.get_system_automation_by_action('video_add_airing_episodes') + if not auto or auto.get('trigger_type') == 'daily_time': + return + cfg = {'time': '01:00'} + nr_dt = next_run_at('daily_time', cfg, now_utc=_utcnow(), default_tz=self._default_tz) + self.db.update_automation( + auto['id'], trigger_type='daily_time', trigger_config=json.dumps(cfg), + next_run=_dt_to_db_str(nr_dt) if nr_dt is not None else None) + logger.info("Migrated 'Auto-Wishlist Episodes Airing Today' to a fixed daily 01:00 (id=%s)", + auto.get('id')) + except Exception: + logger.exception("airing automation schedule migration failed") + def get_system_automation_next_run_seconds(self, action_type): """Get seconds until next run for a system automation. Returns 0 if not found or disabled.""" auto = self.db.get_system_automation_by_action(action_type) diff --git a/tests/automation/test_engine_schedule_integration.py b/tests/automation/test_engine_schedule_integration.py index 7eba1f03..5e55ca54 100644 --- a/tests/automation/test_engine_schedule_integration.py +++ b/tests/automation/test_engine_schedule_integration.py @@ -453,3 +453,40 @@ def test_video_scan_library_system_automation_is_cleaned_up(): db2.get_system_automation_by_action.return_value = None AutomationEngine(db2)._fix_video_scan_default() db2.delete_automation.assert_not_called() + + +def test_airing_automation_migrates_24h_interval_to_daily_1am(): + """The old rolling-24h 'Auto-Wishlist Episodes Airing Today' row is rewritten to a + fixed daily 01:00 (server-local) so it stops drifting with restarts.""" + db = MagicMock() + db.get_system_automation_by_action.return_value = { + 'id': 42, 'is_system': 1, 'trigger_type': 'schedule', + 'trigger_config': json.dumps({'interval': 24, 'unit': 'hours'})} + eng = AutomationEngine(db) + eng._default_tz = 'UTC' + eng._fix_airing_automation_schedule() + + db.update_automation.assert_called_once() + args, kwargs = db.update_automation.call_args + assert args[0] == 42 + assert kwargs['trigger_type'] == 'daily_time' + assert json.loads(kwargs['trigger_config']) == {'time': '01:00'} + # next_run is armed for 01:00 UTC (the next occurrence) + assert kwargs['next_run'].endswith(' 01:00:00') + + +def test_airing_automation_migration_is_idempotent(): + """Once the row is already daily_time, re-running the migration is a no-op (it + must not rewrite a user's edited time or re-arm next_run every startup).""" + db = MagicMock() + db.get_system_automation_by_action.return_value = { + 'id': 42, 'is_system': 1, 'trigger_type': 'daily_time', + 'trigger_config': json.dumps({'time': '06:30'})} + AutomationEngine(db)._fix_airing_automation_schedule() + db.update_automation.assert_not_called() + + # absent (fresh install, created straight as daily_time) → no-op, never errors + db2 = MagicMock() + db2.get_system_automation_by_action.return_value = None + AutomationEngine(db2)._fix_airing_automation_schedule() + db2.update_automation.assert_not_called()