diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index f6dc0a3..2b130ff 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -58,15 +58,8 @@ from unshackle.core.tracks.attachment import Attachment from unshackle.core.tracks.hybrid import Hybrid from unshackle.core.utilities import get_system_fonts, is_close_match, time_elapsed_since from unshackle.core.utils import tags -from unshackle.core.utils.click_types import ( - LANGUAGE_RANGE, - QUALITY_LIST, - SEASON_RANGE, - ContextData, - MultipleChoice, - SubtitleCodecChoice, - VideoCodecChoice, -) +from unshackle.core.utils.click_types import (LANGUAGE_RANGE, QUALITY_LIST, SEASON_RANGE, ContextData, MultipleChoice, + SubtitleCodecChoice, VideoCodecChoice) from unshackle.core.utils.collections import merge_dict from unshackle.core.utils.subprocess import ffprobe from unshackle.core.vaults import Vaults @@ -876,6 +869,33 @@ class dl: download_table = Table.grid() 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() if skip_dl: @@ -1237,6 +1257,9 @@ class dl: if not drm: return + if isinstance(track, Video) and track.height: + pass + if isinstance(drm, Widevine): if not isinstance(self.cdm, (WidevineCdm, DecryptLabsRemoteCDM)) or ( isinstance(self.cdm, DecryptLabsRemoteCDM) and self.cdm.is_playready @@ -1245,6 +1268,7 @@ class dl: if widevine_cdm: self.log.info("Switching to Widevine CDM for Widevine content") self.cdm = widevine_cdm + elif isinstance(drm, PlayReady): if not isinstance(self.cdm, (PlayReadyCdm, DecryptLabsRemoteCDM)) or ( isinstance(self.cdm, DecryptLabsRemoteCDM) and not self.cdm.is_playready @@ -1517,9 +1541,11 @@ class dl: service: str, profile: Optional[str] = None, drm: Optional[str] = None, + quality: Optional[int] = None, ) -> Optional[object]: """ 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. """ cdm_name = config.cdm.get(service) or config.cdm.get("default") @@ -1527,23 +1553,82 @@ class dl: return None 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: - if not profile: + if quality: + quality_match = None + quality_keys = [] + + 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.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 - 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) if cdm_api: diff --git a/unshackle/unshackle-example.yaml b/unshackle/unshackle-example.yaml index 2a6414a..4ad46ff 100644 --- a/unshackle/unshackle-example.yaml +++ b/unshackle/unshackle-example.yaml @@ -88,6 +88,26 @@ cdm: jane_uhd: nexus_5_l1 # Profile 'jane_uhd' uses Nexus 5 L1 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 remote_cdm: - name: "chrome" @@ -106,16 +126,16 @@ remote_cdm: secret: secret_key - name: "decrypt_labs_chrome" - type: "decrypt_labs" # Required to identify as DecryptLabs CDM - device_name: "ChromeCDM" # Scheme identifier - must match exactly + type: "decrypt_labs" # Required to identify as DecryptLabs CDM + device_name: "ChromeCDM" # Scheme identifier - must match exactly device_type: CHROME system_id: 4464 # Doesn't matter security_level: 3 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" type: "decrypt_labs" - device_name: "L1" # Scheme identifier - must match exactly + device_name: "L1" # Scheme identifier - must match exactly device_type: ANDROID system_id: 4464 security_level: 1 @@ -124,7 +144,7 @@ remote_cdm: - name: "decrypt_labs_l2" type: "decrypt_labs" - device_name: "L2" # Scheme identifier - must match exactly + device_name: "L2" # Scheme identifier - must match exactly device_type: ANDROID system_id: 4464 security_level: 2 @@ -133,7 +153,7 @@ remote_cdm: - name: "decrypt_labs_playready_sl2" type: "decrypt_labs" - device_name: "SL2" # Scheme identifier - must match exactly + device_name: "SL2" # Scheme identifier - must match exactly device_type: PLAYREADY system_id: 0 security_level: 2000 @@ -142,7 +162,7 @@ remote_cdm: - name: "decrypt_labs_playready_sl3" type: "decrypt_labs" - device_name: "SL3" # Scheme identifier - must match exactly + device_name: "SL3" # Scheme identifier - must match exactly device_type: PLAYREADY system_id: 0 security_level: 3000