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:
parent
8053bf339c
commit
0514931140
2 changed files with 83 additions and 7 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue