From cf470326609692f71907f6d1d63573afb6fe1adc Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Sun, 21 Jun 2026 12:39:53 -0700 Subject: [PATCH] Video deep scans: fixed weekly times (TV Mon 2am, Movies Tue 2am) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- core/automation_engine.py | 55 ++++++++++++++----- .../test_engine_schedule_integration.py | 38 +++++++++++++ 2 files changed, 78 insertions(+), 15 deletions(-) diff --git a/core/automation_engine.py b/core/automation_engine.py index 37725527..19633c7a 100644 --- a/core/automation_engine.py +++ b/core/automation_engine.py @@ -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) diff --git a/tests/automation/test_engine_schedule_integration.py b/tests/automation/test_engine_schedule_integration.py index 5e55ca54..083c6edd 100644 --- a/tests/automation/test_engine_schedule_integration.py +++ b/tests/automation/test_engine_schedule_integration.py @@ -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()