""" Video Probing Module Extracts video metadata (codec, resolution, duration, frame rate) using ffprobe. Handles errors gracefully for corrupt or unsupported files. """ import json import logging import subprocess import time from dataclasses import dataclass, field from typing import Optional logger = logging.getLogger(__name__) @dataclass class VideoMetadata: """Metadata extracted from a video file.""" file_path: str codec: Optional[str] = None resolution_w: Optional[int] = None resolution_h: Optional[int] = None duration: Optional[float] = None frame_rate: Optional[float] = None file_size: Optional[int] = None status: str = "PENDING" error_message: Optional[str] = None probe_time: float = field(default_factory=time.time) @property def is_valid(self) -> bool: return self.status == "PENDING" and self.codec is not None @property def is_unscannable(self) -> bool: return self.status == "UNSCANNABLE" def to_dict(self) -> dict: return { "file_path": self.file_path, "codec": self.codec, "resolution_w": self.resolution_w, "resolution_h": self.resolution_h, "duration": self.duration, "frame_rate": self.frame_rate, "file_size": self.file_size, "status": self.status, "error_message": self.error_message, } class VideoProber: """Probe video files using ffprobe to extract metadata.""" def __init__(self, timeout: int = 10): self.timeout = timeout def probe(self, file_path: str) -> VideoMetadata: """Probe a video file and return metadata.""" metadata = VideoMetadata(file_path=file_path) # Get file size try: metadata.file_size = self._get_file_size(file_path) except OSError as e: metadata.status = "UNSCANNABLE" metadata.error_message = f"Cannot read file: {e}" logger.warning("File access error for %s: %s", file_path, e) return metadata # Run ffprobe try: result = self._run_ffprobe(file_path) if result is None: metadata.status = "UNSCANNABLE" metadata.error_message = "ffprobe timed out or failed" logger.warning("ffprobe failed for %s", file_path) return metadata metadata = self._parse_ffprobe_output(result, file_path) except subprocess.TimeoutExpired: metadata.status = "UNSCANNABLE" metadata.error_message = f"ffprobe timed out after {self.timeout}s" logger.warning("ffprobe timeout for %s", file_path) except Exception as e: metadata.status = "UNSCANNABLE" metadata.error_message = f"ffprobe error: {e}" logger.warning("ffprobe error for %s: %s", file_path, e) return metadata def _run_ffprobe(self, file_path: str) -> Optional[str]: """Run ffprobe and return JSON output.""" cmd = [ "ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=codec_name,width,height,r_frame_rate,duration", "-show_entries", "format=duration,size", "-of", "json", file_path, ] try: output = subprocess.run( cmd, capture_output=True, text=True, timeout=self.timeout, ) if output.returncode != 0: logger.debug("ffprobe stderr for %s: %s", file_path, output.stderr) return None return output.stdout except subprocess.TimeoutExpired: return None def _parse_ffprobe_output(self, output: str, file_path: str) -> VideoMetadata: """Parse ffprobe JSON output into VideoMetadata.""" metadata = VideoMetadata(file_path=file_path) try: data = json.loads(output) except json.JSONDecodeError as e: metadata.status = "UNSCANNABLE" metadata.error_message = f"Invalid ffprobe output: {e}" return metadata # Extract stream info streams = data.get("streams", []) if not streams: metadata.status = "UNSCANNABLE" metadata.error_message = "No video streams found" return metadata stream = streams[0] metadata.codec = stream.get("codec_name") metadata.resolution_w = stream.get("width") metadata.resolution_h = stream.get("height") # Parse frame rate r_frame_rate = stream.get("r_frame_rate") if r_frame_rate and "/" in r_frame_rate: try: num, den = r_frame_rate.split("/") metadata.frame_rate = float(num) / float(den) if float(den) != 0 else None except (ValueError, ZeroDivisionError): pass # Extract format info format_info = data.get("format", {}) if format_info.get("duration"): try: metadata.duration = float(format_info["duration"]) except (ValueError, TypeError): pass if format_info.get("size"): try: metadata.file_size = int(format_info["size"]) except (ValueError, TypeError): pass # Validate we got essential data if not metadata.codec: metadata.status = "UNSCANNABLE" metadata.error_message = "No codec information available" elif not metadata.duration: metadata.status = "UNSCANNABLE" metadata.error_message = "No duration information available" return metadata @staticmethod def _get_file_size(file_path: str) -> int: """Get file size in bytes.""" return os.path.getsize(file_path)