mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-14 21:15:06 +00:00
1197 lines
49 KiB
Python
1197 lines
49 KiB
Python
import base64
|
|
import html
|
|
import logging
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
from collections import defaultdict
|
|
from copy import copy
|
|
from dataclasses import dataclass
|
|
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 requests import Response, Session
|
|
from requests.adapters import HTTPAdapter, Retry
|
|
|
|
from unshackle.core import binaries
|
|
from unshackle.core.cdm.detect import is_playready_cdm, is_widevine_cdm
|
|
from unshackle.core.config import config
|
|
from unshackle.core.constants import DOWNLOAD_CANCELLED, DOWNLOAD_LICENCE_ONLY, DownloadCancelled
|
|
from unshackle.core.downloaders import requests
|
|
from unshackle.core.drm import DRM_T, ClearKeyCENC, PlayReady, Widevine
|
|
from unshackle.core.events import events
|
|
from unshackle.core.session import RnetSession
|
|
from unshackle.core.tracks import resume
|
|
from unshackle.core.utilities import get_boxes, log_event, try_ensure_utf8
|
|
from unshackle.core.utils.subprocess import ffprobe
|
|
|
|
DRM_PREFERENCE_TYPES: dict[str, Union[type[Widevine], type[PlayReady]]] = {
|
|
"wv": Widevine,
|
|
"widevine": Widevine,
|
|
"pr": PlayReady,
|
|
"playready": PlayReady,
|
|
}
|
|
|
|
|
|
def direct_session(session: Union[Session, "RnetSession"]) -> Session:
|
|
"""requests.Session with copied headers/cookies and no proxy."""
|
|
new = Session()
|
|
headers = getattr(session, "headers", None)
|
|
if headers is not None:
|
|
try:
|
|
new.headers.update(dict(headers))
|
|
except Exception:
|
|
pass
|
|
cookies = getattr(session, "cookies", None)
|
|
if cookies is not None:
|
|
jar = getattr(cookies, "jar", None)
|
|
try:
|
|
new.cookies.update(jar if jar is not None else cookies)
|
|
except Exception:
|
|
pass
|
|
new.mount(
|
|
"https://",
|
|
HTTPAdapter(
|
|
max_retries=Retry(total=5, backoff_factor=0.2, status_forcelist=[429, 500, 502, 503, 504]),
|
|
pool_maxsize=64,
|
|
pool_block=True,
|
|
),
|
|
)
|
|
new.mount("http://", new.adapters["https://"])
|
|
return new
|
|
|
|
|
|
def read_top_level_box(path: Path, box_type: bytes) -> Optional[bytes]:
|
|
"""Read a single top-level box from an MP4 file.
|
|
|
|
This function walks only the box headers, so the wanted box is the only payload it reads into memory.
|
|
Returns None if the file has no such top-level box, or if a box header is truncated or declares an
|
|
impossible size.
|
|
"""
|
|
file_size = path.stat().st_size
|
|
with path.open("rb") as f:
|
|
start = 0
|
|
while start + 8 <= file_size:
|
|
header = f.read(8)
|
|
if len(header) < 8:
|
|
return None
|
|
size = int.from_bytes(header[:4], "big")
|
|
if size == 1:
|
|
large = f.read(8)
|
|
if len(large) < 8:
|
|
return None
|
|
size = int.from_bytes(large, "big")
|
|
elif size == 0:
|
|
size = file_size - start
|
|
if size < 8:
|
|
return None
|
|
if header[4:8] == box_type:
|
|
f.seek(start)
|
|
return f.read(size)
|
|
start += size
|
|
f.seek(start)
|
|
return None
|
|
|
|
|
|
def iter_top_level_boxes(path: Path) -> Iterable[tuple[bytes, int, int]]:
|
|
"""Yield (box_type, offset, size) for each top-level box, stopping at the first bad header."""
|
|
file_size = path.stat().st_size
|
|
with path.open("rb") as f:
|
|
offset = 0
|
|
while offset + 8 <= file_size:
|
|
f.seek(offset)
|
|
header = f.read(8)
|
|
if len(header) < 8:
|
|
return
|
|
size = int.from_bytes(header[:4], "big")
|
|
if size == 1:
|
|
large = f.read(8)
|
|
if len(large) < 8:
|
|
return
|
|
size = int.from_bytes(large, "big")
|
|
elif size == 0:
|
|
size = file_size - offset
|
|
if size < 8:
|
|
return
|
|
yield header[4:8], offset, size
|
|
offset += size
|
|
|
|
|
|
def find_child_box(buf: bytes, box_type: bytes) -> Optional[bytes]:
|
|
"""Return the first box of the given type nested anywhere in buf, header included."""
|
|
i = buf.find(box_type, 4)
|
|
while i != -1:
|
|
size = int.from_bytes(buf[i - 4 : i], "big")
|
|
if 8 <= size <= len(buf) - (i - 4):
|
|
return buf[i - 4 : i - 4 + size]
|
|
i = buf.find(box_type, i + 1)
|
|
return None
|
|
|
|
|
|
def strip_duplicate_init_boxes(src: Path, dst: Path) -> int:
|
|
"""Write src to dst without its repeated ftyp/moov pairs, returning how many it dropped.
|
|
|
|
An HLS track whose playlist carries a discontinuity gets a fresh EXT-X-MAP init for each
|
|
period, so the merged file holds one ftyp+moov per period. That plays, and mkvmerge takes
|
|
it, but MP4Box rejects the whole file with "Duplicate 'ftyp' detected!".
|
|
|
|
A packager stamps each init with the fetch time, so copies that describe the same format
|
|
still differ byte for byte. Only the stsd decides how samples decode, so this compares only
|
|
the stsd: a later moov with a different stsd is a real format change, and dropping it would
|
|
corrupt the output. In that case, and when there is no duplicate at all, this writes nothing,
|
|
returns 0, and leaves the caller with the original file.
|
|
"""
|
|
boxes = list(iter_top_level_boxes(src))
|
|
duplicates = [(t, o, s) for t, o, s in boxes if t in (b"ftyp", b"moov")]
|
|
if len(duplicates) <= 2:
|
|
return 0
|
|
|
|
with src.open("rb") as f:
|
|
stsds = set()
|
|
for box_type, offset, size in duplicates:
|
|
if box_type != b"moov":
|
|
continue
|
|
f.seek(offset)
|
|
stsd = find_child_box(f.read(size), b"stsd")
|
|
if stsd is None:
|
|
return 0
|
|
stsds.add(stsd)
|
|
if len(stsds) > 1:
|
|
return 0
|
|
|
|
dropped = 0
|
|
seen: set[bytes] = set()
|
|
with src.open("rb") as f, dst.open("wb") as out:
|
|
for box_type, offset, size in boxes:
|
|
if box_type in (b"ftyp", b"moov"):
|
|
if box_type in seen:
|
|
dropped += 1
|
|
continue
|
|
seen.add(box_type)
|
|
f.seek(offset)
|
|
remaining = size
|
|
while remaining:
|
|
chunk = f.read(min(remaining, 8 * 1024 * 1024))
|
|
if not chunk:
|
|
break
|
|
out.write(chunk)
|
|
remaining -= len(chunk)
|
|
return dropped
|
|
|
|
|
|
def has_dts_uhd_sample_entry(path: Path) -> bool:
|
|
"""True if the MP4 holds a DTS-UHD (DTS:X Profile 2) audio sample entry.
|
|
|
|
Matroska has no CodecID for DTS-UHD, so mkvmerge stores it as an A_QUICKTIME
|
|
passthrough and every reader reports the container fallback instead of the codec. A
|
|
file this returns True for has to go into MP4 to keep its codec readable.
|
|
|
|
Only "dtsx"/"dtsy" mean DTS-UHD. DTS:X Profile 1 rides inside DTS-HD Master Audio as
|
|
"dtsc"/"dtse"/"dtsh"/"dtsl", which Matroska maps to A_DTS and stores properly, so it
|
|
must not match here. Reading the entry rather than trusting the manifest keeps a
|
|
mislabelled codec from moving a Profile 1 track out of Matroska.
|
|
|
|
A 4CC scan scoped to the moov box, not a structural parse; the scoping avoids chance
|
|
byte collisions in mdat. Any read error -> False.
|
|
"""
|
|
try:
|
|
moov = read_top_level_box(path, b"moov")
|
|
except OSError:
|
|
return False
|
|
if not moov:
|
|
return False
|
|
for tok in (b"dtsx", b"dtsy"):
|
|
i = moov.find(tok, 4)
|
|
while i != -1:
|
|
size = int.from_bytes(moov[i - 4 : i], "big")
|
|
if 16 <= size <= len(moov) - (i - 4):
|
|
return True
|
|
i = moov.find(tok, i + 1)
|
|
return False
|
|
|
|
|
|
def has_encrypted_sample_entry(path: Path) -> bool:
|
|
"""True if the MP4's moov still carries an encrypted sample entry (encv/enca).
|
|
|
|
A faithful decrypt rewrites encv/enca back to the real codec 4CC through frma, so a
|
|
survivor means the decrypt tool skipped/failed the track. This is a 4CC scan
|
|
scoped to the moov box (where sample entries live), not a structural parse.
|
|
The scoping avoids chance byte collisions in mdat. Any read error -> False.
|
|
"""
|
|
try:
|
|
moov = read_top_level_box(path, b"moov")
|
|
except OSError:
|
|
return False
|
|
if not moov:
|
|
return False
|
|
for tok in (b"encv", b"enca"):
|
|
i = moov.find(tok, 4)
|
|
while i != -1:
|
|
# require a plausible sample-entry box size right before the 4CC
|
|
size = int.from_bytes(moov[i - 4 : i], "big")
|
|
if 16 <= size <= len(moov) - (i - 4):
|
|
return True
|
|
i = moov.find(tok, i + 1)
|
|
return False
|
|
|
|
|
|
def senc_protects_samples(buf: bytes, body: int, end: int) -> bool:
|
|
"""True if a senc/PIFF-uuid box describes protected samples.
|
|
|
|
An empty senc (sample_count 0) sits on a genuinely clear fragment, so treating it as
|
|
a skipped one would raise on a healthy file. Field order follows
|
|
read_per_sample_iv_size.
|
|
"""
|
|
flags = int.from_bytes(buf[body + 1 : body + 4], "big")
|
|
pos = body + 4 + (20 if flags & 0x1 else 0) # PIFF override: AlgorithmID(3)+IV_size(1)+KID(16)
|
|
if pos + 4 > end:
|
|
return False
|
|
return int.from_bytes(buf[pos : pos + 4], "big") > 0
|
|
|
|
|
|
def moof_still_encrypted(moof: bytes) -> bool:
|
|
# senc only: a decrypter detaches just the atom it consumed, so a PIFF uuid can
|
|
# outlive a good decrypt. ISM arrives as senc via piff_senc_to_cenc.
|
|
# structural walk: a 4CC byte scan collides with trun payload
|
|
from unshackle.core.manifests.ism_init import iter_boxes
|
|
|
|
body = 16 if int.from_bytes(moof[:4], "big") == 1 else 8
|
|
for box_type, _, traf_body, traf_end in iter_boxes(moof, body, len(moof)):
|
|
if box_type != b"traf":
|
|
continue
|
|
for child, _usertype, child_body, child_end in iter_boxes(moof, traf_body, traf_end):
|
|
if child == b"senc" and senc_protects_samples(moof, child_body, child_end):
|
|
return True
|
|
return False
|
|
|
|
|
|
def assert_fragments_decrypted(path: Path) -> None:
|
|
"""Raise if any fragment survived decryption still carrying a sample-encryption box.
|
|
|
|
mp4decrypt strips senc from every fragment it processes and leaves it on the ones it
|
|
skips, so a survivor marks a silent skip. One cause is a tfhd.sample_description_index
|
|
dangling past the stsd entry count, which mp4decrypt reports as success (exit 0, no
|
|
stderr) while leaving the payload as ciphertext. Unlike has_encrypted_sample_entry
|
|
(moov-scoped) this is fragment-scoped. The moov comes out clean in that failure.
|
|
|
|
Only a standard senc counts as evidence. Some DASH titles ship a PIFF uuid beside it,
|
|
and a decrypter detaches only the atom it consumed, so that uuid outlives a good
|
|
decrypt. Counting it would condemn a healthy file.
|
|
|
|
The guarantee runs in one direction: a survivor proves a skip, while a clean pass is
|
|
only as good as the decrypter's own behaviour. shaka-packager (the default
|
|
`decryption` backend) remuxes into a single fragment and drops senc whether or not it
|
|
decrypted, so this cannot fire on shaka output. The walk does not use a buffer and reads
|
|
only box headers. It seeks over mdat and never reads it. A malformed box size aborts the
|
|
walk with a warning and returns normally, so a file this function cannot parse is
|
|
never escalated into a raise. The logged warning is the only signal of that. A caller
|
|
sees the same silent return it gets from a verified clean file.
|
|
"""
|
|
surviving = 0
|
|
total = 0
|
|
first_offset = None
|
|
try:
|
|
file_size = path.stat().st_size
|
|
with path.open("rb", buffering=0) as f:
|
|
start = 0
|
|
while start + 8 <= file_size:
|
|
f.seek(start)
|
|
header = f.read(8)
|
|
if len(header) < 8:
|
|
break
|
|
size = int.from_bytes(header[:4], "big")
|
|
box_header = 8
|
|
if size == 1:
|
|
large = f.read(8)
|
|
if len(large) < 8:
|
|
break
|
|
size = int.from_bytes(large, "big")
|
|
box_header = 16
|
|
elif size == 0:
|
|
size = file_size - start
|
|
if size < box_header or start + size > file_size:
|
|
logging.getLogger("track").warning(
|
|
f"{path.name}: malformed box size at offset {start}; cannot verify the "
|
|
"remaining fragments were decrypted."
|
|
)
|
|
break
|
|
if header[4:8] == b"moof":
|
|
total += 1
|
|
f.seek(start)
|
|
if moof_still_encrypted(f.read(size)):
|
|
surviving += 1
|
|
if first_offset is None:
|
|
first_offset = start
|
|
start += size
|
|
except Exception as e:
|
|
logging.getLogger("track").warning(f"{path.name}: fragment decryption check did not complete ({e}).")
|
|
return
|
|
if surviving:
|
|
log_event(
|
|
"decrypt_fragments_still_encrypted",
|
|
level="ERROR",
|
|
message=f"{surviving}/{total} fragments still encrypted after decryption",
|
|
file=path.name,
|
|
surviving=surviving,
|
|
total=total,
|
|
first_offset=first_offset,
|
|
)
|
|
raise ValueError(
|
|
f"{path.name}: {surviving}/{total} fragment(s) still encrypted after decryption (first at "
|
|
f"byte {first_offset}). The decrypt tool skipped them silently, so check "
|
|
f"tfhd.sample_description_index against the stsd entry count in the init segment."
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class DownloadContext:
|
|
"""Shared arguments passed to each manifest's ``download_track``."""
|
|
|
|
save_path: Path
|
|
save_dir: Path
|
|
progress: partial
|
|
session: Optional[Union[Session, "RnetSession"]] = None
|
|
proxy: Optional[str] = None
|
|
max_workers: Optional[int] = None
|
|
adaptive_workers: bool = False
|
|
download_processes: int = 1
|
|
license_widevine: Optional[Callable] = None
|
|
cdm: Optional[object] = None
|
|
|
|
def ensure_session(self) -> Union[Session, "RnetSession"]:
|
|
"""Return the HTTP session, or a new ``Session`` if none was set."""
|
|
session = self.session
|
|
if not session:
|
|
session = Session()
|
|
elif not isinstance(session, (Session, RnetSession)):
|
|
raise TypeError(f"Expected session to be a {Session} or {RnetSession}, not {session!r}")
|
|
return session
|
|
|
|
|
|
class Track:
|
|
class Descriptor(Enum):
|
|
URL = 1
|
|
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,
|
|
session: Optional[Union[Session, "RnetSession"]] = None,
|
|
downloader: Optional[Callable] = None,
|
|
downloader_args: Optional[dict] = None,
|
|
from_file: Optional[Path] = 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, list, type(None))):
|
|
raise TypeError(f"Expected edition to be a {str}, {list}, or None, 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(downloader_args, (dict, type(None))):
|
|
raise TypeError(f"Expected downloader_args to be a {dict}, not {type(downloader_args)}")
|
|
if not isinstance(from_file, (Path, type(None))):
|
|
raise TypeError(f"Expected from_file to be a {Path}, not {type(from_file)}")
|
|
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 = requests
|
|
|
|
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._drm_preference: Optional[str] = None
|
|
self.edition: list[str] = [edition] if isinstance(edition, str) else (edition or [])
|
|
self.session = session
|
|
self.downloader = downloader
|
|
self.downloader_args = downloader_args
|
|
self.from_file = from_file
|
|
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
|
|
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.
|
|
|
|
This uses a defaultdict 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 note that these values can 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
|
|
|
|
@property
|
|
def drm_preference(self) -> Optional[str]:
|
|
"""
|
|
DRM system this track must license with, one of the names in DRM_PREFERENCE_TYPES.
|
|
|
|
None (the default) lets the loaded CDM choose. Set it when the manifest advertises more
|
|
than one DRM system but only one of them licenses this track.
|
|
"""
|
|
return self._drm_preference
|
|
|
|
@drm_preference.setter
|
|
def drm_preference(self, value: Optional[str]) -> None:
|
|
if value is None:
|
|
self._drm_preference = None
|
|
return
|
|
if not isinstance(value, str) or value.lower() not in DRM_PREFERENCE_TYPES:
|
|
raise ValueError(
|
|
f"Expected drm_preference to be one of {sorted(DRM_PREFERENCE_TYPES)} or None, not {value!r}"
|
|
)
|
|
self._drm_preference = value.lower()
|
|
|
|
def prefers_playready(self, cdm: Optional[object]) -> bool:
|
|
"""Whether to try PlayReady before Widevine. The track's preference wins over the loaded CDM."""
|
|
if self._drm_preference:
|
|
return DRM_PREFERENCE_TYPES[self._drm_preference] is PlayReady
|
|
return is_playready_cdm(cdm)
|
|
|
|
def download(
|
|
self,
|
|
session: Union[Session, "RnetSession"],
|
|
prepare_drm: partial,
|
|
max_workers: Optional[int] = None,
|
|
progress: Optional[partial] = None,
|
|
*,
|
|
cdm: Optional[object] = None,
|
|
no_proxy_download: bool = False,
|
|
adaptive_workers: bool = False,
|
|
download_processes: int = 1,
|
|
):
|
|
"""Download and optionally Decrypt this Track.
|
|
|
|
For a URL-descriptor Video or Audio track with no `drm` set, unshackle probes the DRM from the
|
|
track's init data and stores it on the track, so a service need not declare `drm` itself.
|
|
"""
|
|
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)
|
|
|
|
dl_session = session
|
|
if no_proxy_download and proxy:
|
|
dl_session = direct_session(session)
|
|
proxy = 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.descriptor != self.Descriptor.URL:
|
|
save_dir = save_path.with_name(save_path.name + "_segments")
|
|
else:
|
|
save_dir = save_path.parent
|
|
|
|
keep_segments = config.continue_downloads and self.descriptor != self.Descriptor.URL
|
|
|
|
def cleanup() -> None:
|
|
save_path.unlink(missing_ok=True)
|
|
if save_dir.name.endswith("_segments"):
|
|
if keep_segments:
|
|
if save_dir.exists():
|
|
for partial in save_dir.rglob("*.!dev"):
|
|
partial.unlink(missing_ok=True)
|
|
else:
|
|
if save_dir.exists():
|
|
shutil.rmtree(save_dir)
|
|
resume.clear_sidecar(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)
|
|
|
|
# completed segments are reusable once the parser proves the segmentation
|
|
# unchanged (resume sidecar); partial .!dev files never survive a run boundary
|
|
cleanup()
|
|
|
|
try:
|
|
manifest_parsers = {
|
|
self.Descriptor.HLS: HLS,
|
|
self.Descriptor.DASH: DASH,
|
|
self.Descriptor.ISM: ISM,
|
|
}
|
|
if self.descriptor in manifest_parsers:
|
|
ctx = DownloadContext(
|
|
save_path=save_path,
|
|
save_dir=save_dir,
|
|
progress=progress,
|
|
session=dl_session,
|
|
proxy=proxy,
|
|
max_workers=max_workers,
|
|
adaptive_workers=adaptive_workers,
|
|
download_processes=download_processes,
|
|
license_widevine=prepare_drm,
|
|
cdm=cdm,
|
|
)
|
|
manifest_parsers[self.descriptor].download_track(track=self, ctx=ctx)
|
|
elif self.descriptor == self.Descriptor.URL:
|
|
try:
|
|
if not self.drm and track_type in ("Video", "Audio"):
|
|
if self.prefers_playready(cdm):
|
|
try:
|
|
self.drm = [PlayReady.from_track(self, session)]
|
|
except PlayReady.Exceptions.PSSHNotFound:
|
|
try:
|
|
self.drm = [Widevine.from_track(self, session)]
|
|
except Widevine.Exceptions.PSSHNotFound:
|
|
log.warning(
|
|
"No PlayReady or Widevine PSSH was found for this track, is it DRM free?"
|
|
)
|
|
else:
|
|
try:
|
|
self.drm = [Widevine.from_track(self, session)]
|
|
except Widevine.Exceptions.PSSHNotFound:
|
|
try:
|
|
self.drm = [PlayReady.from_track(self, session)]
|
|
except PlayReady.Exceptions.PSSHNotFound:
|
|
log.warning(
|
|
"No Widevine or PlayReady 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):
|
|
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):
|
|
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")
|
|
elif isinstance(drm, ClearKeyCENC):
|
|
if not prepare_drm:
|
|
raise ValueError("prepare_drm func must be supplied to use ClearKey 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")
|
|
else:
|
|
for status_update in self.downloader(
|
|
urls=self.url,
|
|
output_dir=save_path.parent,
|
|
filename=save_path.name,
|
|
headers=dl_session.headers,
|
|
cookies=dl_session.cookies,
|
|
proxy=proxy,
|
|
max_workers=max_workers,
|
|
session=dl_session,
|
|
adaptive=adaptive_workers,
|
|
processes=download_processes,
|
|
):
|
|
file_downloaded = status_update.get("file_downloaded")
|
|
if not file_downloaded:
|
|
downloaded = status_update.get("downloaded")
|
|
if downloaded and downloaded.endswith("/s"):
|
|
status_update["downloaded"] = f"URL {downloaded}"
|
|
progress(**status_update)
|
|
|
|
self.path = save_path
|
|
events.emit(events.Types.TRACK_DOWNLOADED, track=self)
|
|
|
|
if drm:
|
|
progress(downloaded="Decrypting", completed=0, total=None)
|
|
drm.decrypt(save_path)
|
|
assert_fragments_decrypted(save_path)
|
|
self.drm = None
|
|
events.emit(events.Types.TRACK_DECRYPTED, track=self, drm=drm, segment=None)
|
|
progress(downloaded="Decrypted", completed=100, total=100)
|
|
# residual encv/enca => decrypt tool skipped/failed; force a repack so the
|
|
# muxer isn't first-FourCC-locked to a generic (encrypted) codec. OR-in only.
|
|
if has_encrypted_sample_entry(save_path):
|
|
self.needs_repack = True
|
|
|
|
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 DownloadCancelled:
|
|
raise
|
|
except Exception:
|
|
DOWNLOAD_CANCELLED.set()
|
|
progress(downloaded="[red]FAILED")
|
|
raise
|
|
except DownloadCancelled:
|
|
try:
|
|
cleanup()
|
|
except OSError:
|
|
pass
|
|
progress(downloaded="[yellow]SKIPPED")
|
|
return
|
|
except (Exception, KeyboardInterrupt):
|
|
if not DOWNLOAD_LICENCE_ONLY.is_set():
|
|
cleanup()
|
|
raise
|
|
|
|
if DOWNLOAD_CANCELLED.is_set():
|
|
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 to_dict(self) -> dict[str, Any]:
|
|
"""Serialise the track for export/import (identity/URL/descriptor/language).
|
|
|
|
DRM is not serialised here. The export writer attaches the licensed DRM + keys.
|
|
Subclasses add their own codec/quality fields.
|
|
"""
|
|
data: dict[str, Any] = {
|
|
"type": self.__class__.__name__,
|
|
"id": self.id,
|
|
"url": self.url,
|
|
"language": str(self.language),
|
|
"is_original_lang": self.is_original_lang,
|
|
"descriptor": self.descriptor.name,
|
|
"needs_repack": self.needs_repack,
|
|
"name": self.name,
|
|
"edition": self.edition,
|
|
}
|
|
return data
|
|
|
|
@staticmethod
|
|
def base_kwargs_from_dict(data: dict[str, Any]) -> dict[str, Any]:
|
|
"""Assemble the shared Track constructor kwargs from a ``to_dict()`` payload.
|
|
|
|
DRM is not reconstructed here: ``to_dict`` does not serialise it, and the import
|
|
flow attaches the licensed DRM + content keys separately.
|
|
"""
|
|
return {
|
|
"url": data["url"],
|
|
"language": data.get("language") or "und",
|
|
"is_original_lang": data.get("is_original_lang", False),
|
|
"descriptor": Track.Descriptor[data.get("descriptor", "URL")],
|
|
"needs_repack": data.get("needs_repack", False),
|
|
"name": data.get("name"),
|
|
"edition": data.get("edition") or None,
|
|
"id_": data.get("id"),
|
|
}
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: dict[str, Any]) -> "Track":
|
|
"""Reconstruct the correct Track subclass from a ``to_dict()`` payload."""
|
|
from unshackle.core.tracks.audio import Audio
|
|
from unshackle.core.tracks.subtitle import Subtitle
|
|
from unshackle.core.tracks.video import Video
|
|
|
|
track_type = data.get("type")
|
|
builders = {"Video": Video, "Audio": Audio, "Subtitle": Subtitle}
|
|
builder = builders.get(track_type)
|
|
if builder is None:
|
|
raise ValueError(f"Cannot reconstruct unsupported track type: {track_type!r}")
|
|
return builder.from_dict(data)
|
|
|
|
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 self._drm_preference:
|
|
wanted = DRM_PREFERENCE_TYPES[self._drm_preference]
|
|
for drm in self.drm:
|
|
if isinstance(drm, wanted):
|
|
return drm
|
|
|
|
if is_widevine_cdm(cdm):
|
|
for drm in self.drm:
|
|
if isinstance(drm, Widevine):
|
|
return drm
|
|
elif is_playready_cdm(cdm):
|
|
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 can find the Key ID by probing the track 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. This method then uses the return value
|
|
of `get_init_segment` to find the Key ID.
|
|
|
|
Returns:
|
|
The Key ID as a UUID object, or None if unshackle cannot find the Key ID.
|
|
"""
|
|
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 = uuid_box.data
|
|
if tenc.key_ID.int != 0:
|
|
return tenc.key_ID
|
|
|
|
def load_drm_if_needed(self, service=None) -> bool:
|
|
"""
|
|
Load DRM information for this track if the parser deferred it.
|
|
|
|
Args:
|
|
service (Service | None): Service instance that can fetch track-specific DRM info
|
|
|
|
Returns:
|
|
True if the DRM loaded or is already present, False if the load failed
|
|
"""
|
|
if not getattr(self, "needs_drm_loading", False):
|
|
return bool(self.drm)
|
|
|
|
if self.drm:
|
|
self.needs_drm_loading = False
|
|
return True
|
|
|
|
if not service or not hasattr(service, "get_track_drm"):
|
|
return self.load_drm_from_playlist()
|
|
|
|
try:
|
|
track_drm = service.get_track_drm(self)
|
|
if track_drm:
|
|
self.drm = track_drm if isinstance(track_drm, list) else [track_drm]
|
|
self.needs_drm_loading = False
|
|
return True
|
|
except Exception as e:
|
|
raise ValueError(f"Failed to load DRM from service for track {self.id}: {e}")
|
|
|
|
return self.load_drm_from_playlist()
|
|
|
|
def load_drm_from_playlist(self) -> bool:
|
|
"""
|
|
Fallback method to load DRM by fetching this track's individual playlist.
|
|
"""
|
|
if self.drm:
|
|
self.needs_drm_loading = False
|
|
return True
|
|
|
|
try:
|
|
import m3u8
|
|
from pyplayready.system.pssh import PSSH as PR_PSSH
|
|
from pywidevine.cdm import Cdm as WidevineCdm
|
|
from pywidevine.pssh import PSSH as WV_PSSH
|
|
|
|
session = getattr(self, "session", None) or Session()
|
|
|
|
response = session.get(self.url)
|
|
if isinstance(response, Response):
|
|
response.encoding = response.encoding or "utf-8"
|
|
playlist = m3u8.loads(response.text, self.url)
|
|
|
|
drm_list = []
|
|
|
|
for key in playlist.keys or []:
|
|
if not key or not key.keyformat:
|
|
continue
|
|
|
|
fmt = key.keyformat.lower()
|
|
if fmt == WidevineCdm.urn:
|
|
pssh_b64 = key.uri.split(",")[-1]
|
|
drm = Widevine(pssh=WV_PSSH(pssh_b64))
|
|
drm_list.append(drm)
|
|
elif fmt in {f"urn:uuid:{PR_PSSH.SYSTEM_ID}", "com.microsoft.playready"}:
|
|
pssh_b64 = key.uri.split(",")[-1]
|
|
drm = PlayReady(pssh=PR_PSSH(pssh_b64), pssh_b64=pssh_b64)
|
|
drm_list.append(drm)
|
|
|
|
if drm_list:
|
|
self.drm = drm_list
|
|
self.needs_drm_loading = False
|
|
return True
|
|
|
|
except Exception as e:
|
|
raise ValueError(f"Failed to load DRM from playlist for track {self.id}: {e}")
|
|
|
|
return False
|
|
|
|
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.
|
|
|
|
HLS and DASH tracks must explicitly give a URL to the init segment or file.
|
|
Give the byte-range for the init segment where possible.
|
|
|
|
If `byte_range` is not set, it will make a HEAD request and examine the size of
|
|
the file. If it cannot find the size, 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 response body length if byte-range is
|
|
not used, if unshackle cannot find the body size, or if the body size
|
|
is larger than it. Use a value of 20000 (20KB) or higher.
|
|
url: Explicit init map or file URL to probe from.
|
|
byte_range: Range of bytes to download from the explicit or implicit URL.
|
|
session: HTTP session context, for example 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, RnetSession, type(None))):
|
|
raise TypeError(f"Expected session to be a {Session} or {RnetSession}, 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
|
|
s = session.get(url, stream=True)
|
|
for chunk in s.iter_content(content_length):
|
|
init_data = chunk
|
|
break
|
|
s.close()
|
|
if not init_data:
|
|
raise ValueError(f"Failed to read {content_length} bytes from the track URI.")
|
|
|
|
return init_data
|
|
|
|
def repackage(self, bsf_v: Optional[str] = None) -> bool:
|
|
"""Remux the track with FFmpeg ``-c copy``.
|
|
|
|
A given ``bsf_v`` goes into the same pass as ``-bsf:v``, which normalises video VUI
|
|
colour metadata without a second full-file remux. Repackaging is mandatory. The
|
|
bitstream filter is best-effort: if the combined pass fails, and it is not the
|
|
AAC-retry case, unshackle tries it again once without ``bsf_v`` so the remux still
|
|
succeeds. Returns True if unshackle applied the requested ``bsf_v`` (always False
|
|
when ``bsf_v`` is None, because the caller requested nothing).
|
|
"""
|
|
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, bsf: Optional[str] = None):
|
|
args = [
|
|
binaries.FFMPEG,
|
|
"-nostdin",
|
|
"-hide_banner",
|
|
"-loglevel",
|
|
"error",
|
|
"-i",
|
|
original_path,
|
|
*(extra_args or []),
|
|
]
|
|
|
|
if hasattr(self, "data") and self.data.get("audio_language"):
|
|
audio_lang = self.data["audio_language"]
|
|
audio_name = self.data.get("audio_language_name", audio_lang)
|
|
args.extend(
|
|
[
|
|
"-metadata:s:a:0",
|
|
f"language={audio_lang}",
|
|
"-metadata:s:a:0",
|
|
f"title={audio_name}",
|
|
"-metadata:s:a:0",
|
|
f"handler_name={audio_name}",
|
|
]
|
|
)
|
|
|
|
args.extend(
|
|
[
|
|
"-map_metadata",
|
|
"-1",
|
|
"-fflags",
|
|
"bitexact",
|
|
"-codec",
|
|
"copy",
|
|
]
|
|
)
|
|
if bsf:
|
|
# ffmpeg exits 0 after dropping packets the bsf cannot parse
|
|
args.extend(["-bsf:v", bsf, "-xerror"])
|
|
args.append(str(output_path))
|
|
|
|
subprocess.run(
|
|
args,
|
|
check=True,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
)
|
|
|
|
log_event(
|
|
"repackage",
|
|
level="DEBUG",
|
|
message=f"Repackaging {self.__class__.__name__} {original_path.name} with ffmpeg",
|
|
context={"track_type": self.__class__.__name__, "id": self.id, "file": original_path.name},
|
|
)
|
|
|
|
bsf_applied = False
|
|
try:
|
|
ffmpeg(bsf=bsf_v)
|
|
bsf_applied = bsf_v is not None
|
|
except subprocess.CalledProcessError as e:
|
|
if b"Malformed AAC bitstream detected" in e.stderr:
|
|
ffmpeg(["-y", "-bsf:a", "aac_adtstoasc"], bsf=bsf_v)
|
|
bsf_applied = bsf_v is not None
|
|
elif bsf_v is not None:
|
|
# Repack is mandatory, the VUI bitstream filter is best-effort: retry
|
|
# without it so the remux still succeeds; caller falls back to normalize_vui.
|
|
output_path.unlink(missing_ok=True)
|
|
ffmpeg()
|
|
else:
|
|
raise
|
|
|
|
original_path.unlink()
|
|
self.path = output_path
|
|
|
|
log_event(
|
|
"repackage_complete",
|
|
level="DEBUG",
|
|
message=f"Repackaged {self.__class__.__name__} -> {output_path.name}",
|
|
context={
|
|
"track_type": self.__class__.__name__,
|
|
"id": self.id,
|
|
"output": output_path.name,
|
|
"output_size": output_path.stat().st_size if output_path.exists() else 0,
|
|
},
|
|
)
|
|
return bsf_applied
|
|
|
|
|
|
__all__ = ("Track", "DownloadContext")
|