Files
Transcriptarr/backend/core/worker.py
Dasemu c019e96cfa feat(workers): add multiprocessing worker pool system
- Add Worker class with CPU/GPU support
- Add WorkerPool for orchestrating multiple workers
- Support dynamic add/remove workers at runtime
- Add health monitoring with graceful shutdown
2026-01-16 16:56:42 +01:00

494 lines
18 KiB
Python

"""Individual worker for processing transcription jobs."""
import logging
import multiprocessing as mp
import os
import time
import traceback
from datetime import datetime, timezone
from enum import IntEnum, Enum
from typing import Optional
from backend.core.database import Database
from backend.core.models import Job, JobStatus, JobStage
from backend.core.queue_manager import QueueManager
logger = logging.getLogger(__name__)
class WorkerType(str, Enum):
"""Worker device type."""
CPU = "cpu"
GPU = "gpu"
class WorkerStatus(IntEnum):
"""Worker status states."""
IDLE = 0
BUSY = 1
STOPPING = 2
STOPPED = 3
ERROR = 4
def to_string(self) -> str:
"""Convert to string representation."""
return {
0: "idle",
1: "busy",
2: "stopping",
3: "stopped",
4: "error"
}.get(self.value, "unknown")
class Worker:
"""
Individual worker process for transcription.
Each worker runs in its own process and can handle one job at a time.
Workers communicate with the main process via multiprocessing primitives.
"""
def __init__(
self,
worker_id: str,
worker_type: WorkerType,
device_id: Optional[int] = None
):
"""
Initialize worker.
Args:
worker_id: Unique identifier for this worker
worker_type: CPU or GPU
device_id: GPU device ID (only for GPU workers)
"""
self.worker_id = worker_id
self.worker_type = worker_type
self.device_id = device_id
# Multiprocessing primitives
self.process: Optional[mp.Process] = None
self.stop_event = mp.Event()
self.status = mp.Value('i', WorkerStatus.IDLE.value) # type: ignore
self.current_job_id = mp.Array('c', 36) # type: ignore # UUID string
# Stats
self.jobs_completed = mp.Value('i', 0) # type: ignore
self.jobs_failed = mp.Value('i', 0) # type: ignore
self.started_at: Optional[datetime] = None
def start(self):
"""Start the worker process."""
if self.process and self.process.is_alive():
logger.warning(f"Worker {self.worker_id} is already running")
return
self.stop_event.clear()
self.process = mp.Process(
target=self._worker_loop,
name=f"Worker-{self.worker_id}",
daemon=True
)
self.process.start()
self.started_at = datetime.now(timezone.utc)
logger.info(
f"Worker {self.worker_id} started (PID: {self.process.pid}, "
f"Type: {self.worker_type.value})"
)
def stop(self, timeout: float = 5.0):
"""
Stop the worker process gracefully.
Args:
timeout: Maximum time to wait for worker to stop
"""
if not self.process or not self.process.is_alive():
logger.debug(f"Worker {self.worker_id} is not running")
return
logger.info(f"Stopping worker {self.worker_id}...")
self.stop_event.set()
self.process.join(timeout=timeout)
if self.process.is_alive():
logger.warning(f"Worker {self.worker_id} did not stop gracefully, terminating...")
self.process.terminate()
self.process.join(timeout=2.0)
if self.process.is_alive():
logger.error(f"Worker {self.worker_id} did not terminate, killing...")
self.process.kill()
self.process.join(timeout=1.0)
logger.info(f"Worker {self.worker_id} stopped")
def is_alive(self) -> bool:
"""Check if worker process is alive."""
return self.process is not None and self.process.is_alive()
def get_status(self) -> dict:
"""Get worker status information."""
status_value = self.status.value
status_enum = WorkerStatus.IDLE
for s in WorkerStatus:
if s.value == status_value:
status_enum = s
break
current_job = self.current_job_id.value.decode('utf-8').strip('\x00')
return {
"worker_id": self.worker_id,
"type": self.worker_type.value,
"device_id": self.device_id,
"status": status_enum.to_string(), # Convert to string
"current_job_id": current_job if current_job else None,
"jobs_completed": self.jobs_completed.value,
"jobs_failed": self.jobs_failed.value,
"is_alive": self.is_alive(),
"pid": self.process.pid if self.process else None,
"started_at": self.started_at.isoformat() if self.started_at else None,
}
def _worker_loop(self):
"""
Main worker loop (runs in separate process).
This is the entry point for the worker process.
"""
# Set up logging in the worker process
logging.basicConfig(
level=logging.INFO,
format=f'[Worker-{self.worker_id}] %(levelname)s: %(message)s'
)
logger.info(f"Worker {self.worker_id} loop started")
# Initialize database and queue manager in worker process
# Each process needs its own DB connection
try:
db = Database(auto_create_tables=False)
queue_mgr = QueueManager()
except Exception as e:
logger.error(f"Failed to initialize worker: {e}")
self._set_status(WorkerStatus.ERROR)
return
# Main work loop
while not self.stop_event.is_set():
try:
# Try to get next job from queue
job = queue_mgr.get_next_job(self.worker_id)
if job is None:
# No jobs available, idle for a bit
self._set_status(WorkerStatus.IDLE)
time.sleep(2)
continue
# Process the job
self._set_status(WorkerStatus.BUSY)
self._set_current_job(job.id)
logger.info(f"Processing job {job.id}: {job.file_name}")
try:
self._process_job(job, queue_mgr)
self.jobs_completed.value += 1
logger.info(f"Job {job.id} completed successfully")
except Exception as e:
self.jobs_failed.value += 1
error_msg = f"Job processing failed: {str(e)}\n{traceback.format_exc()}"
logger.error(error_msg)
queue_mgr.mark_job_failed(job.id, error_msg)
finally:
self._clear_current_job()
except Exception as e:
logger.error(f"Worker loop error: {e}\n{traceback.format_exc()}")
time.sleep(5) # Back off on errors
self._set_status(WorkerStatus.STOPPED)
logger.info(f"Worker {self.worker_id} loop ended")
def _process_job(self, job: Job, queue_mgr: QueueManager):
"""
Process a job (transcription or language detection).
Args:
job: Job to process
queue_mgr: Queue manager for updating progress
"""
from backend.core.models import JobType
# Route to appropriate handler based on job type
if job.job_type == JobType.LANGUAGE_DETECTION:
self._process_language_detection(job, queue_mgr)
else:
self._process_transcription(job, queue_mgr)
def _process_language_detection(self, job: Job, queue_mgr: QueueManager):
"""
Process a language detection job using fast Whisper model.
Args:
job: Language detection job
queue_mgr: Queue manager for updating progress
"""
start_time = time.time()
try:
logger.info(f"Worker {self.worker_id} processing LANGUAGE DETECTION job {job.id}: {job.file_name}")
# Stage 1: Detecting language (20% progress)
queue_mgr.update_job_progress(
job.id, progress=20.0, stage=JobStage.DETECTING_LANGUAGE, eta_seconds=10
)
# Use language detector with tiny model
from backend.scanning.language_detector import LanguageDetector
language, confidence = LanguageDetector.detect_language(
file_path=job.file_path,
sample_duration=30
)
# Stage 2: Finalizing (80% progress)
queue_mgr.update_job_progress(
job.id, progress=80.0, stage=JobStage.FINALIZING, eta_seconds=2
)
if language:
# Calculate processing time
processing_time = time.time() - start_time
# Use ISO 639-1 format (ja, en, es) throughout the system
lang_code = language.value[0] if language else "unknown"
result_text = f"Language detected: {lang_code} ({language.name.title() if language else 'Unknown'})\nConfidence: {confidence}%"
# Store in ISO 639-1 format (ja, en, es) for consistency
queue_mgr.mark_job_completed(
job.id,
output_path=None,
segments_count=0,
srt_content=result_text,
detected_language=lang_code # Use ISO 639-1 (ja, en, es)
)
logger.info(
f"Worker {self.worker_id} completed detection job {job.id}: "
f"{lang_code} (confidence: {confidence}%) in {processing_time:.1f}s"
)
# Check if file matches any scan rules and queue transcription job
self._check_and_queue_transcription(job, lang_code)
else:
# Detection failed
queue_mgr.mark_job_failed(job.id, "Language detection failed - could not detect language")
logger.error(f"Worker {self.worker_id} failed detection job {job.id}: No language detected")
except Exception as e:
logger.error(f"Worker {self.worker_id} failed detection job {job.id}: {e}", exc_info=True)
queue_mgr.mark_job_failed(job.id, str(e))
def _process_transcription(self, job: Job, queue_mgr: QueueManager):
"""
Process a transcription/translation job using Whisper.
Args:
job: Transcription job
queue_mgr: Queue manager for updating progress
"""
from backend.transcription import WhisperTranscriber
from backend.transcription.audio_utils import handle_multiple_audio_tracks
from backend.core.language_code import LanguageCode
transcriber = None
start_time = time.time()
try:
logger.info(f"Worker {self.worker_id} processing TRANSCRIPTION job {job.id}: {job.file_name}")
# Stage 1: Loading model
queue_mgr.update_job_progress(
job.id, progress=5.0, stage=JobStage.LOADING_MODEL, eta_seconds=None
)
# Determine device for transcriber
if self.worker_type == WorkerType.GPU:
device = f"cuda:{self.device_id}" if self.device_id is not None else "cuda"
else:
device = "cpu"
transcriber = WhisperTranscriber(device=device)
transcriber.load_model()
# Stage 2: Preparing audio
queue_mgr.update_job_progress(
job.id, progress=10.0, stage=JobStage.EXTRACTING_AUDIO, eta_seconds=None
)
# Handle multiple audio tracks if needed
source_lang = (
LanguageCode.from_string(job.source_lang) if job.source_lang else None
)
audio_data = handle_multiple_audio_tracks(job.file_path, source_lang)
# Stage 3: Transcribing
queue_mgr.update_job_progress(
job.id, progress=15.0, stage=JobStage.TRANSCRIBING, eta_seconds=None
)
# Progress callback for real-time updates
def progress_callback(seek, total):
# Reserve 15%-75% for Whisper (60% range)
# If translate mode, reserve 75%-90% for translation (15% range)
whisper_progress = 15.0 + (seek / total) * 60.0
queue_mgr.update_job_progress(job.id, progress=whisper_progress, stage=JobStage.TRANSCRIBING)
# Stage 3A: Whisper transcription to English
# IMPORTANT: Both 'transcribe' and 'translate' modes use task='translate' here
# to convert audio to English subtitles
logger.info(f"Running Whisper with task='translate' to convert audio to English")
# job.source_lang is already in ISO 639-1 format (ja, en, es)
# Whisper accepts ISO 639-1, so we can use it directly
if audio_data:
result = transcriber.transcribe_audio_data(
audio_data=audio_data.read(),
language=job.source_lang, # Already ISO 639-1 (ja, en, es)
task="translate", # ALWAYS translate to English first
progress_callback=progress_callback,
)
else:
result = transcriber.transcribe_file(
file_path=job.file_path,
language=job.source_lang, # Already ISO 639-1 (ja, en, es)
task="translate", # ALWAYS translate to English first
progress_callback=progress_callback,
)
# Generate English SRT filename
file_base = os.path.splitext(job.file_path)[0]
english_srt_path = f"{file_base}.eng.srt"
# Save English SRT
result.to_srt(english_srt_path, word_level=False)
logger.info(f"English subtitles saved to {english_srt_path}")
# Stage 3B: Optional translation to target language
if job.transcribe_or_translate == "translate" and job.target_lang and job.target_lang.lower() != "eng":
queue_mgr.update_job_progress(
job.id, progress=75.0, stage=JobStage.FINALIZING, eta_seconds=10
)
logger.info(f"Translating English subtitles to {job.target_lang}")
from backend.transcription import translate_srt_file
# Generate target language SRT filename
target_srt_path = f"{file_base}.{job.target_lang}.srt"
# Translate English SRT to target language
success = translate_srt_file(
input_path=english_srt_path,
output_path=target_srt_path,
target_language=job.target_lang
)
if success:
logger.info(f"Translated subtitles saved to {target_srt_path}")
output_path = target_srt_path
else:
logger.warning(f"Translation failed, keeping English subtitles only")
output_path = english_srt_path
else:
# For 'transcribe' mode or if target is English, use English SRT
output_path = english_srt_path
# Stage 4: Finalize
queue_mgr.update_job_progress(
job.id, progress=90.0, stage=JobStage.FINALIZING, eta_seconds=5
)
# Calculate processing time
processing_time = time.time() - start_time
# Get SRT content for storage
srt_content = result.get_srt_content()
# Mark job as completed
queue_mgr.mark_job_completed(
job.id,
output_path=output_path,
segments_count=result.segments_count,
srt_content=srt_content,
model_used=transcriber.model_name,
device_used=transcriber.device,
processing_time_seconds=processing_time,
)
logger.info(
f"Worker {self.worker_id} completed job {job.id}: "
f"{result.segments_count} segments in {processing_time:.1f}s"
)
except Exception as e:
logger.error(f"Worker {self.worker_id} failed job {job.id}: {e}", exc_info=True)
queue_mgr.mark_job_failed(job.id, str(e))
finally:
# Always unload model after job
if transcriber:
try:
transcriber.unload_model()
except Exception as e:
logger.error(f"Error unloading model: {e}")
def _set_status(self, status: WorkerStatus):
"""Set worker status (thread-safe)."""
self.status.value = status.value
def _set_current_job(self, job_id: str):
"""Set the current job ID (thread-safe)."""
job_id_bytes = job_id.encode('utf-8')
for i, byte in enumerate(job_id_bytes):
if i < len(self.current_job_id):
self.current_job_id[i] = byte
def _clear_current_job(self):
"""Clear current job ID (thread-safe)."""
for i in range(len(self.current_job_id)):
self.current_job_id[i] = b'\x00'
def _check_and_queue_transcription(self, job: Job, detected_lang_code: str):
"""
Check if detected language matches any scan rules and queue transcription job.
Args:
job: Completed language detection job
detected_lang_code: Detected language code (ISO 639-1, e.g., 'ja', 'en')
"""
try:
from backend.scanning.library_scanner import library_scanner
logger.info(
f"Language detection completed for {job.file_path}: {detected_lang_code}. "
f"Checking scan rules..."
)
# Use the scanner's method to check rules and queue transcription
library_scanner._check_and_queue_transcription_for_file(
job.file_path, detected_lang_code
)
except Exception as e:
logger.error(
f"Error checking scan rules for {job.file_path}: {e}",
exc_info=True
)