Compare commits

..

No commits in common. "master" and "v3.3.3" have entirely different histories.

12 changed files with 51 additions and 129 deletions

2
.gitignore vendored
View File

@ -1,6 +1,4 @@
# devine # devine
devine.yaml
devine.yml
*.mkv *.mkv
*.mp4 *.mp4
*.exe *.exe

View File

@ -343,8 +343,6 @@ Please refrain from spam or asking for questions that infringe upon a Service's
<a href="https://github.com/Shivelight"><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/20620780?v=4&h=25&w=25&fit=cover&mask=circle&maxage=7d" alt="Shivelight"/></a> <a href="https://github.com/Shivelight"><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/20620780?v=4&h=25&w=25&fit=cover&mask=circle&maxage=7d" alt="Shivelight"/></a>
<a href="https://github.com/knowhere01"><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/113712042?v=4&h=25&w=25&fit=cover&mask=circle&maxage=7d" alt="knowhere01"/></a> <a href="https://github.com/knowhere01"><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/113712042?v=4&h=25&w=25&fit=cover&mask=circle&maxage=7d" alt="knowhere01"/></a>
<a href="https://github.com/retouching"><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/33735357?v=4&h=25&w=25&fit=cover&mask=circle&maxage=7d" alt="retouching"/></a> <a href="https://github.com/retouching"><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/33735357?v=4&h=25&w=25&fit=cover&mask=circle&maxage=7d" alt="retouching"/></a>
<a href="https://github.com/pandamoon21"><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/33972938?v=4&h=25&w=25&fit=cover&mask=circle&maxage=7d" alt="pandamoon21"/></a>
<a href="https://github.com/adbbbb"><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/56319336?v=4&h=25&w=25&fit=cover&mask=circle&maxage=7d" alt="adbbbb"/></a>
## Licensing ## Licensing

View File

@ -178,10 +178,9 @@ class dl:
except ValueError as e: except ValueError as e:
self.log.error(f"Failed to load Widevine CDM, {e}") self.log.error(f"Failed to load Widevine CDM, {e}")
sys.exit(1) sys.exit(1)
if self.cdm: self.log.info(
self.log.info( f"Loaded {self.cdm.__class__.__name__} Widevine CDM: {self.cdm.system_id} (L{self.cdm.security_level})"
f"Loaded {self.cdm.__class__.__name__} Widevine CDM: {self.cdm.system_id} (L{self.cdm.security_level})" )
)
with console.status("Loading Key Vaults...", spinner="dots"): with console.status("Loading Key Vaults...", spinner="dots"):
self.vaults = Vaults(self.service) self.vaults = Vaults(self.service)
@ -702,22 +701,16 @@ class dl:
): ):
for task_id, task_tracks in multiplex_tasks: for task_id, task_tracks in multiplex_tasks:
progress.start_task(task_id) # TODO: Needed? progress.start_task(task_id) # TODO: Needed?
muxed_path, return_code, errors = task_tracks.mux( muxed_path, return_code = task_tracks.mux(
str(title), str(title),
progress=partial(progress.update, task_id=task_id), progress=partial(progress.update, task_id=task_id),
delete=False delete=False
) )
muxed_paths.append(muxed_path) muxed_paths.append(muxed_path)
if return_code >= 2: if return_code == 1:
self.log.error(f"Failed to Mux video to Matroska file ({return_code}):") self.log.warning("mkvmerge had at least one warning, will continue anyway...")
elif return_code == 1 or errors: elif return_code >= 2:
self.log.warning("mkvmerge had at least one warning or error, continuing anyway...") self.log.error(f"Failed to Mux video to Matroska file ({return_code})")
for line in errors:
if line.startswith("#GUI#error"):
self.log.error(line)
else:
self.log.warning(line)
if return_code >= 2:
sys.exit(1) sys.exit(1)
for video_track in task_tracks.videos: for video_track in task_tracks.videos:
video_track.delete() video_track.delete()
@ -937,21 +930,21 @@ class dl:
return Credential.loads(credentials) # type: ignore return Credential.loads(credentials) # type: ignore
@staticmethod @staticmethod
def get_cdm(service: str, profile: Optional[str] = None) -> Optional[WidevineCdm]: def get_cdm(service: str, profile: Optional[str] = None) -> WidevineCdm:
""" """
Get CDM for a specified service (either Local or Remote CDM). Get CDM for a specified service (either Local or Remote CDM).
Raises a ValueError if there's a problem getting a CDM. Raises a ValueError if there's a problem getting a CDM.
""" """
cdm_name = config.cdm.get(service) or config.cdm.get("default") cdm_name = config.cdm.get(service) or config.cdm.get("default")
if not cdm_name: if not cdm_name:
return None raise ValueError("A CDM to use wasn't listed in the config")
if isinstance(cdm_name, dict): if isinstance(cdm_name, dict):
if not profile: if not profile:
return None raise ValueError("CDM config is mapped for profiles, but no profile was chosen")
cdm_name = cdm_name.get(profile) or config.cdm.get("default") cdm_name = cdm_name.get(profile) or config.cdm.get("default")
if not cdm_name: if not cdm_name:
return None raise ValueError(f"A CDM to use was not mapped for the profile {profile}")
cdm_api = next(iter(x for x in config.remote_cdm if x["name"] == cdm_name), None) cdm_api = next(iter(x for x in config.remote_cdm if x["name"] == cdm_name), None)
if cdm_api: if cdm_api:

View File

@ -26,7 +26,6 @@ ShakaPackager = find(
"shaka-packager", "shaka-packager",
"packager", "packager",
f"packager-{__shaka_platform}", f"packager-{__shaka_platform}",
f"packager-{__shaka_platform}-arm64",
f"packager-{__shaka_platform}-x64" f"packager-{__shaka_platform}-x64"
) )
Aria2 = find("aria2c", "aria2") Aria2 = find("aria2c", "aria2")

View File

@ -7,7 +7,7 @@ from typing import Optional, Union
from urllib.parse import urljoin from urllib.parse import urljoin
from Cryptodome.Cipher import AES from Cryptodome.Cipher import AES
from Cryptodome.Util.Padding import unpad from Cryptodome.Util.Padding import pad, unpad
from m3u8.model import Key from m3u8.model import Key
from requests import Session from requests import Session
@ -43,7 +43,7 @@ class ClearKey:
decrypted = AES. \ decrypted = AES. \
new(self.key, AES.MODE_CBC, self.iv). \ new(self.key, AES.MODE_CBC, self.iv). \
decrypt(path.read_bytes()) decrypt(pad(path.read_bytes(), AES.block_size))
try: try:
decrypted = unpad(decrypted, AES.block_size) decrypted = unpad(decrypted, AES.block_size)

View File

