from __future__ import annotations import json import shutil import subprocess from pathlib import Path from typing import Any from voice_transcriptor.models import MediaInfo, ToolStatus class MediaProbeError(RuntimeError): """A media file could not be validated or inspected.""" class InvalidMediaPathError(MediaProbeError): """The selected path is not a readable local file.""" class ProbeOutputError(MediaProbeError): """FFprobe returned output that cannot be interpreted.""" class ProbeExecutionError(MediaProbeError): """FFprobe could not start or inspect the selected media.""" class ProbeTimeoutError(MediaProbeError): """FFprobe did not finish before its configured timeout.""" def detect_tools() -> ToolStatus: ffmpeg = shutil.which("ffmpeg") ffprobe = shutil.which("ffprobe") return ToolStatus(Path(ffmpeg) if ffmpeg else None, Path(ffprobe) if ffprobe else None) def validate_media_path(path: Path) -> Path: path = path.expanduser() if not path.exists(): raise InvalidMediaPathError("The selected file does not exist.") if not path.is_file(): raise InvalidMediaPathError("The selected path is not a file.") try: path.stat() except OSError as exc: raise InvalidMediaPathError("The selected file cannot be read.") from exc return path.resolve() def parse_probe_output(path: Path, size_bytes: int, payload: str) -> MediaInfo: try: data: Any = json.loads(payload) except (json.JSONDecodeError, TypeError) as exc: raise ProbeOutputError("FFprobe returned invalid data.") from exc if not isinstance(data, dict): raise ProbeOutputError("FFprobe returned invalid data.") format_data = data.get("format", {}) streams = data.get("streams", []) if ( not isinstance(format_data, dict) or not isinstance(streams, list) or not all(isinstance(stream, dict) for stream in streams) ): raise ProbeOutputError("FFprobe returned invalid data.") duration: float | None = None raw_duration = format_data.get("duration") try: if raw_duration is not None: duration = float(raw_duration) except (TypeError, ValueError): duration = None codec = next( (stream.get("codec_name") for stream in streams if stream.get("codec_type") == "audio"), None, ) return MediaInfo(path=path, size_bytes=size_bytes, duration_seconds=duration, audio_codec=codec) class MediaProbeService: def __init__(self, ffprobe_path: Path, timeout_seconds: int = 30) -> None: self.ffprobe_path = ffprobe_path self.timeout_seconds = timeout_seconds def probe(self, path: Path) -> MediaInfo: media_path = validate_media_path(path) command = [ str(self.ffprobe_path), "-v", "error", "-show_entries", "format=duration:stream=codec_type,codec_name", "-of", "json", str(media_path), ] startup_flags = getattr(subprocess, "CREATE_NO_WINDOW", 0) try: result = subprocess.run( command, capture_output=True, text=True, timeout=self.timeout_seconds, check=False, creationflags=startup_flags, ) except subprocess.TimeoutExpired as exc: raise ProbeTimeoutError("FFprobe timed out while inspecting the file.") from exc except OSError as exc: raise ProbeExecutionError("FFprobe could not be started.") from exc if result.returncode != 0: raise ProbeExecutionError("FFprobe could not inspect this media file.") return parse_probe_output(media_path, media_path.stat().st_size, result.stdout)