You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

242 lines
8.5 KiB

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"<MenuItem '{self.name}' at {self.start}, duration {self.duration}>"
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 self.keep_display_aspect:
self.display_width = float(self.resolution.width)
self.pixel_aspect_ratio = Ratio(1, 1)
else:
self.display_width = self.resolution.height * self.display_aspect_ratio.ratio
self.pixel_aspect_ratio = self.display_aspect_ratio
@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)