From d97b3d18468874003d8da25ec91d06b79b624c7d Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sat, 7 Mar 2026 11:42:44 -0800 Subject: [PATCH] Fix automation timezone bug --- core/automation_engine.py | 52 +++++++++++++++++++++++++-------------- web_server.py | 12 ++++----- webui/static/script.js | 9 +++++-- 3 files changed, 46 insertions(+), 27 deletions(-) diff --git a/core/automation_engine.py b/core/automation_engine.py index 584f1925..bf92d8d0 100644 --- a/core/automation_engine.py +++ b/core/automation_engine.py @@ -17,11 +17,23 @@ import re import time import threading import requests -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from utils.logging_config import get_logger logger = get_logger("automation_engine") +def _utcnow(): + """Return current UTC time as timezone-aware datetime.""" + return datetime.now(timezone.utc) + +def _utcnow_str(): + """Return current UTC time as naive string for DB storage (consistent with SQLite CURRENT_TIMESTAMP).""" + return datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S') + +def _utc_after(seconds): + """Return UTC time N seconds from now as naive string for DB storage.""" + return (datetime.now(timezone.utc) + timedelta(seconds=seconds)).strftime('%Y-%m-%d %H:%M:%S') + SYSTEM_AUTOMATIONS = [ { 'name': 'Auto-Process Wishlist', @@ -161,8 +173,8 @@ class AutomationEngine: if existing: # Only reset next_run for timer-based triggers that have an initial delay if spec.get('initial_delay') is not None: - next_run = (datetime.now() + timedelta(seconds=spec['initial_delay'])).strftime('%Y-%m-%d %H:%M:%S') - self.db.update_automation(existing['id'], next_run=next_run) + nr = _utc_after(spec['initial_delay']) + self.db.update_automation(existing['id'], next_run=nr) logger.info(f"System automation '{spec['name']}' next_run reset to {spec['initial_delay']}s from now") else: logger.info(f"System automation '{spec['name']}' ready (event-based)") @@ -173,8 +185,8 @@ class AutomationEngine: if not auto or not auto.get('enabled') or not auto.get('next_run'): return 0 try: - next_run = datetime.strptime(auto['next_run'], '%Y-%m-%d %H:%M:%S') - remaining = (next_run - datetime.now()).total_seconds() + next_run = datetime.strptime(auto['next_run'], '%Y-%m-%d %H:%M:%S').replace(tzinfo=timezone.utc) + remaining = (next_run - _utcnow()).total_seconds() return max(0, int(remaining)) except (ValueError, TypeError): return 0 @@ -561,20 +573,22 @@ class AutomationEngine: try: trigger_config = json.loads(auto.get('trigger_config') or '{}') if trigger_type == 'daily_time': - # Next run is tomorrow at the configured time + # Next run is tomorrow at the configured time (compute delay from local time, store as UTC) time_str = trigger_config.get('time', '00:00') hour, minute = map(int, time_str.split(':')) - target = datetime.now().replace(hour=hour, minute=minute, second=0, microsecond=0) + timedelta(days=1) - next_run_str = target.strftime('%Y-%m-%d %H:%M:%S') + now_local = datetime.now() + target = now_local.replace(hour=hour, minute=minute, second=0, microsecond=0) + timedelta(days=1) + next_run_str = _utc_after((target - now_local).total_seconds()) elif trigger_type == 'weekly_time': time_str = trigger_config.get('time', '00:00') hour, minute = map(int, time_str.split(':')) + now_local = datetime.now() target = self._next_weekly_occurrence(hour, minute, trigger_config.get('days', [])) - next_run_str = target.strftime('%Y-%m-%d %H:%M:%S') + next_run_str = _utc_after((target - now_local).total_seconds()) else: delay = self._calc_delay_seconds(trigger_config) if delay: - next_run_str = (datetime.now() + timedelta(seconds=delay)).strftime('%Y-%m-%d %H:%M:%S') + next_run_str = _utc_after(delay) except Exception: pass @@ -623,14 +637,14 @@ class AutomationEngine: auto = self.db.get_automation(automation_id) if auto and auto.get('next_run'): try: - next_run = datetime.strptime(auto['next_run'], '%Y-%m-%d %H:%M:%S') - remaining = (next_run - datetime.now()).total_seconds() + next_run = datetime.strptime(auto['next_run'], '%Y-%m-%d %H:%M:%S').replace(tzinfo=timezone.utc) + remaining = (next_run - _utcnow()).total_seconds() if remaining > 0: delay = remaining except (ValueError, TypeError): pass - next_run_str = (datetime.now() + timedelta(seconds=delay)).strftime('%Y-%m-%d %H:%M:%S') + next_run_str = _utc_after(delay) self.db.update_automation(automation_id, next_run=next_run_str) timer = threading.Timer(delay, self.run_automation, args=(automation_id,)) @@ -650,14 +664,14 @@ class AutomationEngine: except (ValueError, AttributeError): hour, minute = 0, 0 - now = datetime.now() - target = now.replace(hour=hour, minute=minute, second=0, microsecond=0) - if target <= now: + now_local = datetime.now() + target = now_local.replace(hour=hour, minute=minute, second=0, microsecond=0) + if target <= now_local: target += timedelta(days=1) - delay = (target - now).total_seconds() + delay = (target - now_local).total_seconds() - next_run_str = target.strftime('%Y-%m-%d %H:%M:%S') + next_run_str = _utc_after(delay) self.db.update_automation(automation_id, next_run=next_run_str) timer = threading.Timer(delay, self.run_automation, args=(automation_id,)) @@ -680,7 +694,7 @@ class AutomationEngine: target = self._next_weekly_occurrence(hour, minute, config.get('days', [])) delay = (target - datetime.now()).total_seconds() - next_run_str = target.strftime('%Y-%m-%d %H:%M:%S') + next_run_str = _utc_after(delay) self.db.update_automation(automation_id, next_run=next_run_str) timer = threading.Timer(delay, self.run_automation, args=(automation_id,)) diff --git a/web_server.py b/web_server.py index 8e43ad5b..9c500300 100644 --- a/web_server.py +++ b/web_server.py @@ -65,7 +65,7 @@ from core.web_scan_manager import WebScanManager from core.lyrics_client import lyrics_client from database.music_database import get_database from services.sync_service import PlaylistSyncService -from datetime import datetime +from datetime import datetime, timezone import yt_dlp from core.matching_engine import MusicMatchingEngine from beatport_unified_scraper import BeatportUnifiedScraper @@ -968,11 +968,11 @@ def _register_automation_handlers(): state = automation_progress_states.get(aid) if state: started_at = state.get('started_at') - finished_at = state.get('finished_at') or datetime.now().isoformat() + finished_at = state.get('finished_at') or datetime.now(timezone.utc).isoformat() log_entries = list(state.get('log', [])) else: - started_at = datetime.now().isoformat() - finished_at = datetime.now().isoformat() + started_at = datetime.now(timezone.utc).isoformat() + finished_at = datetime.now(timezone.utc).isoformat() log_entries = [] # Compute duration @@ -1181,7 +1181,7 @@ def _init_automation_progress(automation_id, automation_name, action_type): 'progress': 0, 'phase': 'Starting...', 'current_item': '', 'processed': 0, 'total': 0, 'log': [{'type': 'info', 'text': f'Starting {automation_name}'}], - 'started_at': datetime.now().isoformat(), + 'started_at': datetime.now(timezone.utc).isoformat(), 'finished_at': None, } @@ -1202,7 +1202,7 @@ def _update_automation_progress(automation_id, **kwargs): state[k] = v # Immediate emit on finish so frontend gets final state without waiting for loop if kwargs.get('status') in ('finished', 'error'): - state['finished_at'] = datetime.now().isoformat() + state['finished_at'] = datetime.now(timezone.utc).isoformat() try: socketio.emit('automation:progress', {str(automation_id): dict(state)}) except Exception: diff --git a/webui/static/script.js b/webui/static/script.js index 3619b1c1..f073c010 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -43515,15 +43515,20 @@ function _autoFormatNotify(type) { if (type === 'fire_signal') return '\u26A1 Signal'; return type || ''; } +function _autoParseUTC(ts) { + // If timestamp already has timezone info (+00:00 or Z), parse as-is; otherwise append Z to treat as UTC + if (/[Zz]$/.test(ts) || /[+-]\d{2}:\d{2}$/.test(ts)) return new Date(ts).getTime(); + return new Date(ts + 'Z').getTime(); +} function _autoTimeAgo(ts) { if (!ts) return 'Never'; - const d = (Date.now() - new Date(ts + 'Z').getTime()) / 1000; + const d = (Date.now() - _autoParseUTC(ts)) / 1000; if (d < 60) return 'just now'; if (d < 3600) return Math.floor(d/60) + 'm ago'; if (d < 86400) return Math.floor(d/3600) + 'h ago'; return Math.floor(d/86400) + 'd ago'; } function _autoTimeUntil(ts) { if (!ts) return ''; - const d = (new Date(ts + 'Z').getTime() - Date.now()) / 1000; + const d = (_autoParseUTC(ts) - Date.now()) / 1000; if (d <= 0) return 'soon'; if (d < 60) return 'in ' + Math.ceil(d) + 's'; if (d < 3600) return 'in ' + Math.ceil(d/60) + 'm'; if (d < 86400) return 'in ' + Math.round(d/3600) + 'h'; return 'in ' + Math.round(d/86400) + 'd';