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.

235 lines
7.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
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:
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" })