11 Commits

Author SHA1 Message Date
Andy
bc26bf3046 feat: update changelog for version 1.4.7 2025-09-25 06:29:46 +00:00
Andy
35efdbff6d feat: add curl_cffi session support with browser impersonation
Add new session utility with curl_cffi support for anti-bot protection
Update all manifest parsers (DASH, HLS, ISM, M3U8) to accept curl_cffi sessions
Add browser impersonation support (Chrome, Firefox, Safari)
Fix cookie handling compatibility between requests and curl_cffi
Suppress HTTPS proxy warnings for better UX
Maintain full backward compatibility with requests.Session
2025-09-25 06:27:14 +00:00
Andy
63b7a49c1a feat: Add decrypt_labs_api_key to Config initialization and change duplicate track log level to debug 2025-09-25 06:22:50 +00:00
Andy
98ecf6f876 feat: Add download retry count option to download function 2025-09-23 01:32:00 +00:00
Andy
5df6914536 feat: Add options for required subtitles and best available quality in download command 2025-09-23 01:28:55 +00:00
Andy
c1df074965 Change new dynamic CDM selection text to be in Debug only 2025-09-14 04:25:57 +00:00
Andy
da60a396dd Fix: Prevent KeyError when reusing remote CDMs in dynamic selection
Creates a copy of the CDM dictionary before modification to prevent the original configuration from being mutated, allowing the same CDM to be selected multiple times within a session without errors.
2025-09-14 01:14:01 +00:00
Andy
a99a391395 chore: bump version to 1.4.6 and update changelog 2025-09-13 04:01:45 +00:00
Andy
ed32939d83 feat: Add quality-based CDM selection for dynamic CDM switching
Implements dynamic CDM selection based on video track resolution to optimize
CDM usage. Automatically selects appropriate security level (L3/SL2K for ≤1080p, L1/SL3K for >1080p) based on content requirements.

Key Features:
- Quality-based CDM configuration with threshold operators (>=, >, <=, <)
- Pre-selection based on highest quality across all video tracks
- Maintains backward compatibility with existing CDM configurations
- Single CDM per session to avoid inefficient switching
2025-09-13 03:59:13 +00:00
Andy
4006593a8a Fix: Implement lazy DRM loading for multi-track key retrieval
- Add deferred DRM loading to M3U8 parser to mark tracks for later processing
- Optimize prepare_drm to load DRM just-in-time during download process
2025-09-12 06:38:14 +00:00
Andy
307be4549b Fix vault caching count and NoneType iteration issues
- Fix 'NoneType' object is not iterable error in decrypt_labs_remote_cdm
- Fix vault count display showing 0/3 instead of actual successful vault count
2025-09-10 06:33:46 +00:00
18 changed files with 589 additions and 183 deletions

View File

@@ -5,6 +5,89 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.4.7] - 2025-09-25
### Added
- **curl_cffi Session Support**: Enhanced anti-bot protection with browser impersonation
- Added new session utility with curl_cffi support for bypassing anti-bot measures
- Browser impersonation support for Chrome, Firefox, and Safari user agents
- Full backward compatibility with requests.Session maintained
- Suppressed HTTPS proxy warnings for improved user experience
- **Download Retry Functionality**: Configurable retry mechanism for failed downloads
- Added retry count option to download function for improved reliability
- **Subtitle Requirements Options**: Enhanced subtitle download control
- Added options for required subtitles in download command
- Better control over subtitle track selection and requirements
- **Quality Selection Enhancement**: Improved quality selection options
- Added best available quality option in download command for optimal track selection
- **DecryptLabs API Integration**: Enhanced remote CDM configuration
- Added decrypt_labs_api_key to Config initialization for better API integration
### Changed
- **Manifest Parser Updates**: Enhanced compatibility across all parsers
- Updated DASH, HLS, ISM, and M3U8 parsers to accept curl_cffi sessions
- Improved cookie handling compatibility between requests and curl_cffi
- **Logging Improvements**: Reduced log verbosity for better user experience
- Changed duplicate track log level to debug to reduce console noise
- Dynamic CDM selection messages moved to debug-only output
### Fixed
- **Remote CDM Reuse**: Fixed KeyError in dynamic CDM selection
- Prevents KeyError when reusing remote CDMs in dynamic selection process
- Creates copy of CDM dictionary before modification to prevent configuration mutation
- Allows same CDM to be selected multiple times within session without errors
## [1.4.6] - 2025-09-13
### Added
- **Quality-Based CDM Selection**: Dynamic CDM selection based on video resolution
- Automatically selects appropriate CDM (L3/L1) based on video track quality
- Supports quality thresholds in configuration (>=, >, <=, <, exact match)
- Pre-selects optimal CDM based on highest quality across all video tracks
- Maintains backward compatibility with existing CDM configurations
- **Automatic Audio Language Metadata**: Intelligent embedded audio language detection
- Automatically sets audio language metadata when no separate audio tracks exist
- Smart video track selection based on title language with fallbacks
- Enhanced FFmpeg repackaging with audio stream metadata injection
- **Lazy DRM Loading**: Deferred DRM loading for multi-track key retrieval optimization
- Add deferred DRM loading to M3U8 parser to mark tracks for later processing
- Just-in-time DRM loading during download process for better performance
### Changed
- **Enhanced CDM Management**: Improved CDM switching logic for multi-quality downloads
- CDM selection now based on highest quality track to avoid inefficient switching
- Quality-based selection only within same DRM type (Widevine-to-Widevine, PlayReady-to-PlayReady)
- Single CDM used per session for better performance and reliability
### Fixed
- **Vault Caching Issues**: Fixed vault count display and NoneType iteration errors
- Fix 'NoneType' object is not iterable error in DecryptLabsRemoteCDM
- Fix vault count display showing 0/3 instead of actual successful vault count
- **Service Name Transmission**: Resolved DecryptLabsRemoteCDM service name issues
- Fixed DecryptLabsRemoteCDM sending 'generic' instead of proper service names
- Added case-insensitive vault lookups for SQLite/MySQL vaults
- Added local vault integration to DecryptLabsRemoteCDM
- **Import Organization**: Improved import ordering and code formatting
- Reorder imports in decrypt_labs_remote_cdm.py for better organization
- Clean up trailing whitespace in vault files
### Configuration
- **New CDM Configuration Format**: Extended `cdm:` section supports quality-based selection
```yaml
cdm:
SERVICE_NAME:
"<=1080": l3_cdm_name
">1080": l1_cdm_name
default: l3_cdm_name
```
## [1.4.5] - 2025-09-09 ## [1.4.5] - 2025-09-09
### Added ### Added

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "unshackle" name = "unshackle"
version = "1.4.4" version = "1.4.6"
description = "Modular Movie, TV, and Music Archival Software." description = "Modular Movie, TV, and Music Archival Software."
authors = [{ name = "unshackle team" }] authors = [{ name = "unshackle team" }]
requires-python = ">=3.10,<3.13" requires-python = ">=3.10,<3.13"

View File

