gRPC and worker
This commit is contained in:
15
worker/__init__.py
Normal file
15
worker/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""
|
||||
MPR Worker Module
|
||||
|
||||
Provides executor abstraction and Celery tasks for job processing.
|
||||
"""
|
||||
|
||||
from .executor import Executor, LocalExecutor, get_executor
|
||||
from .tasks import run_transcode_job
|
||||
|
||||
__all__ = [
|
||||
"Executor",
|
||||
"LocalExecutor",
|
||||
"get_executor",
|
||||
"run_transcode_job",
|
||||
]
|
||||
149
worker/executor.py
Normal file
149
worker/executor.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""
|
||||
Executor abstraction for job processing.
|
||||
|
||||
Supports different backends:
|
||||
- LocalExecutor: FFmpeg via Celery (default)
|
||||
- LambdaExecutor: AWS Lambda (future)
|
||||
"""
|
||||
|
||||
import os
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
from core.ffmpeg.transcode import TranscodeConfig, transcode
|
||||
|
||||
# Configuration from environment
|
||||
MPR_EXECUTOR = os.environ.get("MPR_EXECUTOR", "local")
|
||||
|
||||
|
||||
class Executor(ABC):
|
||||
"""Abstract base class for job executors."""
|
||||
|
||||
@abstractmethod
|
||||
def run(
|
||||
self,
|
||||
job_id: str,
|
||||
source_path: str,
|
||||
output_path: str,
|
||||
preset: Optional[Dict[str, Any]] = None,
|
||||
trim_start: Optional[float] = None,
|
||||
trim_end: Optional[float] = None,
|
||||
duration: Optional[float] = None,
|
||||
progress_callback: Optional[Callable[[int, Dict[str, Any]], None]] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Execute a transcode/trim job.
|
||||
|
||||
Args:
|
||||
job_id: Unique job identifier
|
||||
source_path: Path to source file
|
||||
output_path: Path for output file
|
||||
preset: Transcode preset dict (optional, None = trim only)
|
||||
trim_start: Trim start time in seconds (optional)
|
||||
trim_end: Trim end time in seconds (optional)
|
||||
duration: Source duration in seconds (for progress calculation)
|
||||
progress_callback: Called with (percent, details_dict)
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class LocalExecutor(Executor):
|
||||
"""Execute jobs locally using FFmpeg."""
|
||||
|
||||
def run(
|
||||
self,
|
||||
job_id: str,
|
||||
source_path: str,
|
||||
output_path: str,
|
||||
preset: Optional[Dict[str, Any]] = None,
|
||||
trim_start: Optional[float] = None,
|
||||
trim_end: Optional[float] = None,
|
||||
duration: Optional[float] = None,
|
||||
progress_callback: Optional[Callable[[int, Dict[str, Any]], None]] = None,
|
||||
) -> bool:
|
||||
"""Execute job using local FFmpeg."""
|
||||
|
||||
# Build config from preset or use stream copy for trim-only
|
||||
if preset:
|
||||
config = TranscodeConfig(
|
||||
input_path=source_path,
|
||||
output_path=output_path,
|
||||
video_codec=preset.get("video_codec", "libx264"),
|
||||
video_bitrate=preset.get("video_bitrate"),
|
||||
video_crf=preset.get("video_crf"),
|
||||
video_preset=preset.get("video_preset"),
|
||||
resolution=preset.get("resolution"),
|
||||
framerate=preset.get("framerate"),
|
||||
audio_codec=preset.get("audio_codec", "aac"),
|
||||
audio_bitrate=preset.get("audio_bitrate"),
|
||||
audio_channels=preset.get("audio_channels"),
|
||||
audio_samplerate=preset.get("audio_samplerate"),
|
||||
container=preset.get("container", "mp4"),
|
||||
extra_args=preset.get("extra_args", []),
|
||||
trim_start=trim_start,
|
||||
trim_end=trim_end,
|
||||
)
|
||||
else:
|
||||
# Trim-only: stream copy
|
||||
config = TranscodeConfig(
|
||||
input_path=source_path,
|
||||
output_path=output_path,
|
||||
video_codec="copy",
|
||||
audio_codec="copy",
|
||||
trim_start=trim_start,
|
||||
trim_end=trim_end,
|
||||
)
|
||||
|
||||
# Wrapper to convert float percent to int
|
||||
def wrapped_callback(percent: float, details: Dict[str, Any]) -> None:
|
||||
if progress_callback:
|
||||
progress_callback(int(percent), details)
|
||||
|
||||
return transcode(
|
||||
config,
|
||||
duration=duration,
|
||||
progress_callback=wrapped_callback if progress_callback else None,
|
||||
)
|
||||
|
||||
|
||||
class LambdaExecutor(Executor):
|
||||
"""Execute jobs via AWS Lambda (future implementation)."""
|
||||
|
||||
def run(
|
||||
self,
|
||||
job_id: str,
|
||||
source_path: str,
|
||||
output_path: str,
|
||||
preset: Optional[Dict[str, Any]] = None,
|
||||
trim_start: Optional[float] = None,
|
||||
trim_end: Optional[float] = None,
|
||||
duration: Optional[float] = None,
|
||||
progress_callback: Optional[Callable[[int, Dict[str, Any]], None]] = None,
|
||||
) -> bool:
|
||||
"""Execute job via AWS Lambda."""
|
||||
raise NotImplementedError("LambdaExecutor not yet implemented")
|
||||
|
||||
|
||||
# Executor registry
|
||||
_executors: Dict[str, type] = {
|
||||
"local": LocalExecutor,
|
||||
"lambda": LambdaExecutor,
|
||||
}
|
||||
|
||||
_executor_instance: Optional[Executor] = None
|
||||
|
||||
|
||||
def get_executor() -> Executor:
|
||||
"""Get the configured executor instance."""
|
||||
global _executor_instance
|
||||
|
||||
if _executor_instance is None:
|
||||
executor_type = MPR_EXECUTOR.lower()
|
||||
if executor_type not in _executors:
|
||||
raise ValueError(f"Unknown executor type: {executor_type}")
|
||||
_executor_instance = _executors[executor_type]()
|
||||
|
||||
return _executor_instance
|
||||
96
worker/tasks.py
Normal file
96
worker/tasks.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""
|
||||
Celery tasks for job processing.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from celery import shared_task
|
||||
|
||||
from grpc.server import update_job_progress
|
||||
from worker.executor import get_executor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Media paths from environment
|
||||
MEDIA_ROOT = os.environ.get("MEDIA_ROOT", "/app/media")
|
||||
|
||||
|
||||
@shared_task(bind=True, max_retries=3, default_retry_delay=60)
|
||||
def run_transcode_job(
|
||||
self,
|
||||
job_id: str,
|
||||
source_path: str,
|
||||
output_path: str,
|
||||
preset: Optional[Dict[str, Any]] = None,
|
||||
trim_start: Optional[float] = None,
|
||||
trim_end: Optional[float] = None,
|
||||
duration: Optional[float] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Celery task to run a transcode/trim job.
|
||||
|
||||
Args:
|
||||
job_id: Unique job identifier
|
||||
source_path: Path to source file
|
||||
output_path: Path for output file
|
||||
preset: Transcode preset dict (optional)
|
||||
trim_start: Trim start time in seconds (optional)
|
||||
trim_end: Trim end time in seconds (optional)
|
||||
duration: Source duration for progress calculation
|
||||
|
||||
Returns:
|
||||
Result dict with status and output_path
|
||||
"""
|
||||
logger.info(f"Starting job {job_id}: {source_path} -> {output_path}")
|
||||
|
||||
# Update status to processing
|
||||
update_job_progress(job_id, progress=0, status="processing")
|
||||
|
||||
def progress_callback(percent: int, details: Dict[str, Any]) -> None:
|
||||
"""Update gRPC progress state."""
|
||||
update_job_progress(
|
||||
job_id,
|
||||
progress=percent,
|
||||
current_time=details.get("time", 0.0),
|
||||
status="processing",
|
||||
)
|
||||
|
||||
try:
|
||||
executor = get_executor()
|
||||
success = executor.run(
|
||||
job_id=job_id,
|
||||
source_path=source_path,
|
||||
output_path=output_path,
|
||||
preset=preset,
|
||||
trim_start=trim_start,
|
||||
trim_end=trim_end,
|
||||
duration=duration,
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
|
||||
if success:
|
||||
logger.info(f"Job {job_id} completed successfully")
|
||||
update_job_progress(job_id, progress=100, status="completed")
|
||||
return {
|
||||
"status": "completed",
|
||||
"job_id": job_id,
|
||||
"output_path": output_path,
|
||||
}
|
||||
else:
|
||||
raise RuntimeError("Executor returned False")
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Job {job_id} failed: {e}")
|
||||
update_job_progress(job_id, progress=0, status="failed", error=str(e))
|
||||
|
||||
# Retry on transient errors
|
||||
if self.request.retries < self.max_retries:
|
||||
raise self.retry(exc=e)
|
||||
|
||||
return {
|
||||
"status": "failed",
|
||||
"job_id": job_id,
|
||||
"error": str(e),
|
||||
}
|
||||
Reference in New Issue
Block a user