4 Commits

Author SHA1 Message Date
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
10 changed files with 366 additions and 141 deletions

View File

@@ -5,6 +5,54 @@ 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.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

@@ -862,9 +862,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.info(
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.info(
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 +1180,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 +1257,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 +1268,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 +1288,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 +1313,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 +1397,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 +1422,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"
@@ -1482,9 +1541,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,23 +1553,82 @@ 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 = { for key in cdm_name.keys():
"wv": "widevine", if (
"widevine": "widevine", isinstance(key, str)
"pr": "playready", and any(op in key for op in [">=", ">", "<=", "<"])
"playready": "playready", or (isinstance(key, str) and key.isdigit())
}.get(drm.lower()) ):
cdm_name = lower_keys.get(drm_key or "widevine") or lower_keys.get("playready") quality_keys.append(key)
else:
if not profile: 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.info(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.info(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.info(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.info(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.info(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 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) cdm_api = next(iter(x for x in config.remote_cdm if x["name"] == cdm_name), None)
if cdm_api: if cdm_api:

View File

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

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

@@ -2,17 +2,11 @@
from __future__ import annotations from __future__ import annotations
from typing import Optional, Union from typing import Optional
import httpx
import m3u8 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
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 +15,17 @@ def parse(
master: m3u8.M3U8, master: m3u8.M3U8,
language: str, language: str,
*, *,
session: Optional[Union[Session, httpx.Client]] = None, session: Optional[Session] = 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

View File

@@ -473,6 +473,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,

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" },