From 2330297ea49f77027894e867585f1bbb06279f90 Mon Sep 17 00:00:00 2001 From: Andy Date: Wed, 3 Sep 2025 14:50:51 +0000 Subject: [PATCH 1/7] =?UTF-8?q?feat(kv):=20=E2=9C=A8=20Enhance=20vault=20l?= =?UTF-8?q?oading=20and=20key=20copying=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Implemented `_load_vaults` function to load and validate vaults by name. * Improved `_copy_service_data` to handle key copying with better logging and error handling. * Updated `copy` command to utilize the new vault loading function and streamline the process. * Enhanced key insertion logic in MySQL and SQLite vaults to avoid inserting existing keys. --- unshackle/commands/kv.py | 149 ++++++++++++++++++++----------------- unshackle/vaults/MySQL.py | 19 ++++- unshackle/vaults/SQLite.py | 19 ++++- 3 files changed, 111 insertions(+), 76 deletions(-) diff --git a/unshackle/commands/kv.py b/unshackle/commands/kv.py index 60498c8..035f7f7 100644 --- a/unshackle/commands/kv.py +++ b/unshackle/commands/kv.py @@ -12,84 +12,113 @@ from unshackle.core.vault import Vault from unshackle.core.vaults import Vaults +def _load_vaults(vault_names: list[str]) -> Vaults: + """Load and validate vaults by name.""" + vaults = Vaults() + for vault_name in vault_names: + vault_config = next((x for x in config.key_vaults if x["name"] == vault_name), None) + if not vault_config: + raise click.ClickException(f"Vault ({vault_name}) is not defined in the config.") + + vault_type = vault_config["type"] + vault_args = vault_config.copy() + del vault_args["type"] + + if not vaults.load(vault_type, **vault_args): + raise click.ClickException(f"Failed to load vault ({vault_name}).") + + return vaults + + +def _process_service_keys(from_vault: Vault, service: str, log: logging.Logger) -> dict[str, str]: + """Get and validate keys from a vault for a specific service.""" + content_keys = list(from_vault.get_keys(service)) + + bad_keys = {kid: key for kid, key in content_keys if not key or key.count("0") == len(key)} + for kid, key in bad_keys.items(): + log.warning(f"Skipping NULL key: {kid}:{key}") + + return {kid: key for kid, key in content_keys if kid not in bad_keys} + + +def _copy_service_data(to_vault: Vault, from_vault: Vault, service: str, log: logging.Logger) -> int: + """Copy data for a single service between vaults.""" + content_keys = _process_service_keys(from_vault, service, log) + total_count = len(content_keys) + + if total_count == 0: + log.info(f"{service}: No keys found in {from_vault}") + return 0 + + try: + added = to_vault.add_keys(service, content_keys) + except PermissionError: + log.warning(f"{service}: No permission to create table in {to_vault}, skipped") + return 0 + + existed = total_count - added + + if added > 0 and existed > 0: + log.info(f"{service}: {added} added, {existed} skipped ({total_count} total)") + elif added > 0: + log.info(f"{service}: {added} added ({total_count} total)") + else: + log.info(f"{service}: {existed} skipped (all existed)") + + return added + + @click.group(short_help="Manage and configure Key Vaults.", context_settings=context_settings) def kv() -> None: """Manage and configure Key Vaults.""" @kv.command() -@click.argument("to_vault", type=str) -@click.argument("from_vaults", nargs=-1, type=click.UNPROCESSED) +@click.argument("to_vault_name", type=str) +@click.argument("from_vault_names", nargs=-1, type=click.UNPROCESSED) @click.option("-s", "--service", type=str, default=None, help="Only copy data to and from a specific service.") -def copy(to_vault: str, from_vaults: list[str], service: Optional[str] = None) -> None: +def copy(to_vault_name: str, from_vault_names: list[str], service: Optional[str] = None) -> None: """ Copy data from multiple Key Vaults into a single Key Vault. Rows with matching KIDs are skipped unless there's no KEY set. Existing data is not deleted or altered. - The `to_vault` argument is the key vault you wish to copy data to. + The `to_vault_name` argument is the key vault you wish to copy data to. It should be the name of a Key Vault defined in the config. - The `from_vaults` argument is the key vault(s) you wish to take + The `from_vault_names` argument is the key vault(s) you wish to take data from. You may supply multiple key vaults. """ - if not from_vaults: + if not from_vault_names: raise click.ClickException("No Vaults were specified to copy data from.") log = logging.getLogger("kv") - vaults = Vaults() - for vault_name in [to_vault] + list(from_vaults): - vault = next((x for x in config.key_vaults if x["name"] == vault_name), None) - if not vault: - raise click.ClickException(f"Vault ({vault_name}) is not defined in the config.") - vault_type = vault["type"] - vault_args = vault.copy() - del vault_args["type"] - if not vaults.load(vault_type, **vault_args): - raise click.ClickException(f"Failed to load vault ({vault_name}).") + all_vault_names = [to_vault_name] + list(from_vault_names) + vaults = _load_vaults(all_vault_names) - to_vault: Vault = vaults.vaults[0] - from_vaults: list[Vault] = vaults.vaults[1:] + to_vault = vaults.vaults[0] + from_vaults = vaults.vaults[1:] + + vault_names = ", ".join([v.name for v in from_vaults]) + log.info(f"Copying data from {vault_names} → {to_vault.name}") - log.info(f"Copying data from {', '.join([x.name for x in from_vaults])}, into {to_vault.name}") if service: service = Services.get_tag(service) - log.info(f"Only copying data for service {service}") + log.info(f"Filtering by service: {service}") total_added = 0 for from_vault in from_vaults: - if service: - services = [service] - else: - services = from_vault.get_services() - - for service_ in services: - log.info(f"Getting data from {from_vault} for {service_}") - content_keys = list(from_vault.get_keys(service_)) # important as it's a generator we iterate twice - - bad_keys = {kid: key for kid, key in content_keys if not key or key.count("0") == len(key)} - - for kid, key in bad_keys.items(): - log.warning(f"Cannot add a NULL Content Key to a Vault, skipping: {kid}:{key}") - - content_keys = {kid: key for kid, key in content_keys if kid not in bad_keys} - - total_count = len(content_keys) - log.info(f"Adding {total_count} Content Keys to {to_vault} for {service_}") - - try: - added = to_vault.add_keys(service_, content_keys) - except PermissionError: - log.warning(f" - No permission to create table ({service_}) in {to_vault}, skipping...") - continue + services_to_copy = [service] if service else from_vault.get_services() + for service_tag in services_to_copy: + added = _copy_service_data(to_vault, from_vault, service_tag, log) total_added += added - existed = total_count - added - log.info(f"{to_vault} ({service_}): {added} newly added, {existed} already existed (skipped)") - - log.info(f"{to_vault}: {total_added} total newly added") + if total_added > 0: + log.info(f"Successfully added {total_added} new keys to {to_vault}") + else: + log.info("Copy completed - no new keys to add") @kv.command() @@ -106,9 +135,9 @@ def sync(ctx: click.Context, vaults: list[str], service: Optional[str] = None) - if not len(vaults) > 1: raise click.ClickException("You must provide more than one Vault to sync.") - ctx.invoke(copy, to_vault=vaults[0], from_vaults=vaults[1:], service=service) + ctx.invoke(copy, to_vault_name=vaults[0], from_vault_names=vaults[1:], service=service) for i in range(1, len(vaults)): - ctx.invoke(copy, to_vault=vaults[i], from_vaults=[vaults[i - 1]], service=service) + ctx.invoke(copy, to_vault_name=vaults[i], from_vault_names=[vaults[i - 1]], service=service) @kv.command() @@ -135,15 +164,7 @@ def add(file: Path, service: str, vaults: list[str]) -> None: log = logging.getLogger("kv") service = Services.get_tag(service) - vaults_ = Vaults() - for vault_name in vaults: - vault = next((x for x in config.key_vaults if x["name"] == vault_name), None) - if not vault: - raise click.ClickException(f"Vault ({vault_name}) is not defined in the config.") - vault_type = vault["type"] - vault_args = vault.copy() - del vault_args["type"] - vaults_.load(vault_type, **vault_args) + vaults_ = _load_vaults(list(vaults)) data = file.read_text(encoding="utf8") kid_keys: dict[str, str] = {} @@ -173,15 +194,7 @@ def prepare(vaults: list[str]) -> None: """Create Service Tables on Vaults if not yet created.""" log = logging.getLogger("kv") - vaults_ = Vaults() - for vault_name in vaults: - vault = next((x for x in config.key_vaults if x["name"] == vault_name), None) - if not vault: - raise click.ClickException(f"Vault ({vault_name}) is not defined in the config.") - vault_type = vault["type"] - vault_args = vault.copy() - del vault_args["type"] - vaults_.load(vault_type, **vault_args) + vaults_ = _load_vaults(vaults) for vault in vaults_: if hasattr(vault, "has_table") and hasattr(vault, "create_table"): diff --git a/unshackle/vaults/MySQL.py b/unshackle/vaults/MySQL.py index ecd7a90..01b35bb 100644 --- a/unshackle/vaults/MySQL.py +++ b/unshackle/vaults/MySQL.py @@ -131,16 +131,27 @@ class MySQL(Vault): if any(isinstance(kid, UUID) for kid, key_ in kid_keys.items()): kid_keys = {kid.hex if isinstance(kid, UUID) else kid: key_ for kid, key_ in kid_keys.items()} + if not kid_keys: + return 0 + conn = self.conn_factory.get() cursor = conn.cursor() try: + placeholders = ",".join(["%s"] * len(kid_keys)) + cursor.execute(f"SELECT kid FROM `{service}` WHERE kid IN ({placeholders})", list(kid_keys.keys())) + existing_kids = {row["kid"] for row in cursor.fetchall()} + + new_keys = {kid: key for kid, key in kid_keys.items() if kid not in existing_kids} + + if not new_keys: + return 0 + cursor.executemany( - # TODO: SQL injection risk - f"INSERT IGNORE INTO `{service}` (kid, key_) VALUES (%s, %s)", - kid_keys.items(), + f"INSERT INTO `{service}` (kid, key_) VALUES (%s, %s)", + new_keys.items(), ) - return cursor.rowcount + return len(new_keys) finally: conn.commit() cursor.close() diff --git a/unshackle/vaults/SQLite.py b/unshackle/vaults/SQLite.py index 5eaf6a8..d796bfa 100644 --- a/unshackle/vaults/SQLite.py +++ b/unshackle/vaults/SQLite.py @@ -102,16 +102,27 @@ class SQLite(Vault): if any(isinstance(kid, UUID) for kid, key_ in kid_keys.items()): kid_keys = {kid.hex if isinstance(kid, UUID) else kid: key_ for kid, key_ in kid_keys.items()} + if not kid_keys: + return 0 + conn = self.conn_factory.get() cursor = conn.cursor() try: + placeholders = ",".join(["?"] * len(kid_keys)) + cursor.execute(f"SELECT kid FROM `{service}` WHERE kid IN ({placeholders})", list(kid_keys.keys())) + existing_kids = {row[0] for row in cursor.fetchall()} + + new_keys = {kid: key for kid, key in kid_keys.items() if kid not in existing_kids} + + if not new_keys: + return 0 + cursor.executemany( - # TODO: SQL injection risk - f"INSERT OR IGNORE INTO `{service}` (kid, key_) VALUES (?, ?)", - kid_keys.items(), + f"INSERT INTO `{service}` (kid, key_) VALUES (?, ?)", + new_keys.items(), ) - return cursor.rowcount + return len(new_keys) finally: conn.commit() cursor.close() From f722ec69b69bb77efbd3f19fdf62d038b5c45954 Mon Sep 17 00:00:00 2001 From: Andy Date: Wed, 3 Sep 2025 14:51:22 +0000 Subject: [PATCH 2/7] =?UTF-8?q?fix(tags):=20=F0=9F=90=9B=20Fix=20formattin?= =?UTF-8?q?g=20issues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- unshackle/core/utils/tags.py | 2 +- unshackle/unshackle-example.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/unshackle/core/utils/tags.py b/unshackle/core/utils/tags.py index 56d25f0..728c03d 100644 --- a/unshackle/core/utils/tags.py +++ b/unshackle/core/utils/tags.py @@ -359,7 +359,7 @@ def tag_file(path: Path, title: Title, tmdb_id: Optional[int] | None = None) -> standard_tags["TVDB2"] = f"series/{show_ids['tvdb']}" if show_ids.get("tmdbtv"): standard_tags["TMDB"] = f"tv/{show_ids['tmdbtv']}" - + # Handle movie data from Simkl elif simkl_data.get("type") == "movie" and "movie" in simkl_data: movie_ids = simkl_data.get("movie", {}).get("ids", {}) diff --git a/unshackle/unshackle-example.yaml b/unshackle/unshackle-example.yaml index aa8819b..2a6414a 100644 --- a/unshackle/unshackle-example.yaml +++ b/unshackle/unshackle-example.yaml @@ -117,7 +117,7 @@ remote_cdm: type: "decrypt_labs" device_name: "L1" # Scheme identifier - must match exactly device_type: ANDROID - system_id: 4464 + system_id: 4464 security_level: 1 host: "https://keyxtractor.decryptlabs.com" secret: "your_decrypt_labs_api_key_here" From 16ee4175a40d7dfdc65999b2e1ba6cc9a3bd347d Mon Sep 17 00:00:00 2001 From: Andy Date: Fri, 5 Sep 2025 02:15:10 +0000 Subject: [PATCH 3/7] =?UTF-8?q?feat(dl):=20=E2=9C=A8=20Truncate=20PSSH=20s?= =?UTF-8?q?tring=20for=20display=20in=20non-debug=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Added `_truncate_pssh_for_display` method to limit the width of PSSH strings shown in the console. * Ensures better readability of DRM information by truncating long strings. --- unshackle/commands/dl.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index eac5e7d..14c6481 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -66,6 +66,18 @@ from unshackle.core.vaults import Vaults class dl: + @staticmethod + def _truncate_pssh_for_display(pssh_string: str, drm_type: str) -> str: + """Truncate PSSH string for display when not in debug mode.""" + if logging.root.level == logging.DEBUG or not pssh_string: + return pssh_string + + max_width = console.width - len(drm_type) - 12 + if len(pssh_string) <= max_width: + return pssh_string + + return pssh_string[: max_width - 3] + "..." + @click.command( short_help="Download, Decrypt, and Mux tracks for titles from a Service.", cls=Services, @@ -1228,7 +1240,8 @@ class dl: if isinstance(drm, Widevine): with self.DRM_TABLE_LOCK: - cek_tree = Tree(Text.assemble(("Widevine", "cyan"), (f"({drm.pssh.dumps()})", "text"), overflow="fold")) + pssh_display = self._truncate_pssh_for_display(drm.pssh.dumps(), "Widevine") + cek_tree = Tree(Text.assemble(("Widevine", "cyan"), (f"({pssh_display})", "text"), overflow="fold")) pre_existing_tree = next( (x for x in table.columns[0].cells if isinstance(x, Tree) and x.label == cek_tree.label), None ) @@ -1320,10 +1333,11 @@ class dl: elif isinstance(drm, PlayReady): with self.DRM_TABLE_LOCK: + pssh_display = self._truncate_pssh_for_display(drm.pssh_b64 or "", "PlayReady") cek_tree = Tree( Text.assemble( ("PlayReady", "cyan"), - (f"({drm.pssh_b64 or ''})", "text"), + (f"({pssh_display})", "text"), overflow="fold", ) ) From ea8a7b00c9b68f941a1b7bac5523b9addd9f0366 Mon Sep 17 00:00:00 2001 From: Andy Date: Sat, 6 Sep 2025 18:52:20 +0000 Subject: [PATCH 4/7] fix(cdm): Clean up session data when retrieving cached keys Remove decrypt_labs_session_id and challenge from session when cached keys exist but there are missing kids, ensuring clean state for subsequent requests. --- unshackle/core/cdm/decrypt_labs_remote_cdm.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/unshackle/core/cdm/decrypt_labs_remote_cdm.py b/unshackle/core/cdm/decrypt_labs_remote_cdm.py index 58fd355..54f267a 100644 --- a/unshackle/core/cdm/decrypt_labs_remote_cdm.py +++ b/unshackle/core/cdm/decrypt_labs_remote_cdm.py @@ -435,6 +435,12 @@ class DecryptLabsRemoteCDM: if missing_kids: session["cached_keys"] = parsed_keys request_data["get_cached_keys_if_exists"] = False + + if "decrypt_labs_session_id" in session: + del session["decrypt_labs_session_id"] + if "challenge" in session: + del session["challenge"] + response = self._http_session.post(f"{self.host}/get-request", json=request_data, timeout=30) if response.status_code == 200: data = response.json() From 83b600e99946d91a89839a2c5a7481d389cb814c Mon Sep 17 00:00:00 2001 From: Andy Date: Sat, 6 Sep 2025 18:52:20 +0000 Subject: [PATCH 5/7] fix(cdm): Clean up session data when retrieving cached keys Remove decrypt_labs_session_id and challenge from session when cached keys exist but there are missing kids, ensuring clean state for subsequent requests. --- unshackle/core/cdm/decrypt_labs_remote_cdm.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/unshackle/core/cdm/decrypt_labs_remote_cdm.py b/unshackle/core/cdm/decrypt_labs_remote_cdm.py index 58fd355..5363e95 100644 --- a/unshackle/core/cdm/decrypt_labs_remote_cdm.py +++ b/unshackle/core/cdm/decrypt_labs_remote_cdm.py @@ -250,12 +250,14 @@ class DecryptLabsRemoteCDM: "pssh": None, "challenge": None, "decrypt_labs_session_id": None, + "tried_cache": False, + "cached_keys": None, } return session_id def close(self, session_id: bytes) -> None: """ - Close a CDM session. + Close a CDM session and perform comprehensive cleanup. Args: session_id: Session identifier @@ -266,6 +268,8 @@ class DecryptLabsRemoteCDM: if session_id not in self._sessions: raise DecryptLabsRemoteCDMExceptions.InvalidSession(f"Invalid session ID: {session_id.hex()}") + session = self._sessions[session_id] + session.clear() del self._sessions[session_id] def get_service_certificate(self, session_id: bytes) -> Optional[bytes]: @@ -435,6 +439,10 @@ class DecryptLabsRemoteCDM: if missing_kids: session["cached_keys"] = parsed_keys request_data["get_cached_keys_if_exists"] = False + session["decrypt_labs_session_id"] = None + session["challenge"] = None + session["tried_cache"] = False + response = self._http_session.post(f"{self.host}/get-request", json=request_data, timeout=30) if response.status_code == 200: data = response.json() @@ -580,6 +588,7 @@ class DecryptLabsRemoteCDM: all_keys.append(license_key) session["keys"] = all_keys + session["cached_keys"] = None else: session["keys"] = license_keys From ad66502c0c6f2305708cd09fd247ce6ed9357a3c Mon Sep 17 00:00:00 2001 From: Andy Date: Sat, 6 Sep 2025 20:30:11 +0000 Subject: [PATCH 6/7] feat(cdm): Add fallback to Widevine common cert for L1 devices - Use default Widevine common privacy certificate when no service certificate is provided for L1 devices - Add get_widevine_service_certificate method to EXAMPLE service for config-based certificates - Improve certificate handling with more descriptive return messages --- unshackle/core/cdm/decrypt_labs_remote_cdm.py | 10 ++++++++-- unshackle/services/EXAMPLE/__init__.py | 4 ++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/unshackle/core/cdm/decrypt_labs_remote_cdm.py b/unshackle/core/cdm/decrypt_labs_remote_cdm.py index 5363e95..b11434f 100644 --- a/unshackle/core/cdm/decrypt_labs_remote_cdm.py +++ b/unshackle/core/cdm/decrypt_labs_remote_cdm.py @@ -6,6 +6,7 @@ from typing import Any, Dict, List, Optional, Union from uuid import UUID import requests +from pywidevine.cdm import Cdm as WidevineCdm from pywidevine.device import DeviceTypes from requests import Session @@ -308,8 +309,13 @@ class DecryptLabsRemoteCDM: raise DecryptLabsRemoteCDMExceptions.InvalidSession(f"Invalid session ID: {session_id.hex()}") if certificate is None: - self._sessions[session_id]["service_certificate"] = None - return "Removed" + if not self._is_playready and self.device_name == "L1": + certificate = WidevineCdm.common_privacy_cert + self._sessions[session_id]["service_certificate"] = base64.b64decode(certificate) + return "Using default Widevine common privacy certificate for L1" + else: + self._sessions[session_id]["service_certificate"] = None + return "No certificate set (not required for this device type)" if isinstance(certificate, str): certificate = base64.b64decode(certificate) diff --git a/unshackle/services/EXAMPLE/__init__.py b/unshackle/services/EXAMPLE/__init__.py index 64fdb8f..2590e87 100644 --- a/unshackle/services/EXAMPLE/__init__.py +++ b/unshackle/services/EXAMPLE/__init__.py @@ -282,6 +282,10 @@ class EXAMPLE(Service): return chapters + def get_widevine_service_certificate(self, **_: any) -> str: + """Return the Widevine service certificate from config, if available.""" + return self.config.get("certificate") + def get_playready_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[bytes]: """Retrieve a PlayReady license for a given track.""" From 5f022635cb39ed494612a337084895565bd0af21 Mon Sep 17 00:00:00 2001 From: Andy Date: Sat, 6 Sep 2025 22:10:35 +0000 Subject: [PATCH 7/7] feat(cdm): Optimize get_cached_keys_if_exists for L1/L2 devices - Always send get_cached_keys_if_exists=True for L1/L2 devices to leverage - the API's automatic caching optimization. This reduces unnecessary license - requests by prioritizing cached keys for these security levels. --- unshackle/core/cdm/decrypt_labs_remote_cdm.py | 40 ++++++++++++++++--- 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/unshackle/core/cdm/decrypt_labs_remote_cdm.py b/unshackle/core/cdm/decrypt_labs_remote_cdm.py index b11434f..c6fee9c 100644 --- a/unshackle/core/cdm/decrypt_labs_remote_cdm.py +++ b/unshackle/core/cdm/decrypt_labs_remote_cdm.py @@ -80,15 +80,17 @@ class DecryptLabsRemoteCDM: Key Features: - Compatible with both Widevine and PlayReady DRM schemes - Intelligent caching that compares required vs. available keys + - Optimized caching for L1/L2 devices (leverages API auto-optimization) - Automatic key combination for mixed cache/license scenarios - Seamless fallback to license requests when keys are missing Intelligent Caching System: 1. DRM classes (PlayReady/Widevine) provide required KIDs via set_required_kids() 2. get_license_challenge() first checks for cached keys - 3. If cached keys satisfy requirements, returns empty challenge (no license needed) - 4. If keys are missing, makes targeted license request for remaining keys - 5. parse_license() combines cached and license keys intelligently + 3. For L1/L2 devices, always attempts cached keys first (API optimized) + 4. If cached keys satisfy requirements, returns empty challenge (no license needed) + 5. If keys are missing, makes targeted license request for remaining keys + 6. parse_license() combines cached and license keys intelligently """ service_certificate_challenge = b"\x08\x04" @@ -356,6 +358,8 @@ class DecryptLabsRemoteCDM: 4. Returns empty challenge if all required keys are cached The intelligent caching works as follows: + - For L1/L2 devices: Always prioritizes cached keys (API automatically optimizes) + - For other devices: Uses cache retry logic based on session state - With required KIDs set: Only requests license for missing keys - Without required KIDs: Returns any available cached keys - For PlayReady: Combines cached keys with license keys seamlessly @@ -375,6 +379,7 @@ class DecryptLabsRemoteCDM: Note: Call set_required_kids() before this method for optimal caching behavior. + L1/L2 devices automatically use cached keys when available per API design. """ _ = license_type, privacy_mode @@ -387,10 +392,15 @@ class DecryptLabsRemoteCDM: init_data = self._get_init_data_from_pssh(pssh_or_wrm) already_tried_cache = session.get("tried_cache", False) + if self.device_name in ["L1", "L2"]: + get_cached_keys = True + else: + get_cached_keys = not already_tried_cache + request_data = { "scheme": self.device_name, "init_data": init_data, - "get_cached_keys_if_exists": not already_tried_cache, + "get_cached_keys_if_exists": get_cached_keys, } if self.device_name in ["L1", "L2", "SL2", "SL3"] and self.service_name: @@ -444,12 +454,30 @@ class DecryptLabsRemoteCDM: if missing_kids: session["cached_keys"] = parsed_keys - request_data["get_cached_keys_if_exists"] = False + + if self.device_name in ["L1", "L2"]: + license_request_data = { + "scheme": self.device_name, + "init_data": init_data, + "get_cached_keys_if_exists": False, + } + if self.service_name: + license_request_data["service"] = self.service_name + if session["service_certificate"]: + license_request_data["service_certificate"] = base64.b64encode( + session["service_certificate"] + ).decode("utf-8") + else: + license_request_data = request_data.copy() + license_request_data["get_cached_keys_if_exists"] = False + session["decrypt_labs_session_id"] = None session["challenge"] = None session["tried_cache"] = False - response = self._http_session.post(f"{self.host}/get-request", json=request_data, timeout=30) + response = self._http_session.post( + f"{self.host}/get-request", json=license_request_data, timeout=30 + ) if response.status_code == 200: data = response.json() if data.get("message") == "success" and "challenge" in data: