import json import re from typing import Any, Dict, Iterable, List, NamedTuple, Optional from config_local import Config from subprocess import Popen, PIPE from pathlib import Path from io import BytesIO, TextIOWrapper from pytimeparse.timeparse import timeparse from datetime import timedelta from fractions import Fraction class Translation(NamedTuple): start: int end: int multiplier: float min_br: int max_br: int class Resolution: def __init__(self, width: int, height: int): self.width = width self.height = height self.ratio = self.width / self.height def __repr__(self) -> str: return f"Resolution(width={self.width},height={self.height})" def __str__(self) -> str: return f"{self.width}x{self.height}" def __eq__(self, o: object) -> bool: return self.width == o.width and self.height == o.height class Ratio: def __init__(self, x: int, y: int): self.x = x self.y = y self.ratio = x / y def __repr__(self) -> str: return f"Ratio(x={self.x},y={self.y})" def __str__(self) -> str: return f"{self.x}x{self.y}" def __eq__(self, o: object) -> bool: return self.ratio == o.ratio class AudioInfo: def __init__(self, audio_dict: Dict[str, str]): self.data = audio_dict @property def language(self) -> str: return self.data.get("Language", "Unknown") @property def name(self) -> str: return self.data.get("Title", "Unknown") class MenuItem: TIME_RE = re.compile(r"(\d{2})[^\d]+(\d{2})[^\d]+(\d{2})(?:[^\d]+(\d+))") def __init__(self, *, start: str, duration: str, name: str): self.start = start self.duration = duration self.name = name @property def str_start(self) -> timedelta: match = self.TIME_RE.search(self.start) hours, minutes, seconds, millis = tuple(map(int, (match.group(i + 1) for i in range(4)))) return timedelta(hours=hours, minutes=minutes, seconds=seconds, milliseconds=millis) def __str__(self) -> str: return f"'{self.name}' at {self.str_start}, duration {self.duration}" def __repr__(self) -> str: return f"" class MenuInfo: REGEX = re.compile(r"^_\d{2}_\d{2}_\d{2}") def __init__(self, menu_dict: Dict[str, str], duration: str = None): self.data = menu_dict self.duration: Optional[float] = timeparse(duration) if duration else None @property def items(self) -> List[MenuItem]: if not "extra" in self.data: duration = str(timedelta(seconds=round(timeparse(list(self.data.keys())[0])))) return [MenuItem(start="00:00:00", duration=f"{duration:0>8}", name='Chapter 1')] keys = sorted(key for key in self.data["extra"] if type(self).REGEX.match(key)) td0 = timedelta(seconds=0) td = None result = [] for key in keys: td = timedelta( hours=int(key[1:3]), minutes=int(key[4:6]), seconds=int(key[7:9]), milliseconds=float(key[10:]) ) dur_td = timedelta(seconds=round((td - td0).total_seconds())) duration = str(dur_td) duration = f"{duration:0>8}" td0 = td name = self.data["extra"][key].replace(" ", " ") if result: result[-1].duration = duration result.append(MenuItem(start=key, duration="", name=name)) if result: td = timedelta(seconds=float(self.data.get("Duration", self.duration))) dur_td = timedelta(seconds=round((td - td0).total_seconds())) duration = str(dur_td) duration = f"{duration:0>8}" result[-1].duration = duration return result class MediaInfo: def __init__(self): self.path: Path self.general: Optional[Dict[str, Any]] = None self.video: Optional[Dict[str, Any]] = None self.audio: List[Dict[str, Any]] = [] self.menu_data: Optional[Dict[str, Any]] = None self.codec: str self.bitrate: int self.framerate: float self.duration: str self.resolution: Resolution self.display_aspect_ratio: Ratio self.keep_display_aspect: bool self.display_width: float self.pixel_aspect_ratio: Ratio def load_file(self, path: Path): self.path = path procinfo = Popen([Config.MEDIAINFO_BINARY, path, "--Output=JSON"], stdout=PIPE) stdout, _ = procinfo.communicate() data = json.loads(stdout.decode('utf-8'))["media"]["track"] for track in data: if track["@type"] == "General": self.general = track elif track["@type"] == "Video": self.video = track elif track["@type"] == "Audio": self.audio.append(track) elif track["@type"] == "Menu": self.menu_data = track self.determine_information_properties() self.determine_aspect_properties() def determine_information_properties(self): self.codec = self.video["Format"] try: bitrate = self.video["BitRate"] except KeyError: bitrate = self.general["OverallBitRate"] self.bitrate = int(bitrate) // 1000 keys = ["FrameRate_Maximum", "FrameRate", "FrameRate_Nominal"] for key in keys: if key in self.video: self.framerate = float(self.video[key]) if not self.framerate: print(json.dumps(self.general, indent=2)) print(json.dumps(self.video, indent=2)) raise Exception("No frame rate for video " + str(self.path)) places_to_look = [self.video, self.general] for place in places_to_look: if "Duration" in place: seconds = float(place["Duration"]) self.duration = str(timedelta(seconds=seconds)) if not self.duration: raise Exception("No duration for video " + str(self.path)) def determine_aspect_properties(self): width = int(self.video["Width"]) height = int(self.video["Height"]) self.resolution = Resolution(width=width, height=height) ratio = [float(x) for x in str(self.video["DisplayAspectRatio"]).split(':')] if len(ratio) != 2: if -0.01 <= ratio[0] - self.resolution.ratio <= 0.01: frac = Fraction(self.resolution.width, self.resolution.height) else: new_width = round(self.resolution.height * ratio[0]) frac = Fraction(new_width, self.resolution.height) ratio = (frac.numerator, frac.denominator) self.display_aspect_ratio = Ratio(x=ratio[0], y=ratio[1]) self.keep_display_aspect = 0.95 < self.resolution.ratio / self.display_aspect_ratio.ratio < 1.05 if not self.keep_display_aspect: self.display_width = self.resolution.height * self.display_aspect_ratio.ratio self.pixel_aspect_ratio = self.display_aspect_ratio else: self.display_width = float(self.resolution.width) self.pixel_aspect_ratio = Ratio( self.display_aspect_ratio.x, self.resolution.width ) @property def output_bitrate(self) -> int: if self.codec == "MPEG-4 Visual": translations = [ Translation(0, 800, 0.5, 200, 600), Translation(800, 1500, 0.4, 400, 800), Translation(1500, 99999999999, 0.33, 600, 1200), ] else: translations = [ Translation(0, 800, 0.7, 450, 700), Translation(800, 1500, 0.6, 550, 1000), Translation(1500, 99999999999, 0.45, 800, 5000), ] bitrate = self.bitrate for t in translations: if t.start <= bitrate < t.end: return int(min(t.max_br, max(t.min_br, bitrate * t.multiplier))) @property def tracks(self) -> List[AudioInfo]: result = [] for audio in self.audio: track = AudioInfo(audio) result.append(track) return result @property def menu(self) -> MenuInfo: return MenuInfo(self.menu_data or { self.duration: "Chapter 1" }, duration=self.duration)