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 def __repr__(self) -> str: return f"Resolution(width={self.width},height={self.height})" def __str__(self) -> str: return f"{self.width}x{self.height}" class Ratio: def __init__(self, x: int, y: int): self.x = x self.y = 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}" 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: def __init__(self, *, start: str, duration: str, name: str): self.start = start self.duration = duration self.name = name class MenuInfo: REGEX = re.compile(r"^_\d{2}_\d{2}_\d{2}") def __init__(self, menu_dict: Dict[str, str]): self.data = menu_dict @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["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, 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"] 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 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 # print(json.dumps(self.video, indent=2)) @property def codec(self) -> str: return self.video["Format"] @property def bitrate(self) -> int: try: bitrate = self.video["BitRate"] except KeyError: bitrate = self.general["OverallBitRate"] ibitrate = int(bitrate) // 1000 return ibitrate @property def framerate(self) -> float: keys = ["FrameRate_Maximum", "FrameRate", "FrameRate_Nominal"] for key in keys: if key in self.video: return float(self.video[key]) print(json.dumps(self.general, indent=2)) print(json.dumps(self.video, indent=2)) raise Exception("No frame rate for video " + str(self.path)) @property def duration(self) -> str: places_to_look = [self.video, self.general] for place in places_to_look: if "Duration" in place: seconds = float(place["Duration"]) td = str(timedelta(seconds=seconds)) return td # f"{td:0>8}" raise Exception("No duration for video " + str(self.path)) @property def resolution(self) -> Resolution: width = int(self.video["Width"]) height = int(self.video["Height"]) return Resolution(width=width, height=height) @property def display_aspect_ratio(self) -> Ratio: ratio = [float(x) for x in str(self.video["DisplayAspectRatio"]).split(':')] if len(ratio) != 2: res = self.resolution if -0.01 <= ratio[0] - res.width / res.height <= 0.01: frac = Fraction(res.width, res.height) else: new_width = round(res.height * ratio[0]) frac = Fraction(new_width, res.height) ratio = (frac.numerator, frac.denominator) return Ratio(x=ratio[0], y=ratio[1]) @property def keep_display_aspect(self) -> bool: ratio = self.display_aspect_ratio resolution = self.resolution return 0.95 < (resolution.width / resolution.height) / (ratio.x / ratio.y) < 1.05 @property def display_width(self) -> float: ratio = self.display_aspect_ratio if not self.keep_display_aspect: display_width = self.resolution.height * ratio.x / ratio.y else: display_width = float(self.resolution.width) return display_width @property def pixel_aspect_ratio(self) -> Ratio: if self.keep_display_aspect: return Ratio(1.0, 1.0) else: return Ratio(self.display_aspect_ratio.x, self.resolution.width) @property def output_bitrate(self) -> int: print(self.codec) 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" })