mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2025-10-23 15:11:08 +00:00
Initial Commit
This commit is contained in:
10
unshackle/core/tracks/__init__.py
Normal file
10
unshackle/core/tracks/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from .attachment import Attachment
|
||||
from .audio import Audio
|
||||
from .chapter import Chapter
|
||||
from .chapters import Chapters
|
||||
from .subtitle import Subtitle
|
||||
from .track import Track
|
||||
from .tracks import Tracks
|
||||
from .video import Video
|
||||
|
||||
__all__ = ("Audio", "Attachment", "Chapter", "Chapters", "Subtitle", "Track", "Tracks", "Video")
|
||||
146
unshackle/core/tracks/attachment.py
Normal file
146
unshackle/core/tracks/attachment.py
Normal file
@@ -0,0 +1,146 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import mimetypes
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union
|
||||
from urllib.parse import urlparse
|
||||
from zlib import crc32
|
||||
|
||||
import requests
|
||||
|
||||
from unshackle.core.config import config
|
||||
|
||||
|
||||
class Attachment:
|
||||
def __init__(
|
||||
self,
|
||||
path: Union[Path, str, None] = None,
|
||||
url: Optional[str] = None,
|
||||
name: Optional[str] = None,
|
||||
mime_type: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
session: Optional[requests.Session] = None,
|
||||
):
|
||||
"""
|
||||
Create a new Attachment.
|
||||
|
||||
If providing a path, the file must already exist.
|
||||
If providing a URL, the file will be downloaded to the temp directory.
|
||||
Either path or url must be provided.
|
||||
|
||||
If name is not provided it will use the file name (without extension).
|
||||
If mime_type is not provided, it will try to guess it.
|
||||
|
||||
Args:
|
||||
path: Path to an existing file.
|
||||
url: URL to download the attachment from.
|
||||
name: Name of the attachment.
|
||||
mime_type: MIME type of the attachment.
|
||||
description: Description of the attachment.
|
||||
session: Optional requests session to use for downloading.
|
||||
"""
|
||||
if path is None and url is None:
|
||||
raise ValueError("Either path or url must be provided.")
|
||||
|
||||
if url:
|
||||
if not isinstance(url, str):
|
||||
raise ValueError("The attachment URL must be a string.")
|
||||
|
||||
# If a URL is provided, download the file to the temp directory
|
||||
parsed_url = urlparse(url)
|
||||
file_name = os.path.basename(parsed_url.path) or "attachment"
|
||||
|
||||
# Use provided name for the file if available
|
||||
if name:
|
||||
file_name = f"{name.replace(' ', '_')}{os.path.splitext(file_name)[1]}"
|
||||
|
||||
download_path = config.directories.temp / file_name
|
||||
|
||||
# Download the file
|
||||
try:
|
||||
session = session or requests.Session()
|
||||
response = session.get(url, stream=True)
|
||||
response.raise_for_status()
|
||||
download_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(download_path, "wb") as f:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
f.write(chunk)
|
||||
|
||||
path = download_path
|
||||
except Exception as e:
|
||||
raise ValueError(f"Failed to download attachment from URL: {e}")
|
||||
|
||||
if not isinstance(path, (str, Path)):
|
||||
raise ValueError("The attachment path must be provided.")
|
||||
|
||||
path = Path(path)
|
||||
if not path.exists():
|
||||
raise ValueError("The attachment file does not exist.")
|
||||
|
||||
name = (name or path.stem).strip()
|
||||
mime_type = (mime_type or "").strip() or None
|
||||
description = (description or "").strip() or None
|
||||
|
||||
if not mime_type:
|
||||
mime_type = {
|
||||
".ttf": "application/x-truetype-font",
|
||||
".otf": "application/vnd.ms-opentype",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
}.get(path.suffix.lower(), mimetypes.guess_type(path)[0])
|
||||
if not mime_type:
|
||||
raise ValueError("The attachment mime-type could not be automatically detected.")
|
||||
|
||||
self.path = path
|
||||
self.name = name
|
||||
self.mime_type = mime_type
|
||||
self.description = description
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "{name}({items})".format(
|
||||
name=self.__class__.__name__, items=", ".join([f"{k}={repr(v)}" for k, v in self.__dict__.items()])
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return " | ".join(filter(bool, ["ATT", self.name, self.mime_type, self.description]))
|
||||
|
||||
@property
|
||||
def id(self) -> str:
|
||||
"""Compute an ID from the attachment data."""
|
||||
checksum = crc32(self.path.read_bytes())
|
||||
return hex(checksum)
|
||||
|
||||
def delete(self) -> None:
|
||||
if self.path:
|
||||
self.path.unlink()
|
||||
self.path = None
|
||||
|
||||
@classmethod
|
||||
def from_url(
|
||||
cls,
|
||||
url: str,
|
||||
name: Optional[str] = None,
|
||||
mime_type: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
session: Optional[requests.Session] = None,
|
||||
) -> "Attachment":
|
||||
"""
|
||||
Create an attachment from a URL.
|
||||
|
||||
Args:
|
||||
url: URL to download the attachment from.
|
||||
name: Name of the attachment.
|
||||
mime_type: MIME type of the attachment.
|
||||
description: Description of the attachment.
|
||||
session: Optional requests session to use for downloading.
|
||||
|
||||
Returns:
|
||||
Attachment: A new attachment instance.
|
||||
"""
|
||||
return cls(url=url, name=name, mime_type=mime_type, description=description, session=session)
|
||||
|
||||
|
||||
__all__ = ("Attachment",)
|
||||
188
unshackle/core/tracks/audio.py
Normal file
188
unshackle/core/tracks/audio.py
Normal file
@@ -0,0 +1,188 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from enum import Enum
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
from unshackle.core.tracks.track import Track
|
||||
|
||||
|
||||
class Audio(Track):
|
||||
class Codec(str, Enum):
|
||||
AAC = "AAC" # https://wikipedia.org/wiki/Advanced_Audio_Coding
|
||||
AC3 = "DD" # https://wikipedia.org/wiki/Dolby_Digital
|
||||
EC3 = "DD+" # https://wikipedia.org/wiki/Dolby_Digital_Plus
|
||||
OPUS = "OPUS" # https://wikipedia.org/wiki/Opus_(audio_format)
|
||||
OGG = "VORB" # https://wikipedia.org/wiki/Vorbis
|
||||
DTS = "DTS" # https://en.wikipedia.org/wiki/DTS_(company)#DTS_Digital_Surround
|
||||
ALAC = "ALAC" # https://en.wikipedia.org/wiki/Apple_Lossless_Audio_Codec
|
||||
FLAC = "FLAC" # https://en.wikipedia.org/wiki/FLAC
|
||||
|
||||
@property
|
||||
def extension(self) -> str:
|
||||
return self.name.lower()
|
||||
|
||||
@staticmethod
|
||||
def from_mime(mime: str) -> Audio.Codec:
|
||||
mime = mime.lower().strip().split(".")[0]
|
||||
if mime == "mp4a":
|
||||
return Audio.Codec.AAC
|
||||
if mime == "ac-3":
|
||||
return Audio.Codec.AC3
|
||||
if mime == "ec-3":
|
||||
return Audio.Codec.EC3
|
||||
if mime == "opus":
|
||||
return Audio.Codec.OPUS
|
||||
if mime == "dtsc":
|
||||
return Audio.Codec.DTS
|
||||
if mime == "alac":
|
||||
return Audio.Codec.ALAC
|
||||
if mime == "flac":
|
||||
return Audio.Codec.FLAC
|
||||
raise ValueError(f"The MIME '{mime}' is not a supported Audio Codec")
|
||||
|
||||
@staticmethod
|
||||
def from_codecs(codecs: str) -> Audio.Codec:
|
||||
for codec in codecs.lower().split(","):
|
||||
mime = codec.strip().split(".")[0]
|
||||
try:
|
||||
return Audio.Codec.from_mime(mime)
|
||||
except ValueError:
|
||||
pass
|
||||
raise ValueError(f"No MIME types matched any supported Audio Codecs in '{codecs}'")
|
||||
|
||||
@staticmethod
|
||||
def from_netflix_profile(profile: str) -> Audio.Codec:
|
||||
profile = profile.lower().strip()
|
||||
if profile.startswith("heaac"):
|
||||
return Audio.Codec.AAC
|
||||
if profile.startswith("dd-"):
|
||||
return Audio.Codec.AC3
|
||||
if profile.startswith("ddplus"):
|
||||
return Audio.Codec.EC3
|
||||
if profile.startswith("playready-oggvorbis"):
|
||||
return Audio.Codec.OGG
|
||||
raise ValueError(f"The Content Profile '{profile}' is not a supported Audio Codec")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*args: Any,
|
||||
codec: Optional[Audio.Codec] = None,
|
||||
bitrate: Optional[Union[str, int, float]] = None,
|
||||
channels: Optional[Union[str, int, float]] = None,
|
||||
joc: Optional[int] = None,
|
||||
descriptive: Union[bool, int] = False,
|
||||
**kwargs: Any,
|
||||
):
|
||||
"""
|
||||
Create a new Audio track object.
|
||||
|
||||
Parameters:
|
||||
codec: An Audio.Codec enum representing the audio codec.
|
||||
If not specified, MediaInfo will be used to retrieve the codec
|
||||
once the track has been downloaded.
|
||||
bitrate: A number or float representing the average bandwidth in bytes/s.
|
||||
Float values are rounded up to the nearest integer.
|
||||
channels: A number, float, or string representing the number of audio channels.
|
||||
Strings may represent numbers or floats. Expanded layouts like 7.1.1 is
|
||||
not supported. All numbers and strings will be cast to float.
|
||||
joc: The number of Joint-Object-Coding Channels/Objects in the audio stream.
|
||||
descriptive: Mark this audio as being descriptive audio for the blind.
|
||||
|
||||
Note: If codec, bitrate, channels, or joc is not specified some checks may be
|
||||
skipped or assume a value. Specifying as much information as possible is highly
|
||||
recommended.
|
||||
"""
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if not isinstance(codec, (Audio.Codec, type(None))):
|
||||
raise TypeError(f"Expected codec to be a {Audio.Codec}, not {codec!r}")
|
||||
if not isinstance(bitrate, (str, int, float, type(None))):
|
||||
raise TypeError(f"Expected bitrate to be a {str}, {int}, or {float}, not {bitrate!r}")
|
||||
if not isinstance(channels, (str, int, float, type(None))):
|
||||
raise TypeError(f"Expected channels to be a {str}, {int}, or {float}, not {channels!r}")
|
||||
if not isinstance(joc, (int, type(None))):
|
||||
raise TypeError(f"Expected joc to be a {int}, not {joc!r}")
|
||||
if not isinstance(descriptive, (bool, int)) or (isinstance(descriptive, int) and descriptive not in (0, 1)):
|
||||
raise TypeError(f"Expected descriptive to be a {bool} or bool-like {int}, not {descriptive!r}")
|
||||
|
||||
self.codec = codec
|
||||
|
||||
try:
|
||||
self.bitrate = int(math.ceil(float(bitrate))) if bitrate else None
|
||||
except (ValueError, TypeError) as e:
|
||||
raise ValueError(f"Expected bitrate to be a number or float, {e}")
|
||||
|
||||
try:
|
||||
self.channels = self.parse_channels(channels) if channels else None
|
||||
except (ValueError, NotImplementedError) as e:
|
||||
raise ValueError(f"Expected channels to be a number, float, or a string, {e}")
|
||||
|
||||
self.joc = joc
|
||||
self.descriptive = bool(descriptive)
|
||||
|
||||
@property
|
||||
def atmos(self) -> bool:
|
||||
"""Return True if the audio track contains Dolby Atmos."""
|
||||
return bool(self.joc)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return " | ".join(
|
||||
filter(
|
||||
bool,
|
||||
[
|
||||
"AUD",
|
||||
f"[{self.codec.value}]" if self.codec else None,
|
||||
str(self.language),
|
||||
", ".join(
|
||||
filter(
|
||||
bool,
|
||||
[
|
||||
str(self.channels) if self.channels else None,
|
||||
"Atmos" if self.atmos else None,
|
||||
f"JOC {self.joc}" if self.joc else None,
|
||||
],
|
||||
)
|
||||
),
|
||||
f"{self.bitrate // 1000} kb/s" if self.bitrate else None,
|
||||
self.get_track_name(),
|
||||
self.edition,
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def parse_channels(channels: Union[str, int, float]) -> float:
|
||||
"""
|
||||
Converts a Channel string to a float representing audio channel count and layout.
|
||||
E.g. "3" -> "3.0", "2.1" -> "2.1", ".1" -> "0.1".
|
||||
|
||||
This does not validate channel strings as genuine channel counts or valid layouts.
|
||||
It does not convert the value to assume a sub speaker channel layout, e.g. 5.1->6.0.
|
||||
It also does not support expanded surround sound channel layout strings like 7.1.2.
|
||||
"""
|
||||
if isinstance(channels, str):
|
||||
# TODO: Support all possible DASH channel configurations (https://datatracker.ietf.org/doc/html/rfc8216)
|
||||
if channels.upper() == "A000":
|
||||
return 2.0
|
||||
elif channels.upper() == "F801":
|
||||
return 5.1
|
||||
elif channels.replace("ch", "").replace(".", "", 1).isdigit():
|
||||
# e.g., '2ch', '2', '2.0', '5.1ch', '5.1'
|
||||
return float(channels.replace("ch", ""))
|
||||
raise NotImplementedError(f"Unsupported Channels string value, '{channels}'")
|
||||
|
||||
return float(channels)
|
||||
|
||||
def get_track_name(self) -> Optional[str]:
|
||||
"""Return the base Track Name."""
|
||||
track_name = super().get_track_name() or ""
|
||||
flag = self.descriptive and "Descriptive"
|
||||
if flag:
|
||||
if track_name:
|
||||
flag = f" ({flag})"
|
||||
track_name += flag
|
||||
return track_name or None
|
||||
|
||||
|
||||
__all__ = ("Audio",)
|
||||
77
unshackle/core/tracks/chapter.py
Normal file
77
unshackle/core/tracks/chapter.py
Normal file
@@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Optional, Union
|
||||
from zlib import crc32
|
||||
|
||||
TIMESTAMP_FORMAT = re.compile(r"^(?P<hour>\d{2}):(?P<minute>\d{2}):(?P<second>\d{2})(?P<ms>.\d{3}|)$")
|
||||
|
||||
|
||||
class Chapter:
|
||||
def __init__(self, timestamp: Union[str, int, float], name: Optional[str] = None):
|
||||
"""
|
||||
Create a new Chapter with a Timestamp and optional name.
|
||||
|
||||
The timestamp may be in the following formats:
|
||||
- "HH:MM:SS" string, e.g., `25:05:23`.
|
||||
- "HH:MM:SS.mss" string, e.g., `25:05:23.120`.
|
||||
- a timecode integer in milliseconds, e.g., `90323120` is `25:05:23.120`.
|
||||
- a timecode float in seconds, e.g., `90323.12` is `25:05:23.120`.
|
||||
|
||||
If you have a timecode integer in seconds, just multiply it by 1000.
|
||||
If you have a timecode float in milliseconds (no decimal value), just convert
|
||||
it to an integer.
|
||||
"""
|
||||
if timestamp is None:
|
||||
raise ValueError("The timestamp must be provided.")
|
||||
|
||||
if not isinstance(timestamp, (str, int, float)):
|
||||
raise TypeError(f"Expected timestamp to be {str}, {int} or {float}, not {type(timestamp)}")
|
||||
if not isinstance(name, (str, type(None))):
|
||||
raise TypeError(f"Expected name to be {str}, not {type(name)}")
|
||||
|
||||
if not isinstance(timestamp, str):
|
||||
if isinstance(timestamp, int): # ms
|
||||
hours, remainder = divmod(timestamp, 1000 * 60 * 60)
|
||||
minutes, remainder = divmod(remainder, 1000 * 60)
|
||||
seconds, ms = divmod(remainder, 1000)
|
||||
elif isinstance(timestamp, float): # seconds.ms
|
||||
hours, remainder = divmod(timestamp, 60 * 60)
|
||||
minutes, remainder = divmod(remainder, 60)
|
||||
seconds, ms = divmod(int(remainder * 1000), 1000)
|
||||
else:
|
||||
raise TypeError
|
||||
timestamp = f"{int(hours):02}:{int(minutes):02}:{int(seconds):02}.{str(ms).zfill(3)[:3]}"
|
||||
|
||||
timestamp_m = TIMESTAMP_FORMAT.match(timestamp)
|
||||
if not timestamp_m:
|
||||
raise ValueError(f"The timestamp format is invalid: {timestamp}")
|
||||
|
||||
hour, minute, second, ms = timestamp_m.groups()
|
||||
if not ms:
|
||||
timestamp += ".000"
|
||||
|
||||
self.timestamp = timestamp
|
||||
self.name = name
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "{name}({items})".format(
|
||||
name=self.__class__.__name__, items=", ".join([f"{k}={repr(v)}" for k, v in self.__dict__.items()])
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return " | ".join(filter(bool, ["CHP", self.timestamp, self.name]))
|
||||
|
||||
@property
|
||||
def id(self) -> str:
|
||||
"""Compute an ID from the Chapter data."""
|
||||
checksum = crc32(str(self).encode("utf8"))
|
||||
return hex(checksum)
|
||||
|
||||
@property
|
||||
def named(self) -> bool:
|
||||
"""Check if Chapter is named."""
|
||||
return bool(self.name)
|
||||
|
||||
|
||||
__all__ = ("Chapter",)
|
||||
144
unshackle/core/tracks/chapters.py
Normal file
144
unshackle/core/tracks/chapters.py
Normal file
@@ -0,0 +1,144 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from abc import ABC
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Optional, Union
|
||||
from zlib import crc32
|
||||
|
||||
from sortedcontainers import SortedKeyList
|
||||
|
||||
from unshackle.core.tracks import Chapter
|
||||
|
||||
OGM_SIMPLE_LINE_1_FORMAT = re.compile(r"^CHAPTER(?P<number>\d+)=(?P<timestamp>\d{2,}:\d{2}:\d{2}\.\d{3})$")
|
||||
OGM_SIMPLE_LINE_2_FORMAT = re.compile(r"^CHAPTER(?P<number>\d+)NAME=(?P<name>.*)$")
|
||||
|
||||
|
||||
class Chapters(SortedKeyList, ABC):
|
||||
def __init__(self, iterable: Optional[Iterable[Chapter]] = None):
|
||||
super().__init__(key=lambda x: x.timestamp or 0)
|
||||
for chapter in iterable or []:
|
||||
self.add(chapter)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "{name}({items})".format(
|
||||
name=self.__class__.__name__, items=", ".join([f"{k}={repr(v)}" for k, v in self.__dict__.items()])
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return "\n".join(
|
||||
[
|
||||
" | ".join(filter(bool, ["CHP", f"[{i:02}]", chapter.timestamp, chapter.name]))
|
||||
for i, chapter in enumerate(self, start=1)
|
||||
]
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def loads(cls, data: str) -> Chapters:
|
||||
"""Load chapter data from a string."""
|
||||
lines = [line.strip() for line in data.strip().splitlines(keepends=False)]
|
||||
|
||||
if len(lines) % 2 != 0:
|
||||
raise ValueError("The number of chapter lines must be even.")
|
||||
|
||||
chapters = []
|
||||
|
||||
for line_1, line_2 in zip(lines[::2], lines[1::2]):
|
||||
line_1_match = OGM_SIMPLE_LINE_1_FORMAT.match(line_1)
|
||||
if not line_1_match:
|
||||
raise SyntaxError(f"An unexpected syntax error occurred on: {line_1}")
|
||||
line_2_match = OGM_SIMPLE_LINE_2_FORMAT.match(line_2)
|
||||
if not line_2_match:
|
||||
raise SyntaxError(f"An unexpected syntax error occurred on: {line_2}")
|
||||
|
||||
line_1_number, timestamp = line_1_match.groups()
|
||||
line_2_number, name = line_2_match.groups()
|
||||
|
||||
if line_1_number != line_2_number:
|
||||
raise SyntaxError(
|
||||
f"The chapter numbers {line_1_number} and {line_2_number} do not match on:\n{line_1}\n{line_2}"
|
||||
)
|
||||
|
||||
if not timestamp:
|
||||
raise SyntaxError(f"The timestamp is missing on: {line_1}")
|
||||
|
||||
chapters.append(Chapter(timestamp, name))
|
||||
|
||||
return cls(chapters)
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: Union[Path, str]) -> Chapters:
|
||||
"""Load chapter data from a file."""
|
||||
if isinstance(path, str):
|
||||
path = Path(path)
|
||||
return cls.loads(path.read_text(encoding="utf8"))
|
||||
|
||||
def dumps(self, fallback_name: str = "") -> str:
|
||||
"""
|
||||
Return chapter data in OGM-based Simple Chapter format.
|
||||
https://mkvtoolnix.download/doc/mkvmerge.html#mkvmerge.chapters.simple
|
||||
|
||||
Parameters:
|
||||
fallback_name: Name used for Chapters without a Name set.
|
||||
|
||||
The fallback name can use the following variables in f-string style:
|
||||
|
||||
- {i}: The Chapter number starting at 1.
|
||||
E.g., `"Chapter {i}"`: "Chapter 1", "Intro", "Chapter 3".
|
||||
- {j}: A number starting at 1 that increments any time a Chapter has no name.
|
||||
E.g., `"Chapter {j}"`: "Chapter 1", "Intro", "Chapter 2".
|
||||
|
||||
These are formatted with f-strings, directives are supported.
|
||||
For example, `"Chapter {i:02}"` will result in `"Chapter 01"`.
|
||||
"""
|
||||
chapters = []
|
||||
j = 0
|
||||
|
||||
for i, chapter in enumerate(self, start=1):
|
||||
if not chapter.name:
|
||||
j += 1
|
||||
chapters.append(
|
||||
"CHAPTER{num}={time}\nCHAPTER{num}NAME={name}".format(
|
||||
num=f"{i:02}", time=chapter.timestamp, name=chapter.name or fallback_name.format(i=i, j=j)
|
||||
)
|
||||
)
|
||||
|
||||
return "\n".join(chapters)
|
||||
|
||||
def dump(self, path: Union[Path, str], *args: Any, **kwargs: Any) -> int:
|
||||
"""
|
||||
Write chapter data in OGM-based Simple Chapter format to a file.
|
||||
|
||||
Parameters:
|
||||
path: The file path to write the Chapter data to, overwriting
|
||||
any existing data.
|
||||
|
||||
See `Chapters.dumps` for more parameter documentation.
|
||||
"""
|
||||
if isinstance(path, str):
|
||||
path = Path(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
ogm_text = self.dumps(*args, **kwargs)
|
||||
return path.write_text(ogm_text, encoding="utf8")
|
||||
|
||||
def add(self, value: Chapter) -> None:
|
||||
if not isinstance(value, Chapter):
|
||||
raise TypeError(f"Can only add {Chapter} objects, not {type(value)}")
|
||||
|
||||
if any(chapter.timestamp == value.timestamp for chapter in self):
|
||||
raise ValueError(f"A Chapter with the Timestamp {value.timestamp} already exists")
|
||||
|
||||
super().add(value)
|
||||
|
||||
if not any(chapter.timestamp == "00:00:00.000" for chapter in self):
|
||||
self.add(Chapter(0))
|
||||
|
||||
@property
|
||||
def id(self) -> str:
|
||||
"""Compute an ID from the Chapter data."""
|
||||
checksum = crc32("\n".join([chapter.id for chapter in self]).encode("utf8"))
|
||||
return hex(checksum)
|
||||
|
||||
|
||||
__all__ = ("Chapters", "Chapter")
|
||||
726
unshackle/core/tracks/subtitle.py
Normal file
726
unshackle/core/tracks/subtitle.py
Normal file
@@ -0,0 +1,726 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
from collections import defaultdict
|
||||
from enum import Enum
|
||||
from functools import partial
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Iterable, Optional, Union
|
||||
|
||||
import pycaption
|
||||
import requests
|
||||
from construct import Container
|
||||
from pycaption import Caption, CaptionList, CaptionNode, WebVTTReader
|
||||
from pycaption.geometry import Layout
|
||||
from pymp4.parser import MP4
|
||||
from subtitle_filter import Subtitles
|
||||
|
||||
from unshackle.core import binaries
|
||||
from unshackle.core.tracks.track import Track
|
||||
from unshackle.core.utilities import try_ensure_utf8
|
||||
from unshackle.core.utils.webvtt import merge_segmented_webvtt
|
||||
|
||||
|
||||
class Subtitle(Track):
|
||||
class Codec(str, Enum):
|
||||
SubRip = "SRT" # https://wikipedia.org/wiki/SubRip
|
||||
SubStationAlpha = "SSA" # https://wikipedia.org/wiki/SubStation_Alpha
|
||||
SubStationAlphav4 = "ASS" # https://wikipedia.org/wiki/SubStation_Alpha#Advanced_SubStation_Alpha=
|
||||
TimedTextMarkupLang = "TTML" # https://wikipedia.org/wiki/Timed_Text_Markup_Language
|
||||
WebVTT = "VTT" # https://wikipedia.org/wiki/WebVTT
|
||||
# MPEG-DASH box-encapsulated subtitle formats
|
||||
fTTML = "STPP" # https://www.w3.org/TR/2018/REC-ttml-imsc1.0.1-20180424
|
||||
fVTT = "WVTT" # https://www.w3.org/TR/webvtt1
|
||||
|
||||
@property
|
||||
def extension(self) -> str:
|
||||
return self.value.lower()
|
||||
|
||||
@staticmethod
|
||||
def from_mime(mime: str) -> Subtitle.Codec:
|
||||
mime = mime.lower().strip().split(".")[0]
|
||||
if mime == "srt":
|
||||
return Subtitle.Codec.SubRip
|
||||
elif mime == "ssa":
|
||||
return Subtitle.Codec.SubStationAlpha
|
||||
elif mime == "ass":
|
||||
return Subtitle.Codec.SubStationAlphav4
|
||||
elif mime == "ttml":
|
||||
return Subtitle.Codec.TimedTextMarkupLang
|
||||
elif mime == "vtt":
|
||||
return Subtitle.Codec.WebVTT
|
||||
elif mime == "stpp":
|
||||
return Subtitle.Codec.fTTML
|
||||
elif mime == "wvtt":
|
||||
return Subtitle.Codec.fVTT
|
||||
raise ValueError(f"The MIME '{mime}' is not a supported Subtitle Codec")
|
||||
|
||||
@staticmethod
|
||||
def from_codecs(codecs: str) -> Subtitle.Codec:
|
||||
for codec in codecs.lower().split(","):
|
||||
mime = codec.strip().split(".")[0]
|
||||
try:
|
||||
return Subtitle.Codec.from_mime(mime)
|
||||
except ValueError:
|
||||
pass
|
||||
raise ValueError(f"No MIME types matched any supported Subtitle Codecs in '{codecs}'")
|
||||
|
||||
@staticmethod
|
||||
def from_netflix_profile(profile: str) -> Subtitle.Codec:
|
||||
profile = profile.lower().strip()
|
||||
if profile.startswith("webvtt"):
|
||||
return Subtitle.Codec.WebVTT
|
||||
if profile.startswith("dfxp"):
|
||||
return Subtitle.Codec.TimedTextMarkupLang
|
||||
raise ValueError(f"The Content Profile '{profile}' is not a supported Subtitle Codec")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*args: Any,
|
||||
codec: Optional[Subtitle.Codec] = None,
|
||||
cc: bool = False,
|
||||
sdh: bool = False,
|
||||
forced: bool = False,
|
||||
**kwargs: Any,
|
||||
):
|
||||
"""
|
||||
Create a new Subtitle track object.
|
||||
|
||||
Parameters:
|
||||
codec: A Subtitle.Codec enum representing the subtitle format.
|
||||
If not specified, MediaInfo will be used to retrieve the format
|
||||
once the track has been downloaded.
|
||||
cc: Closed Caption.
|
||||
- Intended as if you couldn't hear the audio at all.
|
||||
- Can have Sound as well as Dialogue, but doesn't have to.
|
||||
- Original source would be from an EIA-CC encoded stream. Typically all
|
||||
upper-case characters.
|
||||
Indicators of it being CC without knowing original source:
|
||||
- Extracted with CCExtractor, or
|
||||
- >>> (or similar) being used at the start of some or all lines, or
|
||||
- All text is uppercase or at least the majority, or
|
||||
- Subtitles are Scrolling-text style (one line appears, oldest line
|
||||
then disappears).
|
||||
Just because you downloaded it as a SRT or VTT or such, doesn't mean it
|
||||
isn't from an EIA-CC stream. And I wouldn't take the streaming services
|
||||
(CC) as gospel either as they tend to get it wrong too.
|
||||
sdh: Deaf or Hard-of-Hearing. Also known as HOH in the UK (EU?).
|
||||
- Intended as if you couldn't hear the audio at all.
|
||||
- MUST have Sound as well as Dialogue to be considered SDH.
|
||||
- It has no "syntax" or "format" but is not transmitted using archaic
|
||||
forms like EIA-CC streams, would be intended for transmission via
|
||||
SubRip (SRT), WebVTT (VTT), TTML, etc.
|
||||
If you can see important audio/sound transcriptions and not just dialogue
|
||||
and it doesn't have the indicators of CC, then it's most likely SDH.
|
||||
If it doesn't have important audio/sounds transcriptions it might just be
|
||||
regular subtitling (you wouldn't mark as CC or SDH). This would be the
|
||||
case for most translation subtitles. Like Anime for example.
|
||||
forced: Typically used if there's important information at some point in time
|
||||
like watching Dubbed content and an important Sign or Letter is shown
|
||||
or someone talking in a different language.
|
||||
Forced tracks are recommended by the Matroska Spec to be played if
|
||||
the player's current playback audio language matches a subtitle
|
||||
marked as "forced".
|
||||
However, that doesn't mean every player works like this but there is
|
||||
no other way to reliably work with Forced subtitles where multiple
|
||||
forced subtitles may be in the output file. Just know what to expect
|
||||
with "forced" subtitles.
|
||||
|
||||
Note: If codec is not specified some checks may be skipped or assume a value.
|
||||
Specifying as much information as possible is highly recommended.
|
||||
|
||||
Information on Subtitle Types:
|
||||
https://bit.ly/2Oe4fLC (3PlayMedia Blog on SUB vs CC vs SDH).
|
||||
However, I wouldn't pay much attention to the claims about SDH needing to
|
||||
be in the original source language. It's logically not true.
|
||||
|
||||
CC == Closed Captions. Source: Basically every site.
|
||||
SDH = Subtitles for the Deaf or Hard-of-Hearing. Source: Basically every site.
|
||||
HOH = Exact same as SDH. Is a term used in the UK. Source: https://bit.ly/2PGJatz (ICO UK)
|
||||
|
||||
More in-depth information, examples, and stuff to look for can be found in the Parameter
|
||||
explanation list above.
|
||||
"""
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if not isinstance(codec, (Subtitle.Codec, type(None))):
|
||||
raise TypeError(f"Expected codec to be a {Subtitle.Codec}, not {codec!r}")
|
||||
if not isinstance(cc, (bool, int)) or (isinstance(cc, int) and cc not in (0, 1)):
|
||||
raise TypeError(f"Expected cc to be a {bool} or bool-like {int}, not {cc!r}")
|
||||
if not isinstance(sdh, (bool, int)) or (isinstance(sdh, int) and sdh not in (0, 1)):
|
||||
raise TypeError(f"Expected sdh to be a {bool} or bool-like {int}, not {sdh!r}")
|
||||
if not isinstance(forced, (bool, int)) or (isinstance(forced, int) and forced not in (0, 1)):
|
||||
raise TypeError(f"Expected forced to be a {bool} or bool-like {int}, not {forced!r}")
|
||||
|
||||
self.codec = codec
|
||||
|
||||
self.cc = bool(cc)
|
||||
self.sdh = bool(sdh)
|
||||
self.forced = bool(forced)
|
||||
|
||||
if self.cc and self.sdh:
|
||||
raise ValueError("A text track cannot be both CC and SDH.")
|
||||
|
||||
if self.forced and (self.cc or self.sdh):
|
||||
raise ValueError("A text track cannot be CC/SDH as well as Forced.")
|
||||
|
||||
# TODO: Migrate to new event observer system
|
||||
# Called after Track has been converted to another format
|
||||
self.OnConverted: Optional[Callable[[Subtitle.Codec], None]] = None
|
||||
|
||||
def __str__(self) -> str:
|
||||
return " | ".join(
|
||||
filter(
|
||||
bool,
|
||||
["SUB", f"[{self.codec.value}]" if self.codec else None, str(self.language), self.get_track_name()],
|
||||
)
|
||||
)
|
||||
|
||||
def get_track_name(self) -> Optional[str]:
|
||||
"""Return the base Track Name."""
|
||||
track_name = super().get_track_name() or ""
|
||||
flag = self.cc and "CC" or self.sdh and "SDH" or self.forced and "Forced"
|
||||
if flag:
|
||||
if track_name:
|
||||
flag = f" ({flag})"
|
||||
track_name += flag
|
||||
return track_name or None
|
||||
|
||||
def download(
|
||||
self,
|
||||
session: requests.Session,
|
||||
prepare_drm: partial,
|
||||
max_workers: Optional[int] = None,
|
||||
progress: Optional[partial] = None,
|
||||
*,
|
||||
cdm: Optional[object] = None,
|
||||
):
|
||||
super().download(session, prepare_drm, max_workers, progress, cdm=cdm)
|
||||
if not self.path:
|
||||
return
|
||||
|
||||
if self.codec == Subtitle.Codec.fTTML:
|
||||
self.convert(Subtitle.Codec.TimedTextMarkupLang)
|
||||
elif self.codec == Subtitle.Codec.fVTT:
|
||||
self.convert(Subtitle.Codec.WebVTT)
|
||||
elif self.codec == Subtitle.Codec.WebVTT:
|
||||
text = self.path.read_text("utf8")
|
||||
if self.descriptor == Track.Descriptor.DASH:
|
||||
if len(self.data["dash"]["segment_durations"]) > 1:
|
||||
text = merge_segmented_webvtt(
|
||||
text,
|
||||
segment_durations=self.data["dash"]["segment_durations"],
|
||||
timescale=self.data["dash"]["timescale"],
|
||||
)
|
||||
elif self.descriptor == Track.Descriptor.HLS:
|
||||
if len(self.data["hls"]["segment_durations"]) > 1:
|
||||
text = merge_segmented_webvtt(
|
||||
text,
|
||||
segment_durations=self.data["hls"]["segment_durations"],
|
||||
timescale=1, # ?
|
||||
)
|
||||
|
||||
# Sanitize WebVTT timestamps before parsing
|
||||
text = Subtitle.sanitize_webvtt_timestamps(text)
|
||||
|
||||
try:
|
||||
caption_set = pycaption.WebVTTReader().read(text)
|
||||
Subtitle.merge_same_cues(caption_set)
|
||||
subtitle_text = pycaption.WebVTTWriter().write(caption_set)
|
||||
self.path.write_text(subtitle_text, encoding="utf8")
|
||||
except pycaption.exceptions.CaptionReadSyntaxError:
|
||||
# If first attempt fails, try more aggressive sanitization
|
||||
text = Subtitle.sanitize_webvtt(text)
|
||||
try:
|
||||
caption_set = pycaption.WebVTTReader().read(text)
|
||||
Subtitle.merge_same_cues(caption_set)
|
||||
subtitle_text = pycaption.WebVTTWriter().write(caption_set)
|
||||
self.path.write_text(subtitle_text, encoding="utf8")
|
||||
except Exception:
|
||||
# Keep the sanitized version even if parsing failed
|
||||
self.path.write_text(text, encoding="utf8")
|
||||
|
||||
@staticmethod
|
||||
def sanitize_webvtt_timestamps(text: str) -> str:
|
||||
"""
|
||||
Fix invalid timestamps in WebVTT files, particularly negative timestamps.
|
||||
|
||||
Parameters:
|
||||
text: The WebVTT content as string
|
||||
|
||||
Returns:
|
||||
Sanitized WebVTT content
|
||||
"""
|
||||
# Replace negative timestamps with 00:00:00.000
|
||||
return re.sub(r"(-\d+:\d+:\d+\.\d+)", "00:00:00.000", text)
|
||||
|
||||
@staticmethod
|
||||
def sanitize_webvtt(text: str) -> str:
|
||||
"""
|
||||
More thorough sanitization of WebVTT files to handle multiple potential issues.
|
||||
|
||||
Parameters:
|
||||
text: The WebVTT content as string
|
||||
|
||||
Returns:
|
||||
Sanitized WebVTT content
|
||||
"""
|
||||
# Make sure we have a proper WEBVTT header
|
||||
if not text.strip().startswith("WEBVTT"):
|
||||
text = "WEBVTT\n\n" + text
|
||||
|
||||
lines = text.split("\n")
|
||||
sanitized_lines = []
|
||||
timestamp_pattern = re.compile(r"^((?:\d+:)?\d+:\d+\.\d+)\s+-->\s+((?:\d+:)?\d+:\d+\.\d+)")
|
||||
|
||||
# Skip invalid headers - keep only WEBVTT
|
||||
header_done = False
|
||||
for line in lines:
|
||||
if not header_done:
|
||||
if line.startswith("WEBVTT"):
|
||||
sanitized_lines.append("WEBVTT")
|
||||
header_done = True
|
||||
continue
|
||||
|
||||
# Replace negative timestamps
|
||||
if "-" in line and "-->" in line:
|
||||
line = re.sub(r"(-\d+:\d+:\d+\.\d+)", "00:00:00.000", line)
|
||||
|
||||
# Validate timestamp format
|
||||
match = timestamp_pattern.match(line)
|
||||
if match:
|
||||
start_time = match.group(1)
|
||||
end_time = match.group(2)
|
||||
|
||||
# Ensure proper format with hours if missing
|
||||
if start_time.count(":") == 1:
|
||||
start_time = f"00:{start_time}"
|
||||
if end_time.count(":") == 1:
|
||||
end_time = f"00:{end_time}"
|
||||
|
||||
line = f"{start_time} --> {end_time}"
|
||||
|
||||
sanitized_lines.append(line)
|
||||
|
||||
return "\n".join(sanitized_lines)
|
||||
|
||||
def convert(self, codec: Subtitle.Codec) -> Path:
|
||||
"""
|
||||
Convert this Subtitle to another Format.
|
||||
|
||||
The file path location of the Subtitle data will be kept at the same
|
||||
location but the file extension will be changed appropriately.
|
||||
|
||||
Supported formats:
|
||||
- SubRip - SubtitleEdit or pycaption.SRTWriter
|
||||
- TimedTextMarkupLang - SubtitleEdit or pycaption.DFXPWriter
|
||||
- WebVTT - SubtitleEdit or pycaption.WebVTTWriter
|
||||
- SubStationAlphav4 - SubtitleEdit
|
||||
- fTTML* - custom code using some pycaption functions
|
||||
- fVTT* - custom code using some pycaption functions
|
||||
*: Can read from format, but cannot convert to format
|
||||
|
||||
Note: It currently prioritizes using SubtitleEdit over PyCaption as
|
||||
I have personally noticed more oddities with PyCaption parsing over
|
||||
SubtitleEdit. Especially when working with TTML/DFXP where it would
|
||||
often have timecodes and stuff mixed in/duplicated.
|
||||
|
||||
Returns the new file path of the Subtitle.
|
||||
"""
|
||||
if not self.path or not self.path.exists():
|
||||
raise ValueError("You must download the subtitle track first.")
|
||||
|
||||
if self.codec == codec:
|
||||
return self.path
|
||||
|
||||
output_path = self.path.with_suffix(f".{codec.value.lower()}")
|
||||
original_path = self.path
|
||||
|
||||
if binaries.SubtitleEdit and self.codec not in (Subtitle.Codec.fTTML, Subtitle.Codec.fVTT):
|
||||
sub_edit_format = {
|
||||
Subtitle.Codec.SubStationAlphav4: "AdvancedSubStationAlpha",
|
||||
Subtitle.Codec.TimedTextMarkupLang: "TimedText1.0",
|
||||
}.get(codec, codec.name)
|
||||
sub_edit_args = [
|
||||
binaries.SubtitleEdit,
|
||||
"/Convert",
|
||||
self.path,
|
||||
sub_edit_format,
|
||||
f"/outputfilename:{output_path.name}",
|
||||
"/encoding:utf8",
|
||||
]
|
||||
if codec == Subtitle.Codec.SubRip:
|
||||
sub_edit_args.append("/ConvertColorsToDialog")
|
||||
subprocess.run(sub_edit_args, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
else:
|
||||
writer = {
|
||||
# pycaption generally only supports these subtitle formats
|
||||
Subtitle.Codec.SubRip: pycaption.SRTWriter,
|
||||
Subtitle.Codec.TimedTextMarkupLang: pycaption.DFXPWriter,
|
||||
Subtitle.Codec.WebVTT: pycaption.WebVTTWriter,
|
||||
}.get(codec)
|
||||
if writer is None:
|
||||
raise NotImplementedError(f"Cannot yet convert {self.codec.name} to {codec.name}.")
|
||||
|
||||
caption_set = self.parse(self.path.read_bytes(), self.codec)
|
||||
Subtitle.merge_same_cues(caption_set)
|
||||
subtitle_text = writer().write(caption_set)
|
||||
|
||||
output_path.write_text(subtitle_text, encoding="utf8")
|
||||
|
||||
if original_path.exists() and original_path != output_path:
|
||||
original_path.unlink()
|
||||
|
||||
self.path = output_path
|
||||
self.codec = codec
|
||||
|
||||
if callable(self.OnConverted):
|
||||
self.OnConverted(codec)
|
||||
|
||||
return output_path
|
||||
|
||||
@staticmethod
|
||||
def parse(data: bytes, codec: Subtitle.Codec) -> pycaption.CaptionSet:
|
||||
if not isinstance(data, bytes):
|
||||
raise ValueError(f"Subtitle data must be parsed as bytes data, not {type(data).__name__}")
|
||||
|
||||
try:
|
||||
if codec == Subtitle.Codec.SubRip:
|
||||
text = try_ensure_utf8(data).decode("utf8")
|
||||
caption_set = pycaption.SRTReader().read(text)
|
||||
elif codec == Subtitle.Codec.fTTML:
|
||||
caption_lists: dict[str, pycaption.CaptionList] = defaultdict(pycaption.CaptionList)
|
||||
for segment in (
|
||||
Subtitle.parse(box.data, Subtitle.Codec.TimedTextMarkupLang)
|
||||
for box in MP4.parse_stream(BytesIO(data))
|
||||
if box.type == b"mdat"
|
||||
):
|
||||
for lang in segment.get_languages():
|
||||
caption_lists[lang].extend(segment.get_captions(lang))
|
||||
caption_set: pycaption.CaptionSet = pycaption.CaptionSet(caption_lists)
|
||||
elif codec == Subtitle.Codec.TimedTextMarkupLang:
|
||||
text = try_ensure_utf8(data).decode("utf8")
|
||||
text = text.replace("tt:", "")
|
||||
# negative size values aren't allowed in TTML/DFXP spec, replace with 0
|
||||
text = re.sub(r'"(-\d+(\.\d+)?(px|em|%|c|pt))"', '"0"', text)
|
||||
caption_set = pycaption.DFXPReader().read(text)
|
||||
elif codec == Subtitle.Codec.fVTT:
|
||||
caption_lists: dict[str, pycaption.CaptionList] = defaultdict(pycaption.CaptionList)
|
||||
caption_list, language = Subtitle.merge_segmented_wvtt(data)
|
||||
caption_lists[language] = caption_list
|
||||
caption_set: pycaption.CaptionSet = pycaption.CaptionSet(caption_lists)
|
||||
elif codec == Subtitle.Codec.WebVTT:
|
||||
text = try_ensure_utf8(data).decode("utf8")
|
||||
text = Subtitle.sanitize_broken_webvtt(text)
|
||||
text = Subtitle.space_webvtt_headers(text)
|
||||
caption_set = pycaption.WebVTTReader().read(text)
|
||||
else:
|
||||
raise ValueError(f'Unknown Subtitle format "{codec}"...')
|
||||
except pycaption.exceptions.CaptionReadSyntaxError as e:
|
||||
raise SyntaxError(f'A syntax error has occurred when reading the "{codec}" subtitle: {e}')
|
||||
except pycaption.exceptions.CaptionReadNoCaptions:
|
||||
return pycaption.CaptionSet({"en": []})
|
||||
|
||||
# remove empty caption lists or some code breaks, especially if it's the first list
|
||||
for language in caption_set.get_languages():
|
||||
if not caption_set.get_captions(language):
|
||||
# noinspection PyProtectedMember
|
||||
del caption_set._captions[language]
|
||||
|
||||
return caption_set
|
||||
|
||||
@staticmethod
|
||||
def sanitize_broken_webvtt(text: str) -> str:
|
||||
"""
|
||||
Remove or fix corrupted WebVTT lines, particularly those with invalid timestamps.
|
||||
|
||||
Parameters:
|
||||
text: The WebVTT content as string
|
||||
|
||||
Returns:
|
||||
Sanitized WebVTT content with corrupted lines removed
|
||||
"""
|
||||
lines = text.splitlines()
|
||||
sanitized_lines = []
|
||||
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
# Skip empty lines
|
||||
if not lines[i].strip():
|
||||
sanitized_lines.append(lines[i])
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Check for timestamp lines
|
||||
if "-->" in lines[i]:
|
||||
# Validate timestamp format
|
||||
timestamp_parts = lines[i].split("-->")
|
||||
if len(timestamp_parts) != 2 or not timestamp_parts[1].strip() or timestamp_parts[1].strip() == "0":
|
||||
# Skip this timestamp and its content until next timestamp or end
|
||||
j = i + 1
|
||||
while j < len(lines) and "-->" not in lines[j] and lines[j].strip():
|
||||
j += 1
|
||||
i = j
|
||||
continue
|
||||
|
||||
# Add valid timestamp line
|
||||
sanitized_lines.append(lines[i])
|
||||
else:
|
||||
# Add non-timestamp line
|
||||
sanitized_lines.append(lines[i])
|
||||
|
||||
i += 1
|
||||
|
||||
return "\n".join(sanitized_lines)
|
||||
|
||||
@staticmethod
|
||||
def space_webvtt_headers(data: Union[str, bytes]):
|
||||
"""
|
||||
Space out the WEBVTT Headers from Captions.
|
||||
|
||||
Segmented VTT when merged may have the WEBVTT headers part of the next caption
|
||||
as they were not separated far enough from the previous caption and ended up
|
||||
being considered as caption text rather than the header for the next segment.
|
||||
"""
|
||||
if isinstance(data, bytes):
|
||||
data = try_ensure_utf8(data).decode("utf8")
|
||||
elif not isinstance(data, str):
|
||||
raise ValueError(f"Expecting data to be a str, not {data!r}")
|
||||
|
||||
text = (
|
||||
data.replace("WEBVTT", "\n\nWEBVTT").replace("\r", "").replace("\n\n\n", "\n \n\n").replace("\n\n<", "\n<")
|
||||
)
|
||||
|
||||
return text
|
||||
|
||||
@staticmethod
|
||||
def merge_same_cues(caption_set: pycaption.CaptionSet):
|
||||
"""Merge captions with the same timecodes and text as one in-place."""
|
||||
for lang in caption_set.get_languages():
|
||||
captions = caption_set.get_captions(lang)
|
||||
last_caption = None
|
||||
concurrent_captions = pycaption.CaptionList()
|
||||
merged_captions = pycaption.CaptionList()
|
||||
for caption in captions:
|
||||
if last_caption:
|
||||
if (caption.start, caption.end) == (last_caption.start, last_caption.end):
|
||||
if caption.get_text() != last_caption.get_text():
|
||||
concurrent_captions.append(caption)
|
||||
last_caption = caption
|
||||
continue
|
||||
else:
|
||||
merged_captions.append(pycaption.base.merge(concurrent_captions))
|
||||
concurrent_captions = [caption]
|
||||
last_caption = caption
|
||||
|
||||
if concurrent_captions:
|
||||
merged_captions.append(pycaption.base.merge(concurrent_captions))
|
||||
if merged_captions:
|
||||
caption_set.set_captions(lang, merged_captions)
|
||||
|
||||
@staticmethod
|
||||
def merge_segmented_wvtt(data: bytes, period_start: float = 0.0) -> tuple[CaptionList, Optional[str]]:
|
||||
"""
|
||||
Convert Segmented DASH WebVTT cues into a pycaption Caption List.
|
||||
Also returns an ISO 639-2 alpha-3 language code if available.
|
||||
|
||||
Code ported originally by xhlove to Python from shaka-player.
|
||||
Has since been improved upon by rlaphoenix using pymp4 and
|
||||
pycaption functions.
|
||||
"""
|
||||
captions = CaptionList()
|
||||
|
||||
# init:
|
||||
saw_wvtt_box = False
|
||||
timescale = None
|
||||
language = None
|
||||
|
||||
# media:
|
||||
# > tfhd
|
||||
default_duration = None
|
||||
# > tfdt
|
||||
saw_tfdt_box = False
|
||||
base_time = 0
|
||||
# > trun
|
||||
saw_trun_box = False
|
||||
samples = []
|
||||
|
||||
def flatten_boxes(box: Container) -> Iterable[Container]:
|
||||
for child in box:
|
||||
if hasattr(child, "children"):
|
||||
yield from flatten_boxes(child.children)
|
||||
del child["children"]
|
||||
if hasattr(child, "entries"):
|
||||
yield from flatten_boxes(child.entries)
|
||||
del child["entries"]
|
||||
# some boxes (mainly within 'entries') uses format not type
|
||||
child["type"] = child.get("type") or child.get("format")
|
||||
yield child
|
||||
|
||||
for box in flatten_boxes(MP4.parse_stream(BytesIO(data))):
|
||||
# init
|
||||
if box.type == b"mdhd":
|
||||
timescale = box.timescale
|
||||
language = box.language
|
||||
|
||||
if box.type == b"wvtt":
|
||||
saw_wvtt_box = True
|
||||
|
||||
# media
|
||||
if box.type == b"styp":
|
||||
# essentially the start of each segment
|
||||
# media var resets
|
||||
# > tfhd
|
||||
default_duration = None
|
||||
# > tfdt
|
||||
saw_tfdt_box = False
|
||||
base_time = 0
|
||||
# > trun
|
||||
saw_trun_box = False
|
||||
samples = []
|
||||
|
||||
if box.type == b"tfhd":
|
||||
if box.flags.default_sample_duration_present:
|
||||
default_duration = box.default_sample_duration
|
||||
|
||||
if box.type == b"tfdt":
|
||||
saw_tfdt_box = True
|
||||
base_time = box.baseMediaDecodeTime
|
||||
|
||||
if box.type == b"trun":
|
||||
saw_trun_box = True
|
||||
samples = box.sample_info
|
||||
|
||||
if box.type == b"mdat":
|
||||
if not timescale:
|
||||
raise ValueError("Timescale was not found in the Segmented WebVTT.")
|
||||
if not saw_wvtt_box:
|
||||
raise ValueError("The WVTT box was not found in the Segmented WebVTT.")
|
||||
if not saw_tfdt_box:
|
||||
raise ValueError("The TFDT box was not found in the Segmented WebVTT.")
|
||||
if not saw_trun_box:
|
||||
raise ValueError("The TRUN box was not found in the Segmented WebVTT.")
|
||||
|
||||
vttc_boxes = MP4.parse_stream(BytesIO(box.data))
|
||||
current_time = base_time + period_start
|
||||
|
||||
for sample, vttc_box in zip(samples, vttc_boxes):
|
||||
duration = sample.sample_duration or default_duration
|
||||
if sample.sample_composition_time_offsets:
|
||||
current_time += sample.sample_composition_time_offsets
|
||||
|
||||
start_time = current_time
|
||||
end_time = current_time + (duration or 0)
|
||||
current_time = end_time
|
||||
|
||||
if vttc_box.type == b"vtte":
|
||||
# vtte is a vttc that's empty, skip
|
||||
continue
|
||||
|
||||
layout: Optional[Layout] = None
|
||||
nodes: list[CaptionNode] = []
|
||||
|
||||
for cue_box in vttc_box.children:
|
||||
if cue_box.type == b"vsid":
|
||||
# this is a V(?) Source ID box, we don't care
|
||||
continue
|
||||
if cue_box.type == b"sttg":
|
||||
layout = Layout(webvtt_positioning=cue_box.settings)
|
||||
elif cue_box.type == b"payl":
|
||||
nodes.extend(
|
||||
[
|
||||
node
|
||||
for line in cue_box.cue_text.split("\n")
|
||||
for node in [
|
||||
CaptionNode.create_text(WebVTTReader()._decode(line)),
|
||||
CaptionNode.create_break(),
|
||||
]
|
||||
]
|
||||
)
|
||||
nodes.pop()
|
||||
|
||||
if nodes:
|
||||
caption = Caption(
|
||||
start=start_time * timescale, # as microseconds
|
||||
end=end_time * timescale,
|
||||
nodes=nodes,
|
||||
layout_info=layout,
|
||||
)
|
||||
p_caption = captions[-1] if captions else None
|
||||
if p_caption and caption.start == p_caption.end and str(caption.nodes) == str(p_caption.nodes):
|
||||
# it's a duplicate, but lets take its end time
|
||||
p_caption.end = caption.end
|
||||
continue
|
||||
captions.append(caption)
|
||||
|
||||
return captions, language
|
||||
|
||||
def strip_hearing_impaired(self) -> None:
|
||||
"""
|
||||
Strip captions for hearing impaired (SDH).
|
||||
It uses SubtitleEdit if available, otherwise filter-subs.
|
||||
"""
|
||||
if not self.path or not self.path.exists():
|
||||
raise ValueError("You must download the subtitle track first.")
|
||||
|
||||
if binaries.SubtitleEdit:
|
||||
if self.codec == Subtitle.Codec.SubStationAlphav4:
|
||||
output_format = "AdvancedSubStationAlpha"
|
||||
elif self.codec == Subtitle.Codec.TimedTextMarkupLang:
|
||||
output_format = "TimedText1.0"
|
||||
else:
|
||||
output_format = self.codec.name
|
||||
subprocess.run(
|
||||
[
|
||||
binaries.SubtitleEdit,
|
||||
"/Convert",
|
||||
self.path,
|
||||
output_format,
|
||||
"/encoding:utf8",
|
||||
"/overwrite",
|
||||
"/RemoveTextForHI",
|
||||
],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
)
|
||||
else:
|
||||
sub = Subtitles(self.path)
|
||||
sub.filter(rm_fonts=True, rm_ast=True, rm_music=True, rm_effects=True, rm_names=True, rm_author=True)
|
||||
sub.save()
|
||||
|
||||
def reverse_rtl(self) -> None:
|
||||
"""
|
||||
Reverse RTL (Right to Left) Start/End on Captions.
|
||||
This can be used to fix the positioning of sentence-ending characters.
|
||||
"""
|
||||
if not self.path or not self.path.exists():
|
||||
raise ValueError("You must download the subtitle track first.")
|
||||
|
||||
if not binaries.SubtitleEdit:
|
||||
raise EnvironmentError("SubtitleEdit executable not found...")
|
||||
|
||||
if self.codec == Subtitle.Codec.SubStationAlphav4:
|
||||
output_format = "AdvancedSubStationAlpha"
|
||||
elif self.codec == Subtitle.Codec.TimedTextMarkupLang:
|
||||
output_format = "TimedText1.0"
|
||||
else:
|
||||
output_format = self.codec.name
|
||||
|
||||
subprocess.run(
|
||||
[
|
||||
binaries.SubtitleEdit,
|
||||
"/Convert",
|
||||
self.path,
|
||||
output_format,
|
||||
"/ReverseRtlStartEnd",
|
||||
"/encoding:utf8",
|
||||
"/overwrite",
|
||||
],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ("Subtitle",)
|
||||
597
unshackle/core/tracks/track.py
Normal file
597
unshackle/core/tracks/track.py
Normal file
@@ -0,0 +1,597 @@
|
||||
import base64
|
||||
import html
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from collections import defaultdict
|
||||
from copy import copy
|
||||
from enum import Enum
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Iterable, Optional, Union
|
||||
from uuid import UUID
|
||||
from zlib import crc32
|
||||
|
||||
from langcodes import Language
|
||||
from pyplayready.cdm import Cdm as PlayReadyCdm
|
||||
from pywidevine.cdm import Cdm as WidevineCdm
|
||||
from requests import Session
|
||||
|
||||
from unshackle.core import binaries
|
||||
from unshackle.core.config import config
|
||||
from unshackle.core.constants import DOWNLOAD_CANCELLED, DOWNLOAD_LICENCE_ONLY
|
||||
from unshackle.core.downloaders import aria2c, curl_impersonate, n_m3u8dl_re, requests
|
||||
from unshackle.core.drm import DRM_T, PlayReady, Widevine
|
||||
from unshackle.core.events import events
|
||||
from unshackle.core.utilities import get_boxes, try_ensure_utf8
|
||||
from unshackle.core.utils.subprocess import ffprobe
|
||||
|
||||
|
||||
class Track:
|
||||
class Descriptor(Enum):
|
||||
URL = 1 # Direct URL, nothing fancy
|
||||
HLS = 2 # https://en.wikipedia.org/wiki/HTTP_Live_Streaming
|
||||
DASH = 3 # https://en.wikipedia.org/wiki/Dynamic_Adaptive_Streaming_over_HTTP
|
||||
ISM = 4 # https://learn.microsoft.com/en-us/silverlight/smooth-streaming
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: Union[str, list[str]],
|
||||
language: Union[Language, str],
|
||||
is_original_lang: bool = False,
|
||||
descriptor: Descriptor = Descriptor.URL,
|
||||
needs_repack: bool = False,
|
||||
name: Optional[str] = None,
|
||||
drm: Optional[Iterable[DRM_T]] = None,
|
||||
edition: Optional[str] = None,
|
||||
downloader: Optional[Callable] = None,
|
||||
data: Optional[Union[dict, defaultdict]] = None,
|
||||
id_: Optional[str] = None,
|
||||
extra: Optional[Any] = None,
|
||||
) -> None:
|
||||
if not isinstance(url, (str, list)):
|
||||
raise TypeError(f"Expected url to be a {str}, or list of {str}, not {type(url)}")
|
||||
if not isinstance(language, (Language, str)):
|
||||
raise TypeError(f"Expected language to be a {Language} or {str}, not {type(language)}")
|
||||
if not isinstance(is_original_lang, bool):
|
||||
raise TypeError(f"Expected is_original_lang to be a {bool}, not {type(is_original_lang)}")
|
||||
if not isinstance(descriptor, Track.Descriptor):
|
||||
raise TypeError(f"Expected descriptor to be a {Track.Descriptor}, not {type(descriptor)}")
|
||||
if not isinstance(needs_repack, bool):
|
||||
raise TypeError(f"Expected needs_repack to be a {bool}, not {type(needs_repack)}")
|
||||
if not isinstance(name, (str, type(None))):
|
||||
raise TypeError(f"Expected name to be a {str}, not {type(name)}")
|
||||
if not isinstance(id_, (str, type(None))):
|
||||
raise TypeError(f"Expected id_ to be a {str}, not {type(id_)}")
|
||||
if not isinstance(edition, (str, type(None))):
|
||||
raise TypeError(f"Expected edition to be a {str}, not {type(edition)}")
|
||||
if not isinstance(downloader, (Callable, type(None))):
|
||||
raise TypeError(f"Expected downloader to be a {Callable}, not {type(downloader)}")
|
||||
if not isinstance(data, (dict, defaultdict, type(None))):
|
||||
raise TypeError(f"Expected data to be a {dict} or {defaultdict}, not {type(data)}")
|
||||
|
||||
invalid_urls = ", ".join(set(type(x) for x in url if not isinstance(x, str)))
|
||||
if invalid_urls:
|
||||
raise TypeError(f"Expected all items in url to be a {str}, but found {invalid_urls}")
|
||||
|
||||
if drm is not None:
|
||||
try:
|
||||
iter(drm)
|
||||
except TypeError:
|
||||
raise TypeError(f"Expected drm to be an iterable, not {type(drm)}")
|
||||
|
||||
if downloader is None:
|
||||
downloader = {
|
||||
"aria2c": aria2c,
|
||||
"curl_impersonate": curl_impersonate,
|
||||
"requests": requests,
|
||||
"n_m3u8dl_re": n_m3u8dl_re,
|
||||
}[config.downloader]
|
||||
|
||||
self.path: Optional[Path] = None
|
||||
self.url = url
|
||||
self.language = Language.get(language)
|
||||
self.is_original_lang = is_original_lang
|
||||
self.descriptor = descriptor
|
||||
self.needs_repack = needs_repack
|
||||
self.name = name
|
||||
self.drm = drm
|
||||
self.edition: str = edition
|
||||
self.downloader = downloader
|
||||
self._data: defaultdict[Any, Any] = defaultdict(dict)
|
||||
self.data = data or {}
|
||||
self.extra: Any = extra or {} # allow anything for extra, but default to a dict
|
||||
|
||||
if self.name is None:
|
||||
lang = Language.get(self.language)
|
||||
if (lang.language or "").lower() == (lang.territory or "").lower():
|
||||
lang.territory = None # e.g. en-en, de-DE
|
||||
reduced = lang.simplify_script()
|
||||
extra_parts = []
|
||||
if reduced.script is not None:
|
||||
script = reduced.script_name(max_distance=25)
|
||||
if script and script != "Zzzz":
|
||||
extra_parts.append(script)
|
||||
if reduced.territory is not None:
|
||||
territory = reduced.territory_name(max_distance=25)
|
||||
if territory and territory != "ZZ":
|
||||
territory = territory.removesuffix(" SAR China")
|
||||
extra_parts.append(territory)
|
||||
self.name = ", ".join(extra_parts) or None
|
||||
|
||||
if not id_:
|
||||
this = copy(self)
|
||||
this.url = self.url.rsplit("?", maxsplit=1)[0]
|
||||
checksum = crc32(repr(this).encode("utf8"))
|
||||
id_ = hex(checksum)[2:]
|
||||
|
||||
self.id = id_
|
||||
|
||||
# TODO: Currently using OnFoo event naming, change to just segment_filter
|
||||
self.OnSegmentFilter: Optional[Callable] = None
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "{name}({items})".format(
|
||||
name=self.__class__.__name__, items=", ".join([f"{k}={repr(v)}" for k, v in self.__dict__.items()])
|
||||
)
|
||||
|
||||
def __eq__(self, other: Any) -> bool:
|
||||
return isinstance(other, Track) and self.id == other.id
|
||||
|
||||
@property
|
||||
def data(self) -> defaultdict[Any, Any]:
|
||||
"""
|
||||
Arbitrary track data dictionary.
|
||||
|
||||
A defaultdict is used with a dict as the factory for easier
|
||||
nested saving and safer exists-checks.
|
||||
|
||||
Reserved keys:
|
||||
|
||||
- "hls" used by the HLS class.
|
||||
- playlist: m3u8.model.Playlist - The primary track information.
|
||||
- media: m3u8.model.Media - The audio/subtitle track information.
|
||||
- segment_durations: list[int] - A list of each segment's duration.
|
||||
- "dash" used by the DASH class.
|
||||
- manifest: lxml.ElementTree - DASH MPD manifest.
|
||||
- period: lxml.Element - The period of this track.
|
||||
- adaptation_set: lxml.Element - The adaptation set of this track.
|
||||
- representation: lxml.Element - The representation of this track.
|
||||
- timescale: int - The timescale of the track's segments.
|
||||
- segment_durations: list[int] - A list of each segment's duration.
|
||||
|
||||
You should not add, change, or remove any data within reserved keys.
|
||||
You may use their data but do note that the values of them may change
|
||||
or be removed at any point.
|
||||
"""
|
||||
return self._data
|
||||
|
||||
@data.setter
|
||||
def data(self, value: Union[dict, defaultdict]) -> None:
|
||||
if not isinstance(value, (dict, defaultdict)):
|
||||
raise TypeError(f"Expected data to be a {dict} or {defaultdict}, not {type(value)}")
|
||||
if isinstance(value, dict):
|
||||
value = defaultdict(dict, **value)
|
||||
self._data = value
|
||||
|
||||
def download(
|
||||
self,
|
||||
session: Session,
|
||||
prepare_drm: partial,
|
||||
max_workers: Optional[int] = None,
|
||||
progress: Optional[partial] = None,
|
||||
*,
|
||||
cdm: Optional[object] = None,
|
||||
):
|
||||
"""Download and optionally Decrypt this Track."""
|
||||
from unshackle.core.manifests import DASH, HLS, ISM
|
||||
|
||||
if DOWNLOAD_LICENCE_ONLY.is_set():
|
||||
progress(downloaded="[yellow]SKIPPING")
|
||||
|
||||
if DOWNLOAD_CANCELLED.is_set():
|
||||
progress(downloaded="[yellow]SKIPPED")
|
||||
return
|
||||
|
||||
log = logging.getLogger("track")
|
||||
|
||||
proxy = next(iter(session.proxies.values()), None)
|
||||
|
||||
track_type = self.__class__.__name__
|
||||
save_path = config.directories.temp / f"{track_type}_{self.id}.mp4"
|
||||
if track_type == "Subtitle":
|
||||
save_path = save_path.with_suffix(f".{self.codec.extension}")
|
||||
if self.downloader.__name__ == "n_m3u8dl_re":
|
||||
self.downloader = requests
|
||||
|
||||
if self.descriptor != self.Descriptor.URL:
|
||||
save_dir = save_path.with_name(save_path.name + "_segments")
|
||||
else:
|
||||
save_dir = save_path.parent
|
||||
|
||||
def cleanup():
|
||||
# track file (e.g., "foo.mp4")
|
||||
save_path.unlink(missing_ok=True)
|
||||
# aria2c control file (e.g., "foo.mp4.aria2" or "foo.mp4.aria2__temp")
|
||||
save_path.with_suffix(f"{save_path.suffix}.aria2").unlink(missing_ok=True)
|
||||
save_path.with_suffix(f"{save_path.suffix}.aria2__temp").unlink(missing_ok=True)
|
||||
if save_dir.exists() and save_dir.name.endswith("_segments"):
|
||||
shutil.rmtree(save_dir)
|
||||
|
||||
if not DOWNLOAD_LICENCE_ONLY.is_set():
|
||||
if config.directories.temp.is_file():
|
||||
raise ValueError(f"Temp Directory '{config.directories.temp}' must be a Directory, not a file")
|
||||
|
||||
config.directories.temp.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Delete any pre-existing temp files matching this track.
|
||||
# We can't re-use or continue downloading these tracks as they do not use a
|
||||
# lock file. Or at least the majority don't. Even if they did I've encountered
|
||||
# corruptions caused by sudden interruptions to the lock file.
|
||||
cleanup()
|
||||
|
||||
try:
|
||||
if self.descriptor == self.Descriptor.HLS:
|
||||
HLS.download_track(
|
||||
track=self,
|
||||
save_path=save_path,
|
||||
save_dir=save_dir,
|
||||
progress=progress,
|
||||
session=session,
|
||||
proxy=proxy,
|
||||
max_workers=max_workers,
|
||||
license_widevine=prepare_drm,
|
||||
cdm=cdm,
|
||||
)
|
||||
elif self.descriptor == self.Descriptor.DASH:
|
||||
DASH.download_track(
|
||||
track=self,
|
||||
save_path=save_path,
|
||||
save_dir=save_dir,
|
||||
progress=progress,
|
||||
session=session,
|
||||
proxy=proxy,
|
||||
max_workers=max_workers,
|
||||
license_widevine=prepare_drm,
|
||||
cdm=cdm,
|
||||
)
|
||||
elif self.descriptor == self.Descriptor.ISM:
|
||||
ISM.download_track(
|
||||
track=self,
|
||||
save_path=save_path,
|
||||
save_dir=save_dir,
|
||||
progress=progress,
|
||||
session=session,
|
||||
proxy=proxy,
|
||||
max_workers=max_workers,
|
||||
license_widevine=prepare_drm,
|
||||
cdm=cdm,
|
||||
)
|
||||
elif self.descriptor == self.Descriptor.URL:
|
||||
try:
|
||||
if not self.drm and track_type in ("Video", "Audio"):
|
||||
# the service might not have explicitly defined the `drm` property
|
||||
# try find widevine DRM information from the init data of URL
|
||||
try:
|
||||
self.drm = [Widevine.from_track(self, session)]
|
||||
except Widevine.Exceptions.PSSHNotFound:
|
||||
# it might not have Widevine DRM, or might not have found the PSSH
|
||||
log.warning("No Widevine PSSH was found for this track, is it DRM free?")
|
||||
|
||||
if self.drm:
|
||||
track_kid = self.get_key_id(session=session)
|
||||
drm = self.get_drm_for_cdm(cdm)
|
||||
if isinstance(drm, Widevine):
|
||||
# license and grab content keys
|
||||
if not prepare_drm:
|
||||
raise ValueError("prepare_drm func must be supplied to use Widevine DRM")
|
||||
progress(downloaded="LICENSING")
|
||||
prepare_drm(drm, track_kid=track_kid)
|
||||
progress(downloaded="[yellow]LICENSED")
|
||||
elif isinstance(drm, PlayReady):
|
||||
# license and grab content keys
|
||||
if not prepare_drm:
|
||||
raise ValueError("prepare_drm func must be supplied to use PlayReady DRM")
|
||||
progress(downloaded="LICENSING")
|
||||
prepare_drm(drm, track_kid=track_kid)
|
||||
progress(downloaded="[yellow]LICENSED")
|
||||
else:
|
||||
drm = None
|
||||
|
||||
if DOWNLOAD_LICENCE_ONLY.is_set():
|
||||
progress(downloaded="[yellow]SKIPPED")
|
||||
elif track_type != "Subtitle" and self.downloader.__name__ == "n_m3u8dl_re":
|
||||
progress(downloaded="[red]FAILED")
|
||||
error = f"[N_m3u8DL-RE]: {self.descriptor} is currently not supported"
|
||||
raise ValueError(error)
|
||||
else:
|
||||
for status_update in self.downloader(
|
||||
urls=self.url,
|
||||
output_dir=save_path.parent,
|
||||
filename=save_path.name,
|
||||
headers=session.headers,
|
||||
cookies=session.cookies,
|
||||
proxy=proxy,
|
||||
max_workers=max_workers,
|
||||
):
|
||||
file_downloaded = status_update.get("file_downloaded")
|
||||
if not file_downloaded:
|
||||
progress(**status_update)
|
||||
|
||||
# see https://github.com/devine-dl/devine/issues/71
|
||||
save_path.with_suffix(f"{save_path.suffix}.aria2__temp").unlink(missing_ok=True)
|
||||
|
||||
self.path = save_path
|
||||
events.emit(events.Types.TRACK_DOWNLOADED, track=self)
|
||||
|
||||
if drm:
|
||||
progress(downloaded="Decrypting", completed=0, total=100)
|
||||
drm.decrypt(save_path)
|
||||
self.drm = None
|
||||
events.emit(events.Types.TRACK_DECRYPTED, track=self, drm=drm, segment=None)
|
||||
progress(downloaded="Decrypted", completed=100)
|
||||
|
||||
if track_type == "Subtitle" and self.codec.name not in ("fVTT", "fTTML"):
|
||||
track_data = self.path.read_bytes()
|
||||
track_data = try_ensure_utf8(track_data)
|
||||
track_data = (
|
||||
track_data.decode("utf8")
|
||||
.replace("‎", html.unescape("‎"))
|
||||
.replace("‏", html.unescape("‏"))
|
||||
.encode("utf8")
|
||||
)
|
||||
self.path.write_bytes(track_data)
|
||||
|
||||
progress(downloaded="Downloaded")
|
||||
except KeyboardInterrupt:
|
||||
DOWNLOAD_CANCELLED.set()
|
||||
progress(downloaded="[yellow]CANCELLED")
|
||||
raise
|
||||
except Exception:
|
||||
DOWNLOAD_CANCELLED.set()
|
||||
progress(downloaded="[red]FAILED")
|
||||
raise
|
||||
except (Exception, KeyboardInterrupt):
|
||||
if not DOWNLOAD_LICENCE_ONLY.is_set():
|
||||
cleanup()
|
||||
raise
|
||||
|
||||
if DOWNLOAD_CANCELLED.is_set():
|
||||
# we stopped during the download, let's exit
|
||||
return
|
||||
|
||||
if not DOWNLOAD_LICENCE_ONLY.is_set():
|
||||
if self.path.stat().st_size <= 3: # Empty UTF-8 BOM == 3 bytes
|
||||
raise IOError("Download failed, the downloaded file is empty.")
|
||||
|
||||
events.emit(events.Types.TRACK_DOWNLOADED, track=self)
|
||||
|
||||
def delete(self) -> None:
|
||||
if self.path:
|
||||
self.path.unlink()
|
||||
self.path = None
|
||||
|
||||
def move(self, target: Union[Path, str]) -> Path:
|
||||
"""
|
||||
Move the Track's file from current location, to target location.
|
||||
This will overwrite anything at the target path.
|
||||
|
||||
Raises:
|
||||
TypeError: If the target argument is not the expected type.
|
||||
ValueError: If track has no file to move, or the target does not exist.
|
||||
OSError: If the file somehow failed to move.
|
||||
|
||||
Returns the new location of the track.
|
||||
"""
|
||||
if not isinstance(target, (str, Path)):
|
||||
raise TypeError(f"Expected {target} to be a {Path} or {str}, not {type(target)}")
|
||||
|
||||
if not self.path:
|
||||
raise ValueError("Track has no file to move")
|
||||
|
||||
if not isinstance(target, Path):
|
||||
target = Path(target)
|
||||
|
||||
if not target.exists():
|
||||
raise ValueError(f"Target file {repr(target)} does not exist")
|
||||
|
||||
moved_to = Path(shutil.move(self.path, target))
|
||||
if moved_to.resolve() != target.resolve():
|
||||
raise OSError(f"Failed to move {self.path} to {target}")
|
||||
|
||||
self.path = target
|
||||
return target
|
||||
|
||||
def get_track_name(self) -> Optional[str]:
|
||||
"""Get the Track Name."""
|
||||
return self.name
|
||||
|
||||
def get_drm_for_cdm(self, cdm: Optional[object]) -> Optional[DRM_T]:
|
||||
"""Return the DRM matching the provided CDM, if available."""
|
||||
if not self.drm:
|
||||
return None
|
||||
|
||||
if isinstance(cdm, WidevineCdm):
|
||||
for drm in self.drm:
|
||||
if isinstance(drm, Widevine):
|
||||
return drm
|
||||
elif isinstance(cdm, PlayReadyCdm):
|
||||
for drm in self.drm:
|
||||
if isinstance(drm, PlayReady):
|
||||
return drm
|
||||
|
||||
return self.drm[0]
|
||||
|
||||
def get_key_id(self, init_data: Optional[bytes] = None, *args, **kwargs) -> Optional[UUID]:
|
||||
"""
|
||||
Probe the DRM encryption Key ID (KID) for this specific track.
|
||||
|
||||
It currently supports finding the Key ID by probing the track's stream
|
||||
with ffprobe for `enc_key_id` data, as well as for mp4 `tenc` (Track
|
||||
Encryption) boxes.
|
||||
|
||||
It explicitly ignores PSSH information like the `PSSH` box, as the box
|
||||
is likely to contain multiple Key IDs that may or may not be for this
|
||||
specific track.
|
||||
|
||||
To retrieve the initialization segment, this method calls :meth:`get_init_segment`
|
||||
with the positional and keyword arguments. The return value of `get_init_segment`
|
||||
is then used to determine the Key ID.
|
||||
|
||||
Returns:
|
||||
The Key ID as a UUID object, or None if the Key ID could not be determined.
|
||||
"""
|
||||
if not init_data:
|
||||
init_data = self.get_init_segment(*args, **kwargs)
|
||||
if not isinstance(init_data, bytes):
|
||||
raise TypeError(f"Expected init_data to be bytes, not {init_data!r}")
|
||||
|
||||
probe = ffprobe(init_data)
|
||||
if probe:
|
||||
for stream in probe.get("streams") or []:
|
||||
enc_key_id = stream.get("tags", {}).get("enc_key_id")
|
||||
if enc_key_id:
|
||||
return UUID(bytes=base64.b64decode(enc_key_id))
|
||||
|
||||
for tenc in get_boxes(init_data, b"tenc"):
|
||||
if tenc.key_ID.int != 0:
|
||||
return tenc.key_ID
|
||||
|
||||
for uuid_box in get_boxes(init_data, b"uuid"):
|
||||
if uuid_box.extended_type == UUID("8974dbce-7be7-4c51-84f9-7148f9882554"): # tenc
|
||||
tenc = uuid_box.data
|
||||
if tenc.key_ID.int != 0:
|
||||
return tenc.key_ID
|
||||
|
||||
def get_init_segment(
|
||||
self,
|
||||
maximum_size: int = 20000,
|
||||
url: Optional[str] = None,
|
||||
byte_range: Optional[str] = None,
|
||||
session: Optional[Session] = None,
|
||||
) -> bytes:
|
||||
"""
|
||||
Get the Track's Initial Segment Data Stream.
|
||||
|
||||
HLS and DASH tracks must explicitly provide a URL to the init segment or file.
|
||||
Providing the byte-range for the init segment is recommended where possible.
|
||||
|
||||
If `byte_range` is not set, it will make a HEAD request and check the size of
|
||||
the file. If the size could not be determined, it will download up to the first
|
||||
20KB only, which should contain the entirety of the init segment. You may
|
||||
override this by changing the `maximum_size`.
|
||||
|
||||
The default maximum_size of 20000 (20KB) is a tried-and-tested value that
|
||||
seems to work well across the board.
|
||||
|
||||
Parameters:
|
||||
maximum_size: Size to assume as the content length if byte-range is not
|
||||
used, the content size could not be determined, or the content size
|
||||
is larger than it. A value of 20000 (20KB) or higher is recommended.
|
||||
url: Explicit init map or file URL to probe from.
|
||||
byte_range: Range of bytes to download from the explicit or implicit URL.
|
||||
session: Session context, e.g., authorization and headers.
|
||||
"""
|
||||
if not isinstance(maximum_size, int):
|
||||
raise TypeError(f"Expected maximum_size to be an {int}, not {type(maximum_size)}")
|
||||
if not isinstance(url, (str, type(None))):
|
||||
raise TypeError(f"Expected url to be a {str}, not {type(url)}")
|
||||
if not isinstance(byte_range, (str, type(None))):
|
||||
raise TypeError(f"Expected byte_range to be a {str}, not {type(byte_range)}")
|
||||
if not isinstance(session, (Session, type(None))):
|
||||
raise TypeError(f"Expected session to be a {Session}, not {type(session)}")
|
||||
|
||||
if not url:
|
||||
if self.descriptor != self.Descriptor.URL:
|
||||
raise ValueError(f"An explicit URL must be provided for {self.descriptor.name} tracks")
|
||||
if not self.url:
|
||||
raise ValueError("An explicit URL must be provided as the track has no URL")
|
||||
url = self.url
|
||||
|
||||
if not session:
|
||||
session = Session()
|
||||
|
||||
content_length = maximum_size
|
||||
|
||||
if byte_range:
|
||||
if not isinstance(byte_range, str):
|
||||
raise TypeError(f"Expected byte_range to be a str, not {byte_range!r}")
|
||||
if not re.match(r"^\d+-\d+$", byte_range):
|
||||
raise ValueError(f"The value of byte_range is unrecognized: '{byte_range}'")
|
||||
start, end = byte_range.split("-")
|
||||
if start > end:
|
||||
raise ValueError(f"The start range cannot be greater than the end range: {start}>{end}")
|
||||
else:
|
||||
size_test = session.head(url)
|
||||
if "Content-Length" in size_test.headers:
|
||||
content_length_header = int(size_test.headers["Content-Length"])
|
||||
if content_length_header > 0:
|
||||
content_length = min(content_length_header, maximum_size)
|
||||
range_test = session.head(url, headers={"Range": "bytes=0-1"})
|
||||
if range_test.status_code == 206:
|
||||
byte_range = f"0-{content_length - 1}"
|
||||
|
||||
if byte_range:
|
||||
res = session.get(url=url, headers={"Range": f"bytes={byte_range}"})
|
||||
res.raise_for_status()
|
||||
init_data = res.content
|
||||
else:
|
||||
init_data = None
|
||||
with session.get(url, stream=True) as s:
|
||||
for chunk in s.iter_content(content_length):
|
||||
init_data = chunk
|
||||
break
|
||||
if not init_data:
|
||||
raise ValueError(f"Failed to read {content_length} bytes from the track URI.")
|
||||
|
||||
return init_data
|
||||
|
||||
def repackage(self) -> None:
|
||||
if not self.path or not self.path.exists():
|
||||
raise ValueError("Cannot repackage a Track that has not been downloaded.")
|
||||
|
||||
if not binaries.FFMPEG:
|
||||
raise EnvironmentError('FFmpeg executable "ffmpeg" was not found but is required for this call.')
|
||||
|
||||
original_path = self.path
|
||||
output_path = original_path.with_stem(f"{original_path.stem}_repack")
|
||||
|
||||
def _ffmpeg(extra_args: list[str] = None):
|
||||
subprocess.run(
|
||||
[
|
||||
binaries.FFMPEG,
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-i",
|
||||
original_path,
|
||||
*(extra_args or []),
|
||||
# Following are very important!
|
||||
"-map_metadata",
|
||||
"-1", # don't transfer metadata to output file
|
||||
"-fflags",
|
||||
"bitexact", # only have minimal tag data, reproducible mux
|
||||
"-codec",
|
||||
"copy",
|
||||
str(output_path),
|
||||
],
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
|
||||
try:
|
||||
_ffmpeg()
|
||||
except subprocess.CalledProcessError as e:
|
||||
if b"Malformed AAC bitstream detected" in e.stderr:
|
||||
# e.g., TruTV's dodgy encodes
|
||||
_ffmpeg(["-y", "-bsf:a", "aac_adtstoasc"])
|
||||
else:
|
||||
raise
|
||||
|
||||
original_path.unlink()
|
||||
self.path = output_path
|
||||
|
||||
|
||||
__all__ = ("Track",)
|
||||
434
unshackle/core/tracks/tracks.py
Normal file
434
unshackle/core/tracks/tracks.py
Normal file
@@ -0,0 +1,434 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import Callable, Iterator, Optional, Sequence, Union
|
||||
|
||||
from langcodes import Language, closest_supported_match
|
||||
from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn, TimeRemainingColumn
|
||||
from rich.table import Table
|
||||
from rich.tree import Tree
|
||||
|
||||
from unshackle.core.config import config
|
||||
from unshackle.core.console import console
|
||||
from unshackle.core.constants import LANGUAGE_MAX_DISTANCE, AnyTrack, TrackT
|
||||
from unshackle.core.events import events
|
||||
from unshackle.core.tracks.attachment import Attachment
|
||||
from unshackle.core.tracks.audio import Audio
|
||||
from unshackle.core.tracks.chapters import Chapter, Chapters
|
||||
from unshackle.core.tracks.subtitle import Subtitle
|
||||
from unshackle.core.tracks.track import Track
|
||||
from unshackle.core.tracks.video import Video
|
||||
from unshackle.core.utilities import is_close_match, sanitize_filename
|
||||
from unshackle.core.utils.collections import as_list, flatten
|
||||
|
||||
|
||||
class Tracks:
|
||||
"""
|
||||
Video, Audio, Subtitle, Chapter, and Attachment Track Store.
|
||||
It provides convenience functions for listing, sorting, and selecting tracks.
|
||||
"""
|
||||
|
||||
TRACK_ORDER_MAP = {Video: 0, Audio: 1, Subtitle: 2, Chapter: 3, Attachment: 4}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*args: Union[
|
||||
Tracks, Sequence[Union[AnyTrack, Chapter, Chapters, Attachment]], Track, Chapter, Chapters, Attachment
|
||||
],
|
||||
):
|
||||
self.videos: list[Video] = []
|
||||
self.audio: list[Audio] = []
|
||||
self.subtitles: list[Subtitle] = []
|
||||
self.chapters = Chapters()
|
||||
self.attachments: list[Attachment] = []
|
||||
|
||||
if args:
|
||||
self.add(args)
|
||||
|
||||
def __iter__(self) -> Iterator[AnyTrack]:
|
||||
return iter(as_list(self.videos, self.audio, self.subtitles))
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.videos) + len(self.audio) + len(self.subtitles)
|
||||
|
||||
def __add__(
|
||||
self,
|
||||
other: Union[
|
||||
Tracks, Sequence[Union[AnyTrack, Chapter, Chapters, Attachment]], Track, Chapter, Chapters, Attachment
|
||||
],
|
||||
) -> Tracks:
|
||||
self.add(other)
|
||||
return self
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "{name}({items})".format(
|
||||
name=self.__class__.__name__, items=", ".join([f"{k}={repr(v)}" for k, v in self.__dict__.items()])
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
rep = {Video: [], Audio: [], Subtitle: [], Chapter: [], Attachment: []}
|
||||
tracks = [*list(self), *self.chapters]
|
||||
|
||||
for track in sorted(tracks, key=lambda t: self.TRACK_ORDER_MAP[type(t)]):
|
||||
if not rep[type(track)]:
|
||||
count = sum(type(x) is type(track) for x in tracks)
|
||||
rep[type(track)].append(
|
||||
"{count} {type} Track{plural}{colon}".format(
|
||||
count=count,
|
||||
type=track.__class__.__name__,
|
||||
plural="s" if count != 1 else "",
|
||||
colon=":" if count > 0 else "",
|
||||
)
|
||||
)
|
||||
rep[type(track)].append(str(track))
|
||||
|
||||
for type_ in list(rep):
|
||||
if not rep[type_]:
|
||||
del rep[type_]
|
||||
continue
|
||||
rep[type_] = "\n".join([rep[type_][0]] + [f"├─ {x}" for x in rep[type_][1:-1]] + [f"└─ {rep[type_][-1]}"])
|
||||
rep = "\n".join(list(rep.values()))
|
||||
|
||||
return rep
|
||||
|
||||
def tree(self, add_progress: bool = False) -> tuple[Tree, list[partial]]:
|
||||
all_tracks = [*list(self), *self.chapters, *self.attachments]
|
||||
|
||||
progress_callables = []
|
||||
|
||||
tree = Tree("", hide_root=True)
|
||||
for track_type in self.TRACK_ORDER_MAP:
|
||||
tracks = list(x for x in all_tracks if isinstance(x, track_type))
|
||||
if not tracks:
|
||||
continue
|
||||
num_tracks = len(tracks)
|
||||
track_type_plural = track_type.__name__ + ("s" if track_type != Audio and num_tracks != 1 else "")
|
||||
tracks_tree = tree.add(f"[repr.number]{num_tracks}[/] {track_type_plural}")
|
||||
for track in tracks:
|
||||
if add_progress and track_type not in (Chapter, Attachment):
|
||||
progress = Progress(
|
||||
SpinnerColumn(finished_text=""),
|
||||
BarColumn(),
|
||||
"•",
|
||||
TimeRemainingColumn(compact=True, elapsed_when_finished=True),
|
||||
"•",
|
||||
TextColumn("[progress.data.speed]{task.fields[downloaded]}"),
|
||||
console=console,
|
||||
speed_estimate_period=10,
|
||||
)
|
||||
task = progress.add_task("", downloaded="-")
|
||||
progress_callables.append(partial(progress.update, task_id=task))
|
||||
track_table = Table.grid()
|
||||
track_table.add_row(str(track)[6:], style="text2")
|
||||
track_table.add_row(progress)
|
||||
tracks_tree.add(track_table)
|
||||
else:
|
||||
tracks_tree.add(str(track)[6:], style="text2")
|
||||
|
||||
return tree, progress_callables
|
||||
|
||||
def exists(self, by_id: Optional[str] = None, by_url: Optional[Union[str, list[str]]] = None) -> bool:
|
||||
"""Check if a track already exists by various methods."""
|
||||
if by_id: # recommended
|
||||
return any(x.id == by_id for x in self)
|
||||
if by_url:
|
||||
return any(x.url == by_url for x in self)
|
||||
return False
|
||||
|
||||
def add(
|
||||
self,
|
||||
tracks: Union[
|
||||
Tracks, Sequence[Union[AnyTrack, Chapter, Chapters, Attachment]], Track, Chapter, Chapters, Attachment
|
||||
],
|
||||
warn_only: bool = False,
|
||||
) -> None:
|
||||
"""Add a provided track to its appropriate array and ensuring it's not a duplicate."""
|
||||
if isinstance(tracks, Tracks):
|
||||
tracks = [*list(tracks), *tracks.chapters, *tracks.attachments]
|
||||
|
||||
duplicates = 0
|
||||
for track in flatten(tracks):
|
||||
if self.exists(by_id=track.id):
|
||||
if not warn_only:
|
||||
raise ValueError(
|
||||
"One or more of the provided Tracks is a duplicate. "
|
||||
"Track IDs must be unique but accurate using static values. The "
|
||||
"value should stay the same no matter when you request the same "
|
||||
"content. Use a value that has relation to the track content "
|
||||
"itself and is static or permanent and not random/RNG data that "
|
||||
"wont change each refresh or conflict in edge cases."
|
||||
)
|
||||
duplicates += 1
|
||||
continue
|
||||
|
||||
if isinstance(track, Video):
|
||||
self.videos.append(track)
|
||||
elif isinstance(track, Audio):
|
||||
self.audio.append(track)
|
||||
elif isinstance(track, Subtitle):
|
||||
self.subtitles.append(track)
|
||||
elif isinstance(track, Chapter):
|
||||
self.chapters.add(track)
|
||||
elif isinstance(track, Attachment):
|
||||
self.attachments.append(track)
|
||||
else:
|
||||
raise ValueError("Track type was not set or is invalid.")
|
||||
|
||||
log = logging.getLogger("Tracks")
|
||||
|
||||
if duplicates:
|
||||
log.warning(f" - Found and skipped {duplicates} duplicate tracks...")
|
||||
|
||||
def sort_videos(self, by_language: Optional[Sequence[Union[str, Language]]] = None) -> None:
|
||||
"""Sort video tracks by bitrate, and optionally language."""
|
||||
if not self.videos:
|
||||
return
|
||||
# bitrate
|
||||
self.videos.sort(key=lambda x: float(x.bitrate or 0.0), reverse=True)
|
||||
# language
|
||||
for language in reversed(by_language or []):
|
||||
if str(language) in ("all", "best"):
|
||||
language = next((x.language for x in self.videos if x.is_original_lang), "")
|
||||
if not language:
|
||||
continue
|
||||
self.videos.sort(key=lambda x: str(x.language))
|
||||
self.videos.sort(key=lambda x: not is_close_match(language, [x.language]))
|
||||
|
||||
def sort_audio(self, by_language: Optional[Sequence[Union[str, Language]]] = None) -> None:
|
||||
"""Sort audio tracks by bitrate, descriptive, and optionally language."""
|
||||
if not self.audio:
|
||||
return
|
||||
# bitrate
|
||||
self.audio.sort(key=lambda x: float(x.bitrate or 0.0), reverse=True)
|
||||
# descriptive
|
||||
self.audio.sort(key=lambda x: str(x.language) if x.descriptive else "")
|
||||
# language
|
||||
for language in reversed(by_language or []):
|
||||
if str(language) in ("all", "best"):
|
||||
language = next((x.language for x in self.audio if x.is_original_lang), "")
|
||||
if not language:
|
||||
continue
|
||||
self.audio.sort(key=lambda x: str(x.language))
|
||||
self.audio.sort(key=lambda x: not is_close_match(language, [x.language]))
|
||||
|
||||
def sort_subtitles(self, by_language: Optional[Sequence[Union[str, Language]]] = None) -> None:
|
||||
"""
|
||||
Sort subtitle tracks by various track attributes to a common P2P standard.
|
||||
You may optionally provide a sequence of languages to prioritize to the top.
|
||||
|
||||
Section Order:
|
||||
- by_language groups prioritized to top, and ascending alphabetically
|
||||
- then rest ascending alphabetically after the prioritized groups
|
||||
(Each section ascending alphabetically, but separated)
|
||||
|
||||
Language Group Order:
|
||||
- Forced
|
||||
- Normal
|
||||
- Hard of Hearing (SDH/CC)
|
||||
(Least to most captions expected in the subtitle)
|
||||
"""
|
||||
if not self.subtitles:
|
||||
return
|
||||
# language groups
|
||||
self.subtitles.sort(key=lambda x: str(x.language))
|
||||
self.subtitles.sort(key=lambda x: x.sdh or x.cc)
|
||||
self.subtitles.sort(key=lambda x: x.forced, reverse=True)
|
||||
# sections
|
||||
for language in reversed(by_language or []):
|
||||
if str(language) == "all":
|
||||
language = next((x.language for x in self.subtitles if x.is_original_lang), "")
|
||||
if not language:
|
||||
continue
|
||||
self.subtitles.sort(key=lambda x: is_close_match(language, [x.language]), reverse=True)
|
||||
|
||||
def select_video(self, x: Callable[[Video], bool]) -> None:
|
||||
self.videos = list(filter(x, self.videos))
|
||||
|
||||
def select_audio(self, x: Callable[[Audio], bool]) -> None:
|
||||
self.audio = list(filter(x, self.audio))
|
||||
|
||||
def select_subtitles(self, x: Callable[[Subtitle], bool]) -> None:
|
||||
self.subtitles = list(filter(x, self.subtitles))
|
||||
|
||||
def by_resolutions(self, resolutions: list[int], per_resolution: int = 0) -> None:
|
||||
# Note: Do not merge these list comprehensions. They must be done separately so the results
|
||||
# from the 16:9 canvas check is only used if there's no exact height resolution match.
|
||||
selected = []
|
||||
for resolution in resolutions:
|
||||
matches = [ # exact matches
|
||||
x for x in self.videos if x.height == resolution
|
||||
]
|
||||
if not matches:
|
||||
matches = [ # 16:9 canvas matches
|
||||
x for x in self.videos if int(x.width * (9 / 16)) == resolution
|
||||
]
|
||||
selected.extend(matches[: per_resolution or None])
|
||||
self.videos = selected
|
||||
|
||||
@staticmethod
|
||||
def by_language(tracks: list[TrackT], languages: list[str], per_language: int = 0) -> list[TrackT]:
|
||||
selected = []
|
||||
for language in languages:
|
||||
selected.extend(
|
||||
[x for x in tracks if closest_supported_match(x.language, [language], LANGUAGE_MAX_DISTANCE)][
|
||||
: per_language or None
|
||||
]
|
||||
)
|
||||
return selected
|
||||
|
||||
def mux(self, title: str, delete: bool = True, progress: Optional[partial] = None) -> tuple[Path, int, list[str]]:
|
||||
"""
|
||||
Multiplex all the Tracks into a Matroska Container file.
|
||||
|
||||
Parameters:
|
||||
title: Set the Matroska Container file title. Usually displayed in players
|
||||
instead of the filename if set.
|
||||
delete: Delete all track files after multiplexing.
|
||||
progress: Update a rich progress bar via `completed=...`. This must be the
|
||||
progress object's update() func, pre-set with task id via functools.partial.
|
||||
"""
|
||||
cl = [
|
||||
"mkvmerge",
|
||||
"--no-date", # remove dates from the output for security
|
||||
]
|
||||
|
||||
if config.muxing.get("set_title", True):
|
||||
cl.extend(["--title", title])
|
||||
|
||||
for i, vt in enumerate(self.videos):
|
||||
if not vt.path or not vt.path.exists():
|
||||
raise ValueError("Video Track must be downloaded before muxing...")
|
||||
events.emit(events.Types.TRACK_MULTIPLEX, track=vt)
|
||||
cl.extend(
|
||||
[
|
||||
"--language",
|
||||
f"0:{vt.language}",
|
||||
"--default-track",
|
||||
f"0:{i == 0}",
|
||||
"--original-flag",
|
||||
f"0:{vt.is_original_lang}",
|
||||
"--compression",
|
||||
"0:none", # disable extra compression
|
||||
"(",
|
||||
str(vt.path),
|
||||
")",
|
||||
]
|
||||
)
|
||||
|
||||
for i, at in enumerate(self.audio):
|
||||
if not at.path or not at.path.exists():
|
||||
raise ValueError("Audio Track must be downloaded before muxing...")
|
||||
events.emit(events.Types.TRACK_MULTIPLEX, track=at)
|
||||
cl.extend(
|
||||
[
|
||||
"--track-name",
|
||||
f"0:{at.get_track_name() or ''}",
|
||||
"--language",
|
||||
f"0:{at.language}",
|
||||
"--default-track",
|
||||
f"0:{at.is_original_lang}",
|
||||
"--visual-impaired-flag",
|
||||
f"0:{at.descriptive}",
|
||||
"--original-flag",
|
||||
f"0:{at.is_original_lang}",
|
||||
"--compression",
|
||||
"0:none", # disable extra compression
|
||||
"(",
|
||||
str(at.path),
|
||||
")",
|
||||
]
|
||||
)
|
||||
|
||||
for st in self.subtitles:
|
||||
if not st.path or not st.path.exists():
|
||||
raise ValueError("Text Track must be downloaded before muxing...")
|
||||
events.emit(events.Types.TRACK_MULTIPLEX, track=st)
|
||||
default = bool(self.audio and is_close_match(st.language, [self.audio[0].language]) and st.forced)
|
||||
cl.extend(
|
||||
[
|
||||
"--track-name",
|
||||
f"0:{st.get_track_name() or ''}",
|
||||
"--language",
|
||||
f"0:{st.language}",
|
||||
"--sub-charset",
|
||||
"0:UTF-8",
|
||||
"--forced-track",
|
||||
f"0:{st.forced}",
|
||||
"--default-track",
|
||||
f"0:{default}",
|
||||
"--hearing-impaired-flag",
|
||||
f"0:{st.sdh}",
|
||||
"--original-flag",
|
||||
f"0:{st.is_original_lang}",
|
||||
"--compression",
|
||||
"0:none", # disable extra compression (probably zlib)
|
||||
"(",
|
||||
str(st.path),
|
||||
")",
|
||||
]
|
||||
)
|
||||
|
||||
if self.chapters:
|
||||
chapters_path = config.directories.temp / config.filenames.chapters.format(
|
||||
title=sanitize_filename(title), random=self.chapters.id
|
||||
)
|
||||
self.chapters.dump(chapters_path, fallback_name=config.chapter_fallback_name)
|
||||
cl.extend(["--chapter-charset", "UTF-8", "--chapters", str(chapters_path)])
|
||||
else:
|
||||
chapters_path = None
|
||||
|
||||
for attachment in self.attachments:
|
||||
if not attachment.path or not attachment.path.exists():
|
||||
raise ValueError("Attachment File was not found...")
|
||||
cl.extend(
|
||||
[
|
||||
"--attachment-description",
|
||||
attachment.description or "",
|
||||
"--attachment-mime-type",
|
||||
attachment.mime_type,
|
||||
"--attachment-name",
|
||||
attachment.name,
|
||||
"--attach-file",
|
||||
str(attachment.path.resolve()),
|
||||
]
|
||||
)
|
||||
|
||||
output_path = (
|
||||
self.videos[0].path.with_suffix(".muxed.mkv")
|
||||
if self.videos
|
||||
else self.audio[0].path.with_suffix(".muxed.mka")
|
||||
if self.audio
|
||||
else self.subtitles[0].path.with_suffix(".muxed.mks")
|
||||
if self.subtitles
|
||||
else chapters_path.with_suffix(".muxed.mkv")
|
||||
if self.chapters
|
||||
else None
|
||||
)
|
||||
if not output_path:
|
||||
raise ValueError("No tracks provided, at least one track must be provided.")
|
||||
|
||||
# let potential failures go to caller, caller should handle
|
||||
try:
|
||||
errors = []
|
||||
p = subprocess.Popen([*cl, "--output", str(output_path), "--gui-mode"], text=True, stdout=subprocess.PIPE)
|
||||
for line in iter(p.stdout.readline, ""):
|
||||
if line.startswith("#GUI#error") or line.startswith("#GUI#warning"):
|
||||
errors.append(line)
|
||||
if "progress" in line:
|
||||
progress(total=100, completed=int(line.strip()[14:-1]))
|
||||
return output_path, p.wait(), errors
|
||||
finally:
|
||||
if chapters_path:
|
||||
chapters_path.unlink()
|
||||
if delete:
|
||||
for track in self:
|
||||
track.delete()
|
||||
for attachment in self.attachments:
|
||||
if attachment.path and attachment.path.exists():
|
||||
attachment.path.unlink()
|
||||
|
||||
|
||||
__all__ = ("Tracks",)
|
||||
451
unshackle/core/tracks/video.py
Normal file
451
unshackle/core/tracks/video.py
Normal file
@@ -0,0 +1,451 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
import subprocess
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
from langcodes import Language
|
||||
|
||||
from unshackle.core import binaries
|
||||
from unshackle.core.config import config
|
||||
from unshackle.core.tracks.subtitle import Subtitle
|
||||
from unshackle.core.tracks.track import Track
|
||||
from unshackle.core.utilities import FPS, get_boxes
|
||||
|
||||
|
||||
class Video(Track):
|
||||
class Codec(str, Enum):
|
||||
AVC = "H.264"
|
||||
HEVC = "H.265"
|
||||
VC1 = "VC-1"
|
||||
VP8 = "VP8"
|
||||
VP9 = "VP9"
|
||||
AV1 = "AV1"
|
||||
|
||||
@property
|
||||
def extension(self) -> str:
|
||||
return self.value.lower().replace(".", "").replace("-", "")
|
||||
|
||||
@staticmethod
|
||||
def from_mime(mime: str) -> Video.Codec:
|
||||
mime = mime.lower().strip().split(".")[0]
|
||||
if mime in (
|
||||
"avc1",
|
||||
"avc2",
|
||||
"avc3",
|
||||
"dva1",
|
||||
"dvav", # Dolby Vision
|
||||
):
|
||||
return Video.Codec.AVC
|
||||
if mime in (
|
||||
"hev1",
|
||||
"hev2",
|
||||
"hev3",
|
||||
"hvc1",
|
||||
"hvc2",
|
||||
"hvc3",
|
||||
"dvh1",
|
||||
"dvhe", # Dolby Vision
|
||||
"lhv1",
|
||||
"lhe1", # Layered
|
||||
):
|
||||
return Video.Codec.HEVC
|
||||
if mime == "vc-1":
|
||||
return Video.Codec.VC1
|
||||
if mime in ("vp08", "vp8"):
|
||||
return Video.Codec.VP8
|
||||
if mime in ("vp09", "vp9"):
|
||||
return Video.Codec.VP9
|
||||
if mime == "av01":
|
||||
return Video.Codec.AV1
|
||||
raise ValueError(f"The MIME '{mime}' is not a supported Video Codec")
|
||||
|
||||
@staticmethod
|
||||
def from_codecs(codecs: str) -> Video.Codec:
|
||||
for codec in codecs.lower().split(","):
|
||||
codec = codec.strip()
|
||||
mime = codec.split(".")[0]
|
||||
try:
|
||||
return Video.Codec.from_mime(mime)
|
||||
except ValueError:
|
||||
pass
|
||||
raise ValueError(f"No MIME types matched any supported Video Codecs in '{codecs}'")
|
||||
|
||||
@staticmethod
|
||||
def from_netflix_profile(profile: str) -> Video.Codec:
|
||||
profile = profile.lower().strip()
|
||||
if profile.startswith(("h264", "playready-h264")):
|
||||
return Video.Codec.AVC
|
||||
if profile.startswith("hevc"):
|
||||
return Video.Codec.HEVC
|
||||
if profile.startswith("vp9"):
|
||||
return Video.Codec.VP9
|
||||
if profile.startswith("av1"):
|
||||
return Video.Codec.AV1
|
||||
raise ValueError(f"The Content Profile '{profile}' is not a supported Video Codec")
|
||||
|
||||
class Range(str, Enum):
|
||||
SDR = "SDR" # No Dynamic Range
|
||||
HLG = "HLG" # https://en.wikipedia.org/wiki/Hybrid_log%E2%80%93gamma
|
||||
HDR10 = "HDR10" # https://en.wikipedia.org/wiki/HDR10
|
||||
HDR10P = "HDR10+" # https://en.wikipedia.org/wiki/HDR10%2B
|
||||
DV = "DV" # https://en.wikipedia.org/wiki/Dolby_Vision
|
||||
|
||||
@staticmethod
|
||||
def from_cicp(primaries: int, transfer: int, matrix: int) -> Video.Range:
|
||||
"""
|
||||
ISO/IEC 23001-8 Coding-independent code points to Video Range.
|
||||
|
||||
Sources:
|
||||
https://www.itu.int/rec/T-REC-H.Sup19-202104-I
|
||||
"""
|
||||
|
||||
class Primaries(Enum):
|
||||
Unspecified = 0
|
||||
BT_709 = 1
|
||||
BT_601_625 = 5
|
||||
BT_601_525 = 6
|
||||
BT_2020_and_2100 = 9
|
||||
SMPTE_ST_2113_and_EG_4321 = 12 # P3D65
|
||||
|
||||
class Transfer(Enum):
|
||||
Unspecified = 0
|
||||
BT_709 = 1
|
||||
BT_601 = 6
|
||||
BT_2020 = 14
|
||||
BT_2100 = 15
|
||||
BT_2100_PQ = 16
|
||||
BT_2100_HLG = 18
|
||||
|
||||
class Matrix(Enum):
|
||||
RGB = 0
|
||||
YCbCr_BT_709 = 1
|
||||
YCbCr_BT_601_625 = 5
|
||||
YCbCr_BT_601_525 = 6
|
||||
YCbCr_BT_2020_and_2100 = 9 # YCbCr BT.2100 shares the same CP
|
||||
ICtCp_BT_2100 = 14
|
||||
|
||||
if transfer == 5:
|
||||
# While not part of any standard, it is typically used as a PAL variant of Transfer.BT_601=6.
|
||||
# i.e. where Transfer 6 would be for BT.601-NTSC and Transfer 5 would be for BT.601-PAL.
|
||||
# The codebase is currently agnostic to either, so a manual conversion to 6 is done.
|
||||
transfer = 6
|
||||
|
||||
primaries = Primaries(primaries)
|
||||
transfer = Transfer(transfer)
|
||||
matrix = Matrix(matrix)
|
||||
|
||||
# primaries and matrix does not strictly correlate to a range
|
||||
|
||||
if (primaries, transfer, matrix) == (0, 0, 0):
|
||||
return Video.Range.SDR
|
||||
elif primaries in (Primaries.BT_601_625, Primaries.BT_601_525):
|
||||
return Video.Range.SDR
|
||||
elif transfer == Transfer.BT_2100_PQ:
|
||||
return Video.Range.HDR10
|
||||
elif transfer == Transfer.BT_2100_HLG:
|
||||
return Video.Range.HLG
|
||||
else:
|
||||
return Video.Range.SDR
|
||||
|
||||
@staticmethod
|
||||
def from_m3u_range_tag(tag: str) -> Optional[Video.Range]:
|
||||
tag = (tag or "").upper().replace('"', "").strip()
|
||||
if not tag:
|
||||
return None
|
||||
if tag == "SDR":
|
||||
return Video.Range.SDR
|
||||
elif tag == "PQ":
|
||||
return Video.Range.HDR10 # technically could be any PQ-transfer range
|
||||
elif tag == "HLG":
|
||||
return Video.Range.HLG
|
||||
# for some reason there's no Dolby Vision info tag
|
||||
raise ValueError(f"The M3U Range Tag '{tag}' is not a supported Video Range")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*args: Any,
|
||||
codec: Optional[Video.Codec] = None,
|
||||
range_: Optional[Video.Range] = None,
|
||||
bitrate: Optional[Union[str, int, float]] = None,
|
||||
width: Optional[int] = None,
|
||||
height: Optional[int] = None,
|
||||
fps: Optional[Union[str, int, float]] = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""
|
||||
Create a new Video track object.
|
||||
|
||||
Parameters:
|
||||
codec: A Video.Codec enum representing the video codec.
|
||||
If not specified, MediaInfo will be used to retrieve the codec
|
||||
once the track has been downloaded.
|
||||
range_: A Video.Range enum representing the video color range.
|
||||
Defaults to SDR if not specified.
|
||||
bitrate: A number or float representing the average bandwidth in bytes/s.
|
||||
Float values are rounded up to the nearest integer.
|
||||
width: The horizontal resolution of the video.
|
||||
height: The vertical resolution of the video.
|
||||
fps: A number, float, or string representing the frames/s of the video.
|
||||
Strings may represent numbers, floats, or a fraction (num/den).
|
||||
All strings will be cast to either a number or float.
|
||||
|
||||
Note: If codec, bitrate, width, height, or fps is not specified some checks
|
||||
may be skipped or assume a value. Specifying as much information as possible
|
||||
is highly recommended.
|
||||
"""
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if not isinstance(codec, (Video.Codec, type(None))):
|
||||
raise TypeError(f"Expected codec to be a {Video.Codec}, not {codec!r}")
|
||||
if not isinstance(range_, (Video.Range, type(None))):
|
||||
raise TypeError(f"Expected range_ to be a {Video.Range}, not {range_!r}")
|
||||
if not isinstance(bitrate, (str, int, float, type(None))):
|
||||
raise TypeError(f"Expected bitrate to be a {str}, {int}, or {float}, not {bitrate!r}")
|
||||
if not isinstance(width, (int, str, type(None))):
|
||||
raise TypeError(f"Expected width to be a {int}, not {width!r}")
|
||||
if not isinstance(height, (int, str, type(None))):
|
||||
raise TypeError(f"Expected height to be a {int}, not {height!r}")
|
||||
if not isinstance(fps, (str, int, float, type(None))):
|
||||
raise TypeError(f"Expected fps to be a {str}, {int}, or {float}, not {fps!r}")
|
||||
|
||||
self.codec = codec
|
||||
self.range = range_ or Video.Range.SDR
|
||||
|
||||
try:
|
||||
self.bitrate = int(math.ceil(float(bitrate))) if bitrate else None
|
||||
except (ValueError, TypeError) as e:
|
||||
raise ValueError(f"Expected bitrate to be a number or float, {e}")
|
||||
|
||||
try:
|
||||
self.width = int(width or 0) or None
|
||||
except ValueError as e:
|
||||
raise ValueError(f"Expected width to be a number, not {width!r}, {e}")
|
||||
|
||||
try:
|
||||
self.height = int(height or 0) or None
|
||||
except ValueError as e:
|
||||
raise ValueError(f"Expected height to be a number, not {height!r}, {e}")
|
||||
|
||||
try:
|
||||
self.fps = (FPS.parse(str(fps)) or None) if fps else None
|
||||
except Exception as e:
|
||||
raise ValueError("Expected fps to be a number, float, or a string as numerator/denominator form, " + str(e))
|
||||
|
||||
def __str__(self) -> str:
|
||||
return " | ".join(
|
||||
filter(
|
||||
bool,
|
||||
[
|
||||
"VID",
|
||||
"[" + (", ".join(filter(bool, [self.codec.value if self.codec else None, self.range.name]))) + "]",
|
||||
str(self.language),
|
||||
", ".join(
|
||||
filter(
|
||||
bool,
|
||||
[
|
||||
" @ ".join(
|
||||
filter(
|
||||
bool,
|
||||
[
|
||||
f"{self.width}x{self.height}" if self.width and self.height else None,
|
||||
f"{self.bitrate // 1000} kb/s" if self.bitrate else None,
|
||||
],
|
||||
)
|
||||
),
|
||||
f"{self.fps:.3f} FPS" if self.fps else None,
|
||||
],
|
||||
)
|
||||
),
|
||||
self.edition,
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
def change_color_range(self, range_: int) -> None:
|
||||
"""Change the Video's Color Range to Limited (0) or Full (1)."""
|
||||
if not self.path or not self.path.exists():
|
||||
raise ValueError("Cannot change the color range flag on a Video that has not been downloaded.")
|
||||
if not self.codec:
|
||||
raise ValueError("Cannot change the color range flag on a Video that has no codec specified.")
|
||||
if self.codec not in (Video.Codec.AVC, Video.Codec.HEVC):
|
||||
raise NotImplementedError(
|
||||
"Cannot change the color range flag on this Video as "
|
||||
f"it's codec, {self.codec.value}, is not yet supported."
|
||||
)
|
||||
|
||||
if not binaries.FFMPEG:
|
||||
raise EnvironmentError('FFmpeg executable "ffmpeg" was not found but is required for this call.')
|
||||
|
||||
filter_key = {Video.Codec.AVC: "h264_metadata", Video.Codec.HEVC: "hevc_metadata"}[self.codec]
|
||||
|
||||
original_path = self.path
|
||||
output_path = original_path.with_stem(f"{original_path.stem}_{['limited', 'full'][range_]}_range")
|
||||
|
||||
subprocess.run(
|
||||
[
|
||||
binaries.FFMPEG,
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"panic",
|
||||
"-i",
|
||||
original_path,
|
||||
"-codec",
|
||||
"copy",
|
||||
"-bsf:v",
|
||||
f"{filter_key}=video_full_range_flag={range_}",
|
||||
str(output_path),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
self.path = output_path
|
||||
original_path.unlink()
|
||||
|
||||
def ccextractor(
|
||||
self, track_id: Any, out_path: Union[Path, str], language: Language, original: bool = False
|
||||
) -> Optional[Subtitle]:
|
||||
"""Return a TextTrack object representing CC track extracted by CCExtractor."""
|
||||
if not self.path:
|
||||
raise ValueError("You must download the track first.")
|
||||
|
||||
if not binaries.CCExtractor:
|
||||
raise EnvironmentError("ccextractor executable was not found.")
|
||||
|
||||
# ccextractor often fails in weird ways unless we repack
|
||||
self.repackage()
|
||||
|
||||
out_path = Path(out_path)
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
[binaries.CCExtractor, "-trim", "-nobom", "-noru", "-ru1", "-o", out_path, self.path],
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
out_path.unlink(missing_ok=True)
|
||||
if not e.returncode == 10: # No captions found
|
||||
raise
|
||||
|
||||
if out_path.exists():
|
||||
cc_track = Subtitle(
|
||||
id_=track_id,
|
||||
url="", # doesn't need to be downloaded
|
||||
codec=Subtitle.Codec.SubRip,
|
||||
language=language,
|
||||
is_original_lang=original,
|
||||
cc=True,
|
||||
)
|
||||
cc_track.path = out_path
|
||||
return cc_track
|
||||
|
||||
return None
|
||||
|
||||
def extract_c608(self) -> list[Subtitle]:
|
||||
"""
|
||||
Extract Apple-Style c608 box (CEA-608) subtitle using ccextractor.
|
||||
|
||||
This isn't much more than a wrapper to the track.ccextractor function.
|
||||
All this does, is actually check if a c608 box exists and only if so
|
||||
does it actually call ccextractor.
|
||||
|
||||
Even though there is a possibility of more than one c608 box, only one
|
||||
can actually be extracted. Not only that but it's very possible this
|
||||
needs to be done before any decryption as the decryption may destroy
|
||||
some of the metadata.
|
||||
|
||||
TODO: Need a test file with more than one c608 box to add support for
|
||||
more than one CEA-608 extraction.
|
||||
"""
|
||||
if not self.path:
|
||||
raise ValueError("You must download the track first.")
|
||||
with self.path.open("rb") as f:
|
||||
# assuming 20KB is enough to contain the c608 box.
|
||||
# ffprobe will fail, so a c608 box check must be done.
|
||||
c608_count = len(list(get_boxes(f.read(20000), b"c608")))
|
||||
if c608_count > 0:
|
||||
# TODO: Figure out the real language, it might be different
|
||||
# CEA-608 boxes doesnt seem to carry language information :(
|
||||
# TODO: Figure out if the CC language is original lang or not.
|
||||
# Will need to figure out above first to do so.
|
||||
track_id = f"ccextractor-{self.id}"
|
||||
cc_lang = self.language
|
||||
cc_track = self.ccextractor(
|
||||
track_id=track_id,
|
||||
out_path=config.directories.temp / config.filenames.subtitle.format(id=track_id, language=cc_lang),
|
||||
language=cc_lang,
|
||||
original=False,
|
||||
)
|
||||
if not cc_track:
|
||||
return []
|
||||
return [cc_track]
|
||||
return []
|
||||
|
||||
def remove_eia_cc(self) -> bool:
|
||||
"""
|
||||
Remove EIA-CC data from Bitstream while keeping SEI data.
|
||||
|
||||
This works by removing all NAL Unit's with the Type of 6 from the bistream
|
||||
and then re-adding SEI data (effectively a new NAL Unit with just the SEI data).
|
||||
Only bitstreams with x264 encoding information is currently supported due to the
|
||||
obscurity on the MDAT mp4 box structure. Therefore, we need to use hacky regex.
|
||||
"""
|
||||
if not self.path or not self.path.exists():
|
||||
raise ValueError("Cannot clean a Track that has not been downloaded.")
|
||||
|
||||
if not binaries.FFMPEG:
|
||||
raise EnvironmentError('FFmpeg executable "ffmpeg" was not found but is required for this call.')
|
||||
|
||||
log = logging.getLogger("x264-clean")
|
||||
log.info("Removing EIA-CC from Video Track with FFMPEG")
|
||||
|
||||
with open(self.path, "rb") as f:
|
||||
file = f.read(60000)
|
||||
|
||||
x264 = re.search(rb"(.{16})(x264)", file)
|
||||
if not x264:
|
||||
log.info(" - No x264 encode settings were found, unsupported...")
|
||||
return False
|
||||
|
||||
uuid = x264.group(1).hex()
|
||||
i = file.index(b"x264")
|
||||
encoding_settings = file[i : i + file[i:].index(b"\x00")].replace(b":", rb"\\:").replace(b",", rb"\,").decode()
|
||||
|
||||
original_path = self.path
|
||||
cleaned_path = original_path.with_suffix(f".cleaned{original_path.suffix}")
|
||||
subprocess.run(
|
||||
[
|
||||
binaries.FFMPEG,
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"panic",
|
||||
"-i",
|
||||
original_path,
|
||||
"-map_metadata",
|
||||
"-1",
|
||||
"-fflags",
|
||||
"bitexact",
|
||||
"-bsf:v",
|
||||
f"filter_units=remove_types=6,h264_metadata=sei_user_data={uuid}+{encoding_settings}",
|
||||
"-codec",
|
||||
"copy",
|
||||
str(cleaned_path),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
log.info(" + Removed")
|
||||
|
||||
self.path = cleaned_path
|
||||
original_path.unlink()
|
||||
|
||||
return True
|
||||
|
||||
|
||||
__all__ = ("Video",)
|
||||
Reference in New Issue
Block a user