@@ -173,6 +173,12 @@ class dl:
help="Language wanted for Audio, overrides -l/--lang for audio tracks.", help="Language wanted for Audio, overrides -l/--lang for audio tracks.",
) )
@click.option("-sl", "--s-lang", type=LANGUAGE_RANGE, default=["all"], help="Language wanted for Subtitles.") @click.option("-sl", "--s-lang", type=LANGUAGE_RANGE, default=["all"], help="Language wanted for Subtitles.")
@click.option(
"--require-subs",
type=LANGUAGE_RANGE,
default=[],
help="Required subtitle languages. Downloads all subtitles only if these languages exist. Cannot be used with --s-lang.",
)
@click.option("-fs", "--forced-subs", is_flag=True, default=False, help="Include forced subtitle tracks.") @click.option("-fs", "--forced-subs", is_flag=True, default=False, help="Include forced subtitle tracks.")
@click.option( @click.option(
"--proxy", "--proxy",
@@ -263,6 +269,13 @@ class dl:
@click.option( @click.option(
"--reset-cache", "reset_cache", is_flag=True, default=False, help="Clear title cache before fetching." "--reset-cache", "reset_cache", is_flag=True, default=False, help="Clear title cache before fetching."
) )
@click.option(
"--best-available",
"best_available",
is_flag=True,
default=False,
help="Continue with best available quality if requested resolutions are not available.",
)
@click.pass_context @click.pass_context
def cli(ctx: click.Context, **kwargs: Any) -> dl: def cli(ctx: click.Context, **kwargs: Any) -> dl:
return dl(ctx, **kwargs) return dl(ctx, **kwargs)
@@ -322,6 +335,16 @@ class dl:
vault_copy = vault.copy() vault_copy = vault.copy()
del vault_copy["type"] del vault_copy["type"]
if vault_type.lower() == "api" and "decrypt_labs" in vault_name.lower():
if "token" not in vault_copy or not vault_copy["token"]:
if config.decrypt_labs_api_key:
vault_copy["token"] = config.decrypt_labs_api_key
else:
self.log.warning(
f"No token provided for DecryptLabs vault '{vault_name}' and no global "
"decrypt_labs_api_key configured"
)
if vault_type.lower() == "sqlite": if vault_type.lower() == "sqlite":
try: try:
self.vaults.load_critical(vault_type, **vault_copy) self.vaults.load_critical(vault_type, **vault_copy)
@@ -442,6 +465,7 @@ class dl:
v_lang: list[str], v_lang: list[str],
a_lang: list[str], a_lang: list[str],
s_lang: list[str], s_lang: list[str],
require_subs: list[str],
forced_subs: bool, forced_subs: bool,
sub_format: Optional[Subtitle.Codec], sub_format: Optional[Subtitle.Codec],
video_only: bool, video_only: bool,
@@ -462,6 +486,7 @@ class dl:
no_source: bool, no_source: bool,
workers: Optional[int], workers: Optional[int],
downloads: int, downloads: int,
best_available: bool,
*_: Any, *_: Any,
**__: Any, **__: Any,
) -> None: ) -> None:
@@ -469,6 +494,10 @@ class dl:
self.search_source = None self.search_source = None
start_time = time.time() start_time = time.time()
if require_subs and s_lang != ["all"]:
self.log.error("--require-subs and --s-lang cannot be used together")
sys.exit(1)
# Check if dovi_tool is available when hybrid mode is requested # Check if dovi_tool is available when hybrid mode is requested
if any(r == Video.Range.HYBRID for r in range_): if any(r == Video.Range.HYBRID for r in range_):
from unshackle.core.binaries import DoviTool from unshackle.core.binaries import DoviTool
@@ -703,8 +732,14 @@ class dl:
res_list = ", ".join([f"{x}p" for x in missing_resolutions[:-1]]) + " or " res_list = ", ".join([f"{x}p" for x in missing_resolutions[:-1]]) + " or "
res_list = f"{res_list}{missing_resolutions[-1]}p" res_list = f"{res_list}{missing_resolutions[-1]}p"
plural = "s" if len(missing_resolutions) > 1 else "" plural = "s" if len(missing_resolutions) > 1 else ""
self.log.error(f"There's no {res_list} Video Track{plural}...")
sys.exit(1) if best_available:
self.log.warning(
f"There's no {res_list} Video Track{plural}, continuing with available qualities..."
)
else:
self.log.error(f"There's no {res_list} Video Track{plural}...")
sys.exit(1)
# choose best track by range and quality # choose best track by range and quality
if any(r == Video.Range.HYBRID for r in range_): if any(r == Video.Range.HYBRID for r in range_):
@@ -740,7 +775,21 @@ class dl:
title.tracks.videos = selected_videos title.tracks.videos = selected_videos
# filter subtitle tracks # filter subtitle tracks
if s_lang and "all" not in s_lang: if require_subs:
missing_langs = [
lang
for lang in require_subs
if not any(is_close_match(lang, [sub.language]) for sub in title.tracks.subtitles)
]
if missing_langs:
self.log.error(f"Required subtitle language(s) not found: {', '.join(missing_langs)}")
sys.exit(1)
self.log.info(
f"Required languages found ({', '.join(require_subs)}), downloading all available subtitles"
)
elif s_lang and "all" not in s_lang:
missing_langs = [ missing_langs = [
lang_ lang_
for lang_ in s_lang for lang_ in s_lang
@@ -862,9 +911,40 @@ class dl:
selected_tracks, tracks_progress_callables = title.tracks.tree(add_progress=True) selected_tracks, tracks_progress_callables = title.tracks.tree(add_progress=True)
for track in title.tracks:
if hasattr(track, "needs_drm_loading") and track.needs_drm_loading:
track.load_drm_if_needed(service)
download_table = Table.grid() download_table = Table.grid()
download_table.add_row(selected_tracks) download_table.add_row(selected_tracks)
video_tracks = title.tracks.videos
if video_tracks:
highest_quality = max((track.height for track in video_tracks if track.height), default=0)
if highest_quality > 0:
if isinstance(self.cdm, (WidevineCdm, DecryptLabsRemoteCDM)) and not (
isinstance(self.cdm, DecryptLabsRemoteCDM) and self.cdm.is_playready
):
quality_based_cdm = self.get_cdm(
self.service, self.profile, drm="widevine", quality=highest_quality
)
if quality_based_cdm and quality_based_cdm != self.cdm:
self.log.debug(
f"Pre-selecting Widevine CDM based on highest quality {highest_quality}p across all video tracks"
)
self.cdm = quality_based_cdm
elif isinstance(self.cdm, (PlayReadyCdm, DecryptLabsRemoteCDM)) and (
isinstance(self.cdm, DecryptLabsRemoteCDM) and self.cdm.is_playready
):
quality_based_cdm = self.get_cdm(
self.service, self.profile, drm="playready", quality=highest_quality
)
if quality_based_cdm and quality_based_cdm != self.cdm:
self.log.debug(
f"Pre-selecting PlayReady CDM based on highest quality {highest_quality}p across all video tracks"
)
self.cdm = quality_based_cdm
dl_start_time = time.time() dl_start_time = time.time()
if skip_dl: if skip_dl:
@@ -1149,7 +1229,11 @@ class dl:
progress.start_task(task_id) # TODO: Needed? progress.start_task(task_id) # TODO: Needed?
audio_expected = not video_only and not no_audio audio_expected = not video_only and not no_audio
muxed_path, return_code, errors = task_tracks.mux( muxed_path, return_code, errors = task_tracks.mux(
str(title), progress=partial(progress.update, task_id=task_id), delete=False, audio_expected=audio_expected, title_language=title.language str(title),
progress=partial(progress.update, task_id=task_id),
delete=False,
audio_expected=audio_expected,
title_language=title.language,
) )
muxed_paths.append(muxed_path) muxed_paths.append(muxed_path)
if return_code >= 2: if return_code >= 2:
@@ -1222,6 +1306,9 @@ class dl:
if not drm: if not drm:
return return
if isinstance(track, Video) and track.height:
pass
if isinstance(drm, Widevine): if isinstance(drm, Widevine):
if not isinstance(self.cdm, (WidevineCdm, DecryptLabsRemoteCDM)) or ( if not isinstance(self.cdm, (WidevineCdm, DecryptLabsRemoteCDM)) or (
isinstance(self.cdm, DecryptLabsRemoteCDM) and self.cdm.is_playready isinstance(self.cdm, DecryptLabsRemoteCDM) and self.cdm.is_playready
@@ -1230,6 +1317,7 @@ class dl:
if widevine_cdm: if widevine_cdm:
self.log.info("Switching to Widevine CDM for Widevine content") self.log.info("Switching to Widevine CDM for Widevine content")
self.cdm = widevine_cdm self.cdm = widevine_cdm
elif isinstance(drm, PlayReady): elif isinstance(drm, PlayReady):
if not isinstance(self.cdm, (PlayReadyCdm, DecryptLabsRemoteCDM)) or ( if not isinstance(self.cdm, (PlayReadyCdm, DecryptLabsRemoteCDM)) or (
isinstance(self.cdm, DecryptLabsRemoteCDM) and not self.cdm.is_playready isinstance(self.cdm, DecryptLabsRemoteCDM) and not self.cdm.is_playready
@@ -1249,7 +1337,12 @@ class dl:
if pre_existing_tree: if pre_existing_tree:
cek_tree = pre_existing_tree cek_tree = pre_existing_tree
for kid in drm.kids: need_license = False
all_kids = list(drm.kids)
if track_kid and track_kid not in all_kids:
all_kids.append(track_kid)
for kid in all_kids:
if kid in drm.content_keys: if kid in drm.content_keys:
continue continue
@@ -1269,46 +1362,51 @@ class dl:
if not pre_existing_tree: if not pre_existing_tree:
table.add_row(cek_tree) table.add_row(cek_tree)
raise Widevine.Exceptions.CEKNotFound(msg) raise Widevine.Exceptions.CEKNotFound(msg)
else:
need_license = True
if kid not in drm.content_keys and not vaults_only: if kid not in drm.content_keys and cdm_only:
from_vaults = drm.content_keys.copy() need_license = True
try: if need_license and not vaults_only:
if self.service == "NF": from_vaults = drm.content_keys.copy()
drm.get_NF_content_keys(cdm=self.cdm, licence=licence, certificate=certificate)
else:
drm.get_content_keys(cdm=self.cdm, licence=licence, certificate=certificate)
except Exception as e:
if isinstance(e, (Widevine.Exceptions.EmptyLicense, Widevine.Exceptions.CEKNotFound)):
msg = str(e)
else:
msg = f"An exception occurred in the Service's license function: {e}"
cek_tree.add(f"[logging.level.error]{msg}")
if not pre_existing_tree:
table.add_row(cek_tree)
raise e
for kid_, key in drm.content_keys.items(): try:
if key == "0" * 32: if self.service == "NF":
key = f"[red]{key}[/]" drm.get_NF_content_keys(cdm=self.cdm, licence=licence, certificate=certificate)
label = f"[text2]{kid_.hex}:{key}{is_track_kid}" else:
if not any(f"{kid_.hex}:{key}" in x.label for x in cek_tree.children): drm.get_content_keys(cdm=self.cdm, licence=licence, certificate=certificate)
cek_tree.add(label) except Exception as e:
if isinstance(e, (Widevine.Exceptions.EmptyLicense, Widevine.Exceptions.CEKNotFound)):
msg = str(e)
else:
msg = f"An exception occurred in the Service's license function: {e}"
cek_tree.add(f"[logging.level.error]{msg}")
if not pre_existing_tree:
table.add_row(cek_tree)
raise e
drm.content_keys = { for kid_, key in drm.content_keys.items():
kid_: key for kid_, key in drm.content_keys.items() if key and key.count("0") != len(key) if key == "0" * 32:
} key = f"[red]{key}[/]"
is_track_kid_marker = ["", "*"][kid_ == track_kid]
label = f"[text2]{kid_.hex}:{key}{is_track_kid_marker}"
if not any(f"{kid_.hex}:{key}" in x.label for x in cek_tree.children):
cek_tree.add(label)
# The CDM keys may have returned blank content keys for KIDs we got from vaults. drm.content_keys = {
# So we re-add the keys from vaults earlier overwriting blanks or removed KIDs data. kid_: key for kid_, key in drm.content_keys.items() if key and key.count("0") != len(key)
drm.content_keys.update(from_vaults) }
successful_caches = self.vaults.add_keys(drm.content_keys) # The CDM keys may have returned blank content keys for KIDs we got from vaults.
self.log.info( # So we re-add the keys from vaults earlier overwriting blanks or removed KIDs data.
f"Cached {len(drm.content_keys)} Key{'' if len(drm.content_keys) == 1 else 's'} to " drm.content_keys.update(from_vaults)
f"{successful_caches}/{len(self.vaults)} Vaults"
) successful_caches = self.vaults.add_keys(drm.content_keys)
break # licensing twice will be unnecessary self.log.info(
f"Cached {len(drm.content_keys)} Key{'' if len(drm.content_keys) == 1 else 's'} to "
f"{successful_caches}/{len(self.vaults)} Vaults"
)
if track_kid and track_kid not in drm.content_keys: if track_kid and track_kid not in drm.content_keys:
msg = f"No Content Key for KID {track_kid.hex} was returned in the License" msg = f"No Content Key for KID {track_kid.hex} was returned in the License"
@@ -1348,7 +1446,12 @@ class dl:
if pre_existing_tree: if pre_existing_tree:
cek_tree = pre_existing_tree cek_tree = pre_existing_tree
for kid in drm.kids: need_license = False
all_kids = list(drm.kids)
if track_kid and track_kid not in all_kids:
all_kids.append(track_kid)
for kid in all_kids:
if kid in drm.content_keys: if kid in drm.content_keys:
continue continue
@@ -1368,35 +1471,40 @@ class dl:
if not pre_existing_tree: if not pre_existing_tree:
table.add_row(cek_tree) table.add_row(cek_tree)
raise PlayReady.Exceptions.CEKNotFound(msg) raise PlayReady.Exceptions.CEKNotFound(msg)
else:
need_license = True
if kid not in drm.content_keys and not vaults_only: if kid not in drm.content_keys and cdm_only:
from_vaults = drm.content_keys.copy() need_license = True
try: if need_license and not vaults_only:
drm.get_content_keys(cdm=self.cdm, licence=licence, certificate=certificate) from_vaults = drm.content_keys.copy()
except Exception as e:
if isinstance(e, (PlayReady.Exceptions.EmptyLicense, PlayReady.Exceptions.CEKNotFound)):
msg = str(e)
else:
msg = f"An exception occurred in the Service's license function: {e}"
cek_tree.add(f"[logging.level.error]{msg}")
if not pre_existing_tree:
table.add_row(cek_tree)
raise e
for kid_, key in drm.content_keys.items(): try:
label = f"[text2]{kid_.hex}:{key}{is_track_kid}" drm.get_content_keys(cdm=self.cdm, licence=licence, certificate=certificate)
if not any(f"{kid_.hex}:{key}" in x.label for x in cek_tree.children): except Exception as e:
cek_tree.add(label) if isinstance(e, (PlayReady.Exceptions.EmptyLicense, PlayReady.Exceptions.CEKNotFound)):
msg = str(e)
else:
msg = f"An exception occurred in the Service's license function: {e}"
cek_tree.add(f"[logging.level.error]{msg}")
if not pre_existing_tree:
table.add_row(cek_tree)
raise e
drm.content_keys.update(from_vaults) for kid_, key in drm.content_keys.items():
is_track_kid_marker = ["", "*"][kid_ == track_kid]
label = f"[text2]{kid_.hex}:{key}{is_track_kid_marker}"
if not any(f"{kid_.hex}:{key}" in x.label for x in cek_tree.children):
cek_tree.add(label)
successful_caches = self.vaults.add_keys(drm.content_keys) drm.content_keys.update(from_vaults)
self.log.info(
f"Cached {len(drm.content_keys)} Key{'' if len(drm.content_keys) == 1 else 's'} to " successful_caches = self.vaults.add_keys(drm.content_keys)
f"{successful_caches}/{len(self.vaults)} Vaults" self.log.info(
) f"Cached {len(drm.content_keys)} Key{'' if len(drm.content_keys) == 1 else 's'} to "
break f"{successful_caches}/{len(self.vaults)} Vaults"
)
if track_kid and track_kid not in drm.content_keys: if track_kid and track_kid not in drm.content_keys:
msg = f"No Content Key for KID {track_kid.hex} was returned in the License" msg = f"No Content Key for KID {track_kid.hex} was returned in the License"
@@ -1456,6 +1564,9 @@ class dl:
@staticmethod @staticmethod
def save_cookies(path: Path, cookies: CookieJar): def save_cookies(path: Path, cookies: CookieJar):
if hasattr(cookies, 'jar'):
cookies = cookies.jar
cookie_jar = MozillaCookieJar(path) cookie_jar = MozillaCookieJar(path)
cookie_jar.load() cookie_jar.load()
for cookie in cookies: for cookie in cookies:
@@ -1482,9 +1593,11 @@ class dl:
service: str, service: str,
profile: Optional[str] = None, profile: Optional[str] = None,
drm: Optional[str] = None, drm: Optional[str] = None,
quality: Optional[int] = None,
) -> Optional[object]: ) -> Optional[object]:
""" """
Get CDM for a specified service (either Local or Remote CDM). Get CDM for a specified service (either Local or Remote CDM).
Now supports quality-based selection when quality is provided.
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")
@@ -1492,31 +1605,99 @@ class dl:
return None return None
if isinstance(cdm_name, dict): if isinstance(cdm_name, dict):
lower_keys = {k.lower(): v for k, v in cdm_name.items()} if quality:
if {"widevine", "playready"} & lower_keys.keys(): quality_match = None
drm_key = None quality_keys = []
if drm:
drm_key = {
"wv": "widevine",
"widevine": "widevine",
"pr": "playready",
"playready": "playready",
}.get(drm.lower())
cdm_name = lower_keys.get(drm_key or "widevine") or lower_keys.get("playready")
else:
if not profile:
return None
cdm_name = cdm_name.get(profile) or config.cdm.get("default")
if not cdm_name:
return None
cdm_api = next(iter(x for x in config.remote_cdm if x["name"] == cdm_name), None) for key in cdm_name.keys():
if (
isinstance(key, str)
and any(op in key for op in [">=", ">", "<=", "<"])
or (isinstance(key, str) and key.isdigit())
):
quality_keys.append(key)
def sort_quality_key(key):
if key.isdigit():
return (0, int(key)) # Exact matches first
elif key.startswith(">="):
return (1, -int(key[2:])) # >= descending
elif key.startswith(">"):
return (1, -int(key[1:])) # > descending
elif key.startswith("<="):
return (2, int(key[2:])) # <= ascending
elif key.startswith("<"):
return (2, int(key[1:])) # < ascending
return (3, 0) # Other keys last
quality_keys.sort(key=sort_quality_key)
for key in quality_keys:
if key.isdigit() and quality == int(key):
quality_match = cdm_name[key]
self.log.debug(f"Selected CDM based on exact quality match {quality}p: {quality_match}")
break
elif key.startswith(">="):
threshold = int(key[2:])
if quality >= threshold:
quality_match = cdm_name[key]
self.log.debug(f"Selected CDM based on quality {quality}p >= {threshold}p: {quality_match}")
break
elif key.startswith(">"):
threshold = int(key[1:])
if quality > threshold:
quality_match = cdm_name[key]
self.log.debug(f"Selected CDM based on quality {quality}p > {threshold}p: {quality_match}")
break
elif key.startswith("<="):
threshold = int(key[2:])
if quality <= threshold:
quality_match = cdm_name[key]
self.log.debug(f"Selected CDM based on quality {quality}p <= {threshold}p: {quality_match}")
break
elif key.startswith("<"):
threshold = int(key[1:])
if quality < threshold:
quality_match = cdm_name[key]
self.log.debug(f"Selected CDM based on quality {quality}p < {threshold}p: {quality_match}")
break
if quality_match:
cdm_name = quality_match
if isinstance(cdm_name, dict):
lower_keys = {k.lower(): v for k, v in cdm_name.items()}
if {"widevine", "playready"} & lower_keys.keys():
drm_key = None
if drm:
drm_key = {
"wv": "widevine",
"widevine": "widevine",
"pr": "playready",
"playready": "playready",
}.get(drm.lower())
cdm_name = lower_keys.get(drm_key or "widevine") or lower_keys.get("playready")
else:
cdm_name = cdm_name.get(profile) or cdm_name.get("default") or config.cdm.get("default")
if not cdm_name:
return None
cdm_api = next(iter(x.copy() for x in config.remote_cdm if x["name"] == cdm_name), None)
if cdm_api: if cdm_api:
is_decrypt_lab = True if cdm_api.get("type") == "decrypt_labs" else False is_decrypt_lab = True if cdm_api.get("type") == "decrypt_labs" else False
if is_decrypt_lab: if is_decrypt_lab:
del cdm_api["name"] del cdm_api["name"]
del cdm_api["type"] del cdm_api["type"]
if "secret" not in cdm_api or not cdm_api["secret"]:
if config.decrypt_labs_api_key:
cdm_api["secret"] = config.decrypt_labs_api_key
else:
raise ValueError(
f"No secret provided for DecryptLabs CDM '{cdm_name}' and no global "
"decrypt_labs_api_key configured"
)
# All DecryptLabs CDMs use DecryptLabsRemoteCDM # All DecryptLabs CDMs use DecryptLabsRemoteCDM
return DecryptLabsRemoteCDM(service_name=service, vaults=self.vaults, **cdm_api) return DecryptLabsRemoteCDM(service_name=service, vaults=self.vaults, **cdm_api)
else: else:

View File

@@ -1 +1 @@
__version__ = "1.4.5" __version__ = "1.4.7"

View File

@@ -625,8 +625,9 @@ class DecryptLabsRemoteCDM:
if "cached_keys" in session: if "cached_keys" in session:
cached_keys = session.get("cached_keys", []) cached_keys = session.get("cached_keys", [])
for cached_key in cached_keys: if cached_keys:
all_keys.append(cached_key) for cached_key in cached_keys:
all_keys.append(cached_key)
for license_key in license_keys: for license_key in license_keys:
already_exists = False already_exists = False

View File

@@ -88,6 +88,7 @@ class Config:
self.tag_group_name: bool = kwargs.get("tag_group_name", True) self.tag_group_name: bool = kwargs.get("tag_group_name", True)
self.tag_imdb_tmdb: bool = kwargs.get("tag_imdb_tmdb", True) self.tag_imdb_tmdb: bool = kwargs.get("tag_imdb_tmdb", True)
self.tmdb_api_key: str = kwargs.get("tmdb_api_key") or "" self.tmdb_api_key: str = kwargs.get("tmdb_api_key") or ""
self.decrypt_labs_api_key: str = kwargs.get("decrypt_labs_api_key") or ""
self.update_checks: bool = kwargs.get("update_checks", True) self.update_checks: bool = kwargs.get("update_checks", True)
self.update_check_interval: int = kwargs.get("update_check_interval", 24) self.update_check_interval: int = kwargs.get("update_check_interval", 24)
self.scene_naming: bool = kwargs.get("scene_naming", True) self.scene_naming: bool = kwargs.get("scene_naming", True)

View File

@@ -150,6 +150,7 @@ def download(
track_type = track.__class__.__name__ track_type = track.__class__.__name__
thread_count = str(config.n_m3u8dl_re.get("thread_count", max_workers)) thread_count = str(config.n_m3u8dl_re.get("thread_count", max_workers))
retry_count = str(config.n_m3u8dl_re.get("retry_count", max_workers))
ad_keyword = config.n_m3u8dl_re.get("ad_keyword") ad_keyword = config.n_m3u8dl_re.get("ad_keyword")
arguments = [ arguments = [
@@ -160,6 +161,8 @@ def download(
output_dir, output_dir,
"--thread-count", "--thread-count",
thread_count, thread_count,
"--download-retry-count",
retry_count,
"--no-log", "--no-log",
"--write-meta-json", "--write-meta-json",
"false", "false",

View File

@@ -8,6 +8,7 @@ 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 unpad
from curl_cffi.requests import Session as CurlSession
from m3u8.model import Key from m3u8.model import Key
from requests import Session from requests import Session
@@ -69,8 +70,8 @@ class ClearKey:
""" """
if not isinstance(m3u_key, Key): if not isinstance(m3u_key, Key):
raise ValueError(f"Provided M3U Key is in an unexpected type {m3u_key!r}") raise ValueError(f"Provided M3U Key is in an unexpected type {m3u_key!r}")
if not isinstance(session, (Session, type(None))): if not isinstance(session, (Session, CurlSession, type(None))):
raise TypeError(f"Expected session to be a {Session}, not a {type(session)}") raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not a {type(session)}")
if not m3u_key.method.startswith("AES"): if not m3u_key.method.startswith("AES"):
raise ValueError(f"Provided M3U Key is not an AES Clear Key, {m3u_key.method}") raise ValueError(f"Provided M3U Key is not an AES Clear Key, {m3u_key.method}")

View File

@@ -15,6 +15,7 @@ from uuid import UUID
from zlib import crc32 from zlib import crc32
import requests import requests
from curl_cffi.requests import Session as CurlSession
from langcodes import Language, tag_is_valid from langcodes import Language, tag_is_valid
from lxml.etree import Element, ElementTree from lxml.etree import Element, ElementTree
from pyplayready.system.pssh import PSSH as PR_PSSH from pyplayready.system.pssh import PSSH as PR_PSSH
@@ -47,7 +48,7 @@ class DASH:
self.url = url self.url = url
@classmethod @classmethod
def from_url(cls, url: str, session: Optional[Session] = None, **args: Any) -> DASH: def from_url(cls, url: str, session: Optional[Union[Session, CurlSession]] = None, **args: Any) -> DASH:
if not url: if not url:
raise requests.URLRequired("DASH manifest URL must be provided for relative path computations.") raise requests.URLRequired("DASH manifest URL must be provided for relative path computations.")
if not isinstance(url, str): if not isinstance(url, str):
@@ -55,8 +56,8 @@ class DASH:
if not session: if not session:
session = Session() session = Session()
elif not isinstance(session, Session): elif not isinstance(session, (Session, CurlSession)):
raise TypeError(f"Expected session to be a {Session}, not {session!r}") raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not {session!r}")
res = session.get(url, **args) res = session.get(url, **args)
if res.url != url: if res.url != url:
@@ -103,6 +104,10 @@ class DASH:
continue continue
if next(iter(period.xpath("SegmentType/@value")), "content") != "content": if next(iter(period.xpath("SegmentType/@value")), "content") != "content":
continue continue
if "urn:amazon:primevideo:cachingBreadth" in [
x.get("schemeIdUri") for x in period.findall("SupplementalProperty")
]:
continue
for adaptation_set in period.findall("AdaptationSet"): for adaptation_set in period.findall("AdaptationSet"):
if self.is_trick_mode(adaptation_set): if self.is_trick_mode(adaptation_set):

View File

@@ -14,9 +14,10 @@ from typing import Any, Callable, Optional, Union
from urllib.parse import urljoin from urllib.parse import urljoin
from zlib import crc32 from zlib import crc32
import httpx
import m3u8 import m3u8
import requests import requests
from curl_cffi.requests import Response as CurlResponse
from curl_cffi.requests import Session as CurlSession
from langcodes import Language, tag_is_valid from langcodes import Language, tag_is_valid
from m3u8 import M3U8 from m3u8 import M3U8
from pyplayready.cdm import Cdm as PlayReadyCdm from pyplayready.cdm import Cdm as PlayReadyCdm
@@ -35,7 +36,7 @@ from unshackle.core.utilities import get_extension, is_close_match, try_ensure_u
class HLS: class HLS:
def __init__(self, manifest: M3U8, session: Optional[Union[Session, httpx.Client]] = None): def __init__(self, manifest: M3U8, session: Optional[Union[Session, CurlSession]] = None):
if not manifest: if not manifest:
raise ValueError("HLS manifest must be provided.") raise ValueError("HLS manifest must be provided.")
if not isinstance(manifest, M3U8): if not isinstance(manifest, M3U8):
@@ -47,7 +48,7 @@ class HLS:
self.session = session or Session() self.session = session or Session()
@classmethod @classmethod
def from_url(cls, url: str, session: Optional[Union[Session, httpx.Client]] = None, **args: Any) -> HLS: def from_url(cls, url: str, session: Optional[Union[Session, CurlSession]] = None, **args: Any) -> HLS:
if not url: if not url:
raise requests.URLRequired("HLS manifest URL must be provided.") raise requests.URLRequired("HLS manifest URL must be provided.")
if not isinstance(url, str): if not isinstance(url, str):
@@ -55,22 +56,22 @@ class HLS:
if not session: if not session:
session = Session() session = Session()
elif not isinstance(session, (Session, httpx.Client)): elif not isinstance(session, (Session, CurlSession)):
raise TypeError(f"Expected session to be a {Session} or {httpx.Client}, not {session!r}") raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not {session!r}")
res = session.get(url, **args) res = session.get(url, **args)
# Handle both requests and httpx response objects # Handle requests and curl_cffi response objects
if isinstance(res, requests.Response): if isinstance(res, requests.Response):
if not res.ok: if not res.ok:
raise requests.ConnectionError("Failed to request the M3U(8) document.", response=res) raise requests.ConnectionError("Failed to request the M3U(8) document.", response=res)
content = res.text content = res.text
elif isinstance(res, httpx.Response): elif isinstance(res, CurlResponse):
if res.status_code >= 400: if not res.ok:
raise requests.ConnectionError("Failed to request the M3U(8) document.", response=res) raise requests.ConnectionError("Failed to request the M3U(8) document.", response=res)
content = res.text content = res.text
else: else:
raise TypeError(f"Expected response to be a requests.Response or httpx.Response, not {type(res)}") raise TypeError(f"Expected response to be a requests.Response or curl_cffi.Response, not {type(res)}")
master = m3u8.loads(content, uri=url) master = m3u8.loads(content, uri=url)
@@ -229,7 +230,7 @@ class HLS:
save_path: Path, save_path: Path,
save_dir: Path, save_dir: Path,
progress: partial, progress: partial,
session: Optional[Union[Session, httpx.Client]] = None, session: Optional[Union[Session, CurlSession]] = None,
proxy: Optional[str] = None, proxy: Optional[str] = None,
max_workers: Optional[int] = None, max_workers: Optional[int] = None,
license_widevine: Optional[Callable] = None, license_widevine: Optional[Callable] = None,
@@ -238,15 +239,13 @@ class HLS:
) -> None: ) -> None:
if not session: if not session:
session = Session() session = Session()
elif not isinstance(session, (Session, httpx.Client)): elif not isinstance(session, (Session, CurlSession)):
raise TypeError(f"Expected session to be a {Session} or {httpx.Client}, not {session!r}") raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not {session!r}")
if proxy: if proxy:
# Handle proxies differently based on session type # Handle proxies differently based on session type
if isinstance(session, Session): if isinstance(session, Session):
session.proxies.update({"all": proxy}) session.proxies.update({"all": proxy})
elif isinstance(session, httpx.Client):
session.proxies = {"http://": proxy, "https://": proxy}
log = logging.getLogger("HLS") log = logging.getLogger("HLS")
@@ -257,13 +256,8 @@ class HLS:
log.error(f"Failed to request the invariant M3U8 playlist: {response.status_code}") log.error(f"Failed to request the invariant M3U8 playlist: {response.status_code}")
sys.exit(1) sys.exit(1)
playlist_text = response.text playlist_text = response.text
elif isinstance(response, httpx.Response):
if response.status_code >= 400:
log.error(f"Failed to request the invariant M3U8 playlist: {response.status_code}")
sys.exit(1)
playlist_text = response.text
else: else:
raise TypeError(f"Expected response to be a requests.Response or httpx.Response, not {type(response)}") raise TypeError(f"Expected response to be a requests.Response or curl_cffi.Response, not {type(response)}")
master = m3u8.loads(playlist_text, uri=track.url) master = m3u8.loads(playlist_text, uri=track.url)
@@ -533,13 +527,9 @@ class HLS:
if isinstance(res, requests.Response): if isinstance(res, requests.Response):
res.raise_for_status() res.raise_for_status()
init_content = res.content init_content = res.content
elif isinstance(res, httpx.Response):
if res.status_code >= 400:
raise requests.HTTPError(f"HTTP Error: {res.status_code}", response=res)
init_content = res.content
else: else:
raise TypeError( raise TypeError(
f"Expected response to be requests.Response or httpx.Response, not {type(res)}" f"Expected response to be requests.Response or curl_cffi.Response, not {type(res)}"
) )
map_data = (segment.init_section, init_content) map_data = (segment.init_section, init_content)
@@ -707,7 +697,7 @@ class HLS:
@staticmethod @staticmethod
def parse_session_data_keys( def parse_session_data_keys(
manifest: M3U8, session: Optional[Union[Session, httpx.Client]] = None manifest: M3U8, session: Optional[Union[Session, CurlSession]] = None
) -> list[m3u8.model.Key]: ) -> list[m3u8.model.Key]:
"""Parse `com.apple.hls.keys` session data and return Key objects.""" """Parse `com.apple.hls.keys` session data and return Key objects."""
keys: list[m3u8.model.Key] = [] keys: list[m3u8.model.Key] = []
@@ -798,7 +788,8 @@ class HLS:
@staticmethod @staticmethod
def get_drm( def get_drm(
key: Union[m3u8.model.SessionKey, m3u8.model.Key], session: Optional[Union[Session, httpx.Client]] = None key: Union[m3u8.model.SessionKey, m3u8.model.Key],
session: Optional[Union[Session, CurlSession]] = None,
) -> DRM_T: ) -> DRM_T:
""" """
Convert HLS EXT-X-KEY data to an initialized DRM object. Convert HLS EXT-X-KEY data to an initialized DRM object.
@@ -810,8 +801,8 @@ class HLS:
Raises a NotImplementedError if the key system is not supported. Raises a NotImplementedError if the key system is not supported.
""" """
if not isinstance(session, (Session, httpx.Client, type(None))): if not isinstance(session, (Session, CurlSession, type(None))):
raise TypeError(f"Expected session to be a {Session} or {httpx.Client}, not {type(session)}") raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not {type(session)}")
if not session: if not session:
session = Session() session = Session()

View File

@@ -10,6 +10,7 @@ from pathlib import Path
from typing import Any, Callable, Optional, Union from typing import Any, Callable, Optional, Union
import requests import requests
from curl_cffi.requests import Session as CurlSession
from langcodes import Language, tag_is_valid from langcodes import Language, tag_is_valid
from lxml.etree import Element from lxml.etree import Element
from pyplayready.system.pssh import PSSH as PR_PSSH from pyplayready.system.pssh import PSSH as PR_PSSH
@@ -34,11 +35,13 @@ class ISM:
self.url = url self.url = url
@classmethod @classmethod
def from_url(cls, url: str, session: Optional[Session] = None, **kwargs: Any) -> "ISM": def from_url(cls, url: str, session: Optional[Union[Session, CurlSession]] = None, **kwargs: Any) -> "ISM":
if not url: if not url:
raise requests.URLRequired("ISM manifest URL must be provided") raise requests.URLRequired("ISM manifest URL must be provided")
if not session: if not session:
session = Session() session = Session()
elif not isinstance(session, (Session, CurlSession)):
raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not {session!r}")
res = session.get(url, **kwargs) res = session.get(url, **kwargs)
if res.url != url: if res.url != url:
url = res.url url = res.url

View File

@@ -4,15 +4,10 @@ from __future__ import annotations
from typing import Optional, Union from typing import Optional, Union
import httpx
import m3u8 import m3u8
from pyplayready.cdm import Cdm as PlayReadyCdm from curl_cffi.requests import Session as CurlSession
from pyplayready.system.pssh import PSSH as PR_PSSH
from pywidevine.cdm import Cdm as WidevineCdm
from pywidevine.pssh import PSSH as WV_PSSH
from requests import Session from requests import Session
from unshackle.core.drm import PlayReady, Widevine
from unshackle.core.manifests.hls import HLS from unshackle.core.manifests.hls import HLS
from unshackle.core.tracks import Tracks from unshackle.core.tracks import Tracks
@@ -21,54 +16,17 @@ def parse(
master: m3u8.M3U8, master: m3u8.M3U8,
language: str, language: str,
*, *,
session: Optional[Union[Session, httpx.Client]] = None, session: Optional[Union[Session, CurlSession]] = None,
) -> Tracks: ) -> Tracks:
"""Parse a variant playlist to ``Tracks`` with DRM information.""" """Parse a variant playlist to ``Tracks`` with basic information, defer DRM loading."""
tracks = HLS(master, session=session).to_tracks(language) tracks = HLS(master, session=session).to_tracks(language)
need_wv = not any(isinstance(d, Widevine) for t in tracks for d in (t.drm or [])) bool(master.session_keys or HLS.parse_session_data_keys(master, session or Session()))
need_pr = not any(isinstance(d, PlayReady) for t in tracks for d in (t.drm or []))
if (need_wv or need_pr) and tracks.videos: if True:
if not session: for t in tracks.videos + tracks.audio:
session = Session() t.needs_drm_loading = True
t.session = session
session_keys = list(master.session_keys or [])
session_keys.extend(HLS.parse_session_data_keys(master, session))
for drm_obj in HLS.get_all_drm(session_keys):
if need_wv and isinstance(drm_obj, Widevine):
for t in tracks.videos + tracks.audio:
t.drm = [d for d in (t.drm or []) if not isinstance(d, Widevine)] + [drm_obj]
need_wv = False
elif need_pr and isinstance(drm_obj, PlayReady):
for t in tracks.videos + tracks.audio:
t.drm = [d for d in (t.drm or []) if not isinstance(d, PlayReady)] + [drm_obj]
need_pr = False
if not need_wv and not need_pr:
break
if (need_wv or need_pr) and tracks.videos:
first_video = tracks.videos[0]
playlist = m3u8.load(first_video.url)
for key in playlist.keys or []:
if not key or not key.keyformat:
continue
fmt = key.keyformat.lower()
if need_wv and fmt == WidevineCdm.urn:
pssh_b64 = key.uri.split(",")[-1]
drm = Widevine(pssh=WV_PSSH(pssh_b64))
for t in tracks.videos + tracks.audio:
t.drm = [d for d in (t.drm or []) if not isinstance(d, Widevine)] + [drm]
need_wv = False
elif need_pr and (fmt == PlayReadyCdm or "com.microsoft.playready" in fmt):
pssh_b64 = key.uri.split(",")[-1]
drm = PlayReady(pssh=PR_PSSH(pssh_b64), pssh_b64=pssh_b64)
for t in tracks.videos + tracks.audio:
t.drm = [d for d in (t.drm or []) if not isinstance(d, PlayReady)] + [drm]
need_pr = False
if not need_wv and not need_pr:
break
return tracks return tracks

79
unshackle/core/session.py Normal file
View File

@@ -0,0 +1,79 @@
"""Session utilities for creating HTTP sessions with different backends."""
from __future__ import annotations
import warnings
from curl_cffi.requests import Session as CurlSession
from unshackle.core.config import config
# Globally suppress curl_cffi HTTPS proxy warnings since some proxy providers
# (like NordVPN) require HTTPS URLs but curl_cffi expects HTTP format
warnings.filterwarnings(
"ignore", message="Make sure you are using https over https proxy.*", category=RuntimeWarning, module="curl_cffi.*"
)
class Session(CurlSession):
"""curl_cffi Session with warning suppression."""
def request(self, method, url, **kwargs):
with warnings.catch_warnings():
warnings.filterwarnings(
"ignore", message="Make sure you are using https over https proxy.*", category=RuntimeWarning
)
return super().request(method, url, **kwargs)
def session(browser: str | None = None, **kwargs) -> Session:
"""
Create a curl_cffi session that impersonates a browser.
This is a full replacement for requests.Session with browser impersonation
and anti-bot capabilities. The session uses curl-impersonate under the hood
to mimic real browser behavior.
Args:
browser: Browser to impersonate (e.g. "chrome124", "firefox", "safari").
Uses the configured default from curl_impersonate.browser if not specified.
See https://github.com/lexiforest/curl_cffi#sessions for available options.
**kwargs: Additional arguments passed to CurlSession constructor:
- headers: Additional headers (dict)
- cookies: Cookie jar or dict
- auth: HTTP basic auth tuple (username, password)
- proxies: Proxy configuration dict
- verify: SSL certificate verification (bool, default True)
- timeout: Request timeout in seconds (float or tuple)
- allow_redirects: Follow redirects (bool, default True)
- max_redirects: Maximum redirect count (int)
- cert: Client certificate (str or tuple)
Returns:
curl_cffi.requests.Session configured with browser impersonation, common headers,
and equivalent retry behavior to requests.Session.
Example:
from unshackle.core.session import session
class MyService(Service):
@staticmethod
def get_session():
return session() # Uses config default browser
"""
if browser is None:
browser = config.curl_impersonate.get("browser", "chrome124")
session_config = {
"impersonate": browser,
"timeout": 30.0,
"allow_redirects": True,
"max_redirects": 15,
"verify": True,
}
session_config.update(kwargs)
session_obj = Session(**session_config)
session_obj.headers.update(config.headers)
return session_obj

View File

@@ -13,6 +13,7 @@ from typing import Any, Callable, Iterable, Optional, Union
from uuid import UUID from uuid import UUID
from zlib import crc32 from zlib import crc32
from curl_cffi.requests import Session as CurlSession
from langcodes import Language from langcodes import Language
from pyplayready.cdm import Cdm as PlayReadyCdm from pyplayready.cdm import Cdm as PlayReadyCdm
from pywidevine.cdm import Cdm as WidevineCdm from pywidevine.cdm import Cdm as WidevineCdm
@@ -473,6 +474,83 @@ class Track:
if tenc.key_ID.int != 0: if tenc.key_ID.int != 0:
return tenc.key_ID return tenc.key_ID
def load_drm_if_needed(self, service=None) -> bool:
"""
Load DRM information for this track if it was deferred during parsing.
Args:
service: Service instance that can fetch track-specific DRM info
Returns:
True if DRM was loaded or already present, False if 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.cdm import Cdm as PlayReadyCdm
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)
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 == PlayReadyCdm or "com.microsoft.playready" in fmt:
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( def get_init_segment(
self, self,
maximum_size: int = 20000, maximum_size: int = 20000,
@@ -508,8 +586,8 @@ class Track:
raise TypeError(f"Expected url to be a {str}, not {type(url)}") raise TypeError(f"Expected url to be a {str}, not {type(url)}")
if not isinstance(byte_range, (str, type(None))): if not isinstance(byte_range, (str, type(None))):
raise TypeError(f"Expected byte_range to be a {str}, not {type(byte_range)}") raise TypeError(f"Expected byte_range to be a {str}, not {type(byte_range)}")
if not isinstance(session, (Session, type(None))): if not isinstance(session, (Session, CurlSession, type(None))):
raise TypeError(f"Expected session to be a {Session}, not {type(session)}") raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not {type(session)}")
if not url: if not url:
if self.descriptor != self.Descriptor.URL: if self.descriptor != self.Descriptor.URL:

View File

@@ -181,7 +181,7 @@ class Tracks:
log = logging.getLogger("Tracks") log = logging.getLogger("Tracks")
if duplicates: if duplicates:
log.warning(f" - Found and skipped {duplicates} duplicate tracks...") log.debug(f" - Found and skipped {duplicates} duplicate tracks...")
def sort_videos(self, by_language: Optional[Sequence[Union[str, Language]]] = None) -> None: def sort_videos(self, by_language: Optional[Sequence[Union[str, Language]]] = None) -> None:
"""Sort video tracks by bitrate, and optionally language.""" """Sort video tracks by bitrate, and optionally language."""

View File

@@ -74,7 +74,9 @@ class Vaults:
for vault in self.vaults: for vault in self.vaults:
if not vault.no_push: if not vault.no_push:
try: try:
success += bool(vault.add_keys(self.service, kid_keys)) # Count each vault that successfully processes the keys (whether new or existing)
vault.add_keys(self.service, kid_keys)
success += 1
except (PermissionError, NotImplementedError): except (PermissionError, NotImplementedError):
pass pass
return success return success

View File

@@ -88,6 +88,26 @@ cdm:
jane_uhd: nexus_5_l1 # Profile 'jane_uhd' uses Nexus 5 L1 jane_uhd: nexus_5_l1 # Profile 'jane_uhd' uses Nexus 5 L1
default: generic_android_l3 # Default CDM for this service default: generic_android_l3 # Default CDM for this service
# NEW: Quality-based CDM selection
# Use different CDMs based on video resolution
# Supports operators: >=, >, <=, <, or exact match
EXAMPLE_QUALITY:
"<=1080": generic_android_l3 # Use L3 for 1080p and below
">1080": nexus_5_l1 # Use L1 for above 1080p (1440p, 2160p)
default: generic_android_l3 # Optional: fallback if no quality match
# You can mix profiles and quality thresholds in the same service
NETFLIX:
# Profile-based selection (existing functionality)
john: netflix_l3_profile
jane: netflix_l1_profile
# Quality-based selection (new functionality)
"<=720": netflix_mobile_l3
"1080": netflix_standard_l3
">=1440": netflix_premium_l1
# Fallback
default: netflix_standard_l3
# Use pywidevine Serve-compliant Remote CDMs # Use pywidevine Serve-compliant Remote CDMs
remote_cdm: remote_cdm:
- name: "chrome" - name: "chrome"
@@ -106,16 +126,16 @@ remote_cdm:
secret: secret_key secret: secret_key
- name: "decrypt_labs_chrome" - name: "decrypt_labs_chrome"
type: "decrypt_labs" # Required to identify as DecryptLabs CDM type: "decrypt_labs" # Required to identify as DecryptLabs CDM
device_name: "ChromeCDM" # Scheme identifier - must match exactly device_name: "ChromeCDM" # Scheme identifier - must match exactly
device_type: CHROME device_type: CHROME
system_id: 4464 # Doesn't matter system_id: 4464 # Doesn't matter
security_level: 3 security_level: 3
host: "https://keyxtractor.decryptlabs.com" host: "https://keyxtractor.decryptlabs.com"
secret: "your_decrypt_labs_api_key_here" # Replace with your API key secret: "your_decrypt_labs_api_key_here" # Replace with your API key
- name: "decrypt_labs_l1" - name: "decrypt_labs_l1"
type: "decrypt_labs" type: "decrypt_labs"
device_name: "L1" # Scheme identifier - must match exactly device_name: "L1" # Scheme identifier - must match exactly
device_type: ANDROID device_type: ANDROID
system_id: 4464 system_id: 4464
security_level: 1 security_level: 1
@@ -124,7 +144,7 @@ remote_cdm:
- name: "decrypt_labs_l2" - name: "decrypt_labs_l2"
type: "decrypt_labs" type: "decrypt_labs"
device_name: "L2" # Scheme identifier - must match exactly device_name: "L2" # Scheme identifier - must match exactly
device_type: ANDROID device_type: ANDROID
system_id: 4464 system_id: 4464
security_level: 2 security_level: 2
@@ -133,7 +153,7 @@ remote_cdm:
- name: "decrypt_labs_playready_sl2" - name: "decrypt_labs_playready_sl2"
type: "decrypt_labs" type: "decrypt_labs"
device_name: "SL2" # Scheme identifier - must match exactly device_name: "SL2" # Scheme identifier - must match exactly
device_type: PLAYREADY device_type: PLAYREADY
system_id: 0 system_id: 0
security_level: 2000 security_level: 2000
@@ -142,7 +162,7 @@ remote_cdm:
- name: "decrypt_labs_playready_sl3" - name: "decrypt_labs_playready_sl3"
type: "decrypt_labs" type: "decrypt_labs"
device_name: "SL3" # Scheme identifier - must match exactly device_name: "SL3" # Scheme identifier - must match exactly
device_type: PLAYREADY device_type: PLAYREADY
system_id: 0 system_id: 0
security_level: 3000 security_level: 3000

2
uv.lock generated
View File

@@ -1499,7 +1499,7 @@ wheels = [
[[package]] [[package]]
name = "unshackle" name = "unshackle"
version = "1.4.4" version = "1.4.6"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "appdirs" }, { name = "appdirs" },