mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-06-22 08:57:25 +00:00
Compare commits
11 Commits
a82828768d
...
1.4.7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc26bf3046 | ||
|
|
35efdbff6d | ||
|
|
63b7a49c1a | ||
|
|
98ecf6f876 | ||
|
|
5df6914536 | ||
|
|
c1df074965 | ||
|
|
da60a396dd | ||
|
|
a99a391395 | ||
|
|
ed32939d83 | ||
|
|
4006593a8a | ||
|
|
307be4549b |
83
CHANGELOG.md
83
CHANGELOG.md
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = "1.4.5"
|
__version__ = "1.4.7"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
79
unshackle/core/session.py
Normal 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
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user