"""Activity-triggered videos sync. The hourly scheduled videos sync is gone; instead, every authenticated request (require_session) calls maybe_trigger_videos_sync(). The check must stay cheap: if the sync is not running and enough idle time has passed since the last completed sync, a background thread is started which calls sync.sync_videos with its own DB session. """ import json import logging import threading from datetime import datetime, timedelta, timezone from app.config import settings from app.db import SessionLocal from app.services import sync from app.services.state import get_setting logger = logging.getLogger(__name__) def is_videos_sync_due(finished_at_iso: str | None, now: datetime, idle_hours: int) -> bool: """Pure predicate: should an automatic videos sync start now? True when the last completed sync's finished_at is missing/unparseable or older than `idle_hours`.""" if not finished_at_iso: return True if not isinstance(finished_at_iso, str): return True try: finished_at = datetime.fromisoformat(finished_at_iso.replace("Z", "+00:00")) except (ValueError, TypeError): return True if finished_at.tzinfo is None: finished_at = finished_at.replace(tzinfo=timezone.utc) if now.tzinfo is None: now = now.replace(tzinfo=timezone.utc) return now - finished_at >= timedelta(hours=idle_hours) def _extract_finished_at(raw: str | None) -> str | None: if not raw: return None try: payload = json.loads(raw) except (ValueError, TypeError): return None if not isinstance(payload, dict): return None return payload.get("finished_at") def _run_videos_sync() -> None: db = SessionLocal() try: sync.sync_videos(db) except sync.SyncInProgress: # Lost the race against a manual sync or another trigger: fine, the # other run will record the status. logger.info("Triggered videos sync skipped: already running") except Exception: logger.exception("Triggered videos sync failed") finally: db.close() def maybe_trigger_videos_sync() -> None: """Cheap per-request check; never raises. Starts a background videos sync when due, or does nothing when a sync is already running.""" try: if sync.is_videos_sync_running(): return db = SessionLocal() try: raw = get_setting(db, sync.VIDEOS_SYNC_STATUS_KEY) finished_at = _extract_finished_at(raw) if not is_videos_sync_due(finished_at, datetime.now(timezone.utc), settings.videos_sync_idle_hours): return finally: db.close() logger.info( "Videos sync due (last finished: %s), starting in background thread", finished_at ) threading.Thread(target=_run_videos_sync, daemon=True).start() except Exception: # Never break the request because of the trigger itself. logger.exception("Failed to evaluate videos sync trigger")