Auto-wishlist airing: run at a fixed daily 1am, not a rolling 24h interval

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.
This commit is contained in:
BoulderBadgeDad 2026-06-21 11:41:34 -07:00
parent 8053bf339c
commit 0514931140
2 changed files with 83 additions and 7 deletions

View file

@ -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)

View file

@ -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()