refactor(drm): drop the hardcoded service branch in the license flow

One service needed session_id in its license call, which a near-duplicate of get_content_keys supplied behind a service-tag test. service_licence now passes each service only the arguments its own license function declares, so the duplicate and the branch are gone. The REST API license paths and the base PlayReady delegate get the same filtering, replacing their fixed argument set.

Two behaviour changes ride along: an empty license challenge now raises EmptyLicense for every service, not just the one, and a service that declares only challenge now receives a working call instead of a TypeError.
This commit is contained in:
imSp4rky
2026-09-05 22:14:47 -06:00
parent e845e9ba28
commit 0f9997be4c
8 changed files with 564 additions and 61 deletions
+34
View File
@@ -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
@@ -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)
)
@@ -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 "<License>ok</License>"
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 "<License>ok</License>"
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 "<License>ok</License>"
# --- 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=["<WRMHEADER/>"])
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)
+7 -7
View File
@@ -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}")
+17 -4
View File
@@ -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,
+5 -47
View File
@@ -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.
+6 -2
View File
@@ -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
+19 -1
View File
@@ -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",