@ -292,7 +292,6 @@ class DASH:
if segment_template is not None: if segment_template is not None:
segment_template = copy(segment_template) segment_template = copy(segment_template)
start_number = int(segment_template.get("startNumber") or 1) start_number = int(segment_template.get("startNumber") or 1)
end_number = int(segment_template.get("endNumber") or 0) or None
segment_timeline = segment_template.find("SegmentTimeline") segment_timeline = segment_template.find("SegmentTimeline")
segment_timescale = float(segment_template.get("timescale") or 1) segment_timescale = float(segment_template.get("timescale") or 1)
@ -329,11 +328,9 @@ class DASH:
for _ in range(1 + (int(s.get("r") or 0))): for _ in range(1 + (int(s.get("r") or 0))):
segment_durations.append(current_time) segment_durations.append(current_time)
current_time += int(s.get("d")) current_time += int(s.get("d"))
seg_num_list = list(range(start_number, len(segment_durations) + start_number))
if not end_number: for t, n in zip(segment_durations, seg_num_list):
end_number = len(segment_durations)
for t, n in zip(segment_durations, range(start_number, end_number + 1)):
segments.append(( segments.append((
DASH.replace_fields( DASH.replace_fields(
segment_template.get("media"), segment_template.get("media"),
@ -348,11 +345,9 @@ class DASH:
raise ValueError("Duration of the Period was unable to be determined.") raise ValueError("Duration of the Period was unable to be determined.")
period_duration = DASH.pt_to_sec(period_duration) period_duration = DASH.pt_to_sec(period_duration)
segment_duration = float(segment_template.get("duration")) or 1 segment_duration = float(segment_template.get("duration")) or 1
total_segments = math.ceil(period_duration / (segment_duration / segment_timescale))
if not end_number: for s in range(start_number, start_number + total_segments):
end_number = math.ceil(period_duration / (segment_duration / segment_timescale))
for s in range(start_number, end_number + 1):
segments.append(( segments.append((
DASH.replace_fields( DASH.replace_fields(
segment_template.get("media"), segment_template.get("media"),
@ -472,7 +467,6 @@ class DASH:
if downloader.__name__ == "aria2c" and any(bytes_range is not None for url, bytes_range in segments): if downloader.__name__ == "aria2c" and any(bytes_range is not None for url, bytes_range in segments):
# aria2(c) is shit and doesn't support the Range header, fallback to the requests downloader # aria2(c) is shit and doesn't support the Range header, fallback to the requests downloader
downloader = requests_downloader downloader = requests_downloader
log.warning("Falling back to the requests downloader as aria2(c) doesn't support the Range header")
for status_update in downloader( for status_update in downloader(
urls=[ urls=[

View File

@ -254,12 +254,6 @@ class HLS:
progress(total=total_segments) progress(total=total_segments)
downloader = track.downloader downloader = track.downloader
if (
downloader.__name__ == "aria2c" and
any(x.byterange for x in master.segments if x not in unwanted_segments)
):
downloader = requests_downloader
log.warning("Falling back to the requests downloader as aria2(c) doesn't support the Range header")
urls: list[dict[str, Any]] = [] urls: list[dict[str, Any]] = []
segment_durations: list[int] = [] segment_durations: list[int] = []
@ -272,6 +266,9 @@ class HLS:
segment_durations.append(int(segment.duration)) segment_durations.append(int(segment.duration))
if segment.byterange: if segment.byterange:
if downloader.__name__ == "aria2c":
# aria2(c) is shit and doesn't support the Range header, fallback to the requests downloader
downloader = requests_downloader
byte_range = HLS.calculate_byte_range(segment.byterange, range_offset) byte_range = HLS.calculate_byte_range(segment.byterange, range_offset)
range_offset = byte_range.split("-")[0] range_offset = byte_range.split("-")[0]
else: else:
@ -387,27 +384,15 @@ class HLS:
elif len(files) != range_len: elif len(files) != range_len:
raise ValueError(f"Missing {range_len - len(files)} segment files for {segment_range}...") raise ValueError(f"Missing {range_len - len(files)} segment files for {segment_range}...")
if isinstance(drm, Widevine): merge(
# with widevine we can merge all segments and decrypt once to=merged_path,
merge( via=files,
to=merged_path, delete=True,
via=files, include_map_data=True
delete=True, )
include_map_data=True
) drm.decrypt(merged_path)
drm.decrypt(merged_path) merged_path.rename(decrypted_path)
merged_path.rename(decrypted_path)
else:
# with other drm we must decrypt separately and then merge them
# for aes this is because each segment likely has 16-byte padding
for file in files:
drm.decrypt(file)
merge(
to=merged_path,
via=files,
delete=True,
include_map_data=True
)
events.emit( events.emit(
events.Types.TRACK_DECRYPTED, events.Types.TRACK_DECRYPTED,

View File

@ -37,7 +37,7 @@ class Attachment:
mime_type = { mime_type = {
".ttf": "application/x-truetype-font", ".ttf": "application/x-truetype-font",
".otf": "application/vnd.ms-opentype" ".otf": "application/vnd.ms-opentype"
}.get(path.suffix.lower(), mimetypes.guess_type(path)[0]) }.get(path.suffix, mimetypes.guess_type(path)[0])
if not mime_type: if not mime_type:
raise ValueError("The attachment mime-type could not be automatically detected.") raise ValueError("The attachment mime-type could not be automatically detected.")

View File

@ -206,19 +206,17 @@ class Subtitle(Track):
elif self.codec == Subtitle.Codec.WebVTT: elif self.codec == Subtitle.Codec.WebVTT:
text = self.path.read_text("utf8") text = self.path.read_text("utf8")
if self.descriptor == Track.Descriptor.DASH: if self.descriptor == Track.Descriptor.DASH:
if len(self.data["dash"]["segment_durations"]) > 1: text = merge_segmented_webvtt(
text = merge_segmented_webvtt( text,
text, segment_durations=self.data["dash"]["segment_durations"],
segment_durations=self.data["dash"]["segment_durations"], timescale=self.data["dash"]["timescale"]
timescale=self.data["dash"]["timescale"] )
)
elif self.descriptor == Track.Descriptor.HLS: elif self.descriptor == Track.Descriptor.HLS:
if len(self.data["hls"]["segment_durations"]) > 1: text = merge_segmented_webvtt(
text = merge_segmented_webvtt( text,
text, segment_durations=self.data["hls"]["segment_durations"],
segment_durations=self.data["hls"]["segment_durations"], timescale=1 # ?
timescale=1 # ? )
)
caption_set = pycaption.WebVTTReader().read(text) caption_set = pycaption.WebVTTReader().read(text)
Subtitle.merge_same_cues(caption_set) Subtitle.merge_same_cues(caption_set)
subtitle_text = pycaption.WebVTTWriter().write(caption_set) subtitle_text = pycaption.WebVTTWriter().write(caption_set)

View File

@ -4,7 +4,6 @@ import logging
import re import re
import shutil import shutil
import subprocess import subprocess
from collections import defaultdict
from copy import copy from copy import copy
from enum import Enum from enum import Enum
from functools import partial from functools import partial
@ -43,7 +42,7 @@ class Track:
drm: Optional[Iterable[DRM_T]] = None, drm: Optional[Iterable[DRM_T]] = None,
edition: Optional[str] = None, edition: Optional[str] = None,
downloader: Optional[Callable] = None, downloader: Optional[Callable] = None,
data: Optional[Union[dict, defaultdict]] = None, data: Optional[dict] = None,
id_: Optional[str] = None, id_: Optional[str] = None,
) -> None: ) -> None:
if not isinstance(url, (str, list)): if not isinstance(url, (str, list)):
@ -64,8 +63,8 @@ class Track:
raise TypeError(f"Expected edition to be a {str}, not {type(edition)}") raise TypeError(f"Expected edition to be a {str}, not {type(edition)}")
if not isinstance(downloader, (Callable, type(None))): if not isinstance(downloader, (Callable, type(None))):
raise TypeError(f"Expected downloader to be a {Callable}, not {type(downloader)}") raise TypeError(f"Expected downloader to be a {Callable}, not {type(downloader)}")
if not isinstance(data, (dict, defaultdict, type(None))): if not isinstance(data, (dict, type(None))):
raise TypeError(f"Expected data to be a {dict} or {defaultdict}, not {type(data)}") raise TypeError(f"Expected data to be a {dict}, not {type(data)}")
invalid_urls = ", ".join(set(type(x) for x in url if not isinstance(x, str))) invalid_urls = ", ".join(set(type(x) for x in url if not isinstance(x, str)))
if invalid_urls: if invalid_urls:
@ -94,7 +93,6 @@ class Track:
self.drm = drm self.drm = drm
self.edition: str = edition self.edition: str = edition
self.downloader = downloader self.downloader = downloader
self._data: defaultdict[Any, Any] = defaultdict(dict)
self.data = data or {} self.data = data or {}
if self.name is None: if self.name is None:
@ -134,42 +132,6 @@ class Track:
def __eq__(self, other: Any) -> bool: def __eq__(self, other: Any) -> bool:
return isinstance(other, Track) and self.id == other.id 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( def download(
self, self,
session: Session, session: Session,
@ -542,7 +504,6 @@ class Track:
else: else:
raise raise
original_path.unlink()
self.path = output_path self.path = output_path

View File

@ -316,7 +316,7 @@ class Tracks:
][:per_language or None]) ][:per_language or None])
return selected return selected
def mux(self, title: str, delete: bool = True, progress: Optional[partial] = None) -> tuple[Path, int, list[str]]: def mux(self, title: str, delete: bool = True, progress: Optional[partial] = None) -> tuple[Path, int]:
""" """
Multiplex all the Tracks into a Matroska Container file. Multiplex all the Tracks into a Matroska Container file.
@ -410,18 +410,15 @@ class Tracks:
# let potential failures go to caller, caller should handle # let potential failures go to caller, caller should handle
try: try:
errors = []
p = subprocess.Popen([ p = subprocess.Popen([
*cl, *cl,
"--output", str(output_path), "--output", str(output_path),
"--gui-mode" "--gui-mode"
], text=True, stdout=subprocess.PIPE) ], text=True, stdout=subprocess.PIPE)
for line in iter(p.stdout.readline, ""): for line in iter(p.stdout.readline, ""):
if line.startswith("#GUI#error") or line.startswith("#GUI#warning"):
errors.append(line)
if "progress" in line: if "progress" in line:
progress(total=100, completed=int(line.strip()[14:-1])) progress(total=100, completed=int(line.strip()[14:-1]))
return output_path, p.wait(), errors return output_path, p.wait()
finally: finally:
if chapters_path: if chapters_path:
# regardless of delete param, we delete as it's a file we made during muxing # regardless of delete param, we delete as it's a file we made during muxing

View File

@ -123,18 +123,18 @@ def get_boxes(data: bytes, box_type: bytes, as_bytes: bool = False) -> Box:
# since it doesn't care what child box the wanted box is from, this works fine. # since it doesn't care what child box the wanted box is from, this works fine.
if not isinstance(data, (bytes, bytearray)): if not isinstance(data, (bytes, bytearray)):
raise ValueError("data must be bytes") raise ValueError("data must be bytes")
offset = 0
while True: while True:
try: try:
index = data[offset:].index(box_type) index = data.index(box_type)
except ValueError: except ValueError:
break break
if index < 0: if index < 0:
break break
index -= 4 # size is before box type and is 4 bytes long if index > 4:
index -= 4 # size is before box type and is 4 bytes long
data = data[index:]
try: try:
box = Box.parse(data[offset:][index:]) box = Box.parse(data)
except IOError: except IOError:
# since get_init_segment might cut off unexpectedly, pymp4 may be unable to read # since get_init_segment might cut off unexpectedly, pymp4 may be unable to read
# the expected amounts of data and complain, so let's just end the function here # the expected amounts of data and complain, so let's just end the function here
@ -147,7 +147,6 @@ def get_boxes(data: bytes, box_type: bytes, as_bytes: bool = False) -> Box:
raise e raise e
if as_bytes: if as_bytes:
box = Box.build(box) box = Box.build(box)
offset += index + len(Box.build(box))
yield box yield box