from __future__ import annotations import asyncio import logging import time from collections.abc import Sequence from pathlib import Path from app.core.config import Settings from app.core.exceptions import ProcessingError from app.core.logger import get_logger logger = get_logger(__name__) class FFmpegService: """Concurrency-limited, shell-free FFmpeg process runner.""" def __init__(self, settings: Settings) -> None: self.settings = settings self._semaphore = asyncio.Semaphore(settings.max_workers) async def run( self, args: Sequence[str | Path], *, operation: str, timeout: float | None = None, cancel_event: asyncio.Event | None = None, ) -> None: command = [self.settings.ffmpeg_binary, "-hide_banner", "-nostdin", "-y"] + [ str(arg) for arg in args ] started = time.monotonic() # Arguments can contain signed URLs, storage locators, and internal # filesystem paths. Keep those out of structured logs while retaining # enough bounded context to correlate and diagnose an invocation. logger.info( "ffmpeg started", extra={"operation": operation, "argument_count": len(command) - 1}, ) async with self._semaphore: try: process = await asyncio.create_subprocess_exec( *command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) stdout_task = asyncio.create_task(_read_limited(process.stdout, 4_000)) stderr_task = asyncio.create_task(_read_limited(process.stderr, 16_000)) try: wait_task = asyncio.create_task(process.wait()) cancel_task = asyncio.create_task(cancel_event.wait()) if cancel_event else None pending: set[asyncio.Task[object]] = set() waitables: set[asyncio.Task[object]] = {wait_task} if cancel_task is not None: waitables.add(cancel_task) done, pending = await asyncio.wait( waitables, timeout=timeout or self.settings.download_timeout_seconds * 4, return_when=asyncio.FIRST_COMPLETED, ) if not done: raise asyncio.TimeoutError if ( cancel_task is not None and cancel_task in done and cancel_event and cancel_event.is_set() ): process.kill() await process.wait() await asyncio.gather(stdout_task, stderr_task, return_exceptions=True) raise ProcessingError( "FFmpeg processing was cancelled", details={"cancelled": True} ) await wait_task except asyncio.TimeoutError: process.kill() await process.wait() await asyncio.gather(stdout_task, stderr_task) raise finally: for task in pending if "pending" in locals() else set(): task.cancel() if "wait_task" in locals() and not wait_task.done(): wait_task.cancel() if ( "cancel_task" in locals() and cancel_task is not None and not cancel_task.done() ): cancel_task.cancel() stdout, stderr = await asyncio.gather(stdout_task, stderr_task) except asyncio.TimeoutError as exc: raise ProcessingError("FFmpeg processing timed out") from exc except FileNotFoundError as exc: raise ProcessingError("FFmpeg is not installed or not available") from exc log_data = { "operation": operation, "argument_count": len(command) - 1, "duration": round(time.monotonic() - started, 4), "return_code": process.returncode, "stdout_bytes": len(stdout), "stderr_bytes": len(stderr), } if process.returncode != 0: logger.error("ffmpeg failed", extra=log_data) raise ProcessingError( "FFmpeg could not process the media", details={"operation": operation}, ) logger.log(logging.INFO, "ffmpeg completed", extra=log_data) async def version(self) -> str: """Return the installed FFmpeg version banner.""" output = await self._capture(["-version"], operation="ffmpeg.version") return output.splitlines()[0] if output else "unknown" async def codecs(self) -> list[dict[str, str | bool]]: """Return structured codec capabilities reported by FFmpeg.""" output = await self._capture(["-hide_banner", "-codecs"], operation="ffmpeg.codecs") codecs: list[dict[str, str | bool]] = [] for line in output.splitlines(): if len(line) < 9 or line[0] != " ": continue flags = line[1:7] if ( flags[0] not in {"D", "."} or flags[1] not in {"E", "."} or flags[2] not in {"V", "A", "S", "D", "."} ): continue remainder = line[8:].strip() if not remainder or " " not in remainder: continue name, description = remainder.split(maxsplit=1) if name == "=": continue codecs.append( { "name": name, "description": description, "decode": flags[0] == "D", "encode": flags[1] == "E", "type": { "V": "video", "A": "audio", "S": "subtitle", }.get(flags[2], "other"), } ) return codecs async def _capture(self, args: Sequence[str], *, operation: str) -> str: command = [self.settings.ffmpeg_binary, *args] logger.info( "ffmpeg information requested", extra={"operation": operation, "command": command} ) try: async with self._semaphore: process = await asyncio.create_subprocess_exec( *command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, ) stdout, _ = await asyncio.wait_for(process.communicate(), timeout=30) except FileNotFoundError as exc: raise ProcessingError("FFmpeg is not installed or not available") from exc except asyncio.TimeoutError as exc: if "process" in locals(): process.kill() await process.wait() raise ProcessingError("FFmpeg information request timed out") from exc if process.returncode != 0: raise ProcessingError("FFmpeg information request failed") return stdout.decode("utf-8", errors="replace")[-2_000_000:] async def _read_limited(stream: asyncio.StreamReader | None, limit: int) -> bytes: if stream is None: return b"" data = bytearray() while chunk := await stream.read(64 * 1024): data.extend(chunk) if len(data) > limit: del data[:-limit] return bytes(data)