"""Background scheduler: free RSS detection, enrichment of new videos, and quota-aware recent-first / deep backfill. Runs inside the API process (single worker).""" import contextvars import threading from datetime import datetime, timezone from typing import Callable from apscheduler.executors.pool import ThreadPoolExecutor from apscheduler.jobstores.base import ConflictingIdError from apscheduler.schedulers.background import BackgroundScheduler from sqlalchemy import select from app import audit, debugsession, jobruns, logs, logsink, progress, quota from app.config import settings from app.db import SessionLocal from app.downloads.gc import run_download_gc from app.models import SchedulerSetting from app.plex.sync import sync as run_plex_sync from app.plex.tmdb import run_enrich as run_tmdb_enrich from app.plex.tmdb import run_people_enrich from app.plex.watch_sync import run_plex_watch_reconcile, run_plex_watch_sync from app.notifications import create_notification from app.runstatus import RunStatus, RunTrigger from app.state import is_sync_paused from app.sync.autotag import run_autotag_all from app.sync.explore import purge_ephemeral from app.sync.maintenance import run_maintenance from app.sync.playlists import sync_all_playlists from app.sync.runner import ( run_deep_backfill, run_enrich, run_recent_backfill, run_rss_poll, run_shorts, run_subscription_resync, ) log = logs.get_logger(__name__) _scheduler: BackgroundScheduler | None = None # What is running RIGHT NOW, per job: the single-run claim and the live progress. In memory on # purpose — the scheduler lives in the API's one process, and progress changes every second. What a # run did is durable instead: its `job_runs` row (app/jobruns.py), which outlives a restart. _activity: dict[str, dict] = {} _activity_lock = threading.Lock() # Set (per-thread) by a manual "run now" trigger to the id of the admin who clicked, so that # run — and only that run, not the recurring scheduled ones — posts a completion notification # to their inbox. Unset (None) for scheduled runs. _manual_actor: contextvars.ContextVar[int | None] = contextvars.ContextVar( "manual_actor", default=None ) # Each interval job's env/config default period (minutes). The admin can override any of # these at runtime (stored in scheduler_settings, applied live); see load_intervals. JOB_INTERVALS: dict[str, int] = { "rss_poll": settings.rss_poll_minutes, "enrich": settings.enrich_interval_minutes, "backfill": settings.backfill_interval_minutes, "autotag": settings.autotag_interval_minutes, "shorts": settings.shorts_probe_interval_minutes, "subscriptions": settings.subscriptions_resync_minutes, "playlist_sync": settings.playlist_sync_minutes, "maintenance": settings.maintenance_interval_minutes, "explore_cleanup": settings.explore_cleanup_minutes, "download_gc": settings.download_gc_minutes, "plex_sync": settings.plex_sync_interval_min, "plex_watch_sync": settings.plex_watch_sync_interval_min, "plex_watch_reconcile": settings.plex_watch_reconcile_interval_min, "tmdb_enrich": settings.tmdb_enrich_interval_min, "people_enrich": settings.people_enrich_interval_min, "audit_gc": settings.audit_gc_minutes, "log_gc": settings.log_gc_minutes, } # Sane bounds for an admin-set interval (minutes). MIN_INTERVAL = 1 MAX_INTERVAL = 1440 # one day def load_intervals(db) -> dict[str, int]: """Effective per-job intervals: the env/config defaults overlaid with any admin overrides saved in scheduler_settings.""" iv = dict(JOB_INTERVALS) for row in db.execute(select(SchedulerSetting)).scalars(): if row.job_id in iv and row.interval_minutes: iv[row.job_id] = row.interval_minutes return iv def apply_interval(db, job_id: str, minutes: int) -> int: """Persist a job's interval override and reschedule the live job immediately (if the scheduler runs in this process). Returns the applied value.""" if job_id not in JOB_INTERVALS: raise KeyError(job_id) minutes = max(MIN_INTERVAL, min(MAX_INTERVAL, int(minutes))) row = db.get(SchedulerSetting, job_id) if row is None: db.add(SchedulerSetting(job_id=job_id, interval_minutes=minutes)) else: row.interval_minutes = minutes db.commit() if _scheduler is not None and _scheduler.get_job(job_id) is not None: _scheduler.reschedule_job(job_id, trigger="interval", minutes=minutes) log.info("rescheduled job %s to every %s min", job_id, minutes) return minutes def _record(name: str, **fields) -> None: with _activity_lock: _activity.setdefault(name, {}).update(fields) def _notify_done( db, actor_id: int, job_id: str, status: str, summary: str | None, result: dict | None = None ) -> None: """Post a completion notice to the triggering admin's inbox (manual runs only). Best effort — a notification failure must never mask the job's own outcome.""" try: create_notification( db, user_id=actor_id, type="scheduler", title=f"{job_id}: {status}", # English fallback; UI renders translated from data body=summary, data={"job_id": job_id, "status": status, "summary": summary, "result": result}, ) except Exception: db.rollback() log.exception("failed to write completion notification for job %s", job_id) def _job(name: str, fn) -> None: # The one place both run paths converge (manual trigger AND scheduled fire), so claim the run # HERE, atomically: whichever reaches this first wins, the other skips. This is the real # single-run guard — trigger_job's pre-check is only a fast best-effort answer for the UI. if not _claim_running(name): log.info("job %s already running — skipping this trigger", name) return actor_id = _manual_actor.get() # set only for a manual "run now" trigger trigger = RunTrigger.SCHEDULED if actor_id is None else RunTrigger.MANUAL _record(name, progress=None) # running already claimed db = SessionLocal() try: try: run_id = jobruns.start(name, trigger, actor_id) except Exception: # No row to tell it in: the database is what failed. The log is the record. log.exception("job %s not started: its run could not be recorded", name) return with logs.bind(run_id=run_id): _run(db, name, fn, run_id, actor_id) finally: # ALWAYS release the claim, even if the body raised before its own status update — so a # failure can never wedge the job as permanently "running". _record(name, running=False, progress=None) db.close() def _run(db, name: str, fn, run_id: int, actor_id: int | None) -> None: """The run itself, inside its `job_runs` row: the pause-skip, the work, and its outcome — on the row, and in the triggering admin's inbox for a manual run.""" def sink(current, total, phase): _record(name, progress={"current": current, "total": total, "phase": phase}) try: if is_sync_paused(db): log.info("job %s skipped (sync paused)", name) jobruns.finish(run_id, RunStatus.SKIPPED, summary="sync paused", result=_PAUSED) if actor_id is not None: _notify_done(db, actor_id, name, "skipped", "sync paused", _PAUSED) return with progress.bind(sink): result = fn(db) summary, data = _finish_ok(name, run_id, result) if actor_id is not None: _notify_done(db, actor_id, name, "ok", summary, data) except Exception as exc: db.rollback() log.exception("job %s failed", name, extra=logs.event("scheduler.job.failed", job=name)) err, failure = _finish_error(run_id, exc) if actor_id is not None: _notify_done(db, actor_id, name, "error", err, failure) def _finish_ok(name: str, run_id: int, result) -> tuple[str | None, dict | None]: """Close a run that returned: its result on the row, as data and as the English one-liner.""" summary = _summarize(result) data = _result_data(result) log.info("job %s -> %s", name, result) jobruns.finish(run_id, RunStatus.OK, summary=summary, result=data) return summary, data def _finish_error(run_id: int, exc: Exception) -> tuple[str, dict | None]: """Close a run that raised. A failure the job named (progress.JobFailed) rides along as data, as a result does, so the client says it in the reader's language; the error stays the English line.""" err = str(exc) or exc.__class__.__name__ failure = {"failure": exc.reason, **exc.params} if isinstance(exc, progress.JobFailed) else None jobruns.finish(run_id, RunStatus.ERROR, error=err, failure=failure) return err, failure # A skipped run's result, in the same shape as a job's own `{"skipped": …}`. _PAUSED = {"skipped": "sync paused"} def _result_data(result) -> dict | None: """A job's result as the client names it in the reader's language: the dict itself (counts, a `skipped` / `reason` word, a nested dict per phase), `enrich`'s bare count as one. The one-line `_summarize` is the English fallback.""" if isinstance(result, dict): return result or None if isinstance(result, int) and not isinstance(result, bool): return {"enriched": result} return None def _summarize(result) -> str | None: """Compact one-line rendering of a job's return value for the dashboard.""" if result is None: return None if isinstance(result, dict): return ", ".join(f"{k}={v}" for k, v in result.items()) or None return str(result) def _iso(ts: datetime | None) -> str | None: return ts.astimezone(timezone.utc).isoformat() if ts else None def scheduler_snapshot(db) -> dict: """What the scheduler is doing, for the admin dashboard: each job's live state from this process and its last run from `job_runs`, so a restart no longer blanks "when did it last run, and how did it go". `running_here` is False where the scheduler runs no interval jobs (SCHEDULER_ENABLED=false), so the UI can say so while still showing the DB-derived history, queue and quota.""" running_here = _scheduler is not None and settings.scheduler_enabled next_runs: dict[str, str | None] = {} live_intervals: dict[str, int] = {} if _scheduler is not None: for job in _scheduler.get_jobs(): nr = getattr(job, "next_run_time", None) next_runs[job.id] = nr.astimezone(timezone.utc).isoformat() if nr else None iv = getattr(job.trigger, "interval", None) if iv is not None: live_intervals[job.id] = round(iv.total_seconds() / 60) with _activity_lock: acts = {k: dict(v) for k, v in _activity.items()} recent = jobruns.recent_runs(db, RECENT_RUNS) jobs = [] for job_id, default_interval in JOB_INTERVALS.items(): a = acts.get(job_id, {}) runs = recent.get(job_id, []) run = runs[0] if runs else None # A run still going has no outcome yet: only an ended run says how it went. ended = run if run is not None and run.status != RunStatus.RUNNING else None jobs.append( { "id": job_id, "interval_minutes": live_intervals.get(job_id, default_interval), "next_run": next_runs.get(job_id), "running": a.get("running", False), "status": ended.status if ended else None, "last_started": _iso(run.started_at) if run else None, "last_finished": _iso(run.finished_at) if run else None, "last_result": ended.summary if ended else None, # The outcome as data: the result, or on a failed run the failure the job named. "result": _outcome_data(ended), "last_error": ended.error if ended else None, "last_trigger": run.trigger if run else None, "last_run_id": run.id if run else None, "quota_units": ended.quota_units if ended else None, "progress": a.get("progress"), # The last few runs, newest first, for the row's history dots. "recent": [_run_brief(r) for r in runs], } ) return {"running_here": running_here, "enabled": settings.scheduler_enabled, "jobs": jobs} # How many past runs a Scheduler row shows as dots; the full history is the run list. RECENT_RUNS = 3 def _run_brief(run) -> dict: return { "id": run.id, "status": run.status, "trigger": run.trigger, "started_at": _iso(run.started_at), "finished_at": _iso(run.finished_at), } def _outcome_data(run) -> dict | None: if run is None: return None return run.failure if run.status == RunStatus.ERROR else run.result def _rss_job() -> None: _job("rss_poll", run_rss_poll) def _enrich_job() -> None: def work(db): with quota.attribute(None, quota.QuotaAction.VIDEOS_ENRICH): return run_enrich(db) _job("enrich", work) def _backfill_job() -> None: # Recent-first for not-yet-synced channels, then deep backfill for the rest. All # background spend is attributed to the system (no actor), split by action. def work(db): with quota.attribute(None, quota.QuotaAction.VIDEOS_BACKFILL_RECENT): recent = run_recent_backfill(db, max_channels=25) with quota.attribute(None, quota.QuotaAction.VIDEOS_BACKFILL_FULL): deep = run_deep_backfill(db, max_channels=10) return {"recent": recent, "deep": deep} _job("backfill", work) def _autotag_job() -> None: _job("autotag", lambda db: run_autotag_all(db, only_missing=True)) def _shorts_job() -> None: _job("shorts", run_shorts) def _subscriptions_job() -> None: def work(db): with quota.attribute(None, quota.QuotaAction.SUBSCRIPTIONS_RESYNC): return run_subscription_resync(db) _job("subscriptions", work) def _playlist_sync_job() -> None: # Mirror each read-scope user's YouTube playlists; per-user quota attribution is # handled inside sync_all_playlists. _job("playlist_sync", sync_all_playlists) def _maintenance_job() -> None: def work(db): with quota.attribute(None, quota.QuotaAction.MAINTENANCE_REVALIDATE): return run_maintenance(db) _job("maintenance", work) def _explore_cleanup_job() -> None: # Reclaim un-kept ephemeral discovery content (explored channels + live-search results) after # their grace periods. 0 quota (pure DB), so no quota attribution. _job("explore_cleanup", purge_ephemeral) def _download_gc_job() -> None: # Retention GC for the download center: pre-expiry warnings, TTL deletion, LRU eviction — and # the week-ahead warning before an API token runs out (`apitokens.warn_expiring`). # Pure disk/DB work (no YouTube quota). _job("download_gc", run_download_gc) def _plex_sync_job() -> None: # Mirror the enabled Plex library sections into the local catalog. Pure metadata (no YouTube # quota); a no-op when the Plex module is disabled. _job("plex_sync", run_plex_sync) def _plex_watch_sync_job() -> None: # Incremental Plex→Siftlode watch-state pull (history + on-deck) + re-push of unsynced local # states. Cheap; a no-op when Plex is disabled or no owner link has sync enabled. _job("plex_watch_sync", run_plex_watch_sync) def _plex_watch_reconcile_job() -> None: # Full watch-state reconcile (whole-section rescan) — catches un-watches the incremental feed # can't. Heavier, so it runs far less often (daily default). _job("plex_watch_reconcile", run_plex_watch_reconcile) def _tmdb_enrich_job() -> None: # Enrich a batch of mirrored Plex titles from TMDB (keywords/crew/budget/revenue/TMDb score/ # recommendations/backdrops), matched by guid. External metadata (no YouTube quota); a no-op when # the TMDB key is unset or the Plex catalog is empty. _job("tmdb_enrich", run_tmdb_enrich) def _people_enrich_job() -> None: # Make the people local: a batch of person bios from TMDB and a batch of portraits resized # through Plex, so a person page and a cast strip never wait on an outside service. _job("people_enrich", run_people_enrich) def _audit_gc_job() -> None: # The history cleanup: audit-log rows past audit_retention_days, YouTube quota history past # quota_retention_days and finished runs past job_run_retention_days (0 = keep forever, each). # Pure DB, no quota. def work(db): from app import sysconfig reaped = audit.gc(db, sysconfig.get_int(db, "audit_retention_days")) quota_reaped = quota.gc(db, sysconfig.get_int(db, "quota_retention_days")) runs_reaped = jobruns.gc(db, sysconfig.get_int(db, "job_run_retention_days")) db.commit() return {"reaped": reaped, "quota_reaped": quota_reaped, "runs_reaped": runs_reaped} _job("audit_gc", work) def _log_gc_job() -> None: # Prune log_events rows older than log_retention_days — the limit the privacy policy states, so # this runs hourly rather than daily. Pure DB, no quota. def work(db): from app import sysconfig days = sysconfig.get_int(db, "log_retention_days") reaped = logsink.gc(db, days) # Debug sessions go with the rows they bundle; an open one past its cap is closed. closed = debugsession.sweep(db) sessions = debugsession.gc(db, days) db.commit() return {"reaped": reaped, "sessions_closed": closed, "sessions_reaped": sessions} _job("log_gc", work) # job_id -> wrapper. The single source of truth for which jobs exist and how to run one, # shared by start_scheduler (recurring registration) and trigger_job (manual "run now"). JOB_FUNCS: dict[str, Callable[[], None]] = { "rss_poll": _rss_job, "enrich": _enrich_job, "backfill": _backfill_job, "autotag": _autotag_job, "shorts": _shorts_job, "subscriptions": _subscriptions_job, "playlist_sync": _playlist_sync_job, "maintenance": _maintenance_job, "explore_cleanup": _explore_cleanup_job, "download_gc": _download_gc_job, "plex_sync": _plex_sync_job, "plex_watch_sync": _plex_watch_sync_job, "plex_watch_reconcile": _plex_watch_reconcile_job, "tmdb_enrich": _tmdb_enrich_job, "people_enrich": _people_enrich_job, "audit_gc": _audit_gc_job, "log_gc": _log_gc_job, } def _is_running(job_id: str) -> bool: with _activity_lock: return bool(_activity.get(job_id, {}).get("running")) def _claim_running(job_id: str) -> bool: """Atomically claim a job as running: True if it was idle (and marks it running now), False if it was already running. Holding the lock across BOTH the check and the set is what makes the single-run guard race-free — called once at the top of _job so a manual trigger and a scheduled fire (or two rapid triggers) converge here and exactly one wins. Released in _job's finally.""" with _activity_lock: if _activity.get(job_id, {}).get("running"): return False _activity.setdefault(job_id, {})["running"] = True return True def running_job_ids() -> set[str]: """Ids of jobs executing right now (any trigger). Lets non-admin surfaces — e.g. the header's sync indicator — reflect real activity instead of just pending-work counts.""" with _activity_lock: return {jid for jid, a in _activity.items() if a.get("running")} def _as_actor(fn: Callable[[], None], actor_id: int | None) -> Callable[[], None]: """`fn` run as `actor_id`'s manual run. The actor is RESET afterwards: the executor's pool threads are reused and keep their context from task to task, so an actor left set would make the next scheduled run that thread picks up look like this admin's manual run.""" def run() -> None: token = _manual_actor.set(actor_id) try: fn() finally: _manual_actor.reset(token) return run def trigger_job(job_id: str, actor_id: int | None = None) -> str: """Run a job now, independent of its interval schedule: a one-shot job on the same scheduler, so a manual run shares the scheduled runs' executor and shutdown — no thread of its own. The wrapper handles its own DB session, run row and pause-skip. Returns "started", "already_running", or raises KeyError for an unknown job. `actor_id` (the admin who clicked "run now") is set in a contextvar inside the run's thread, so the run is recorded as theirs and posts a completion notification to their inbox.""" fn = JOB_FUNCS.get(job_id) if fn is None: raise KeyError(job_id) # Best-effort fast answer for the UI; the authoritative single-run guard is _job's atomic claim # (so a manual trigger and a scheduled fire can't both run). A race here just means the loser's # _job claims, sees it's taken, and skips — no double run. if _is_running(job_id): return "already_running" sched = _scheduler if sched is None: # Only between shutdown and exit: start_scheduler runs at every API startup. raise RuntimeError("the scheduler is not running") try: # One id per job: a second click before the first one-shot has even started is the same # run, not a queued second one. misfire_grace_time=None: a busy pool delays it, never drops it. sched.add_job(_as_actor(fn, actor_id), "date", id=f"{job_id}@now", misfire_grace_time=None) # silent-ok: not a failure — the answer is the 409 the admin sees ("already running"). except ConflictingIdError: return "already_running" return "started" def trigger_all(actor_id: int | None = None) -> list[str]: """Trigger every job that isn't already running; returns the ids actually started.""" started = [] for job_id in JOB_FUNCS: if trigger_job(job_id, actor_id) == "started": started.append(job_id) return started # Work a user starts on one target outside the job list — today the "get full history" backfill of # one channel. It runs on the same scheduler as a recorded run (trigger `ad_hoc`), so what it did and # spent is in `job_runs` like any job's, not a background task only the log ever heard of. CHANNEL_BACKFILL = "channel_backfill" def run_ad_hoc(job: str, fn: Callable[[], dict], *, actor_id: int, target: str) -> None: """Run `fn` now as `actor_id`'s run of `job` on `target`, a one-shot job on the scheduler. Its own exclusion (if any) lives in `fn`, which says so in its result; one id per job and target, so a second request before the first has even started is that same run.""" sched = _scheduler if sched is None: # Only between shutdown and exit: start_scheduler runs at every API startup. raise RuntimeError("the scheduler is not running") try: sched.add_job( _ad_hoc, "date", args=(job, fn, actor_id, target), id=f"{job}@{target}", misfire_grace_time=None, ) # silent-ok: not a failure — the run for this target is already queued, and it does this work. except ConflictingIdError: log.info("ad-hoc %s for %s already queued", job, target) def _ad_hoc(job: str, fn: Callable[[], dict], actor_id: int, target: str) -> None: try: run_id = jobruns.start(job, RunTrigger.AD_HOC, actor_id, target) except Exception: # No row to tell it in: the database is what failed. The log is the record. log.exception("ad-hoc %s for %s not started: its run could not be recorded", job, target) return with logs.bind(run_id=run_id): try: _finish_ok(job, run_id, fn()) except Exception as exc: log.exception("job %s failed", job, extra=logs.event("scheduler.job.failed", job=job)) _finish_error(run_id, exc) # Room for every job at once (a "run all") beside a few ad-hoc runs: APScheduler's default pool of # 10 would queue a scheduled fire behind long manual runs. _POOL_SIZE = len(JOB_FUNCS) + 8 def start_scheduler() -> None: """Start this process's scheduler. It always starts, so "run now" works on every install; only with SCHEDULER_ENABLED does it also register the recurring interval jobs. Before anything can run, every run a previous process left open is closed as interrupted.""" global _scheduler if _scheduler is not None: return left = jobruns.interrupt_leftovers() if left: log.warning("%s run(s) left running by the previous process marked interrupted", left) scheduler = BackgroundScheduler( timezone="UTC", executors={"default": ThreadPoolExecutor(_POOL_SIZE)} ) if settings.scheduler_enabled: # Effective intervals = env defaults overlaid with any admin overrides from the DB. db = SessionLocal() try: iv = load_intervals(db) finally: db.close() for job_id, fn in JOB_FUNCS.items(): scheduler.add_job(fn, "interval", minutes=iv[job_id], id=job_id) scheduler.start() _scheduler = scheduler log.info("scheduler started (interval jobs %s)", "on" if settings.scheduler_enabled else "off") def shutdown_scheduler() -> None: global _scheduler if _scheduler is not None: _scheduler.shutdown(wait=False) _scheduler = None