""" worker.py - Background job pipeline for YouTube SIPOC Tool Handles pause/stop/resume, per-artifact saving, duplicate skipping, random delays, retry logic, and resume-on-failure. """ import time import random import logging import threading from typing import Dict, Callable, Optional import database as db import extractors import config logger = logging.getLogger("worker") # ───────────────────────────────────────────────────────────────────────────── # Job Control Registry # Stores threading.Event objects per job_id for pause/stop/skip signals. # ───────────────────────────────────────────────────────────────────────────── _job_controls: Dict[str, Dict] = {} _registry_lock = threading.Lock() def _get_controls(job_id: str) -> Dict: with _registry_lock: if job_id not in _job_controls: _job_controls[job_id] = { "pause": threading.Event(), # set = paused "stop": threading.Event(), # set = stop requested "skip": threading.Event(), # set = skip current video } return _job_controls[job_id] def _cleanup_controls(job_id: str): with _registry_lock: _job_controls.pop(job_id, None) def pause_job(job_id: str): _get_controls(job_id)["pause"].set() db.update_job_status(job_id, "paused") db.add_log(job_id, "Job paused by user.", level="INFO") def resume_job(job_id: str): _get_controls(job_id)["pause"].clear() db.update_job_status(job_id, "running") db.add_log(job_id, "Job resumed by user.", level="INFO") def stop_job(job_id: str): _get_controls(job_id)["stop"].set() _get_controls(job_id)["pause"].clear() # Unpause so thread can exit db.update_job_status(job_id, "stopped") db.add_log(job_id, "Job stopped by user.", level="INFO") def skip_video(job_id: str): """Signal the worker to skip the current video and move to the next.""" _get_controls(job_id)["skip"].set() db.add_log(job_id, "Skip requested for current video.", level="INFO") # ───────────────────────────────────────────────────────────────────────────── # SSE Event Emitter # ───────────────────────────────────────────────────────────────────────────── class EventEmitter: """Thread-safe event queue for Server-Sent Events.""" def __init__(self): self._listeners: Dict[str, list] = {} self._lock = threading.Lock() def subscribe(self, job_id: str) -> "EventQueue": queue = EventQueue() with self._lock: if job_id not in self._listeners: self._listeners[job_id] = [] self._listeners[job_id].append(queue) return queue def unsubscribe(self, job_id: str, queue: "EventQueue"): with self._lock: if job_id in self._listeners: try: self._listeners[job_id].remove(queue) except ValueError: pass def emit(self, job_id: str, event_type: str, data: dict): with self._lock: listeners = list(self._listeners.get(job_id, [])) for q in listeners: q.put(event_type, data) class EventQueue: """Per-subscriber event queue with thread-safe get.""" def __init__(self): import queue self._q = queue.Queue(maxsize=500) def put(self, event_type: str, data: dict): try: self._q.put_nowait({"type": event_type, "data": data}) except Exception: pass # Drop events if queue is full def get(self, timeout: float = 30.0): try: return self._q.get(timeout=timeout) except Exception: return None # Global emitter instance emitter = EventEmitter() # ───────────────────────────────────────────────────────────────────────────── # Worker Thread Entry Point # ───────────────────────────────────────────────────────────────────────────── def run_job(job_id: str): """ Main worker function. Runs in a background thread. Resolves inputs, iterates videos, processes each artifact, emits SSE events, handles pause/stop/skip/retry. """ controls = _get_controls(job_id) job = db.get_job(job_id) if not job: logger.error(f"Job {job_id} not found in database.") return db.update_job_status(job_id, "running") _emit(job_id, "job_status", {"job_id": job_id, "status": "running"}) db.add_log(job_id, "Job started.", level="INFO") try: # ── Step 1: Resolve input to video list ────────────────────────────── _emit(job_id, "progress", {"message": "Resolving input URLs…"}) db.add_log(job_id, f"Resolving input: type={job['input_type']}", level="INFO") try: videos = extractors.resolve_input(job["input_type"], job["input_value"]) except Exception as e: db.add_log(job_id, f"Failed to resolve input: {e}", level="ERROR") db.update_job_status(job_id, "failed") _emit(job_id, "job_status", {"job_id": job_id, "status": "failed", "error": str(e)}) return if not videos: db.add_log(job_id, "No videos found for input.", level="WARN") db.update_job_status(job_id, "done") _emit(job_id, "job_status", {"job_id": job_id, "status": "done"}) return # Deduplicate by video_id (preserve order) seen = set() unique_videos = [] for v in videos: if v["video_id"] not in seen: seen.add(v["video_id"]) unique_videos.append(v) videos = unique_videos # ── Step 2: Register videos in DB ──────────────────────────────────── for i, v in enumerate(videos): db.upsert_video(job_id, v["video_id"], v["url"], v.get("title", ""), i) db.update_job_progress(job_id, len(videos), 0) _emit(job_id, "video_list", { "job_id": job_id, "total": len(videos), "videos": [{"video_id": v["video_id"], "title": v.get("title", ""), "position": i} for i, v in enumerate(videos)] }) db.add_log(job_id, f"Found {len(videos)} unique videos.", level="INFO") # ── Step 3: Process each video ──────────────────────────────────────── selections = job["selections"] done_count = 0 for idx, video in enumerate(videos): # Check stop if controls["stop"].is_set(): db.add_log(job_id, "Job stopped before processing video.", level="INFO") break # Handle pause (blocking) _wait_if_paused(controls) if controls["stop"].is_set(): break # Reset skip flag for this video controls["skip"].clear() video_id = video["video_id"] db.add_log(job_id, f"Processing video {idx+1}/{len(videos)}: {video_id}", level="INFO") _emit(job_id, "video_start", {"job_id": job_id, "video_id": video_id, "position": idx}) # ── Step 3a: Fetch metadata (once per video) ────────────────── meta = {} try: _emit(job_id, "artifact_start", { "job_id": job_id, "video_id": video_id, "artifact_type": "metadata" }) meta = extractors.fetch_metadata(video_id) title = meta.get("title", "") if title: db.update_video_title(job_id, video_id, title) _emit(job_id, "video_title", {"job_id": job_id, "video_id": video_id, "title": title}) db.add_log(job_id, f"Metadata fetched for {video_id}: {title or '(no title)'}", level="INFO") except Exception as e: db.add_log(job_id, f"Failed to fetch metadata for {video_id}: {e}", level="ERROR") _emit(job_id, "video_error", {"job_id": job_id, "video_id": video_id, "error": str(e)}) db.update_video_status(job_id, video_id, "failed") continue # ── Step 3b: Initialize artifact records ────────────────────── for artifact_type in selections: db.upsert_artifact(job_id, video_id, artifact_type) # ── Step 3c: Process each selected artifact ─────────────────── video_failed = False for artifact_type in selections: # Check stop or skip if controls["stop"].is_set(): break if controls["skip"].is_set(): db.add_log(job_id, f"Skipping remaining artifacts for {video_id} (skip requested).", level="INFO") break # Handle pause _wait_if_paused(controls) if controls["stop"].is_set(): break # Skip if already done if db.artifact_is_done(job_id, video_id, artifact_type): db.add_log(job_id, f"[SKIP-DUP] {artifact_type} for {video_id} already done.", level="INFO") _emit(job_id, "artifact_skip", { "job_id": job_id, "video_id": video_id, "artifact_type": artifact_type, "reason": "already_done" }) continue # Process with retry success = _process_artifact_with_retry( job_id=job_id, video_id=video_id, artifact_type=artifact_type, meta=meta, job=job, controls=controls, ) if not success: video_failed = True # Random delay between artifacts (bot avoidance) if not controls["stop"].is_set() and not controls["skip"].is_set(): delay = random.uniform(job["delay_min"], job["delay_max"]) db.add_log(job_id, f"Waiting {delay:.1f}s before next artifact…", level="DEBUG") _interruptible_sleep(delay, controls) # ── Step 3d: Mark video done/failed ────────────────────────── if not controls["stop"].is_set(): status = "failed" if video_failed else "done" db.update_video_status(job_id, video_id, status) done_count += 1 db.update_job_progress(job_id, len(videos), done_count) _emit(job_id, "video_done", { "job_id": job_id, "video_id": video_id, "status": status, "done": done_count, "total": len(videos), }) # Random delay between videos if idx < len(videos) - 1 and not controls["stop"].is_set(): delay = random.uniform(job["delay_min"], job["delay_max"]) db.add_log(job_id, f"Waiting {delay:.1f}s before next video…", level="DEBUG") _interruptible_sleep(delay, controls) # ── Step 4: Finalize job ────────────────────────────────────────────── if controls["stop"].is_set(): final_status = "stopped" else: stats = db.get_job_stats(job_id) final_status = "done" if stats["artifact_failed"] == 0 else "done_with_errors" db.update_job_status(job_id, final_status) stats = db.get_job_stats(job_id) db.add_log(job_id, f"Job finished with status: {final_status}. Stats: {stats}", level="INFO") _emit(job_id, "job_status", {"job_id": job_id, "status": final_status, "stats": stats}) except Exception as e: logger.exception(f"Unexpected error in job {job_id}: {e}") db.add_log(job_id, f"Unexpected error: {e}", level="ERROR") db.update_job_status(job_id, "failed") _emit(job_id, "job_status", {"job_id": job_id, "status": "failed", "error": str(e)}) finally: _cleanup_controls(job_id) # ───────────────────────────────────────────────────────────────────────────── # Artifact Processing with Retry # ───────────────────────────────────────────────────────────────────────────── def _process_artifact_with_retry( job_id: str, video_id: str, artifact_type: str, meta: Dict, job: Dict, controls: Dict, ) -> bool: """ Attempt to process one artifact, retrying up to MAX_RETRIES times. Returns True on success, False on final failure. """ max_retries = config.MAX_RETRIES output_folder = job["output_folder"] resolution = job.get("resolution", "best") api_key = job.get("openrouter_model", "") # model field # We store the API key in the job separately; retrieve from job dict # (api_key is set in the job via the POST body - see api_server.py) openrouter_api_key = job.get("_openrouter_api_key", "") # injected by api_server openrouter_model = job.get("openrouter_model", config.OPENROUTER_MODEL) summary_prompt = job.get("summary_prompt", config.SUMMARY_SYSTEM_PROMPT) for attempt in range(1, max_retries + 1): if controls["stop"].is_set() or controls["skip"].is_set(): return False db.mark_artifact_started(job_id, video_id, artifact_type) _emit(job_id, "artifact_start", { "job_id": job_id, "video_id": video_id, "artifact_type": artifact_type, "attempt": attempt, }) db.add_log(job_id, f"[{artifact_type}] Starting (attempt {attempt}/{max_retries})", level="INFO", video_id=video_id, artifact_type=artifact_type) try: file_path = _run_extractor( artifact_type=artifact_type, video_id=video_id, meta=meta, output_folder=output_folder, resolution=resolution, openrouter_api_key=openrouter_api_key, openrouter_model=openrouter_model, summary_prompt=summary_prompt, ) db.update_artifact_status(job_id, video_id, artifact_type, "done", file_path=file_path) db.add_log(job_id, f"[{artifact_type}] Done → {file_path}", level="INFO", video_id=video_id, artifact_type=artifact_type) _emit(job_id, "artifact_done", { "job_id": job_id, "video_id": video_id, "artifact_type": artifact_type, "file_path": file_path, }) return True except Exception as e: err_msg = str(e) logger.warning(f"[{job_id}][{video_id}][{artifact_type}] Attempt {attempt} failed: {e}") db.add_log(job_id, f"[{artifact_type}] Attempt {attempt} failed: {e}", level="WARN", video_id=video_id, artifact_type=artifact_type) if attempt < max_retries: # Exponential backoff between retries backoff = min(2 ** attempt * 3, 30) + random.uniform(0, 2) db.add_log(job_id, f"[{artifact_type}] Retry in {backoff:.1f}s…", level="INFO", video_id=video_id, artifact_type=artifact_type) _interruptible_sleep(backoff, controls) else: db.update_artifact_status( job_id, video_id, artifact_type, "failed", error_msg=err_msg ) db.add_log(job_id, f"[{artifact_type}] FAILED after {max_retries} attempts: {e}", level="ERROR", video_id=video_id, artifact_type=artifact_type) _emit(job_id, "artifact_failed", { "job_id": job_id, "video_id": video_id, "artifact_type": artifact_type, "error": err_msg, "attempts": max_retries, }) return False return False def _run_extractor( artifact_type: str, video_id: str, meta: Dict, output_folder: str, resolution: str, openrouter_api_key: str, openrouter_model: str, summary_prompt: str, ) -> str: """Dispatch to the correct extractor function.""" if artifact_type == "title": file_path, _ = extractors.extract_title(video_id, meta, output_folder) return file_path elif artifact_type == "stats": return extractors.extract_stats(video_id, meta, output_folder) elif artifact_type == "transcript": return extractors.extract_transcript(video_id, meta, output_folder) elif artifact_type == "summary": return extractors.extract_summary( video_id, meta, output_folder, api_key=openrouter_api_key, model=openrouter_model, system_prompt=summary_prompt, ) elif artifact_type == "thumbnail": return extractors.extract_thumbnail(video_id, meta, output_folder) elif artifact_type == "mp3": return extractors.extract_mp3(video_id, meta, output_folder) elif artifact_type == "mp4": return extractors.extract_mp4(video_id, meta, output_folder, resolution=resolution) else: raise ValueError(f"Unknown artifact type: {artifact_type}") # ───────────────────────────────────────────────────────────────────────────── # Helpers # ───────────────────────────────────────────────────────────────────────────── def _emit(job_id: str, event_type: str, data: dict): """Emit an SSE event.""" emitter.emit(job_id, event_type, data) def _wait_if_paused(controls: Dict): """Block worker thread while paused, check every 0.5s.""" while controls["pause"].is_set() and not controls["stop"].is_set(): time.sleep(0.5) def _interruptible_sleep(seconds: float, controls: Dict): """Sleep for `seconds` but wake early if stop/skip is set.""" end = time.monotonic() + seconds while time.monotonic() < end: if controls["stop"].is_set() or controls["skip"].is_set(): return time.sleep(min(0.3, end - time.monotonic())) # ───────────────────────────────────────────────────────────────────────────── # Job Launcher # ───────────────────────────────────────────────────────────────────────────── def launch_job(job_id: str, openrouter_api_key: str = ""): """ Launch job in a daemon background thread. openrouter_api_key is injected at runtime (not stored in DB for security). """ # Inject API key into job dict via a side channel stored in controls controls = _get_controls(job_id) controls["_openrouter_api_key"] = openrouter_api_key # Patch the run_job function to use the injected key def _patched_run(): job = db.get_job(job_id) if job: job["_openrouter_api_key"] = openrouter_api_key run_job_with_key(job_id, openrouter_api_key) t = threading.Thread(target=_patched_run, daemon=True, name=f"job-{job_id[:8]}") t.start() return t def run_job_with_key(job_id: str, openrouter_api_key: str): """Wrapper that injects the API key into job processing.""" # Monkey-patch the job dict to include the API key during this run original_get_job = db.get_job def patched_get_job(jid): result = original_get_job(jid) if result and jid == job_id: result["_openrouter_api_key"] = openrouter_api_key return result # Temporarily patch db.get_job for this thread import database _orig = database.get_job database.get_job = patched_get_job try: run_job(job_id) finally: database.get_job = _orig