diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 77fc1961..b95d0f35 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -2656,6 +2656,11 @@ class dl: for kid in all_kids: if kid in drm.content_keys: + is_track_kid = ["", "*"][kid == track_kid] + key = drm.content_keys[kid] + label = f"[text2]{kid.hex}:{key}{is_track_kid}" + if not any(f"{kid.hex}:{key}" in x.label for x in cek_tree.children): + cek_tree.add(label) continue is_track_kid = ["", "*"][kid == track_kid] @@ -2846,6 +2851,11 @@ class dl: for kid in all_kids: if kid in drm.content_keys: + is_track_kid = ["", "*"][kid == track_kid] + key = drm.content_keys[kid] + label = f"[text2]{kid.hex}:{key}{is_track_kid}" + if not any(f"{kid.hex}:{key}" in x.label for x in cek_tree.children): + cek_tree.add(label) continue is_track_kid = ["", "*"][kid == track_kid] diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index 626b954d..c3074b85 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -1241,23 +1241,15 @@ def _create_service_instance( service_kwargs: Dict[str, Any] = {"title": title_id} - for key, value in data.items(): - if key not in [ - "service", - "title_id", - "profile", - "season", - "episode", - "wanted", - "proxy", - "no_proxy", - "credentials", - "cookies", - ]: - service_kwargs[key] = value + transport_keys = {"service", "title_id", "season", "episode", "wanted", "proxy", "no_proxy", "credentials", "cookies", + "cache", "client_region", "no_proxy"} service_init_params = inspect.signature(service_module.__init__).parameters + for key, value in data.items(): + if key not in transport_keys and key in service_init_params: + service_kwargs[key] = value + if hasattr(service_module, "cli") and hasattr(service_module.cli, "params"): for param in service_module.cli.params: if hasattr(param, "name") and param.name not in service_kwargs: @@ -1271,6 +1263,8 @@ def _create_service_instance( service_kwargs[param_name] = None elif param_name == "movie": service_kwargs[param_name] = False + elif param_name == "profile": + service_kwargs[param_name] = profile else: log.warning(f"Unknown required parameter '{param_name}' for service {normalized_service}") @@ -1771,8 +1765,23 @@ def _ensure_track_drm(track: Any) -> None: ) -def _resolve_device_name(user_config: dict, drm_type: str) -> str: - """Get the configured device name for a DRM type from user config.""" +def _resolve_device_name(user_config: dict, drm_type: str, service_tag: str = "") -> str: + """Get the CDM device name, checking service-specific config.cdm first. + + Resolution order: + 1. config.cdm[service_tag] (service-specific CDM mapping) + 2. serve.users.{key}.devices / playready_devices (user device list) + """ + from unshackle.core.config import config as app_config + + cdm_name = app_config.cdm.get(service_tag) if service_tag else None + if isinstance(cdm_name, dict): + drm_key = {"widevine": "widevine", "playready": "playready"}.get(drm_type) + lower_keys = {k.lower(): v for k, v in cdm_name.items()} + cdm_name = lower_keys.get(drm_key) or lower_keys.get("default") or app_config.cdm.get("default") + if cdm_name and isinstance(cdm_name, str): + return cdm_name + if drm_type == "playready": device_name = (user_config.get("playready_devices") or [None])[0] if not device_name: @@ -1784,6 +1793,49 @@ def _resolve_device_name(user_config: dict, drm_type: str) -> str: return device_name +def _check_vaults(kids: list, service_name: str) -> Optional[Dict[str, str]]: + """Check server vaults for existing keys matching all KIDs. + + Returns a KID:KEY dict if ALL KIDs are found, None otherwise. + """ + from uuid import UUID + + from unshackle.core.vaults import Vaults + + try: + vaults = Vaults(service_name) + keys: Dict[str, str] = {} + for kid in kids: + kid_uuid = kid if isinstance(kid, UUID) else UUID(hex=str(kid)) + content_key, vault_used = vaults.get_key(kid_uuid) + if content_key: + keys[kid_uuid.hex] = content_key + else: + return None + if keys: + log.info(f"Vault hit: {len(keys)} key(s) from server vaults, skipping CDM") + return keys + except Exception: + pass + return None + + +def _cache_to_vaults(keys: Dict[str, str], service_name: str) -> None: + """Cache newly obtained keys to server vaults.""" + from uuid import UUID + + from unshackle.core.vaults import Vaults + + try: + vaults = Vaults(service_name) + key_map = {UUID(hex=kid): key for kid, key in keys.items()} + cached = vaults.add_keys(key_map) + if cached: + log.info(f"Cached {cached} key(s) to server vaults") + except Exception: + pass + + def _handle_single_server_cdm( service: Any, title: Any, @@ -1807,8 +1859,6 @@ def _handle_single_server_cdm( api_key = request.headers.get("X-Secret-Key", "anonymous") if request else "anonymous" user_config = app_config.serve.get("users", {}).get(api_key, {}) - device_name = _resolve_device_name(user_config, drm_type) - cdm = load_cdm(device_name, service_name=service.__class__.__name__) if drm_type == "playready": from pyplayready.system.pssh import PSSH as PlayReadyPSSH @@ -1817,6 +1867,13 @@ def _handle_single_server_cdm( pr_pssh = PlayReadyPSSH(base64.b64decode(pssh_b64)) pr_drm = PlayReady(pssh=pr_pssh, pssh_b64=pssh_b64) + + vault_keys = _check_vaults(pr_drm.kids, service.__class__.__name__) + if vault_keys: + return vault_keys + + device_name = _resolve_device_name(user_config, drm_type, service.__class__.__name__) + cdm = load_cdm(device_name, service_name=service.__class__.__name__) pr_drm.get_content_keys( cdm=cdm, certificate=lambda challenge, **_: None, @@ -1830,7 +1887,15 @@ def _handle_single_server_cdm( from unshackle.core.drm import Widevine - wv_drm = Widevine(pssh=WvPSSH(pssh_b64)) + wv_pssh = WvPSSH(pssh_b64) + wv_drm = Widevine(pssh=wv_pssh) + + vault_keys = _check_vaults(wv_drm.kids, service.__class__.__name__) + if vault_keys: + return vault_keys + + device_name = _resolve_device_name(user_config, drm_type, service.__class__.__name__) + cdm = load_cdm(device_name, service_name=service.__class__.__name__) wv_drm.get_content_keys( cdm=cdm, certificate=lambda challenge, **_: service.get_widevine_service_certificate( @@ -1850,6 +1915,7 @@ def _handle_single_server_cdm( if not keys: raise APIError(APIErrorCode.NO_CONTENT, "Server CDM returned no content keys") + _cache_to_vaults(keys, service.__class__.__name__) return keys @@ -1956,6 +2022,8 @@ async def session_license_handler( keys = _handle_single_server_cdm(service, title, track, pssh_str, track_drm_type, request) if keys: all_keys[tid] = keys + except SystemExit: + log.warning(f"Service exited while resolving keys for track {tid[:12]}, skipping") except Exception as e: log.warning(f"Failed to resolve keys for track {tid[:12]}: {e}") @@ -2007,6 +2075,8 @@ async def session_license_handler( except APIError: raise + except SystemExit: + raise APIError(APIErrorCode.SERVICE_ERROR, "Service exited during license request") except Exception as e: log.exception(f"Error proxying license for track {track_id}") debug_mode = request.app.get("debug_api", False) if request else False diff --git a/unshackle/core/remote_service.py b/unshackle/core/remote_service.py index 3318fa98..ffac6232 100644 --- a/unshackle/core/remote_service.py +++ b/unshackle/core/remote_service.py @@ -375,6 +375,7 @@ class RemoteService: server_url: str, api_key: str, services_config: dict, + service_params: Optional[Dict[str, Any]] = None, ) -> None: self.__class__.__name__ = service_tag console.print(Padding(Rule(f"[rule.text]Service: {service_tag} (Remote)"), (1, 2))) @@ -383,6 +384,7 @@ class RemoteService: self.title_id = title_id self.client = RemoteClient(server_url, api_key) self.ctx = ctx + self._service_params = service_params or {} self.log = logging.getLogger(service_tag) self.credential: Optional[Credential] = None self.current_region: Optional[str] = None @@ -482,6 +484,8 @@ class RemoteService: create_data["profile"] = profile if no_proxy: create_data["no_proxy"] = True + if self._service_params: + create_data.update(self._service_params) cache_data = self._load_cache_files() if cache_data: @@ -569,15 +573,16 @@ class RemoteService: from unshackle.core.drm import Widevine + WIDEVINE_SYSTEM_ID = UUID("edef8ba9-79d6-4ace-a3c8-27dcd51d21ed") first_kid = next(iter(track_keys)) - dummy_pssh = WvPSSH.new(key_ids=[UUID(hex=first_kid)]) + dummy_pssh = WvPSSH.new(system_id=WIDEVINE_SYSTEM_ID, key_ids=[UUID(hex=first_kid)]) drm_obj = Widevine(pssh=dummy_pssh, kid=first_kid) for kid_hex, key_hex in track_keys.items(): drm_obj.content_keys[UUID(hex=kid_hex)] = key_hex track.drm = [drm_obj] key_count = sum(len(v) for v in keys_by_track.values()) if key_count: - self.log.info(f"Server CDM resolved {key_count} key(s) for {len(keys_by_track)} track(s)") + self.log.debug(f"Server CDM resolved {key_count} key(s) for {len(keys_by_track)} track(s)") except Exception as e: self.log.warning("Failed to resolve server CDM keys: %s", e) diff --git a/unshackle/core/services.py b/unshackle/core/services.py index e530afca..140a767e 100644 --- a/unshackle/core/services.py +++ b/unshackle/core/services.py @@ -103,7 +103,8 @@ class Services(click.MultiCommand): server_name = ctx.parent.params.get("server") if ctx.parent else None server_url, api_key, services_config = resolve_server(server_name) - return RemoteService(ctx, tag, title, server_url, api_key, services_config) + service_params = {k: v for k, v in kwargs.items() if v is not None and v is not False} + return RemoteService(ctx, tag, title, server_url, api_key, services_config, service_params=service_params) if cli_params: for param in cli_params: