"""MediaInfo-based media stream metadata extraction. Provides run_mediainfo() which returns the same normalized format as run_ffprobe() in ass_utils.py — {"streams": [...]} with ffprobe-compatible field names. """ import functools import json import logging import shutil import subprocess logger = logging.getLogger(__name__) # MediaInfo Format field → ffprobe codec_name MEDIAINFO_CODEC_MAP = { # Subtitle formats "advanced substation alpha": "ass", "ass": "ass", "substation alpha": "ssa", "ssa": "ssa", "utf-8": "subrip", "subrip": "subrip", "srt": "subrip", "vobsub": "dvd_subtitle", "pgs": "hdmv_pgs_subtitle", "pgssub": "hdmv_pgs_subtitle", "dvb subtitle": "dvb_subtitle", "webvtt": "webvtt", "timed text": "mov_text", # Audio formats "aac": "aac", "ac-3": "ac3", "e-ac-3": "eac3", "dts": "dts", "dts-hd ma": "dts", "truehd": "truehd", "flac": "flac", "mp3": "mp3", "opus": "opus", "vorbis": "vorbis", "pcm": "pcm_s16le", } def arr_video_fps(file_info) -> float | None: """The frame rate Sonarr/Radarr scanned for a file (``mediaInfo.videoFps``). Read from the episode or movie file the *arr already returned, so no probe of the file itself. None when unknown or not a positive number. """ media_info = (file_info or {}).get("mediaInfo") if isinstance(file_info, dict) else None value = (media_info or {}).get("videoFps") if isinstance(media_info, dict) else None try: fps = float(value) except (TypeError, ValueError): return None return fps if fps > 0 else None @functools.lru_cache(maxsize=1) def _is_mediainfo_available() -> bool: """Check once per process whether mediainfo is on PATH. Thread-safe via lru_cache.""" return shutil.which("mediainfo") is not None def run_mediainfo(file_path: str) -> dict: """Run mediainfo and return normalized stream data in ffprobe-compatible format. Args: file_path: Path to the video file. Passed as a list argument to subprocess (no shell=True) to prevent command injection. Returns: dict: {"streams": [...]} normalized to ffprobe format. Each stream has: codec_type, codec_name, index, tags (language, title), disposition (forced). Raises: FileNotFoundError: If mediainfo is not installed / not on PATH. RuntimeError: If mediainfo fails, times out, or returns invalid JSON. """ if not _is_mediainfo_available(): raise FileNotFoundError( "mediainfo not found in PATH — install mediainfo or switch engine to ffprobe" ) from security_utils import safe_subprocess_arg cmd = ["mediainfo", "--Output=JSON", safe_subprocess_arg(file_path)] try: result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) except subprocess.TimeoutExpired: raise RuntimeError(f"mediainfo timed out after 30s: {file_path}") if result.returncode != 0: raise RuntimeError(f"mediainfo failed (exit {result.returncode}): {result.stderr.strip()}") try: raw = json.loads(result.stdout) except json.JSONDecodeError as e: raise RuntimeError(f"mediainfo returned invalid JSON: {e}") return _normalize_mediainfo(raw) def _normalize_mediainfo(raw: dict) -> dict: """Convert MediaInfo JSON output to ffprobe-compatible stream format. MediaInfo JSON structure: {"media": {"track": [ {"@type": "General", ...}, {"@type": "Video", ...}, {"@type": "Audio", "Format": "AAC", "Language": "en", ...}, {"@type": "Text", "Format": "ASS", "Language": "jpn", "Forced": "No", ...}, ]}} Only Audio (@type: Audio) and Subtitle (@type: Text) tracks are included. General and Video tracks are skipped. Returns: dict: {"streams": [...]} matching the ffprobe contract used by ass_utils consumers. """ tracks = raw.get("media", {}).get("track", []) streams = [] stream_index = 0 for track in tracks: track_type = track.get("@type", "") if track_type == "Audio": streams.append( { "codec_type": "audio", "codec_name": _map_codec(track.get("Format", "")), "index": stream_index, "tags": { "language": _normalize_language(track.get("Language", "")), "title": track.get("Title", ""), }, "disposition": {}, } ) stream_index += 1 elif track_type == "Text": # Forced: MediaInfo uses "Yes"/"No" string; ffprobe uses int 0/1 forced = track.get("Forced", "No").strip().lower() == "yes" streams.append( { "codec_type": "subtitle", "codec_name": _map_codec(track.get("Format", "")), "index": stream_index, "tags": { "language": _normalize_language(track.get("Language", "")), "title": track.get("Title", ""), }, "disposition": {"forced": 1 if forced else 0}, } ) stream_index += 1 return {"streams": streams} def _map_codec(format_str: str) -> str: """Map a MediaInfo Format string to ffprobe codec_name. Falls back to the lowercase original if no mapping is found. """ return MEDIAINFO_CODEC_MAP.get(format_str.strip().lower(), format_str.strip().lower()) def _normalize_language(lang: str) -> str: """Normalize a MediaInfo language value to a lowercase trimmed string. MediaInfo may return ISO 639-1 (2-letter), ISO 639-2 (3-letter), or full names. We pass through as-is (lowercased) since ass_utils language matching already handles both 2- and 3-letter codes via get_source_lang_tags / get_target_lang_tags. """ return lang.strip().lower()