Compare commits
10 Commits
03f3fec5cc
...
aa60603c76
| Author | SHA1 | Date | |
|---|---|---|---|
| aa60603c76 | |||
| 0a7ac3fdd2 | |||
| 393b12c7f0 | |||
| 8ea3b5b26c | |||
|
|
09eda16882 | ||
|
|
a95d32de9e | ||
|
|
221cd145c4 | ||
|
|
0310646cb2 | ||
|
|
3426fc145f | ||
|
|
e57d755837 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,4 +1,6 @@
|
||||
# devine
|
||||
devine.yaml
|
||||
devine.yml
|
||||
*.mkv
|
||||
*.mp4
|
||||
*.exe
|
||||
|
||||
@@ -178,9 +178,10 @@ class dl:
|
||||
except ValueError as e:
|
||||
self.log.error(f"Failed to load Widevine CDM, {e}")
|
||||
sys.exit(1)
|
||||
self.log.info(
|
||||
f"Loaded {self.cdm.__class__.__name__} Widevine CDM: {self.cdm.system_id} (L{self.cdm.security_level})"
|
||||
)
|
||||
if self.cdm:
|
||||
self.log.info(
|
||||
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"):
|
||||
self.vaults = Vaults(self.service)
|
||||
@@ -936,21 +937,21 @@ class dl:
|
||||
return Credential.loads(credentials) # type: ignore
|
||||
|
||||
@staticmethod
|
||||
def get_cdm(service: str, profile: Optional[str] = None) -> WidevineCdm:
|
||||
def get_cdm(service: str, profile: Optional[str] = None) -> Optional[WidevineCdm]:
|
||||
"""
|
||||
Get CDM for a specified service (either Local or Remote CDM).
|
||||
Raises a ValueError if there's a problem getting a CDM.
|
||||
"""
|
||||
cdm_name = config.cdm.get(service) or config.cdm.get("default")
|
||||
if not cdm_name:
|
||||
raise ValueError("A CDM to use wasn't listed in the config")
|
||||
return None
|
||||
|
||||
if isinstance(cdm_name, dict):
|
||||
if not profile:
|
||||
raise ValueError("CDM config is mapped for profiles, but no profile was chosen")
|
||||
return None
|
||||
cdm_name = cdm_name.get(profile) or config.cdm.get("default")
|
||||
if not cdm_name:
|
||||
raise ValueError(f"A CDM to use was not mapped for the profile {profile}")
|
||||
return None
|
||||
|
||||
cdm_api = next(iter(x for x in config.remote_cdm if x["name"] == cdm_name), None)
|
||||
if cdm_api:
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "3.3.3"
|
||||
__version__ = "3.3.4"
|
||||
|
||||
@@ -220,6 +220,7 @@ def download(
|
||||
|
||||
for dl in stopped_downloads:
|
||||
if dl["status"] == "error":
|
||||
error_code = int(dl.get("errorCode", 0))
|
||||
used_uri = next(
|
||||
uri["uri"]
|
||||
for file in dl["files"]
|
||||
@@ -227,6 +228,11 @@ def download(
|
||||
for uri in file["uris"]
|
||||
if uri["status"] == "used"
|
||||
)
|
||||
|
||||
if error_code == 404:
|
||||
yield dict(downloaded=f"[yellow]Skipped missing segment: {used_uri}")
|
||||
continue # don’t raise, safely ignore
|
||||
|
||||
error = f"Download Error (#{dl['gid']}): {dl['errorMessage']} ({dl['errorCode']}), {used_uri}"
|
||||
error_pretty = "\n ".join(textwrap.wrap(
|
||||
error,
|
||||
|
||||
@@ -7,6 +7,8 @@ from pathlib import Path
|
||||
from typing import Any, Generator, MutableMapping, Optional, Union
|
||||
|
||||
from curl_cffi.requests import Session
|
||||
from curl_cffi.requests.exceptions import HTTPError
|
||||
|
||||
from rich import filesize
|
||||
|
||||
from devine.core.config import config
|
||||
@@ -80,6 +82,10 @@ def download(
|
||||
|
||||
try:
|
||||
stream = session.get(url, stream=True, **kwargs)
|
||||
if stream.status_code == 404:
|
||||
yield dict(downloaded=f"[yellow]Skipped missing segment (404): {url}")
|
||||
break
|
||||
|
||||
stream.raise_for_status()
|
||||
|
||||
try:
|
||||
@@ -118,6 +124,11 @@ def download(
|
||||
)
|
||||
break
|
||||
except Exception as e:
|
||||
if isinstance(e, requests.HTTPError) and e.response.status_code == 404:
|
||||
# Safe skip, don’t bubble up
|
||||
yield dict(downloaded=f"[yellow]Skipped missing segment: {url}")
|
||||
break
|
||||
|
||||
save_path.unlink(missing_ok=True)
|
||||
if DOWNLOAD_CANCELLED.is_set() or attempts == MAX_ATTEMPTS:
|
||||
raise e
|
||||
|
||||
@@ -91,6 +91,11 @@ def download(
|
||||
|
||||
try:
|
||||
stream = session.get(url, stream=True, **kwargs)
|
||||
if stream.status_code == 404:
|
||||
# Skip missing segments gracefully
|
||||
yield dict(downloaded=f"[yellow]Segment missing (404 skipped)")
|
||||
break
|
||||
|
||||
stream.raise_for_status()
|
||||
|
||||
if not segmented:
|
||||
@@ -139,6 +144,11 @@ def download(
|
||||
DOWNLOAD_SIZES.clear()
|
||||
break
|
||||
except Exception as e:
|
||||
if isinstance(e, requests.HTTPError) and e.response.status_code == 404:
|
||||
# Safe skip, don’t bubble up
|
||||
yield dict(downloaded=f"[yellow]Skipped missing segment: {url}")
|
||||
break
|
||||
|
||||
save_path.unlink(missing_ok=True)
|
||||
if DOWNLOAD_CANCELLED.is_set() or attempts == MAX_ATTEMPTS:
|
||||
raise e
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Optional, Union
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from Cryptodome.Cipher import AES
|
||||
from Cryptodome.Util.Padding import pad, unpad
|
||||
from Cryptodome.Util.Padding import unpad
|
||||
from m3u8.model import Key
|
||||
from requests import Session
|
||||
|
||||
@@ -43,7 +43,7 @@ class ClearKey:
|
||||
|
||||
decrypted = AES. \
|
||||
new(self.key, AES.MODE_CBC, self.iv). \
|
||||
decrypt(pad(path.read_bytes(), AES.block_size))
|
||||
decrypt(path.read_bytes())
|
||||
|
||||
try:
|
||||
decrypted = unpad(decrypted, AES.block_size)
|
||||
|
||||
@@ -387,15 +387,27 @@ class HLS:
|
||||
elif len(files) != range_len:
|
||||
raise ValueError(f"Missing {range_len - len(files)} segment files for {segment_range}...")
|
||||
|
||||
merge(
|
||||
to=merged_path,
|
||||
via=files,
|
||||
delete=True,
|
||||
include_map_data=True
|
||||
)
|
||||
|
||||
drm.decrypt(merged_path)
|
||||
merged_path.rename(decrypted_path)
|
||||
if isinstance(drm, Widevine):
|
||||
# with widevine we can merge all segments and decrypt once
|
||||
merge(
|
||||
to=merged_path,
|
||||
via=files,
|
||||
delete=True,
|
||||
include_map_data=True
|
||||
)
|
||||
drm.decrypt(merged_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.Types.TRACK_DECRYPTED,
|
||||
|
||||
@@ -206,17 +206,19 @@ class Subtitle(Track):
|
||||
elif self.codec == Subtitle.Codec.WebVTT:
|
||||
text = self.path.read_text("utf8")
|
||||
if self.descriptor == Track.Descriptor.DASH:
|
||||
text = merge_segmented_webvtt(
|
||||
text,
|
||||
segment_durations=self.data["dash"]["segment_durations"],
|
||||
timescale=self.data["dash"]["timescale"]
|
||||
)
|
||||
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:
|
||||
text = merge_segmented_webvtt(
|
||||
text,
|
||||
segment_durations=self.data["hls"]["segment_durations"],
|
||||
timescale=1 # ?
|
||||
)
|
||||
if len(self.data["hls"]["segment_durations"]) > 1:
|
||||
text = merge_segmented_webvtt(
|
||||
text,
|
||||
segment_durations=self.data["hls"]["segment_durations"],
|
||||
timescale=1 # ?
|
||||
)
|
||||
caption_set = pycaption.WebVTTReader().read(text)
|
||||
Subtitle.merge_same_cues(caption_set)
|
||||
subtitle_text = pycaption.WebVTTWriter().write(caption_set)
|
||||
|
||||
@@ -542,6 +542,7 @@ class Track:
|
||||
else:
|
||||
raise
|
||||
|
||||
original_path.unlink()
|
||||
self.path = output_path
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.poetry]
|
||||
name = "devine"
|
||||
version = "3.3.3"
|
||||
version = "3.3.4"
|
||||
description = "Modular Movie, TV, and Music Archival Software."
|
||||
license = "GPL-3.0-only"
|
||||
authors = ["rlaphoenix <rlaphoenix@pm.me>"]
|
||||
|
||||
Reference in New Issue
Block a user