diff --git a/tests/core/test_playready_multi_header.py b/tests/core/test_playready_multi_header.py
new file mode 100644
index 00000000..f1efb11f
--- /dev/null
+++ b/tests/core/test_playready_multi_header.py
@@ -0,0 +1,416 @@
+from __future__ import annotations
+
+import base64
+from typing import Any
+from uuid import UUID
+
+import pytest
+from pyplayready.system.pssh import PSSH
+from pyplayready.system.wrmheader import WRMHeader
+
+from unshackle.core.drm import PlayReady
+
+KID_A = UUID("00112233-4455-6677-8899-aabbccddeeff")
+KID_B = UUID("ffeeddcc-bbaa-9988-7766-554433221100")
+KEY_A = "00000000000000000000000000000001"
+KEY_B = "00000000000000000000000000000002"
+
+
+def _wrm_header(*kids: UUID, la_url: str = "https://example.invalid/") -> bytes:
+ """A minimal v4.3 WRMHEADER naming the given KIDs, UTF-16LE as the PRO carries it."""
+ kid_xml = "".join(f'' for kid in kids)
+ xml = (
+ ''
+ f"{kid_xml}{la_url}"
+ ""
+ )
+ return xml.encode("utf-16-le")
+
+
+def _pro(*headers: bytes) -> bytes:
+ records = [{"type": 1, "length": len(h), "data": h} for h in headers]
+ return PSSH.PlayreadyHeader.build({"length": 6 + sum(4 + len(h) for h in headers), "records": records})
+
+
+def _drm(*headers: bytes) -> PlayReady:
+ pro = _pro(*headers)
+ return PlayReady(pssh=PSSH(pro), pssh_b64=base64.b64encode(pro).decode())
+
+
+class FakeKey:
+ def __init__(self, kid: UUID, key: str) -> None:
+ self.key_id = kid
+ self.key = bytes.fromhex(key)
+
+
+class FakeCdm:
+ """Answers each challenge with the keys mapped for the header's KIDs; records what it saw."""
+
+ def __init__(self, answers: dict[UUID, str], fail_on: set[UUID] | None = None) -> None:
+ self.answers = answers
+ self.fail_on = fail_on or set()
+ self.challenged: list[list[UUID]] = []
+ self.pssh_b64s: list[str] = []
+ self.required: list[list[UUID]] = []
+ self.attempted: list[list[UUID]] = []
+ self._by_session: dict[str, list[UUID]] = {}
+ self._next = 0
+
+ def open(self) -> str:
+ self._next += 1
+ return f"session-{self._next}"
+
+ def close(self, session_id: str) -> None:
+ self._by_session.pop(session_id, None)
+
+ def set_pssh_b64(self, pssh_b64: str, session_id: Any = None) -> None:
+ self.pssh_b64s.append(pssh_b64)
+
+ def set_required_kids(self, kids: list[UUID], session_id: Any = None) -> None:
+ self.required.append(list(kids))
+
+ def get_license_challenge(self, session_id: str, header: WRMHeader) -> str:
+ kids = [k.value for k in header.key_ids]
+ self.attempted.append(kids)
+ if self.fail_on & set(kids):
+ raise RuntimeError("licence refused")
+ self.challenged.append(kids)
+ self._by_session[session_id] = kids
+ return "challenge"
+
+ def parse_license(self, session_id: str, response: str) -> None:
+ pass
+
+ def get_keys(self, session_id: str) -> list[FakeKey]:
+ return [FakeKey(kid, self.answers[kid]) for kid in self._by_session[session_id] if kid in self.answers]
+
+
+def _licence(**_: Any) -> str:
+ return "x"
+
+
+def _cert(**_: Any) -> None:
+ return None
+
+
+def test_kids_are_collected_from_every_header() -> None:
+ drm = _drm(_wrm_header(KID_A), _wrm_header(KID_B))
+
+ assert len(drm.pssh.wrm_headers) == 2
+ assert drm.kids == [KID_A, KID_B]
+
+
+def test_single_header_sends_one_challenge_with_the_original_pssh() -> None:
+ drm = _drm(_wrm_header(KID_A, KID_B))
+ cdm = FakeCdm({KID_A: KEY_A, KID_B: KEY_B})
+
+ drm.get_content_keys(cdm=cdm, certificate=_cert, licence=_licence)
+
+ assert cdm.challenged == [[KID_A, KID_B]]
+ assert cdm.pssh_b64s == [drm.pssh_b64]
+ assert cdm.required == [[KID_A, KID_B]]
+ assert drm.content_keys == {KID_A: KEY_A, KID_B: KEY_B}
+
+
+def test_two_distinct_headers_are_both_licensed_and_keys_merged() -> None:
+ drm = _drm(_wrm_header(KID_A), _wrm_header(KID_B))
+ cdm = FakeCdm({KID_A: KEY_A, KID_B: KEY_B})
+
+ drm.get_content_keys(cdm=cdm, certificate=_cert, licence=_licence)
+
+ assert cdm.challenged == [[KID_A], [KID_B]]
+ assert drm.content_keys == {KID_A: KEY_A, KID_B: KEY_B}
+ # a remote CDM sends whatever set_pssh_b64 gave it, so each round carries only its own header
+ assert [len(PSSH(b).wrm_headers) for b in cdm.pssh_b64s] == [1, 1]
+ for pro_b64 in cdm.pssh_b64s:
+ pro = base64.b64decode(pro_b64)
+ assert PSSH.PlayreadyHeader.parse(pro).length == len(pro)
+ assert _pro(PSSH(pro).wrm_headers[0].dumps().encode("utf-16-le")) == pro
+ assert [PSSH(b).wrm_headers[0].dumps() for b in cdm.pssh_b64s] == [h.dumps() for h in drm.pssh.wrm_headers]
+
+
+def test_stacked_licence_covering_every_kid_stops_after_one_challenge() -> None:
+ drm = _drm(_wrm_header(KID_A), _wrm_header(KID_B))
+ cdm = FakeCdm({KID_A: KEY_A, KID_B: KEY_B})
+ cdm.get_keys = lambda session_id: [FakeKey(KID_A, KEY_A), FakeKey(KID_B, KEY_B)] # type: ignore[method-assign]
+
+ drm.get_content_keys(cdm=cdm, certificate=_cert, licence=_licence)
+
+ assert cdm.challenged == [[KID_A]]
+ assert drm.content_keys == {KID_A: KEY_A, KID_B: KEY_B}
+
+
+def test_headers_naming_the_same_kids_cost_one_challenge() -> None:
+ drm = _drm(_wrm_header(KID_A), _wrm_header(KID_A))
+ cdm = FakeCdm({KID_A: KEY_A})
+
+ drm.get_content_keys(cdm=cdm, certificate=_cert, licence=_licence)
+
+ assert cdm.challenged == [[KID_A]]
+
+
+def test_a_header_already_covered_by_the_vault_is_skipped() -> None:
+ drm = _drm(_wrm_header(KID_A), _wrm_header(KID_B))
+ drm.content_keys[KID_A] = KEY_A
+ cdm = FakeCdm({KID_A: KEY_A, KID_B: KEY_B})
+
+ drm.get_content_keys(cdm=cdm, certificate=_cert, licence=_licence)
+
+ assert cdm.challenged == [[KID_B]]
+
+
+def test_an_uncovered_kid_warns_instead_of_raising(monkeypatch: pytest.MonkeyPatch) -> None:
+ events: list[dict[str, Any]] = []
+ monkeypatch.setattr(
+ "unshackle.core.drm.playready.log_event",
+ lambda operation, **kw: events.append({"operation": operation, **kw}),
+ )
+ drm = _drm(_wrm_header(KID_A), _wrm_header(KID_B))
+ cdm = FakeCdm({KID_A: KEY_A})
+
+ drm.get_content_keys(cdm=cdm, certificate=_cert, licence=_licence)
+
+ assert drm.content_keys == {KID_A: KEY_A}
+ missing = [e for e in events if e["operation"] == "drm_missing_keys"]
+ assert missing and missing[0]["level"] == "WARNING"
+ assert missing[0]["missing_kids"] == [KID_B.hex]
+
+
+def test_a_refused_header_does_not_lose_the_keys_of_the_others() -> None:
+ drm = _drm(_wrm_header(KID_A), _wrm_header(KID_B))
+ cdm = FakeCdm({KID_A: KEY_A, KID_B: KEY_B}, fail_on={KID_A})
+
+ drm.get_content_keys(cdm=cdm, certificate=_cert, licence=_licence)
+
+ assert drm.content_keys == {KID_B: KEY_B}
+
+
+def test_every_header_refused_raises() -> None:
+ drm = _drm(_wrm_header(KID_A), _wrm_header(KID_B))
+ cdm = FakeCdm({}, fail_on={KID_A, KID_B})
+
+ with pytest.raises(RuntimeError, match="licence refused"):
+ drm.get_content_keys(cdm=cdm, certificate=_cert, licence=_licence)
+
+
+def test_get_drm_for_cdm_folds_sibling_playready_objects_into_the_first() -> None:
+ from pyplayready.cdm import Cdm as PlayReadyCdm
+
+ from unshackle.core.tracks import Video
+
+ first = _drm(_wrm_header(KID_A))
+ second = _drm(_wrm_header(KID_B))
+ track = Video(
+ id_="v", url="https://example.invalid/v.mpd", language="en", codec=Video.Codec.AVC, drm=[first, second]
+ )
+ fake_cdm = PlayReadyCdm.__new__(PlayReadyCdm)
+
+ chosen = track.get_drm_for_cdm(fake_cdm)
+
+ assert chosen is first
+ assert chosen.kids == [KID_A, KID_B]
+ assert [[k.value for k in h.key_ids] for h in chosen.distinct_headers()] == [[KID_A], [KID_B]]
+
+ cdm = FakeCdm({KID_A: KEY_A, KID_B: KEY_B})
+ chosen.get_content_keys(cdm=cdm, certificate=_cert, licence=_licence)
+ assert chosen.content_keys == {KID_A: KEY_A, KID_B: KEY_B}
+
+
+def test_repeated_get_drm_for_cdm_does_not_grow_the_header_list() -> None:
+ from pyplayready.cdm import Cdm as PlayReadyCdm
+
+ from unshackle.core.tracks import Video
+
+ # a KID-less header is exempt from the KID-set dedupe, so only identity-level idempotence protects it
+ first = _drm(_wrm_header(KID_A))
+ second = PlayReady(pssh=PSSH(_pro(_wrm_header(la_url="https://kidless.invalid/"))), kid=KID_B)
+ track = Video(
+ id_="v", url="https://example.invalid/v.mpd", language="en", codec=Video.Codec.AVC, drm=[first, second]
+ )
+ fake_cdm = PlayReadyCdm.__new__(PlayReadyCdm)
+
+ for _ in range(3):
+ chosen = track.get_drm_for_cdm(fake_cdm)
+
+ assert chosen is first
+ assert len(chosen.distinct_headers()) == 2
+ assert chosen.kids == [KID_A, KID_B]
+
+ cdm = FakeCdm({KID_A: KEY_A})
+ chosen.get_content_keys(cdm=cdm, certificate=_cert, licence=_licence)
+ assert len(cdm.challenged) == 2
+
+
+def test_absorb_keeps_every_sibling_under_concurrent_calls() -> None:
+ from concurrent.futures import ThreadPoolExecutor
+
+ first = _drm(_wrm_header(KID_A))
+ # each call brings a sibling nobody else brings, so a lost update shows as a missing header
+ siblings = [PlayReady(pssh=PSSH(_pro(_wrm_header(la_url=f"https://x{i}.invalid/"))), kid=KID_B) for i in range(64)]
+
+ with ThreadPoolExecutor(8) as pool:
+ list(pool.map(lambda sibling: first.absorb(sibling), siblings))
+
+ assert len(first.distinct_headers()) == 65
+ assert first.kids == [KID_A, KID_B]
+
+
+def test_a_track_carrying_playready_survives_deepcopy() -> None:
+ from copy import deepcopy
+
+ from unshackle.core.tracks import Video
+
+ first = _drm(_wrm_header(KID_A))
+ second = _drm(_wrm_header(KID_B))
+ first.absorb(second)
+ track = Video(
+ id_="v", url="https://example.invalid/v.mpd", language="en", codec=Video.Codec.AVC, drm=[first, second]
+ )
+
+ clone = deepcopy(track)
+
+ assert clone.drm[0] is not first
+ assert clone.drm[0].kids == [KID_A, KID_B]
+ assert len(clone.drm[0].distinct_headers()) == 2
+
+
+def _pro_b64(*headers: bytes) -> str:
+ return base64.b64encode(_pro(*headers)).decode()
+
+
+def _xml_header(version: str, body: str) -> bytes:
+ return (
+ f''
+ f"{body}"
+ ).encode("utf-16-le")
+
+
+def _b64le(kid: UUID) -> str:
+ return base64.b64encode(kid.bytes_le).decode()
+
+
+@pytest.mark.parametrize(
+ ("version", "body", "expected"),
+ [
+ (
+ "4.0.0.0",
+ f"16AESCTR{_b64le(KID_A)}",
+ [KID_A],
+ ),
+ ("4.1.0.0", f'', [KID_B]),
+ (
+ "4.2.0.0",
+ f''
+ f'',
+ [KID_A, KID_B],
+ ),
+ ],
+)
+def test_kids_are_read_from_every_header_version(version: str, body: str, expected: list[UUID]) -> None:
+ header = _xml_header(version, body)
+ assert PlayReady(pssh=PSSH(_pro(header)), pssh_b64=_pro_b64(header)).kids == expected
+
+
+def test_customattributes_kids_follow_the_header_kids() -> None:
+ kid_c = UUID("11111111-2222-3333-4444-555555555555")
+ header = _xml_header(
+ "4.1.0.0",
+ f''
+ f'',
+ )
+
+ drm = PlayReady(pssh=PSSH(_pro(header)), pssh_b64=_pro_b64(header))
+
+ # the header KID leads (it is what the licence server acts on); CUSTOMATTRIBUTES KIDs stay reachable
+ assert drm.kids == [KID_A, KID_B, kid_c]
+ assert drm.kid == KID_A
+
+
+def test_device_revoked_on_the_first_header_is_not_swallowed() -> None:
+ drm = _drm(_wrm_header(KID_A), _wrm_header(KID_B))
+ cdm = FakeCdm({KID_A: KEY_A, KID_B: KEY_B})
+
+ calls: list[int] = []
+
+ def licence(**_: Any) -> str:
+ calls.append(1)
+ if len(calls) == 1:
+ raise PlayReady.Exceptions.DeviceRevoked("revoked")
+ return "x"
+
+ with pytest.raises(PlayReady.Exceptions.DeviceRevoked):
+ drm.get_content_keys(cdm=cdm, certificate=_cert, licence=licence)
+ assert cdm.challenged == [[KID_A]]
+
+
+def test_a_revocation_hresult_in_a_refusal_becomes_device_revoked() -> None:
+ drm = _drm(_wrm_header(KID_A), _wrm_header(KID_B))
+ cdm = FakeCdm({KID_A: KEY_A, KID_B: KEY_B})
+
+ calls: list[int] = []
+
+ def licence(**_: Any) -> str:
+ calls.append(1)
+ if len(calls) == 1:
+ raise RuntimeError("0x8004C065")
+ return "x"
+
+ with pytest.raises(PlayReady.Exceptions.DeviceRevoked):
+ drm.get_content_keys(cdm=cdm, certificate=_cert, licence=licence)
+ assert cdm.challenged == [[KID_A]]
+
+
+def _kidless_header() -> bytes:
+ return _wrm_header(la_url="https://kidless.invalid/")
+
+
+def test_a_kidless_header_is_still_sent_when_the_vault_covered_every_kid() -> None:
+ drm = _drm(_wrm_header(KID_A), _kidless_header())
+ drm.content_keys[KID_A] = KEY_A
+ cdm = FakeCdm({KID_A: KEY_A})
+
+ drm.get_content_keys(cdm=cdm, certificate=_cert, licence=_licence)
+
+ assert cdm.challenged == [[]]
+
+
+def test_a_kidless_header_is_still_sent_when_a_licence_round_covered_every_kid() -> None:
+ drm = _drm(_wrm_header(KID_A), _kidless_header())
+ cdm = FakeCdm({KID_A: KEY_A})
+
+ drm.get_content_keys(cdm=cdm, certificate=_cert, licence=_licence)
+
+ assert cdm.challenged == [[KID_A], []]
+
+
+def test_a_header_that_yielded_nothing_is_not_sent_again_by_the_same_object() -> None:
+ drm = _drm(_wrm_header(KID_A), _wrm_header(KID_B))
+ cdm = FakeCdm({KID_A: KEY_A})
+
+ # every track of a title calls this on the shared object; the second call must not repeat B
+ drm.get_content_keys(cdm=cdm, certificate=_cert, licence=_licence)
+ drm.get_content_keys(cdm=cdm, certificate=_cert, licence=_licence)
+
+ assert cdm.challenged == [[KID_A], [KID_B]]
+ assert drm.content_keys == {KID_A: KEY_A}
+
+
+def test_a_refused_header_is_not_sent_again_by_the_same_object() -> None:
+ drm = _drm(_wrm_header(KID_A), _wrm_header(KID_B))
+ cdm = FakeCdm({KID_A: KEY_A, KID_B: KEY_B}, fail_on={KID_B})
+
+ drm.get_content_keys(cdm=cdm, certificate=_cert, licence=_licence)
+ attempts_after_first = len(cdm.attempted)
+ drm.get_content_keys(cdm=cdm, certificate=_cert, licence=_licence)
+
+ assert len(cdm.attempted) == attempts_after_first
+ assert drm.content_keys == {KID_A: KEY_A}
+
+
+def test_a_single_refused_header_still_raises_on_every_call() -> None:
+ drm = _drm(_wrm_header(KID_A))
+ cdm = FakeCdm({}, fail_on={KID_A})
+
+ for _ in range(2):
+ with pytest.raises(RuntimeError, match="licence refused"):
+ drm.get_content_keys(cdm=cdm, certificate=_cert, licence=_licence)
diff --git a/tests/remote/unit/test_licence_declared_kwargs.py b/tests/remote/unit/test_licence_declared_kwargs.py
index b70fc5c2..7faf54a3 100644
--- a/tests/remote/unit/test_licence_declared_kwargs.py
+++ b/tests/remote/unit/test_licence_declared_kwargs.py
@@ -108,6 +108,8 @@ class StubPlayReady(PlayReady):
self.pssh = SimpleNamespace(wrm_headers=[""])
self.pssh_b64 = pssh_b64
self.kids = [KID]
+ self._extra_headers: list[Any] = []
+ self._refused_headers: set[str] = set()
self.content_keys: dict[UUID, str] = {}
def extract_keys_from_cdm(self, cdm: Any, session_id: Any) -> dict[UUID, str]:
diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py
index 4c2582d8..678e2efc 100644
--- a/unshackle/commands/dl.py
+++ b/unshackle/commands/dl.py
@@ -4026,6 +4026,13 @@ class dl:
Tracks that share KIDs still take turns, so the second one finds the content keys in
LICENSE_KEY_CACHE and skips the challenge. Tracks with different KIDs send a challenge
and query the key vaults at the same time.
+
+ Callers take the lock after get_drm_for_cdm(), which folds a track's sibling PlayReady
+ objects into the returned one, so the KID set is complete for that track's DRM list
+ before the lock is chosen. This holds because every manifest parser gives the tracks
+ that share a PlayReady object the same DRM list (ISM attaches one list to all tracks;
+ DASH and HLS build a list per track). Two tracks that share one object but list
+ different siblings would get different locks.
"""
kids = getattr(drm, "kids", None) or []
key = ",".join(sorted(getattr(k, "hex", str(k)) for k in kids)) or getattr(drm, "content_id", None)
diff --git a/unshackle/core/drm/playready.py b/unshackle/core/drm/playready.py
index ea6e46a8..cddb04e7 100644
--- a/unshackle/core/drm/playready.py
+++ b/unshackle/core/drm/playready.py
@@ -7,6 +7,7 @@ import subprocess
import textwrap
import time
from pathlib import Path
+from threading import Lock
from typing import Any, Callable, Optional, Union
from uuid import UUID
@@ -15,6 +16,7 @@ from construct import Container
from pymp4.parser import Box
from pyplayready.cdm import Cdm as PlayReadyCdm
from pyplayready.system.pssh import PSSH
+from pyplayready.system.wrmheader import WRMHeader
from requests import Session
from rich.text import Text
@@ -29,6 +31,8 @@ from unshackle.core.utils.subprocess import ffprobe
class PlayReady:
"""PlayReady DRM System."""
+ _ABSORB_LOCK = Lock()
+
def __init__(
self,
pssh: PSSH,
@@ -41,22 +45,15 @@ class PlayReady:
if not isinstance(pssh, PSSH):
raise TypeError(f"Expected pssh to be a {PSSH}, not {pssh!r}")
+ kids: list[UUID] = []
+ for header in pssh.wrm_headers:
+ for header_kid in self.header_kids(header):
+ if header_kid not in kids:
+ kids.append(header_kid)
if pssh_b64:
- kids = self.extract_kids_from_pssh_b64(pssh_b64)
- else:
- kids = []
-
- # Extract KIDs using pyplayready's WrmHeader key_ids
- if not kids:
- for header in pssh.wrm_headers:
- for signed_key_id in getattr(header, "key_ids", []):
- try:
- if isinstance(signed_key_id.value, UUID):
- kids.append(signed_key_id.value)
- else:
- kids.append(UUID(bytes_le=base64.b64decode(signed_key_id.value)))
- except (ValueError, TypeError):
- continue
+ for extra_kid in self.extract_kids_from_pssh_b64(pssh_b64):
+ if extra_kid not in kids:
+ kids.append(extra_kid)
if kid:
if isinstance(kid, str):
@@ -70,6 +67,8 @@ class PlayReady:
self._pssh = pssh
self._kids = kids
+ self._extra_headers: list[WRMHeader] = []
+ self._refused_headers: set[str] = set()
if not self.kids:
raise PlayReady.Exceptions.KIDNotFound("No Key ID was found within PSSH and none were provided.")
@@ -266,6 +265,72 @@ class PlayReady:
def kids(self) -> list[UUID]:
return self._kids
+ @staticmethod
+ def header_kids(header: WRMHeader) -> list[UUID]:
+ """Return the KIDs one WRM header names, in header order."""
+ kids: list[UUID] = []
+ for signed_key_id in getattr(header, "key_ids", []):
+ try:
+ if isinstance(signed_key_id.value, UUID):
+ kids.append(signed_key_id.value)
+ else:
+ kids.append(UUID(bytes_le=base64.b64decode(signed_key_id.value)))
+ except (ValueError, TypeError):
+ continue
+ return kids
+
+ def absorb(self, *others: PlayReady) -> None:
+ """Fold sibling PlayReady objects into this one so their headers and KIDs get licensed too.
+
+ A manifest can attach one PlayReady object per WRM header to the same track
+ (ISM shares its Protection list across tracks; HLS emits one per EXT-X-KEY). The
+ caller keeps this object's identity, which the decrypt path and drm_lock depend on.
+ """
+ with PlayReady._ABSORB_LOCK:
+ known = {header.dumps() for header in [*self.pssh.wrm_headers, *self._extra_headers]}
+ for other in others:
+ if other is self:
+ continue
+ for header in other.distinct_headers():
+ if header.dumps() not in known:
+ known.add(header.dumps())
+ self._extra_headers.append(header)
+ for kid in other.kids:
+ if kid not in self._kids:
+ self._kids.append(kid)
+ for kid, key in dict(other.content_keys).items():
+ self.content_keys.setdefault(kid, key)
+
+ def distinct_headers(self) -> list[WRMHeader]:
+ """Return the WRM headers to license, deduplicated by the KID set each one names.
+
+ Two headers naming the same KIDs (a v4.0 header beside its v4.3 twin, or one PRH per
+ rendition for one content key) cost one challenge. A header with no readable KID is kept: it
+ may still be the only route to a content key nothing else names.
+ """
+ headers: list[WRMHeader] = []
+ seen: set[frozenset[UUID]] = set()
+ for header in [*self.pssh.wrm_headers, *self._extra_headers]:
+ kid_set = frozenset(self.header_kids(header))
+ if kid_set and kid_set in seen:
+ continue
+ seen.add(kid_set)
+ headers.append(header)
+ return headers
+
+ @staticmethod
+ def pro_b64_for(header: WRMHeader) -> str:
+ """Build a base64 one-record PlayReady Object holding only this header.
+
+ Remote CDMs send the value given to set_pssh_b64 rather than the header passed
+ to get_license_challenge, so a per-header challenge needs a per-header PRO.
+ """
+ record = header.dumps().encode("utf-16-le")
+ pro = PSSH.PlayreadyHeader.build(
+ {"length": 10 + len(record), "records": [{"type": 1, "length": len(record), "data": record}]}
+ )
+ return base64.b64encode(pro).decode()
+
def extract_keys_from_cdm(self, cdm: PlayReadyCdm, session_id: bytes) -> dict:
"""Extract keys from CDM session with cross-library compatibility.
@@ -298,21 +363,96 @@ class PlayReady:
return keys
def get_content_keys(self, cdm: PlayReadyCdm, certificate: Callable, licence: Callable) -> None:
+ """License every distinct WRM header whose KIDs still lack a content key.
+
+ A v4.2+ header may list several KIDs and one response may stack several licences,
+ so one challenge often covers everything. Content that ships several headers (ISM
+ with one ProtectionHeader per track type, HLS with one PRH per playlist) gets one
+ challenge per header that is not yet covered. Headers are sent as-is: Microsoft lets
+ the server ignore the header, so a merged header could ask for keys it never grants.
+
+ A header that a round refused, or that yielded nothing, is not sent again by this
+ object: every track of a title runs this method, and a repeat would only repeat the
+ refusal.
+ """
+ headers = self.distinct_headers()
+ wanted = set(self.kids)
+ for index, header in enumerate(headers):
+ header_kids = self.header_kids(header)
+ if header_kids and set(header_kids) <= self.content_keys.keys():
+ continue
+ if len(headers) > 1 and header.dumps() in self._refused_headers:
+ continue
+ if len(headers) == 1:
+ pssh_b64, required = self.pssh_b64, self.kids
+ else:
+ pssh_b64, required = self.pro_b64_for(header), header_kids or self.kids
+ try:
+ keys = self._license_header(cdm, header, pssh_b64, required, licence)
+ except PlayReady.Exceptions.DeviceRevoked:
+ raise
+ except Exception as e:
+ self._refused_headers.add(header.dumps())
+ if not self.content_keys and index == len(headers) - 1:
+ raise
+ log_event(
+ "drm_license_error",
+ level="WARNING",
+ message=f"PlayReady header {index + 1}/{len(headers)} was refused: {e}",
+ drm_type="PlayReady",
+ header_kids=[kid.hex for kid in header_kids],
+ )
+ continue
+ self.content_keys.update(keys)
+ if keys:
+ log_event(
+ "drm_content_keys",
+ level="INFO",
+ message=f"Recovered {len(keys)} PlayReady content key(s) from CDM",
+ drm_type="PlayReady",
+ key_count=len(keys),
+ keys=[{"kid": k.hex if hasattr(k, "hex") else str(k), "key": v} for k, v in keys.items()],
+ )
+ else:
+ self._refused_headers.add(header.dumps())
+
+ if not self.content_keys:
+ raise PlayReady.Exceptions.EmptyLicense("No Content Keys were within the License")
+
+ missing = wanted - self.content_keys.keys()
+ if missing:
+ log_event(
+ "drm_missing_keys",
+ level="WARNING",
+ message=f"No PlayReady key for KID(s) {', '.join(sorted(kid.hex for kid in missing))}",
+ drm_type="PlayReady",
+ missing_kids=[kid.hex for kid in missing],
+ )
+
+ def _license_header(
+ self,
+ cdm: PlayReadyCdm,
+ header: WRMHeader,
+ pssh_b64: Optional[str],
+ required_kids: list[UUID],
+ licence: Callable,
+ ) -> dict[UUID, str]:
+ """Run one challenge/response round for a single header and return the keys it yielded."""
session_id = cdm.open()
try:
- if hasattr(cdm, "set_pssh_b64") and self.pssh_b64:
+ if hasattr(cdm, "set_pssh_b64") and pssh_b64:
try:
- cdm.set_pssh_b64(self.pssh_b64, session_id=session_id)
+ cdm.set_pssh_b64(pssh_b64, session_id=session_id)
except TypeError: # CDM predating the per-session signature
- cdm.set_pssh_b64(self.pssh_b64)
+ cdm.set_pssh_b64(pssh_b64)
if hasattr(cdm, "set_required_kids"):
try:
- cdm.set_required_kids(self.kids, session_id=session_id)
+ cdm.set_required_kids(required_kids, session_id=session_id)
except TypeError: # CDM predating the per-session signature
- cdm.set_required_kids(self.kids)
+ cdm.set_required_kids(required_kids)
- challenge = cdm.get_license_challenge(session_id, self.pssh.wrm_headers[0])
+ challenge = cdm.get_license_challenge(session_id, header)
if challenge:
license_str = ""
@@ -323,10 +463,10 @@ class PlayReady:
message="Requesting PlayReady license",
drm_type="PlayReady",
challenge_size=len(challenge),
- kid_count=len(self.kids),
+ kid_count=len(required_kids),
)
try:
- license_res = licence(challenge=challenge, pssh_b64=self.pssh_b64)
+ license_res = licence(challenge=challenge, pssh_b64=pssh_b64)
except TypeError:
license_res = licence(challenge=challenge)
if isinstance(license_res, bytes):
@@ -356,24 +496,10 @@ class PlayReady:
raise PlayReady.Exceptions.DeviceRevoked(revoked) from e
raise
- keys = self.extract_keys_from_cdm(cdm, session_id)
- self.content_keys.update(keys)
-
- if keys:
- log_event(
- "drm_content_keys",
- level="INFO",
- message=f"Recovered {len(keys)} PlayReady content key(s) from CDM",
- drm_type="PlayReady",
- key_count=len(keys),
- keys=[{"kid": k.hex if hasattr(k, "hex") else str(k), "key": v} for k, v in keys.items()],
- )
+ return self.extract_keys_from_cdm(cdm, session_id)
finally:
cdm.close(session_id)
- if not self.content_keys:
- raise PlayReady.Exceptions.EmptyLicense("No Content Keys were within the License")
-
@staticmethod
def detect_revocation(text: str) -> Optional[str]:
"""Return the decoded error string if a revocation HRESULT is present, else None.
diff --git a/unshackle/core/tracks/track.py b/unshackle/core/tracks/track.py
index 06c6a513..e58adb1a 100644
--- a/unshackle/core/tracks/track.py
+++ b/unshackle/core/tracks/track.py
@@ -877,9 +877,10 @@ class Track:
if isinstance(drm, Widevine):
return drm
elif is_playready_cdm(cdm):
- for drm in self.drm:
- if isinstance(drm, PlayReady):
- return drm
+ playready = [drm for drm in self.drm if isinstance(drm, PlayReady)]
+ if playready:
+ playready[0].absorb(*playready[1:])
+ return playready[0]
return self.drm[0]