diff --git a/tests/core/test_declared_kwargs.py b/tests/core/test_declared_kwargs.py
new file mode 100644
index 00000000..d322469e
--- /dev/null
+++ b/tests/core/test_declared_kwargs.py
@@ -0,0 +1,34 @@
+from __future__ import annotations
+
+from unshackle.core.utilities import declared_kwargs
+
+SENT = {"challenge": b"c", "title": "t", "track": "tr", "pssh": "P", "session_id": "S"}
+
+
+def test_keeps_only_declared_arguments() -> None:
+ def licence(*, challenge: bytes, title: str, track: str) -> None: ...
+
+ assert sorted(declared_kwargs(licence, SENT)) == ["challenge", "title", "track"]
+
+
+def test_passes_session_id_to_a_function_that_asks_for_it() -> None:
+ def licence(*, challenge: bytes, title: str, track: str, session_id: str | None = None) -> None: ...
+
+ assert sorted(declared_kwargs(licence, SENT)) == ["challenge", "session_id", "title", "track"]
+
+
+def test_a_var_keyword_function_gets_everything() -> None:
+ def licence(*, challenge: bytes, **kwargs: object) -> None: ...
+
+ assert declared_kwargs(licence, SENT) == SENT
+
+
+def test_an_unreadable_signature_gets_everything() -> None:
+ # min() has no readable signature, and a compiled service may not either
+ assert declared_kwargs(min, SENT) == SENT
+
+
+def test_a_signature_we_cannot_call_by_keyword_gets_everything() -> None:
+ def licence(challenge, /, **kwargs: object) -> None: ...
+
+ assert declared_kwargs(licence, SENT) == SENT
diff --git a/tests/core/test_widevine_get_content_keys.py b/tests/core/test_widevine_get_content_keys.py
new file mode 100644
index 00000000..a24b0a61
--- /dev/null
+++ b/tests/core/test_widevine_get_content_keys.py
@@ -0,0 +1,95 @@
+from __future__ import annotations
+
+from typing import Any
+from uuid import UUID
+
+import pytest
+from pywidevine.pssh import PSSH
+
+from unshackle.core.drm import Widevine
+from unshackle.core.utilities import declared_kwargs
+
+KID = UUID("00112233-4455-6677-8899-aabbccddeeff")
+KEY = "00000000000000000000000000000001"
+
+
+class FakeKey:
+ def __init__(self, kid: UUID, key: str) -> None:
+ self.kid = kid
+ self.key = bytes.fromhex(key)
+ self.type = "CONTENT"
+
+
+class FakeCdm:
+ """The smallest CDM the licensing flow accepts; records what it was given."""
+
+ service_certificate_challenge = b"cert-challenge"
+
+ def __init__(self, challenge: bytes = b"challenge") -> None:
+ self._challenge = challenge
+ self.parsed: list[Any] = []
+
+ def open(self) -> str:
+ return "session-1"
+
+ def close(self, session_id: str) -> None:
+ pass
+
+ def get_license_challenge(self, session_id: str, pssh: PSSH) -> bytes:
+ return self._challenge
+
+ def parse_license(self, session_id: str, response: Any) -> None:
+ self.parsed.append(response)
+
+ def get_keys(self, session_id: str, type_: str) -> list[FakeKey]:
+ return [FakeKey(KID, KEY)]
+
+
+def _drm() -> Widevine:
+ return Widevine(pssh=PSSH.new(system_id=PSSH.SystemId.Widevine, key_ids=[KID]), kid=KID)
+
+
+def _licence_for(fn: Any) -> Any:
+ """Build the callable dl.py gives the DRM object, filtered to what the service declares."""
+ return lambda **kw: fn(**declared_kwargs(fn, {**kw, "title": "t", "track": "tr"}))
+
+
+def test_a_session_aware_service_gets_the_open_session_id() -> None:
+ seen: dict[str, Any] = {}
+
+ def get_widevine_license(*, challenge: bytes, title: str, track: str, session_id: Any = None) -> bytes:
+ seen.update(challenge=challenge, title=title, track=track, session_id=session_id)
+ return b"license"
+
+ drm = _drm()
+ cdm = FakeCdm()
+ drm.get_content_keys(cdm=cdm, certificate=lambda **_: None, licence=_licence_for(get_widevine_license))
+
+ assert seen["session_id"] == "session-1"
+ assert "pssh" not in seen
+ assert drm.content_keys == {KID: KEY}
+
+
+def test_a_plain_service_does_not_get_pssh_or_session_id() -> None:
+ seen: dict[str, Any] = {}
+
+ def get_widevine_license(*, challenge: bytes, title: str, track: str) -> bytes:
+ seen.update(challenge=challenge, title=title, track=track)
+ return b"license"
+
+ drm = _drm()
+ drm.get_content_keys(cdm=FakeCdm(), certificate=lambda **_: None, licence=_licence_for(get_widevine_license))
+
+ assert set(seen) == {"challenge", "title", "track"}
+ assert drm.content_keys == {KID: KEY}
+
+
+def test_an_empty_challenge_raises_instead_of_licensing() -> None:
+ def get_widevine_license(*, challenge: bytes, title: str, track: str) -> bytes:
+ raise AssertionError("the licence request must not go out for an empty challenge")
+
+ drm = _drm()
+ with pytest.raises(Widevine.Exceptions.EmptyLicense):
+ drm.get_content_keys(
+ cdm=FakeCdm(challenge=b""), certificate=lambda **_: None, licence=_licence_for(get_widevine_license)
+ )
diff --git a/tests/remote/unit/test_licence_declared_kwargs.py b/tests/remote/unit/test_licence_declared_kwargs.py
new file mode 100644
index 00000000..5601514d
--- /dev/null
+++ b/tests/remote/unit/test_licence_declared_kwargs.py
@@ -0,0 +1,381 @@
+"""The REST API licence paths must give a service only the arguments it declares.
+
+`serve` reaches a service's licence function through three call sites that bypass the
+`dl` command: server_cdm Widevine, server_cdm PlayReady, and the proxy-license forward.
+Each one now filters with `declared_kwargs`, so a service that never asked for `pssh` or
+`session_id` keeps working while a session-aware service finally receives `session_id`
+on the server_cdm path.
+"""
+
+from __future__ import annotations
+
+import base64
+from types import SimpleNamespace
+from typing import Any, Optional
+from uuid import UUID
+
+import pyplayready.system.pssh as pr_pssh_mod
+import pytest
+import pywidevine.pssh as wv_pssh_mod
+
+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
+from unshackle.core.drm import PlayReady, Widevine
+from unshackle.core.service import Service
+
+pytestmark = pytest.mark.unit
+
+KID = UUID("11111111111111111111111111111111")
+KEY = bytes.fromhex("22222222222222222222222222222222")
+PSSH_B64 = base64.b64encode(b"pssh").decode()
+SESSION_ID = b"sid-bytes"
+
+
+# --- fake services, one per licence signature shape -------------------------------------
+
+
+class _Recorder:
+ """Base for the fake services; every licence call lands in `calls`."""
+
+ def __init__(self) -> None:
+ self.calls: list[dict[str, Any]] = []
+
+ def get_widevine_service_certificate(self, *, challenge: Any, title: Any, track: Any) -> None:
+ return None
+
+
+class ClassicService(_Recorder):
+ """EXAMPLE: the pre-`session_id` signature every older service uses."""
+
+ def get_widevine_license(self, *, challenge: bytes, title: Any, track: Any) -> bytes:
+ self.calls.append({"challenge": challenge, "title": title, "track": track})
+ return b"LIC"
+
+ def get_playready_license(self, *, challenge: bytes, title: Any, track: Any) -> str:
+ self.calls.append({"challenge": challenge, "title": title, "track": track})
+ return "ok"
+
+
+class SessionService(_Recorder):
+ """DEMO: a session-aware service, the shape that needs the open CDM session id."""
+
+ def get_widevine_license(
+ self, *, challenge: bytes, title: Any, track: Any, session_id: Optional[bytes] = None
+ ) -> bytes:
+ self.calls.append({"challenge": challenge, "title": title, "track": track, "session_id": session_id})
+ return b"LIC"
+
+ def get_playready_license(
+ self, *, challenge: bytes, title: Any, track: Any, session_id: Optional[bytes] = None
+ ) -> str:
+ self.calls.append({"challenge": challenge, "title": title, "track": track, "session_id": session_id})
+ return "ok"
+
+
+class VarKwService(_Recorder):
+ """DEMO2: a `**kwargs` signature takes whatever the framework offers."""
+
+ def get_widevine_license(self, *, challenge: bytes, **kwargs: Any) -> bytes:
+ self.calls.append({"challenge": challenge, **kwargs})
+ return b"LIC"
+
+ def get_playready_license(self, *, challenge: bytes, **kwargs: Any) -> str:
+ self.calls.append({"challenge": challenge, **kwargs})
+ return "ok"
+
+
+# --- stub DRM objects that keep the real get_content_keys call shape ---------------------
+
+
+class StubWidevine(Widevine):
+ """Real `Widevine.get_content_keys`, with the PSSH parsing skipped."""
+
+ # pssh and kids are read-only properties on the real class; shadow them so a stub can set them
+ pssh: Any = None
+ kids: Any = None
+
+ def __init__(self, pssh: Any = None, **kwargs: Any) -> None:
+ self.pssh = pssh
+ self.kids = [KID]
+ self.content_keys: dict[UUID, str] = {}
+
+
+class StubPlayReady(PlayReady):
+ """Real `PlayReady.get_content_keys`, with the WRMHEADER parsing skipped."""
+
+ # pssh, pssh_b64 and kids are read-only properties on the real class
+ pssh: Any = None
+ pssh_b64: Any = None
+ kids: Any = None
+
+ def __init__(self, pssh: Any = None, pssh_b64: Optional[str] = None, **kwargs: Any) -> None:
+ self.pssh = SimpleNamespace(wrm_headers=[""])
+ self.pssh_b64 = pssh_b64
+ self.kids = [KID]
+ self.content_keys: dict[UUID, str] = {}
+
+ def extract_keys_from_cdm(self, cdm: Any, session_id: Any) -> dict[UUID, str]:
+ return {KID: KEY.hex()}
+
+
+class FakeWidevineCdm:
+ service_certificate_challenge = b"cert-chal"
+
+ def open(self) -> bytes:
+ return SESSION_ID
+
+ def get_license_challenge(self, session_id: bytes, pssh: Any) -> bytes:
+ return b"CHAL"
+
+ def parse_license(self, session_id: bytes, license_res: Any) -> None:
+ return None
+
+ def get_keys(self, session_id: bytes, type_: str) -> list[SimpleNamespace]:
+ return [SimpleNamespace(kid=KID, key=KEY)]
+
+ def close(self, session_id: bytes) -> None:
+ return None
+
+
+class FakePlayReadyCdm:
+ def open(self) -> bytes:
+ return SESSION_ID
+
+ def get_license_challenge(self, session_id: bytes, wrm_header: Any) -> str:
+ return "CHAL"
+
+ def parse_license(self, session_id: bytes, license_str: str) -> None:
+ return None
+
+ def close(self, session_id: bytes) -> None:
+ return None
+
+
+# --- fixtures ---------------------------------------------------------------------------
+
+
+@pytest.fixture
+def widevine_env(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(wv_pssh_mod, "PSSH", lambda b64: SimpleNamespace(b64=b64))
+ monkeypatch.setattr(drm_mod, "Widevine", StubWidevine)
+ monkeypatch.setattr(cdm_mod, "load_cdm", lambda *a, **k: FakeWidevineCdm())
+ monkeypatch.setattr(detect_mod, "is_widevine_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)
+
+
+@pytest.fixture
+def playready_env(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(pr_pssh_mod, "PSSH", lambda data: SimpleNamespace(data=data))
+ monkeypatch.setattr(drm_mod, "PlayReady", StubPlayReady)
+ monkeypatch.setattr(cdm_mod, "load_cdm", lambda *a, **k: FakePlayReadyCdm())
+ 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, "cache_to_vaults", lambda keys, name: None)
+ monkeypatch.setattr(handlers.config, "serve", {"users": {}}, raising=False)
+
+
+def _server_cdm(service: Any, drm_type: str) -> dict[str, str]:
+ return handlers.handle_single_server_cdm(
+ service=service,
+ title=SimpleNamespace(),
+ track=SimpleNamespace(),
+ pssh_b64=PSSH_B64,
+ drm_type=drm_type,
+ request=None,
+ )
+
+
+# --- server_cdm, Widevine ---------------------------------------------------------------
+
+
+def test_server_cdm_widevine_classic_signature_gets_three_arguments(widevine_env: None) -> None:
+ service = ClassicService()
+ keys = _server_cdm(service, "widevine")
+
+ assert keys == {KID.hex: KEY.hex()}
+ assert len(service.calls) == 1
+ assert set(service.calls[0]) == {"challenge", "title", "track"}
+ assert service.calls[0]["challenge"] == b"CHAL"
+
+
+def test_server_cdm_widevine_session_service_receives_the_open_session_id(widevine_env: None) -> None:
+ service = SessionService()
+ _server_cdm(service, "widevine")
+
+ assert set(service.calls[0]) == {"challenge", "title", "track", "session_id"}
+ assert service.calls[0]["session_id"] == SESSION_ID
+
+
+def test_server_cdm_widevine_var_kwargs_service_receives_pssh_and_session_id(widevine_env: None) -> None:
+ service = VarKwService()
+ _server_cdm(service, "widevine")
+
+ assert set(service.calls[0]) == {"challenge", "title", "track", "pssh", "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:
+ # 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`.
+ service = ClassicService()
+ _server_cdm(service, "widevine")
+
+ assert "pssh" not in service.calls[0]
+ assert "session_id" not in service.calls[0]
+
+
+# --- server_cdm, PlayReady --------------------------------------------------------------
+
+
+def test_server_cdm_playready_classic_signature_gets_three_arguments(playready_env: None) -> None:
+ service = ClassicService()
+ keys = _server_cdm(service, "playready")
+
+ assert keys == {KID.hex: KEY.hex()}
+ 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.
+ service = VarKwService()
+ _server_cdm(service, "playready")
+
+ assert set(service.calls[0]) == {"challenge", "title", "track", "pssh_b64"}
+ assert service.calls[0]["pssh_b64"] == PSSH_B64
+
+
+def test_server_cdm_playready_session_service_is_not_passed_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
+
+
+# --- proxy license ----------------------------------------------------------------------
+
+
+def _proxy(service: Any, drm_type: str) -> Any:
+ return handlers.handle_proxy_license(
+ service=service,
+ title=SimpleNamespace(),
+ track=SimpleNamespace(),
+ challenge_b64=base64.b64encode(b"CHAL").decode(),
+ drm_type=drm_type,
+ )
+
+
+@pytest.mark.parametrize("drm_type", ["widevine", "playready"])
+def test_proxy_license_classic_signature(drm_type: str) -> None:
+ service = ClassicService()
+ response = _proxy(service, drm_type)
+
+ assert response.status == 200
+ assert set(service.calls[0]) == {"challenge", "title", "track"}
+ assert service.calls[0]["challenge"] == b"CHAL"
+
+
+@pytest.mark.parametrize("drm_type", ["widevine", "playready"])
+def test_proxy_license_var_kwargs_service_gets_no_session_id(drm_type: str) -> None:
+ # The client holds the CDM, so there is no server-side session to name. A `**kwargs`
+ # service must not see a session_id key at all.
+ service = VarKwService()
+ _proxy(service, drm_type)
+
+ assert set(service.calls[0]) == {"challenge", "title", "track"}
+
+
+@pytest.mark.parametrize("drm_type", ["widevine", "playready"])
+def test_proxy_license_session_service_sees_its_own_default(drm_type: str) -> None:
+ # A session-aware service is called without session_id, so its signature default applies.
+ # A service that then uses session_id unconditionally fails here, before and after the
+ # declared_kwargs change alike.
+ service = SessionService()
+ _proxy(service, drm_type)
+
+ assert set(service.calls[0]) == {"challenge", "title", "track", "session_id"}
+ assert service.calls[0]["session_id"] is None
+
+
+def test_proxy_license_rejects_an_unknown_drm_type() -> None:
+ from unshackle.core.api.errors import APIError
+
+ with pytest.raises(APIError):
+ _proxy(ClassicService(), "monalisa")
+
+
+# --- the base-class PlayReady delegate --------------------------------------------------
+
+
+class _DelegatingService(Service):
+ """A service with no PlayReady implementation: the base class delegates to Widevine."""
+
+ def __init__(self) -> None: # deliberately skips Service.__init__
+ self.calls: list[dict[str, Any]] = []
+
+ def get_titles(self) -> Any: ... # pragma: no cover - abstract stub
+
+ def get_tracks(self, title: Any) -> Any: ... # pragma: no cover - abstract stub
+
+ def get_chapters(self, title: Any) -> Any: ... # pragma: no cover - abstract stub
+
+ def get_widevine_license(self, *, challenge: bytes, title: Any, track: Any) -> bytes:
+ self.calls.append({"challenge": challenge, "title": title, "track": track})
+ return b"LIC"
+
+
+class _DelegatingVarKwService(_DelegatingService):
+ def get_widevine_license(self, *, challenge: bytes, **kwargs: Any) -> bytes: # type: ignore[override]
+ self.calls.append({"challenge": challenge, **kwargs})
+ return b"LIC"
+
+
+def test_base_playready_delegate_filters_to_the_widevine_signature() -> None:
+ service = _DelegatingService()
+ assert service.get_playready_license(challenge=b"C", title="T", track="TR") == b"LIC"
+ assert set(service.calls[0]) == {"challenge", "title", "track"}
+
+
+def test_base_playready_delegate_passes_everything_to_a_var_kwargs_service() -> None:
+ service = _DelegatingVarKwService()
+ service.get_playready_license(challenge=b"C", title="T", track="TR")
+ assert set(service.calls[0]) == {"challenge", "title", "track"}
+
+
+def test_base_playready_delegate_cannot_forward_session_id() -> None:
+ # Service.get_playready_license declares (challenge, title, track) only, so the server_cdm
+ # filter drops session_id before the delegate ever runs. A session-aware service that wants
+ # PlayReady must implement get_playready_license itself.
+ from unshackle.core.utilities import declared_kwargs
+
+ offered = {"challenge": b"C", "title": "T", "track": "TR", "session_id": SESSION_ID}
+ assert "session_id" not in declared_kwargs(Service.get_playready_license, offered)
diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py
index c9d60020..9595b463 100644
--- a/unshackle/commands/dl.py
+++ b/unshackle/commands/dl.py
@@ -67,6 +67,7 @@ from unshackle.core.tracks.hybrid import Hybrid
from unshackle.core.tracks.track import assert_fragments_decrypted, has_encrypted_sample_entry
from unshackle.core.utilities import (
as_requested,
+ declared_kwargs,
embedded_audio_langs,
excluded_language_tags,
find_font_with_fallbacks,
@@ -4007,14 +4008,16 @@ class dl:
def service_licence(self, service: Service, drm_system: Optional[str] = None, **kwargs: Any) -> Any:
"""Call the service's licence function for the track's DRM system.
+ Arguments go through declared_kwargs, so a service receives only the ones its own
+ licence function declares.
+
prepare_drm passes drm_system so the choice follows the track, not the loaded CDM:
a track downloading at the same time can swap self.cdm.
"""
if drm_system is None:
drm_system = "playready" if is_playready_cdm(self.cdm) else "widevine"
- if drm_system == "playready":
- return service.get_playready_license(**kwargs)
- return service.get_widevine_license(**kwargs)
+ fn = service.get_playready_license if drm_system == "playready" else service.get_widevine_license
+ return fn(**declared_kwargs(fn, kwargs))
def prepare_drm(
self,
@@ -4281,10 +4284,7 @@ class dl:
)
try:
- if self.service == "NF":
- drm.get_NF_content_keys(cdm=track_cdm, licence=licence, certificate=certificate)
- else:
- drm.get_content_keys(cdm=track_cdm, licence=licence, certificate=certificate)
+ drm.get_content_keys(cdm=track_cdm, licence=licence, certificate=certificate)
except Exception as e:
if drm.content_keys:
self.log.debug(f"License call failed but keys already in content_keys: {e}")
diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py
index 304b0518..82f8fbe7 100644
--- a/unshackle/core/api/handlers.py
+++ b/unshackle/core/api/handlers.py
@@ -27,6 +27,7 @@ from unshackle.core.proxies.resolve import initialize_proxy_providers, resolve_p
from unshackle.core.services import Services
from unshackle.core.titles import Episode, Movie, Song, Title_T
from unshackle.core.tracks import Audio, Subtitle, Tracks, Video
+from unshackle.core.utilities import declared_kwargs
from unshackle.core.utils.collections import ci_get
from unshackle.core.utils.redact import REDACTED, URL_USERINFO_RE, redact_text
@@ -3678,7 +3679,9 @@ def handle_single_server_cdm(
pr_drm.get_content_keys(
cdm=cdm,
certificate=lambda challenge, **_: None,
- licence=lambda challenge, **_: service.get_playready_license(challenge=challenge, title=title, track=track),
+ licence=lambda **kw: service.get_playready_license(
+ **declared_kwargs(service.get_playready_license, {**kw, "title": title, "track": track})
+ ),
)
keys = {kid.hex: key for kid, key in pr_drm.content_keys.items()}
elif drm_type == "widevine":
@@ -3705,7 +3708,9 @@ def handle_single_server_cdm(
certificate=lambda challenge, **_: service.get_widevine_service_certificate(
challenge=challenge, title=title, track=track
),
- licence=lambda challenge, **_: service.get_widevine_license(challenge=challenge, title=title, track=track),
+ licence=lambda **kw: service.get_widevine_license(
+ **declared_kwargs(service.get_widevine_license, {**kw, "title": title, "track": track})
+ ),
)
keys = {kid.hex: key for kid, key in wv_drm.content_keys.items()}
else:
@@ -3736,9 +3741,17 @@ def handle_proxy_license(
challenge_bytes = base64.b64decode(challenge_b64)
if drm_type == "widevine":
- license_response = service.get_widevine_license(challenge=challenge_bytes, title=title, track=track)
+ license_response = service.get_widevine_license(
+ **declared_kwargs(
+ service.get_widevine_license, {"challenge": challenge_bytes, "title": title, "track": track}
+ )
+ )
elif drm_type == "playready":
- license_response = service.get_playready_license(challenge=challenge_bytes, title=title, track=track)
+ license_response = service.get_playready_license(
+ **declared_kwargs(
+ service.get_playready_license, {"challenge": challenge_bytes, "title": title, "track": track}
+ )
+ )
else:
raise APIError(
APIErrorCode.INVALID_PARAMETERS,
diff --git a/unshackle/core/drm/widevine.py b/unshackle/core/drm/widevine.py
index 4662c178..a6cbf407 100644
--- a/unshackle/core/drm/widevine.py
+++ b/unshackle/core/drm/widevine.py
@@ -220,6 +220,10 @@ class Widevine:
if hasattr(cdm, "has_cached_keys") and cdm.has_cached_keys(session_id):
pass
+ elif not challenge:
+ raise Widevine.Exceptions.EmptyLicense(
+ "The CDM returned an empty licence challenge and has no cached keys"
+ )
else:
log_event(
"drm_license_request",
@@ -231,7 +235,7 @@ class Widevine:
kid_count=len(self.kids),
)
try:
- license_res = licence(challenge=challenge, pssh=self.pssh)
+ 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)
@@ -265,52 +269,6 @@ class Widevine:
finally:
cdm.close(session_id)
- def get_NF_content_keys(self, cdm: WidevineCdm, certificate: Callable, licence: Callable) -> None:
- """
- Make a CDM Session and get Content Keys for this DRM Instance.
- The certificate and license params are functions. unshackle gives them
- the challenge and the session ID.
- """
- for kid in self.kids:
- if kid in self.content_keys:
- continue
-
- session_id = cdm.open()
-
- try:
- cert = certificate(challenge=cdm.service_certificate_challenge)
- if cert and hasattr(cdm, "set_service_certificate"):
- cdm.set_service_certificate(session_id, cert)
-
- if hasattr(cdm, "set_required_kids"):
- try:
- cdm.set_required_kids(self.kids, session_id=session_id)
- except TypeError: # CDM predating the per-session signature
- cdm.set_required_kids(self.kids)
-
- challenge = cdm.get_license_challenge(session_id, self.pssh)
-
- if hasattr(cdm, "has_cached_keys") and cdm.has_cached_keys(session_id):
- pass
- elif not challenge:
- raise Widevine.Exceptions.EmptyLicense(
- "The CDM returned an empty license challenge and has no cached keys"
- )
- else:
- cdm.parse_license(
- session_id,
- licence(session_id=session_id, challenge=challenge),
- )
-
- self.content_keys = {key.kid: key.key.hex() for key in cdm.get_keys(session_id, "CONTENT")}
- if not self.content_keys:
- raise Widevine.Exceptions.EmptyLicense("No Content Keys were within the License")
-
- if kid not in self.content_keys:
- raise Widevine.Exceptions.CEKNotFound(f"No Content Key for KID {kid.hex} within the License")
- finally:
- cdm.close(session_id)
-
def decrypt(self, path: Path) -> None:
"""
Decrypt a Track with Widevine DRM.
diff --git a/unshackle/core/service.py b/unshackle/core/service.py
index 804ef57b..6e3f1cd9 100644
--- a/unshackle/core/service.py
+++ b/unshackle/core/service.py
@@ -38,6 +38,7 @@ from unshackle.core.title_cacher import TitleCacher, get_account_hash, get_regio
from unshackle.core.titles import Title_T, Titles_T, remap_titles
from unshackle.core.tracks import Chapters, Tracks
from unshackle.core.tracks.video import Video
+from unshackle.core.utilities import declared_kwargs
from unshackle.core.utils.ip_info import get_ip_info
from unshackle.core.utils.redact import mask_proxy
@@ -512,8 +513,11 @@ class Service(metaclass=ABCMeta):
:return: The License response as Bytes or a Base64 string. Do not Base64 Encode or
Decode the data, return as is to reduce unnecessary computations.
"""
- # Delegates license handling to the Widevine license method by default if a service-specific PlayReady implementation is not provided.
- return self.get_widevine_license(challenge=challenge, title=title, track=track)
+ # A service that implements Widevine licensing only still gets PlayReady, with the
+ # arguments its own signature declares.
+ return self.get_widevine_license(
+ **declared_kwargs(self.get_widevine_license, {"challenge": challenge, "title": title, "track": track})
+ )
def get_clearkey_license(
self, *, challenge: bytes, title: Title_T, track: AnyTrack
diff --git a/unshackle/core/utilities.py b/unshackle/core/utilities.py
index c1cbe3f3..afb98f64 100644
--- a/unshackle/core/utilities.py
+++ b/unshackle/core/utilities.py
@@ -2,6 +2,7 @@ import ast
import contextlib
import gzip
import importlib.util
+import inspect
import json
import logging
import os
@@ -16,7 +17,7 @@ from collections import defaultdict
from datetime import datetime, timezone
from pathlib import Path
from types import ModuleType
-from typing import Any, Optional, Sequence, Union
+from typing import Any, Callable, Optional, Sequence, Union
from urllib.parse import ParseResult, urlparse
from uuid import uuid4
@@ -1254,6 +1255,23 @@ def close_debug_logger():
debug_logger = None
+def declared_kwargs(fn: Callable[..., Any], kwargs: dict[str, Any]) -> dict[str, Any]:
+ """Keep only the arguments ``fn`` declares, so an extra one never raises TypeError.
+
+ 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.
+ """
+ try:
+ params = inspect.signature(fn).parameters
+ except (TypeError, ValueError):
+ return kwargs
+ if "challenge" not in params or any(p.kind is p.VAR_KEYWORD for p in params.values()):
+ return kwargs
+ return {k: v for k, v in kwargs.items() if k in params}
+
+
__all__ = (
"DebugLogger",
"get_debug_logger",