Compare commits
10 Commits
03f3fec5cc
...
master
| 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
|
||||||
|
devine.yaml
|
||||||
|
devine.yml
|
||||||
*.mkv
|
*.mkv
|
||||||
*.mp4
|
*.mp4
|
||||||
*.exe
|
*.exe
|
||||||
|
|||||||
@@ -178,6 +178,7 @@ 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})"
|
||||||
)
|
)
|
||||||
@@ -936,21 +937,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) -> WidevineCdm:
|
def get_cdm(service: str, profile: Optional[str] = None) -> Optional[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:
|
||||||
raise ValueError("A CDM to use wasn't listed in the config")
|
return None
|
||||||
|
|
||||||
if isinstance(cdm_name, dict):
|
if isinstance(cdm_name, dict):
|
||||||
if not profile:
|
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")
|
cdm_name = cdm_name.get(profile) or config.cdm.get("default")
|
||||||
if not cdm_name:
|
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)
|
cdm_api = next(iter(x for x in config.remote_cdm if x["name"] == cdm_name), None)
|
||||||
if cdm_api:
|
if cdm_api:
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = "3.3.3"
|
__version__ = "3.3.4"
|
||||||
|
|||||||
@@ -220,6 +220,7 @@ def download(
|
|||||||
|
|
||||||
for dl in stopped_downloads:
|
for dl in stopped_downloads:
|
||||||
if dl["status"] == "error":
|
if dl["status"] == "error":
|
||||||
|
error_code = int(dl.get("errorCode", 0))
|
||||||
used_uri = next(
|
used_uri = next(
|
||||||
uri["uri"]
|
uri["uri"]
|
||||||
for file in dl["files"]
|
for file in dl["files"]
|
||||||
@@ -227,6 +228,11 @@ def download(
|
|||||||
for uri in file["uris"]
|
for uri in file["uris"]
|
||||||
if uri["status"] == "used"
|
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 = f"Download Error (#{dl['gid']}): {dl['errorMessage']} ({dl['errorCode']}), {used_uri}"
|
||||||
error_pretty = "\n ".join(textwrap.wrap(
|
error_pretty = "\n ".join(textwrap.wrap(
|
||||||
error,
|
error,
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ from pathlib import Path
|
|||||||
from typing import Any, Generator, MutableMapping, Optional, Union
|
from typing import Any, Generator, MutableMapping, Optional, Union
|
||||||
|
|
||||||
from curl_cffi.requests import Session
|
from curl_cffi.requests import Session
|
||||||
|
from curl_cffi.requests.exceptions import HTTPError
|
||||||
|
|
||||||
from rich import filesize
|
from rich import filesize
|
||||||
|
|
||||||
from devine.core.config import config
|
from devine.core.config import config
|
||||||
@@ -80,6 +82,10 @@ def download(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
stream = session.get(url, stream=True, **kwargs)
|
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()
|
stream.raise_for_status()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -118,6 +124,11 @@ def download(
|
|||||||
)
|
)
|
||||||
break
|
break
|
||||||
except Exception as e:
|
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)
|
save_path.unlink(missing_ok=True)
|
||||||
if DOWNLOAD_CANCELLED.is_set() or attempts == MAX_ATTEMPTS:
|
if DOWNLOAD_CANCELLED.is_set() or attempts == MAX_ATTEMPTS:
|
||||||
raise e
|
raise e
|
||||||
|
|||||||
@@ -91,6 +91,11 @@ def download(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
stream = session.get(url, stream=True, **kwargs)
|
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()
|
stream.raise_for_status()
|
||||||
|
|
||||||
if not segmented:
|
if not segmented:
|
||||||
@@ -139,6 +144,11 @@ def download(
|
|||||||
DOWNLOAD_SIZES.clear()
|
DOWNLOAD_SIZES.clear()
|
||||||
break
|
break
|
||||||
except Exception as e:
|
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)
|
save_path.unlink(missing_ok=True)
|
||||||
if DOWNLOAD_CANCELLED.is_set() or attempts == MAX_ATTEMPTS:
|
if DOWNLOAD_CANCELLED.is_set() or attempts == MAX_ATTEMPTS:
|
||||||
raise e
|
raise e
|
||||||
|
|||||||
@@ -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 pad, unpad
|
from Cryptodome.Util.Padding import 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(pad(path.read_bytes(), AES.block_size))
|
decrypt(path.read_bytes())
|
||||||
|
|
||||||
try:
|
try:
|
||||||
decrypted = unpad(decrypted, AES.block_size)
|
decrypted = unpad(decrypted, AES.block_size)
|
||||||
|
|||||||
@@ -387,15 +387,27 @@ 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):
|
||||||
|
# with widevine we can merge all segments and decrypt once
|
||||||
merge(
|
merge(
|
||||||
to=merged_path,
|
to=merged_path,
|
||||||
via=files,
|
via=files,
|
||||||
delete=True,
|
delete=True,
|
||||||
include_map_data=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,
|
||||||
|
|||||||
@@ -206,12 +206,14 @@ 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"],
|
||||||
|
|||||||
@@ -542,6 +542,7 @@ class Track:
|
|||||||
else:
|
else:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
original_path.unlink()
|
||||||
self.path = output_path
|
self.path = output_path
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
|
|||||||
|
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "devine"
|
name = "devine"
|
||||||
version = "3.3.3"
|
version = "3.3.4"
|
||||||
description = "Modular Movie, TV, and Music Archival Software."
|
description = "Modular Movie, TV, and Music Archival Software."
|
||||||
license = "GPL-3.0-only"
|
license = "GPL-3.0-only"
|
||||||
authors = ["rlaphoenix <rlaphoenix@pm.me>"]
|
authors = ["rlaphoenix <rlaphoenix@pm.me>"]
|
||||||
|
|||||||
Reference in New Issue
Block a user