"""Background scan runner and the scheduler. The scheduler runs in-process. With gunicorn --workers 2 both workers would start one and two scans would race for the SQLite write lock, so the scheduler only starts in the worker that wins an exclusive flock on /data. The scan_lock table is the second line of defence; both are needed, since either alone leaves a race window (§11.4). """ from __future__ import annotations import json import logging import os import threading import time log = logging.getLogger(__name__) _scan_thread: threading.Thread | None = None _scan_lock = threading.Lock() _scheduler = None _flock_handle = None def _run_scan(app, mode: str, trigger: str) -> None: from . import ingest, keeps, providers with app.app_context(): ext = app.extensions["mediashelf"] cfg = ext["config"] db = ext["db"] try: media = providers.build_media_provider(cfg) history, degraded = providers.build_history_provider(cfg, media) if degraded: log.warning("history running degraded: %s", degraded) ext["degraded_reason"] = degraded else: ext["degraded_reason"] = None result = ingest.Ingest(db, cfg, media, history).run(mode, trigger) log.info("scan %s %s: seen=%d added=%d events=%d", result.scan_id, result.status, result.items_seen, result.items_added, result.events_added) if result.status == "succeeded": _export_backups(db, cfg) except Exception: # noqa: BLE001 log.exception("background scan failed") finally: db.close() def _export_backups(db, cfg) -> None: """Keeps and saved views are the only data not reconstructible (§11.5).""" from . import keeps try: data_dir = os.path.dirname(os.path.abspath(cfg.database_path)) or "." keeps.write_export_file(db, os.path.join(data_dir, "keeps.json")) views = [dict(r) for r in db.query("SELECT * FROM saved_view WHERE builtin = 0")] with open(os.path.join(data_dir, "views.json"), "w") as fh: json.dump({"version": 1, "exported_at": int(time.time()), "views": views}, fh, indent=2) except OSError as e: log.warning("could not write keep/view export: %s", e) def start_background_scan(app, mode: str = "incremental", trigger: str = "manual") -> bool: """Returns False if a scan is already running in this process.""" global _scan_thread with _scan_lock: if _scan_thread is not None and _scan_thread.is_alive(): return False real_app = app._get_current_object() if hasattr(app, "_get_current_object") else app _scan_thread = threading.Thread( target=_run_scan, args=(real_app, mode, trigger), name="mediashelf-scan", daemon=True) _scan_thread.start() return True def scan_running() -> bool: return _scan_thread is not None and _scan_thread.is_alive() # ── scheduler ──────────────────────────────────────────────────────────── def _acquire_process_lock(data_dir: str) -> bool: """Exclusive flock, so only one gunicorn worker schedules anything.""" global _flock_handle import fcntl try: os.makedirs(data_dir, exist_ok=True) fh = open(os.path.join(data_dir, ".scheduler.lock"), "w") fcntl.flock(fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) fh.write(str(os.getpid())) fh.flush() _flock_handle = fh # held for the life of the process return True except (OSError, BlockingIOError): return False def _cron_kwargs(expr: str) -> dict: parts = (expr or "").split() if len(parts) != 5: raise ValueError("cron expression must have 5 fields, got %r" % expr) minute, hour, dom, month, dow = parts return {"minute": minute, "hour": hour, "day": dom, "month": month, "day_of_week": dow} def start_scheduler(app) -> bool: """Start the nightly jobs in exactly one process. Returns True if started.""" global _scheduler cfg = app.extensions["mediashelf"]["config"] if not cfg.scheduler_enabled: log.info("scheduler disabled by config") return False data_dir = os.path.dirname(os.path.abspath(cfg.database_path)) or "." if not _acquire_process_lock(data_dir): log.info("another worker holds the scheduler lock; not scheduling here") return False from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.cron import CronTrigger real_app = app._get_current_object() if hasattr(app, "_get_current_object") else app _scheduler = BackgroundScheduler(timezone=cfg.tz) try: _scheduler.add_job( lambda: start_background_scan(real_app, "incremental", "schedule"), CronTrigger(**_cron_kwargs(cfg.scan_schedule_cron), timezone=cfg.tz), id="incremental", replace_existing=True, max_instances=1) _scheduler.add_job( lambda: start_background_scan(real_app, "full", "schedule"), CronTrigger(**_cron_kwargs(cfg.scan_full_sweep_cron), timezone=cfg.tz), id="full-sweep", replace_existing=True, max_instances=1) except ValueError as e: log.error("bad cron configuration, scheduler not started: %s", e) return False _scheduler.start() log.info("scheduler started (incremental %r, full sweep %r, tz %s)", cfg.scan_schedule_cron, cfg.scan_full_sweep_cron, cfg.tz) if cfg.scan_on_startup: start_background_scan(real_app, "full", "startup") return True