mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2025-10-23 15:11:08 +00:00
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
This commit is contained in:
@@ -58,15 +58,8 @@ from unshackle.core.tracks.attachment import Attachment
|
|||||||
from unshackle.core.tracks.hybrid import Hybrid
|
from unshackle.core.tracks.hybrid import Hybrid
|
||||||
from unshackle.core.utilities import get_system_fonts, is_close_match, time_elapsed_since
|
from unshackle.core.utilities import get_system_fonts, is_close_match, time_elapsed_since
|
||||||
from unshackle.core.utils import tags
|
from unshackle.core.utils import tags
|
||||||
from unshackle.core.utils.click_types import (
|
from unshackle.core.utils.click_types import (LANGUAGE_RANGE, QUALITY_LIST, SEASON_RANGE, ContextData, MultipleChoice,
|
||||||
LANGUAGE_RANGE,
|
SubtitleCodecChoice, VideoCodecChoice)
|
||||||
QUALITY_LIST,
|
|
||||||
SEASON_RANGE,
|
|
||||||
ContextData,
|
|
||||||
MultipleChoice,
|
|
||||||
SubtitleCodecChoice,
|
|
||||||
VideoCodecChoice,
|
|
||||||
)
|
|
||||||
from unshackle.core.utils.collections import merge_dict
|
from unshackle.core.utils.collections import merge_dict
|
||||||
from unshackle.core.utils.subprocess import ffprobe
|
from unshackle.core.utils.subprocess import ffprobe
|
||||||
from unshackle.core.vaults import Vaults
|
from unshackle.core.vaults import Vaults
|
||||||
@@ -876,6 +869,33 @@ class dl:
|
|||||||
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:
|
||||||
@@ -1237,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
|
||||||
@@ -1245,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
|
||||||
@@ -1517,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")
|
||||||
@@ -1527,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:
|
||||||
|
|||||||
@@ -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