feat(remote): server vault lookups, service CDM mapping, key display, and service param forwarding

This commit is contained in:
Andy
2026-03-20 21:13:56 -06:00
parent 51d6921eaf
commit 1ad226fbcf
4 changed files with 108 additions and 22 deletions
+10
View File
@@ -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]
+89 -19
View File
@@ -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
+7 -2
View File
@@ -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)
+2 -1
View File
@@ -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: