fix(drm): retry a licence call only for an argument the service rejects

A TypeError raised inside the service no longer sends the challenge again.
This commit is contained in:
imSp4rky
2026-11-02 09:01:42 -07:00
parent 962e47ac6b
commit 31328dc109
5 changed files with 86 additions and 43 deletions
+2
View File
@@ -586,6 +586,8 @@ current `title` and `track`:
| `get_playready_license` | PlayReady | The license response. Defaults to delegating to `get_widevine_license`. |
| `get_clearkey_license` | DASH `org.w3.clearkey` | The JWK Set (dict/JSON/bytes), or `None` to let the framework POST the challenge to the manifest's Laurl. |
A license callback can also declare optional keyword arguments. `get_widevine_license` can ask for `pssh` and `session_id`. `get_playready_license` can ask for `pssh_b64` and `session_id`. `session_id` is the open CDM session. A callback gets only the arguments that its signature declares.
```python
def get_widevine_license(self, *, challenge: bytes, title, track):
response = self.session.post(
@@ -30,7 +30,7 @@ pytestmark = pytest.mark.unit
KID = UUID("11111111111111111111111111111111")
KEY = bytes.fromhex("22222222222222222222222222222222")
PSSH_B64 = base64.b64encode(b"pssh").decode()
SESSION_ID = b"sid-bytes"
SESSION_ID = bytes(range(16))
class _Recorder:
@@ -105,7 +105,7 @@ class StubPlayReady(PlayReady):
kids: Any = None
def __init__(self, pssh: Any = None, pssh_b64: Optional[str] = None, **kwargs: Any) -> None:
self.pssh = SimpleNamespace(wrm_headers=["<WRMHEADER/>"])
self.pssh = SimpleNamespace(wrm_headers=[SimpleNamespace(dumps=lambda: "<WRMHEADER/>")])
self.pssh_b64 = pssh_b64
self.kids = [KID]
self._extra_headers: list[Any] = []
@@ -211,27 +211,6 @@ def test_server_cdm_widevine_var_kwargs_service_receives_pssh_and_session_id(wid
assert service.calls[0]["session_id"] == SESSION_ID
def test_server_cdm_widevine_unnarrowable_signature_retries_without_the_extras(widevine_env: None) -> None:
# declared_kwargs cannot narrow a signature it cannot read (a compiled service) so it offers
# everything. The call raises TypeError and Widevine retries with `challenge` alone, which
# the lambda turns back into the classic challenge/title/track set the service does accept.
service = ClassicService()
calls: list[dict[str, Any]] = []
def compiled(**kw: Any) -> bytes:
calls.append(kw)
if {"pssh", "session_id"} & set(kw):
raise TypeError("unexpected keyword argument")
return b"LIC"
service.get_widevine_license = compiled # type: ignore[method-assign]
_server_cdm(service, "widevine")
assert len(calls) == 2
assert set(calls[0]) == {"challenge", "title", "track", "pssh", "session_id"}
assert set(calls[1]) == {"challenge", "title", "track"}
def test_server_cdm_widevine_lambda_is_never_itself_filtered(widevine_env: None) -> None:
# The `(**kw)` lambda would look like a VAR_KEYWORD service if declared_kwargs were ever
# applied to it, and a classic service would then be handed `pssh` and `session_id`.
@@ -250,22 +229,77 @@ def test_server_cdm_playready_classic_signature_gets_three_arguments(playready_e
assert set(service.calls[0]) == {"challenge", "title", "track"}
def test_server_cdm_playready_offers_pssh_b64_not_pssh_or_session_id(playready_env: None) -> None:
# PlayReady.get_content_keys calls licence(challenge=..., pssh_b64=...); it has no
# session_id to give, so a session-aware service sees the None default there.
def test_server_cdm_playready_offers_pssh_b64_and_session_id_not_pssh(playready_env: None) -> None:
service = VarKwService()
_server_cdm(service, "playready")
assert set(service.calls[0]) == {"challenge", "title", "track", "pssh_b64"}
assert set(service.calls[0]) == {"challenge", "title", "track", "pssh_b64", "session_id"}
assert service.calls[0]["pssh_b64"] == PSSH_B64
assert service.calls[0]["session_id"] == SESSION_ID
def test_server_cdm_playready_session_service_is_not_passed_session_id(playready_env: None) -> None:
def test_server_cdm_playready_session_service_receives_session_id(playready_env: None) -> None:
service = SessionService()
_server_cdm(service, "playready")
assert set(service.calls[0]) == {"challenge", "title", "track", "session_id"}
assert service.calls[0]["session_id"] is None # the signature default, never an offered value
assert service.calls[0]["session_id"] == SESSION_ID
@pytest.mark.parametrize(
("drm_type", "rejects", "last_call"),
[
("widevine", ("pssh", "session_id"), {"challenge", "title", "track"}),
("playready", ("session_id",), {"challenge", "title", "track", "pssh_b64"}),
("playready", ("pssh_b64", "session_id"), {"challenge", "title", "track"}),
],
)
def test_server_cdm_unnarrowable_signature_retries_without_the_rejected_extras(
drm_type: str, rejects: tuple[str, ...], last_call: set[str], request: pytest.FixtureRequest
) -> None:
# declared_kwargs cannot narrow a signature it cannot read (a compiled service) so it offers
# everything. Each TypeError names one rejected extra, and call_licence retries without it.
request.getfixturevalue(f"{drm_type}_env")
service = ClassicService()
classic = getattr(service, f"get_{drm_type}_license")
calls: list[dict[str, Any]] = []
def compiled(**kw: Any) -> Any:
calls.append(kw)
rejected = next((k for k in rejects if k in kw), None)
if rejected:
raise TypeError(f"compiled() got an unexpected keyword argument '{rejected}'")
return classic(challenge=kw["challenge"], title=None, track=None)
setattr(service, f"get_{drm_type}_license", compiled)
_server_cdm(service, drm_type)
assert len(calls) == len(rejects) + 1
assert set(calls[0]) == {
"challenge",
"title",
"track",
"session_id",
"pssh" if drm_type == "widevine" else "pssh_b64",
}
assert set(calls[-1]) == last_call
@pytest.mark.parametrize("drm_type", ["widevine", "playready"])
def test_server_cdm_typeerror_inside_the_service_is_not_retried(drm_type: str, request: pytest.FixtureRequest) -> None:
request.getfixturevalue(f"{drm_type}_env")
service = SessionService()
calls: list[dict[str, Any]] = []
def broken(**kw: Any) -> bytes:
calls.append(kw)
raise TypeError("'NoneType' object is not subscriptable")
setattr(service, f"get_{drm_type}_license", broken)
with pytest.raises(TypeError, match="not subscriptable"):
_server_cdm(service, drm_type)
assert len(calls) == 1
def _proxy(service: Any, drm_type: str) -> Any:
+2 -8
View File
@@ -26,7 +26,7 @@ from unshackle.core.console import console
from unshackle.core.constants import AnyTrack
from unshackle.core.drm import key_args as drm_key_args
from unshackle.core.session import AnySession, utf8_text
from unshackle.core.utilities import get_boxes, log_event
from unshackle.core.utilities import call_licence, get_boxes, log_event
from unshackle.core.utils import run_proxy
from unshackle.core.utils.subprocess import ffprobe
@@ -491,13 +491,7 @@ class PlayReady:
challenge_size=len(challenge),
kid_count=len(required_kids),
)
try:
license_res = licence(challenge=challenge, pssh_b64=pssh_b64, session_id=session_id)
except TypeError:
try:
license_res = licence(challenge=challenge, pssh_b64=pssh_b64)
except TypeError:
license_res = licence(challenge=challenge)
license_res = call_licence(licence, challenge, pssh_b64=pssh_b64, session_id=session_id)
if isinstance(license_res, bytes):
license_str = license_res.decode(errors="ignore")
else:
+2 -5
View File
@@ -22,7 +22,7 @@ from unshackle.core.console import console
from unshackle.core.constants import AnyTrack
from unshackle.core.drm import key_args as drm_key_args
from unshackle.core.session import AnySession, utf8_text
from unshackle.core.utilities import get_boxes, log_event
from unshackle.core.utilities import call_licence, get_boxes, log_event
from unshackle.core.utils import run_proxy
from unshackle.core.utils.subprocess import ffprobe
@@ -237,10 +237,7 @@ class Widevine:
challenge_size=len(challenge),
kid_count=len(self.kids),
)
try:
license_res = licence(challenge=challenge, pssh=self.pssh, session_id=session_id)
except TypeError:
license_res = licence(challenge=challenge)
license_res = call_licence(licence, challenge, pssh=self.pssh, session_id=session_id)
cdm.parse_license(session_id, license_res)
log_event(
"drm_license_response",
+17 -1
View File
@@ -1459,7 +1459,7 @@ def declared_kwargs(fn: Callable[..., Any], kwargs: dict[str, Any]) -> dict[str,
A service licence function that never asked for ``pssh`` or ``session_id`` must not fail
on them. Filter only against a signature this function can read and call by keyword.
Anything else, such as the licence function of a compiled service, gets every argument,
and the caller handles the TypeError.
and ``call_licence`` drops each extra it rejects.
"""
try:
params = inspect.signature(fn).parameters
@@ -1470,6 +1470,22 @@ def declared_kwargs(fn: Callable[..., Any], kwargs: dict[str, Any]) -> dict[str,
return {k: v for k, v in kwargs.items() if k in params}
def call_licence(licence: Callable[..., Any], challenge: Any, **extras: Any) -> Any:
"""Call ``licence`` with ``challenge`` and ``extras``, and drop each extra the call names as unexpected.
A TypeError that names no offered extra comes from inside the licence function, so it
raises at once, and the challenge does not go to the licence server again.
"""
while True:
try:
return licence(challenge=challenge, **extras)
except TypeError as e:
rejected = next((k for k in extras if f"'{k}'" in str(e)), None)
if rejected is None:
raise
del extras[rejected]
__all__ = (
"DebugLogger",
"get_debug_logger",