From 8e261ae2381d9c894736dff3e91591e4022b8bc6 Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Mon, 7 Sep 2026 10:59:33 -0600 Subject: [PATCH] fix(api): license every PlayReady header in the server CDM path extract_pssh_from_track returns the first PSSH only, so a track carrying one PlayReady object per header licensed fewer keys than the local path, which absorbs its siblings first. Relay batch licensing failures to the client too. --- .../unit/test_server_cdm_absorbs_headers.py | 84 +++++++++++++++++++ unshackle/core/api/handlers.py | 20 ++++- 2 files changed, 100 insertions(+), 4 deletions(-) create mode 100644 tests/remote/unit/test_server_cdm_absorbs_headers.py diff --git a/tests/remote/unit/test_server_cdm_absorbs_headers.py b/tests/remote/unit/test_server_cdm_absorbs_headers.py new file mode 100644 index 00000000..693e6927 --- /dev/null +++ b/tests/remote/unit/test_server_cdm_absorbs_headers.py @@ -0,0 +1,84 @@ +"""The server CDM must license every WRM header a track carries, not only the first. + +A track can hold one `PlayReady` object per header: `ISM` shares a Protection list, `HLS` +emits one per `EXT-X-KEY`. `extract_pssh_from_track` returns the first PSSH only, so the +server folds in the siblings. Without that it returns fewer content keys than a local CDM. +""" + +import base64 +from types import SimpleNamespace +from uuid import UUID + +import pyplayready.system.pssh as pr_pssh_mod +import pytest + +import unshackle.core.cdm as cdm_mod +import unshackle.core.cdm.detect as detect_mod +import unshackle.core.drm as drm_mod +from unshackle.core.api import handlers + +pytestmark = pytest.mark.unit + +KID_A = UUID("161219ec3df64e0eadbb3827c0ccc46d") +KID_B = UUID("43de497c6d1945f49acc2951696ec628") + + +class _FakePRPSSH: + def __init__(self, data): + self.data = data + + +class _FakePlayReady: + """Only the headers this object knows about yield keys.""" + + def __init__(self, pssh=None, pssh_b64=None, kid=None, **kwargs): + self.kids = [kid] if kid else [KID_A] + self.content_keys: dict = {} + self.pssh_b64 = pssh_b64 + self.absorbed: list = [] + + def absorb(self, *others): + for other in others: + if other is self: + continue + self.absorbed.append(other) + for kid in other.kids: + if kid not in self.kids: + self.kids.append(kid) + + def get_content_keys(self, cdm, certificate, licence): + self.content_keys = {kid: f"key_{kid.hex[:4]}" for kid in self.kids} + + +@pytest.fixture +def playready_env(monkeypatch): + monkeypatch.setattr(pr_pssh_mod, "PSSH", _FakePRPSSH) + monkeypatch.setattr(drm_mod, "PlayReady", _FakePlayReady) + monkeypatch.setitem(cdm_mod.__dict__, "load_cdm", lambda *a, **k: object()) + monkeypatch.setattr(detect_mod, "is_playready_cdm", lambda cdm: True) + monkeypatch.setattr(handlers, "ensure_track_drm", lambda track, session=None, init_data=None: None) + monkeypatch.setattr(handlers, "resolve_device_name", lambda *a, **k: "dev") + monkeypatch.setattr(handlers, "check_vaults", lambda kids, name: None) + monkeypatch.setattr(handlers, "cache_to_vaults", lambda keys, name: None) + monkeypatch.setattr(handlers.config, "serve", {"users": {}}, raising=False) + + +def _keys_for(track): + return handlers.handle_single_server_cdm( + service=SimpleNamespace(), + title=SimpleNamespace(), + track=track, + pssh_b64=base64.b64encode(b"A").decode(), + drm_type="playready", + request=None, + ) + + +def test_sibling_headers_are_licensed(playready_env): + track = SimpleNamespace(drm=[_FakePlayReady(kid=KID_A), _FakePlayReady(kid=KID_B)]) + assert set(_keys_for(track)) == {KID_A.hex, KID_B.hex} + + +def test_single_header_track_is_untouched(playready_env): + track = SimpleNamespace(drm=[_FakePlayReady(kid=KID_A)]) + assert set(_keys_for(track)) == {KID_A.hex} diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index 113d1d2e..d1161796 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -3687,6 +3687,10 @@ def handle_single_server_cdm( pr_pssh = PlayReadyPSSH(base64.b64decode(pssh_b64)) pr_drm = PlayReady(pssh=pr_pssh, pssh_b64=pssh_b64) + siblings = [d for d in (getattr(track, "drm", None) or []) if isinstance(d, PlayReady)] + if len(siblings) > 1: + pr_drm.absorb(*siblings) + # Gate on the caller's CDM device first: no device, no keys from the vault or CDM. device_name = resolve_device_name(user_config, drm_type, service.__class__.__name__) @@ -3873,6 +3877,16 @@ async def session_license_handler( drm_type_by_pssh: Dict[str, str] = {} actual_drm_type: Optional[str] = None + def warn(message: str) -> None: + """Log on the server and copy into the remote session buffer the client drains. + + The buffer takes only ``service.log``, so a licensing failure raised + here would otherwise reach the client as a bare "no content keys". + """ + log.warning(message) + if session.log_buffer: + session.log_buffer.append(logging.WARNING, message) + def license_track( track: Any, title: Any, candidates: list ) -> tuple[Dict[str, str], Optional[str], Optional[str]]: @@ -3898,11 +3912,9 @@ async def session_license_handler( keys_by_pssh[pssh_str] = keys drm_type_by_pssh[pssh_str] = candidate except SystemExit: - log.warning( - f"Service exited while resolving keys for track {sanitize_log(str(track.id)[:12])}, skipping" - ) + warn(f"Service exited while resolving keys for track {sanitize_log(str(track.id)[:12])}, skipping") except (Exception, SystemExit) as e: - log.warning(f"Failed to resolve keys for track {sanitize_log(str(track.id)[:12])}: {e}") + warn(f"Failed to resolve keys for track {sanitize_log(str(track.id)[:12])}: {e}") return keys_by_pssh[pssh_str], drm_type_by_pssh.get(pssh_str), pssh_str for tid in track_ids: