""" Python wrapper for ffprobe command line tool. ffprobe must exist in the path. """ import asyncio import functools import json import operator import os import subprocess from pathlib import Path import anyio # parameter-less decorator def async_lru_cache_decorator(async_function): @functools.lru_cache def cached_async_function(*args, **kwargs): coroutine = async_function(*args, **kwargs) return asyncio.ensure_future(coroutine) return cached_async_function # decorator with options def async_lru_cache(*lru_cache_args, **lru_cache_kwargs): def async_lru_cache_decorator(async_function): @functools.lru_cache(*lru_cache_args, **lru_cache_kwargs) def cached_async_function(*args, **kwargs): coroutine = async_function(*args, **kwargs) return asyncio.ensure_future(coroutine) return cached_async_function return async_lru_cache_decorator class FFProbeError(Exception): pass class FFStream: """ An object representation of an individual stream in a multimedia file. """ def __init__(self, json_data: dict): for key, val in json_data.items(): setattr(self, key, val) try: self.__dict__["framerate"] = round( functools.reduce(operator.truediv, map(int, self.__dict__.get("avg_frame_rate", "").split("/"))) ) except ValueError: self.__dict__["framerate"] = None except ZeroDivisionError: self.__dict__["framerate"] = 0 def __repr__(self): if "codec_long_name" not in self.__dict__: self.codec_long_name = self.__dict__.get("codec_name", "") if self.is_video(): return f"" if self.is_audio(): return ( f" " ) if self.is_subtitle() or self.is_attachment(): return f"" return f"" def is_audio(self): """ Is this stream labelled as an audio stream? """ return self.__dict__.get("codec_type", None) == "audio" def is_video(self): """ Is the stream labelled as a video stream. """ if self.__dict__.get("codec_type", None) != "video": return False return self.__dict__.get("codec_name", None) not in ["png", "mjpeg", "gif", "bmp", "tiff", "webp"] def is_subtitle(self): """ Is the stream labelled as a subtitle stream. """ return self.__dict__.get("codec_type", None) == "subtitle" def is_attachment(self): """ Is the stream labelled as a attachment stream. """ return self.__dict__.get("codec_type", None) == "attachment" def frame_size(self): """ Returns the pixel frame size as an integer tuple (width,height) if the stream is a video stream. Returns None if it is not a video stream. """ size = None if self.is_video(): width = self.__dict__["width"] height = self.__dict__["height"] if width and height: try: size = (int(width), int(height)) except ValueError as e: msg = f"None integer size {width}:{height}" raise FFProbeError(msg) from e else: return None return size def pixel_format(self): """ Returns a string representing the pixel format of the video stream. e.g. yuv420p. Returns none is it is not a video stream. """ return self.__dict__.get("pix_fmt", None) def frames(self): """ Returns the length of a video stream in frames. Returns 0 if not a video stream. """ if self.is_video() or self.is_audio(): try: frame_count = int(self.__dict__.get("nb_frames", "")) except ValueError as e: msg = "None integer frame count" raise FFProbeError(msg) from e else: frame_count = 0 return frame_count def duration_seconds(self): """ Returns the runtime duration of the video stream as a floating point number of seconds. Returns 0.0 if not a video stream. """ if self.is_video() or self.is_audio(): try: duration = float(self.__dict__.get("duration", "")) except ValueError as e: msg = "None numeric duration" raise FFProbeError(msg) from e else: duration = 0.0 return duration def language(self): """ Returns language tag of stream. e.g. eng """ return self.__dict__.get("TAG:language", None) def codec(self): """ Returns a string representation of the stream codec. """ return self.__dict__.get("codec_name", None) def codec_description(self): """ Returns a long representation of the stream codec. """ return self.__dict__.get("codec_long_name", None) def codec_tag(self): """ Returns a short representative tag of the stream codec. """ return self.__dict__.get("codec_tag_string", None) def bit_rate(self): """ Returns bit_rate as an integer in bps """ try: return int(self.__dict__.get("bit_rate", "")) except ValueError as e: msg = "None integer bit_rate" raise FFProbeError(msg) from e class FFProbeResult: def __init__(self): self.metadata: dict = {} self.video: list[FFStream] = [] self.audio: list[FFStream] = [] self.subtitle: list[FFStream] = [] self.attachment: list[FFStream] = [] @property def is_video(self): return self.has_video() @property def is_audio(self): return self.has_audio() def get(self, key: str, default=None): return getattr(self, key) if hasattr(self, key) else default def streams(self) -> list[FFStream]: """List of all streams.""" return self.video + self.audio + self.subtitle + self.attachment def has_video(self): """Is there a video stream?""" return len(self.video) > 0 def has_audio(self): """Is there an audio stream?""" return len(self.audio) > 0 def has_subtitle(self): """Is there a subtitle stream?""" return len(self.subtitle) > 0 def __repr__(self): return "".format(**vars(self)) def deserialize(self, data: dict): self.metadata = data.get("metadata", {}) self.video = [FFStream(v) for v in data.get("video", [])] self.audio = [FFStream(a) for a in data.get("audio", [])] self.subtitle = [FFStream(s) for s in data.get("subtitle", [])] self.attachment = [FFStream(a) for a in data.get("attachment", [])] def serialize(self) -> dict: return { "metadata": self.metadata, "video": [v.__dict__ for v in self.video], "audio": [a.__dict__ for a in self.audio], "subtitle": [s.__dict__ for s in self.subtitle], "attachment": [a.__dict__ for a in self.attachment], "is_video": self.has_video(), "is_audio": self.has_audio(), } @async_lru_cache(maxsize=512) async def ffprobe(file: str) -> FFProbeResult: """ Run ffprobe on a file and return the parsed data as a dictionary. Args: file (str): The path to the media file. Returns: dict: A dictionary containing the parsed data. """ try: async with await anyio.open_file(os.devnull, "w") as tempf: await asyncio.create_subprocess_exec( "ffprobe", "-h", stdout=tempf, stderr=tempf, creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, ) except FileNotFoundError as e: msg = "ffprobe not found." raise OSError(msg) from e if not Path(file).exists(): msg = f"No such media file '{file}'." raise OSError(msg) args = ["-v", "quiet", "-of", "json", "-show_streams", "-show_format", file] p = await asyncio.create_subprocess_exec( "ffprobe", *args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, ) exitCode = await p.wait() data, err = await p.communicate() if 0 == exitCode: parsed: dict = json.loads(data.decode("utf-8")) else: msg = f"ffprobe returned with non-0 exit code. '{err.decode('utf-8')}'" raise FFProbeError(msg) result = FFProbeResult() result.metadata = parsed.get("format", {}) for raw_stream in parsed.get("streams", []): stream = FFStream(raw_stream) if stream.is_audio(): result.audio.append(stream) elif stream.is_video(): result.video.append(stream) elif stream.is_subtitle(): result.subtitle.append(stream) elif stream.is_attachment(): result.attachment.append(stream) return result