mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 20:15:10 +00:00
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:
@@ -586,6 +586,8 @@ current `title` and `track`:
|
|||||||
| `get_playready_license` | PlayReady | The license response. Defaults to delegating to `get_widevine_license`. |
|
| `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. |
|
| `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
|
```python
|
||||||
def get_widevine_license(self, *, challenge: bytes, title, track):
|
def get_widevine_license(self, *, challenge: bytes, title, track):
|
||||||
response = self.session.post(
|
response = self.session.post(
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ pytestmark = pytest.mark.unit
|
|||||||
KID = UUID("11111111111111111111111111111111")
|
KID = UUID("11111111111111111111111111111111")
|
||||||
KEY = bytes.fromhex("22222222222222222222222222222222")
|
KEY = bytes.fromhex("22222222222222222222222222222222")
|
||||||
PSSH_B64 = base64.b64encode(b"pssh").decode()
|
PSSH_B64 = base64.b64encode(b"pssh").decode()
|
||||||
SESSION_ID = b"sid-bytes"
|
SESSION_ID = bytes(range(16))
|
||||||
|
|
||||||
|
|
||||||
class _Recorder:
|
class _Recorder:
|
||||||
@@ -105,7 +105,7 @@ class StubPlayReady(PlayReady):
|
|||||||
kids: Any = None
|
kids: Any = None
|
||||||
|
|
||||||
def __init__(self, pssh: Any = None, pssh_b64: Optional[str] = None, **kwargs: 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.pssh_b64 = pssh_b64
|
||||||
self.kids = [KID]
|
self.kids = [KID]
|
||||||
self._extra_headers: list[Any] = []
|
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
|
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:
|
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
|
# 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`.
|
# 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"}
|
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:
|
def test_server_cdm_playready_offers_pssh_b64_and_session_id_not_pssh(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.
|
|
||||||
service = VarKwService()
|
service = VarKwService()
|
||||||
_server_cdm(service, "playready")
|
_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]["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()
|
service = SessionService()
|
||||||
_server_cdm(service, "playready")
|
_server_cdm(service, "playready")
|
||||||
|
|
||||||
assert set(service.calls[0]) == {"challenge", "title", "track", "session_id"}
|
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:
|
def _proxy(service: Any, drm_type: str) -> Any:
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ from unshackle.core.console import console
|
|||||||
from unshackle.core.constants import AnyTrack
|
from unshackle.core.constants import AnyTrack
|
||||||
from unshackle.core.drm import key_args as drm_key_args
|
from unshackle.core.drm import key_args as drm_key_args
|
||||||
from unshackle.core.session import AnySession, utf8_text
|
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 import run_proxy
|
||||||
from unshackle.core.utils.subprocess import ffprobe
|
from unshackle.core.utils.subprocess import ffprobe
|
||||||
|
|
||||||
@@ -491,13 +491,7 @@ class PlayReady:
|
|||||||
challenge_size=len(challenge),
|
challenge_size=len(challenge),
|
||||||
kid_count=len(required_kids),
|
kid_count=len(required_kids),
|
||||||
)
|
)
|
||||||
try:
|
license_res = call_licence(licence, challenge, pssh_b64=pssh_b64, session_id=session_id)
|
||||||
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)
|
|
||||||
if isinstance(license_res, bytes):
|
if isinstance(license_res, bytes):
|
||||||
license_str = license_res.decode(errors="ignore")
|
license_str = license_res.decode(errors="ignore")
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from unshackle.core.console import console
|
|||||||
from unshackle.core.constants import AnyTrack
|
from unshackle.core.constants import AnyTrack
|
||||||
from unshackle.core.drm import key_args as drm_key_args
|
from unshackle.core.drm import key_args as drm_key_args
|
||||||
from unshackle.core.session import AnySession, utf8_text
|
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 import run_proxy
|
||||||
from unshackle.core.utils.subprocess import ffprobe
|
from unshackle.core.utils.subprocess import ffprobe
|
||||||
|
|
||||||
@@ -237,10 +237,7 @@ class Widevine:
|
|||||||
challenge_size=len(challenge),
|
challenge_size=len(challenge),
|
||||||
kid_count=len(self.kids),
|
kid_count=len(self.kids),
|
||||||
)
|
)
|
||||||
try:
|
license_res = call_licence(licence, challenge, pssh=self.pssh, session_id=session_id)
|
||||||
license_res = licence(challenge=challenge, pssh=self.pssh, session_id=session_id)
|
|
||||||
except TypeError:
|
|
||||||
license_res = licence(challenge=challenge)
|
|
||||||
cdm.parse_license(session_id, license_res)
|
cdm.parse_license(session_id, license_res)
|
||||||
log_event(
|
log_event(
|
||||||
"drm_license_response",
|
"drm_license_response",
|
||||||
|
|||||||
@@ -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
|
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.
|
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,
|
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:
|
try:
|
||||||
params = inspect.signature(fn).parameters
|
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}
|
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__ = (
|
__all__ = (
|
||||||
"DebugLogger",
|
"DebugLogger",
|
||||||
"get_debug_logger",
|
"get_debug_logger",
|
||||||
|
|||||||
Reference in New Issue
Block a user