Video deep scans: fixed weekly times (TV Mon 2am, Movies Tue 2am)

Switch the two deep-scan system automations from a rolling 7-day interval to
weekly_time at 02:00 server-local — TV Mondays, Movies Tuesdays. Different days
means they never overlap, and a fixed wall-clock time doesn't drift with restarts.
Drop initial_delay (the seeder arms timed system triggers). _fix_deep_scan_schedules
migrates the original interval rows to the weekly schedule (the seeder only creates
rows, never updates a drifted trigger); it skips once trigger_type is weekly_time so
a hand-tuned day/time sticks. Idempotent.
This commit is contained in:
BoulderBadgeDad 2026-06-21 12:39:53 -07:00
parent b6320d1a30
commit cf47032660
2 changed files with 78 additions and 15 deletions

View file

@ -184,25 +184,24 @@ SYSTEM_AUTOMATIONS = [
},
# Video twin of music's 'Auto-Deep Scan Library', split into TWO because Movies
# and TV are independent libraries — a TV scan never pulls in new movies and
# vice-versa. Weekly deep scan (re-read + prune removed). Initial delays are
# staggered so they don't collide on startup; the singleton scanner also skips a
# run that overlaps the other (handler returns 'skipped').
# vice-versa. Fixed weekly deep scan (re-read + prune removed) at 02:00 server-
# local: TV Mondays, Movies Tuesdays — different days so they never overlap. The
# seeder arms timed system triggers; _fix_deep_scan_schedules migrates the
# original rolling-7-day rows. (Busy guard in the scanner is still a safety net.)
{
'name': 'Auto-Deep Scan Movie Library',
'trigger_type': 'schedule',
'trigger_config': {'interval': 7, 'unit': 'days'},
'action_type': 'video_deep_scan_movies',
'action_config': {'mode': 'deep', 'media_type': 'movie'},
'initial_delay': 1500, # 25 min after startup
'name': 'Auto-Deep Scan TV Library',
'trigger_type': 'weekly_time',
'trigger_config': {'time': '02:00', 'days': ['mon']},
'action_type': 'video_deep_scan_tv',
'action_config': {'mode': 'deep', 'media_type': 'show'},
'owned_by': 'video',
},
{
'name': 'Auto-Deep Scan TV Library',
'trigger_type': 'schedule',
'trigger_config': {'interval': 7, 'unit': 'days'},
'action_type': 'video_deep_scan_tv',
'action_config': {'mode': 'deep', 'media_type': 'show'},
'initial_delay': 2400, # 40 min after startup (staggered past the movie scan)
'name': 'Auto-Deep Scan Movie Library',
'trigger_type': 'weekly_time',
'trigger_config': {'time': '02:00', 'days': ['tue']},
'action_type': 'video_deep_scan_movies',
'action_config': {'mode': 'deep', 'media_type': 'movie'},
'owned_by': 'video',
},
]
@ -362,6 +361,7 @@ class AutomationEngine:
logger.info(f"System automation '{spec['name']}' ready (event-based)")
self._fix_video_scan_default()
self._fix_airing_automation_schedule()
self._fix_deep_scan_schedules()
def _fix_video_scan_default(self):
"""Remove the obsolete standalone 'Scan Video Library' SYSTEM automation — it's
@ -407,6 +407,31 @@ class AutomationEngine:
except Exception:
logger.exception("airing automation schedule migration failed")
def _fix_deep_scan_schedules(self):
"""Migrate the two video deep-scan system automations from the original
rolling 7-day interval to fixed weekly times TV Mondays 02:00, Movies
Tuesdays 02:00 (different days so they never overlap). The seeder only
creates rows, never updates a drifted trigger; this rewrites the live rows.
Only converts the original interval rows (skips once trigger_type is already
weekly_time, so a hand-tuned day/time sticks). Idempotent."""
targets = {
'video_deep_scan_tv': {'time': '02:00', 'days': ['mon']},
'video_deep_scan_movies': {'time': '02:00', 'days': ['tue']},
}
for action_type, cfg in targets.items():
try:
auto = self.db.get_system_automation_by_action(action_type)
if not auto or auto.get('trigger_type') == 'weekly_time':
continue
nr_dt = next_run_at('weekly_time', cfg, now_utc=_utcnow(), default_tz=self._default_tz)
self.db.update_automation(
auto['id'], trigger_type='weekly_time', trigger_config=json.dumps(cfg),
next_run=_dt_to_db_str(nr_dt) if nr_dt is not None else None)
logger.info("Set '%s' to weekly %s %s (id=%s)", auto.get('name'),
cfg['days'], cfg['time'], auto.get('id'))
except Exception:
logger.exception("deep-scan schedule migration failed for %s", action_type)
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

@ -490,3 +490,41 @@ def test_airing_automation_migration_is_idempotent():
db2.get_system_automation_by_action.return_value = None
AutomationEngine(db2)._fix_airing_automation_schedule()
db2.update_automation.assert_not_called()
def test_deep_scans_migrate_to_fixed_weekly_times():
"""The two video deep scans move from a rolling 7-day interval to fixed weekly
times TV Mondays 02:00, Movies Tuesdays 02:00."""
rows = {
'video_deep_scan_tv': {'id': 7, 'is_system': 1, 'trigger_type': 'schedule',
'trigger_config': json.dumps({'interval': 7, 'unit': 'days'})},
'video_deep_scan_movies': {'id': 8, 'is_system': 1, 'trigger_type': 'schedule',
'trigger_config': json.dumps({'interval': 7, 'unit': 'days'})},
}
db = MagicMock()
db.get_system_automation_by_action.side_effect = lambda a: rows.get(a)
eng = AutomationEngine(db)
eng._default_tz = 'UTC'
eng._fix_deep_scan_schedules()
by_id = {c.args[0]: c.kwargs for c in db.update_automation.call_args_list}
assert json.loads(by_id[7]['trigger_config']) == {'time': '02:00', 'days': ['mon']} # TV → Mon
assert json.loads(by_id[8]['trigger_config']) == {'time': '02:00', 'days': ['tue']} # Movies → Tue
assert by_id[7]['trigger_type'] == 'weekly_time' and by_id[8]['trigger_type'] == 'weekly_time'
assert by_id[7]['next_run'].endswith(' 02:00:00') and by_id[8]['next_run'].endswith(' 02:00:00')
def test_deep_scan_migration_is_idempotent_and_safe_when_absent():
# already weekly_time → no-op (a hand-tuned day/time isn't reverted)
db = MagicMock()
db.get_system_automation_by_action.side_effect = lambda a: {
'id': 7, 'is_system': 1, 'trigger_type': 'weekly_time',
'trigger_config': json.dumps({'time': '05:00', 'days': ['sat']})}
AutomationEngine(db)._fix_deep_scan_schedules()
db.update_automation.assert_not_called()
# absent (not seeded yet) → no-op, never errors
db2 = MagicMock()
db2.get_system_automation_by_action.return_value = None
AutomationEngine(db2)._fix_deep_scan_schedules()
db2.update_automation.assert_not_called()