8 Commits

Author SHA1 Message Date
Andy
6137146705 chore: bump version to 1.4.5 and update changelog
- Update version from 1.4.4 to 1.4.5 in core/__init__.py
- Add comprehensive changelog entry for v1.4.5 with all changes since 1.4.4
- Include enhanced CDM support, caching improvements, and bug fixes
2025-09-09 03:53:42 +00:00
Andy
859d09693c feat(cdm): Update User-Agent to use dynamic version
- Replace hardcoded version "1.0" with dynamic version import in DecryptLabsRemoteCDM User-Agent header.
2025-09-09 03:49:01 +00:00
Andy
5f022635cb 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.
2025-09-06 22:10:35 +00:00
Andy
ad66502c0c 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
2025-09-06 20:30:11 +00:00
Andy
e462f07b7a Merge branch 'main' of https://github.com/unshackle-dl/unshackle 2025-09-06 19:39:39 +00:00
Andy
83b600e999 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.
2025-09-06 19:38:54 +00:00
Andy
ea8a7b00c9 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.
2025-09-06 18:52:20 +00:00
Andy
16ee4175a4 feat(dl): Truncate PSSH string for display in non-debug mode
* 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.
2025-09-05 02:15:10 +00:00
5 changed files with 106 additions and 13 deletions

View File

@@ -5,6 +5,37 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.4.5] - 2025-09-09
### Added
- **Enhanced CDM Key Caching**: Improved key caching and session management for L1/L2 devices
- Optimized `get_cached_keys_if_exists` functionality for better performance with L1/L2 devices
- Enhanced cached key retrieval logic with improved session handling
- **Widevine Common Certificate Fallback**: Added fallback to Widevine common certificate for L1 devices
- Improved compatibility for L1 devices when service certificates are unavailable
- **Enhanced Vault Loading**: Improved vault loading and key copying logic
- Better error handling and key management in vault operations
- **PSSH Display Optimization**: Truncated PSSH string display in non-debug mode for cleaner output
- **CDM Error Messaging**: Added error messages for missing service certificates in CDM sessions
### Changed
- **Dynamic Version Headers**: Updated User-Agent headers to use dynamic version strings
- DecryptLabsRemoteCDM now uses dynamic version import instead of hardcoded version
- **Intelligent CDM Caching**: Implemented intelligent caching system for CDM license requests
- Enhanced caching logic reduces redundant license requests and improves performance
- **Enhanced Tag Handling**: Improved tag handling for TV shows and movies from Simkl data
- Better metadata processing and formatting for improved media tagging
### Fixed
- **CDM Session Management**: 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
- Ensures clean state for subsequent requests and prevents session conflicts
- **Tag Formatting**: Fixed formatting issues in tag processing
- **Import Order**: Fixed import order issues in tags module
## [1.4.4] - 2025-09-02
### Added

View File

@@ -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",
)
)

View File

@@ -1 +1 @@
__version__ = "1.4.4"
__version__ = "1.4.5"

View File

@@ -6,10 +6,12 @@ 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
from unshackle.core.vaults import Vaults
from unshackle.core import __version__
class MockCertificateChain:
@@ -79,15 +81,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"
@@ -147,7 +151,7 @@ class DecryptLabsRemoteCDM:
{
"decrypt-labs-api-key": self.secret,
"Content-Type": "application/json",
"User-Agent": "unshackle-decrypt-labs-cdm/1.0",
"User-Agent": f"unshackle-decrypt-labs-cdm/{__version__}",
}
)
@@ -250,12 +254,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 +272,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]:
@@ -304,8 +312,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)
@@ -346,6 +359,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
@@ -365,6 +380,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
@@ -377,10 +393,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:
@@ -434,8 +455,30 @@ class DecryptLabsRemoteCDM:
if missing_kids:
session["cached_keys"] = parsed_keys
request_data["get_cached_keys_if_exists"] = False
response = self._http_session.post(f"{self.host}/get-request", json=request_data, timeout=30)
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=license_request_data, timeout=30
)
if response.status_code == 200:
data = response.json()
if data.get("message") == "success" and "challenge" in data:
@@ -580,6 +623,7 @@ class DecryptLabsRemoteCDM:
all_keys.append(license_key)
session["keys"] = all_keys
session["cached_keys"] = None
else:
session["keys"] = license_keys

View File

@@ -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."""