From 4639d9d4969f900f4f00b3936712c26c287cd6ac Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Tue, 22 Sep 2026 10:54:31 -0600 Subject: [PATCH] fix(remote): let the server's CDM mapping decide the DRM system under server_cdm A client with a local PlayReady device asked the server for a PlayReady licence on a service the server maps to a Widevine device. Both server_cdm routes now share one candidate list from one config lookup, report the system that licensed in drm_type, and the client adopts it. --- docs/dev/rest-api/endpoints.md | 4 +- tests/remote/unit/test_extract_pssh_strict.py | 1 + .../test_remote_service_server_cdm_license.py | 99 ++++++++ .../unit/test_server_cdm_key_verification.py | 1 + tests/remote/unit/test_server_cdm_multikey.py | 1 + .../unit/test_server_cdm_type_override.py | 220 ++++++++++++++++++ unshackle/commands/dl.py | 11 +- unshackle/core/api/handlers.py | 191 ++++++++++----- unshackle/core/remote_service.py | 32 ++- 9 files changed, 478 insertions(+), 82 deletions(-) create mode 100644 tests/remote/unit/test_remote_service_server_cdm_license.py create mode 100644 tests/remote/unit/test_server_cdm_type_override.py diff --git a/docs/dev/rest-api/endpoints.md b/docs/dev/rest-api/endpoints.md index 41706fdb..dabb34b9 100644 --- a/docs/dev/rest-api/endpoints.md +++ b/docs/dev/rest-api/endpoints.md @@ -1061,7 +1061,7 @@ Get the content keys for the DRM. The `mode` field selects one of two modes. === "Single: Response `200`" ```json - { "keys": { "": "" }, "vault_keys": [""] } + { "keys": { "": "" }, "vault_keys": [""], "drm_type": "widevine" } ``` === "Batch: Response `200`" @@ -1075,7 +1075,7 @@ Get the content keys for the DRM. The `mode` field selects one of two modes. | `track_id` / `track_ids` | Single track, or a batch of tracks. | | `challenge` | Base64 CDM challenge (proxy mode). | | `pssh` | Base64 PSSH (server-CDM mode). | -| `drm_type` | `widevine` (default) or `playready`. | +| `drm_type` | `widevine` (default) or `playready`. In server-CDM mode the server's `cdm` mapping for the service decides the DRM system and the response `drm_type` names the system that licensed. The client's value decides only when the server has no mapping for the service. | | `mode` | `proxy` (default) or `server_cdm`. | | `clear_tracks` (response) | Requested track ids that carry no DRM, so they have no keys. Absent when every requested track is encrypted. | | `vault_keys` (response) | KIDs whose content key came from a server vault, not the CDM. The client proves such a content key with a decode before it trusts it; the field is absent when every content key came from the CDM. | diff --git a/tests/remote/unit/test_extract_pssh_strict.py b/tests/remote/unit/test_extract_pssh_strict.py index 148e4582..5b9a0f7c 100644 --- a/tests/remote/unit/test_extract_pssh_strict.py +++ b/tests/remote/unit/test_extract_pssh_strict.py @@ -62,6 +62,7 @@ async def _run_batch(monkeypatch, tracks, cdm_type, calls): monkeypatch.setattr(handlers, "ensure_track_drm", lambda track, session=None, init_data=None: None) monkeypatch.setattr(handlers, "find_title_for_track", lambda tid, session: SimpleNamespace()) monkeypatch.setattr(handlers, "detect_cdm_type_for_service", lambda tag, cfg: cdm_type) + monkeypatch.setattr(handlers, "resolve_device_name", lambda *a, **k: "dev") monkeypatch.setattr(handlers, "handle_single_server_cdm", fake_single) monkeypatch.setattr(handlers.config, "serve", {"users": {}}, raising=False) resp = await handlers.session_license_handler( diff --git a/tests/remote/unit/test_remote_service_server_cdm_license.py b/tests/remote/unit/test_remote_service_server_cdm_license.py new file mode 100644 index 00000000..73013845 --- /dev/null +++ b/tests/remote/unit/test_remote_service_server_cdm_license.py @@ -0,0 +1,99 @@ +"""The client's single-track server_cdm licence follows the server's DRM system. + +The server's config.cdm mapping decides the system, so the client sends the header of the +system the server reported at session create, not the one its local device asked for, and +takes the system the server answered with for the next call. +""" + +import logging +from types import SimpleNamespace +from uuid import UUID + +import pytest + +from unshackle.core.remote_service import RemoteService + +pytestmark = pytest.mark.unit + +KID = UUID("161219ec3df64e0eadbb3827c0ccc46d") + + +class _PlayReady: + def __init__(self): + self.data = {"pssh_b64": "PR"} + self.content_keys: dict = {} + + +class _Widevine: + def __init__(self): + self.pssh = SimpleNamespace(dumps=lambda: "WV") + self.content_keys: dict = {} + + +# track_pssh matches on the DRM class name, so name the fakes after the DRM classes +_PlayReady.__name__ = "PlayReady" +_Widevine.__name__ = "Widevine" + + +class _Client: + def __init__(self, answer): + self.answer = answer + self.posts: list = [] + + def post(self, endpoint, payload): + self.posts.append(payload) + return self.answer + + +def _service(server_type, answer): + svc = RemoteService.__new__(RemoteService) + svc._server_cdm = True + svc._server_cdm_type = server_type + svc._session_id = "sess" + svc.server_vault_keys = {} + svc.log = logging.getLogger("test-remote-license") + svc.client = _Client(answer) + return svc + + +def test_server_type_picks_the_header_and_the_posted_type(): + svc = _service("widevine", {"keys": {KID.hex: "k"}, "drm_type": "widevine"}) + track = SimpleNamespace(id="vid", drm=[_PlayReady(), _Widevine()]) + + svc.proxy_license(b"", track, "playready") + + assert svc.client.posts == [{"track_id": "vid", "drm_type": "widevine", "mode": "server_cdm", "pssh": "WV"}] + assert all(drm.content_keys == {KID: "k"} for drm in track.drm) + + +def test_missing_header_of_the_server_type_sends_the_other_one(): + """A PlayReady-only track under a Widevine server: the header goes with its own system name.""" + svc = _service("widevine", {"keys": {KID.hex: "k"}, "drm_type": "widevine"}) + track = SimpleNamespace(id="vid", drm=[_PlayReady()]) + + svc.proxy_license(b"", track, "widevine") + + assert svc.client.posts[0]["drm_type"] == "playready" + assert svc.client.posts[0]["pssh"] == "PR" + + +def test_response_type_refreshes_the_server_type(): + svc = _service("widevine", {"keys": {KID.hex: "k"}, "drm_type": "playready"}) + track = SimpleNamespace(id="vid", drm=[_PlayReady(), _Widevine()]) + + svc.proxy_license(b"", track, "widevine") + + assert svc._server_cdm_type == "playready" + + +def test_proxy_mode_keeps_the_callers_type(): + svc = _service("widevine", {"license": "bGlj"}) + svc._server_cdm = False + svc.drain_server_logs = lambda: None + track = SimpleNamespace(id="vid", drm=[_PlayReady(), _Widevine()]) + + licence = svc.proxy_license(b"chal", track, "playready") + + assert licence == b"lic" + assert svc.client.posts[0]["drm_type"] == "playready" + assert svc.client.posts[0]["pssh"] == "PR" diff --git a/tests/remote/unit/test_server_cdm_key_verification.py b/tests/remote/unit/test_server_cdm_key_verification.py index 0ca26a0c..b8d04ca1 100644 --- a/tests/remote/unit/test_server_cdm_key_verification.py +++ b/tests/remote/unit/test_server_cdm_key_verification.py @@ -117,6 +117,7 @@ async def test_batch_reports_vault_keys_from_the_init_segment_pssh(monkeypatch): monkeypatch.setattr(handlers, "ensure_track_drm", lambda track, session=None, init_data=None: None) monkeypatch.setattr(handlers, "find_title_for_track", lambda tid, session: SimpleNamespace()) monkeypatch.setattr(handlers, "detect_cdm_type_for_service", lambda tag, cfg: "widevine") + monkeypatch.setattr(handlers, "resolve_device_name", lambda *a, **k: "dev") monkeypatch.setattr(handlers, "fetch_init_segment", lambda track, sess: b"init") monkeypatch.setattr( handlers, "drm_from_init_segment", lambda track, session=None, init_data=None: [_WV("init", [CDM_KID])] diff --git a/tests/remote/unit/test_server_cdm_multikey.py b/tests/remote/unit/test_server_cdm_multikey.py index 5912c71b..309162ec 100644 --- a/tests/remote/unit/test_server_cdm_multikey.py +++ b/tests/remote/unit/test_server_cdm_multikey.py @@ -140,6 +140,7 @@ async def test_batch_shares_full_bundle_per_pssh(monkeypatch): monkeypatch.setattr(handlers, "ensure_track_drm", lambda track, session=None, init_data=None: None) monkeypatch.setattr(handlers, "find_title_for_track", lambda tid, session: SimpleNamespace()) monkeypatch.setattr(handlers, "detect_cdm_type_for_service", lambda tag, cfg: "playready") + monkeypatch.setattr(handlers, "resolve_device_name", lambda *a, **k: "dev") monkeypatch.setattr(handlers, "drm_preference_name", lambda track: None) monkeypatch.setattr(handlers, "extract_pssh_from_track", lambda track, drm: track_pssh[track.id]) monkeypatch.setattr(handlers, "handle_single_server_cdm", fake_single) diff --git a/tests/remote/unit/test_server_cdm_type_override.py b/tests/remote/unit/test_server_cdm_type_override.py new file mode 100644 index 00000000..5b2ab564 --- /dev/null +++ b/tests/remote/unit/test_server_cdm_type_override.py @@ -0,0 +1,220 @@ +"""The single-track server_cdm route licenses with the server's configured DRM system. + +A client that holds a local PlayReady device asks for PlayReady, but a server whose +config.cdm maps the service to a Widevine device has no PlayReady device to answer with. +The batch route already ignores the client's choice; this route must match it, down to +licensing a PlayReady-only track with the Widevine device. With no server mapping, the +client's choice decides, and the type the routes plan with must be the type of the device +they load. +""" + +import json +from types import SimpleNamespace +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.api.errors import APIError + +pytestmark = pytest.mark.unit + +KID = UUID("161219ec3df64e0eadbb3827c0ccc46d") +WV_PSSH = "AAAAOHBzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAABgSEBYSGew99k4OrbtjJ8DMxG1I49yVmwY=" +PR_PSSH = "UFI=" + + +class _FakeWidevine: + def __init__(self, pssh=None, **kwargs): + self.kids = [KID] + self.content_keys: dict = {} + self.pssh = pssh + + def get_content_keys(self, cdm, certificate, licence): + self.content_keys = {KID: "key_wv"} + + +class _FakePlayReady: + def __init__(self, pssh=None, pssh_b64=None, kid=None, **kwargs): + self.kids = [KID] + self.content_keys: dict = {} + self.pssh_b64 = pssh_b64 + self.data = {"pssh_b64": pssh_b64} if pssh_b64 else {} + + def absorb(self, *others): + pass + + def get_content_keys(self, cdm, certificate, licence): + self.content_keys = {KID: "key_pr"} + + +# extract_pssh_from_track matches on class name, so name the fakes after the DRM classes +_FakePlayReady.__name__ = "PlayReady" +_FakeWidevine.__name__ = "Widevine" + + +def _pr_drm(): + return _FakePlayReady(pssh_b64=PR_PSSH) + + +def _wv_drm(): + return _FakeWidevine(pssh=SimpleNamespace(dumps=lambda: WV_PSSH)) + + +def _session(track): + # handle_single_server_cdm looks config.cdm up by the service class name, so the fake needs the tag as its name + service = type("PCOK", (SimpleNamespace,), {})( + get_widevine_service_certificate=lambda **k: None, + get_widevine_license=lambda **k: b"", + get_playready_license=lambda **k: b"", + ) + return SimpleNamespace( + service_tag="PCOK", + service_instance=service, + served_keys={}, + tracks=SimpleNamespace(get={"vid": track}.get), + log_buffer=None, + ) + + +@pytest.fixture +def env(monkeypatch): + """Fake DRM classes and PSSH parsers; the CDM is a namespace that names its device.""" + monkeypatch.setattr(drm_mod, "Widevine", _FakeWidevine) + monkeypatch.setattr(drm_mod, "PlayReady", _FakePlayReady) + monkeypatch.setattr(pr_pssh_mod, "PSSH", lambda data: SimpleNamespace(data=data)) + monkeypatch.setattr(wv_pssh_mod, "PSSH", lambda data: SimpleNamespace(data=data)) + monkeypatch.setitem(cdm_mod.__dict__, "load_cdm", lambda name, **k: SimpleNamespace(device=name)) + monkeypatch.setattr(detect_mod, "is_widevine_cdm", lambda cdm: cdm.device.startswith("wv")) + monkeypatch.setattr(detect_mod, "is_playready_cdm", lambda cdm: cdm.device.startswith("pr")) + monkeypatch.setattr(handlers, "ensure_track_drm", lambda track, session=None, init_data=None: None) + monkeypatch.setattr(handlers, "check_vaults", lambda kids, name: None) + monkeypatch.setattr(handlers, "cache_to_vaults", lambda keys, name: None) + monkeypatch.setattr(handlers, "server_cdm_allowed", lambda request, tag: True) + monkeypatch.setattr(handlers, "find_title_for_track", lambda tid, session: SimpleNamespace()) + monkeypatch.setattr(handlers.config, "serve", {"users": {}}, raising=False) + # the device name says what the device is, in place of the .wvd / .prd file on disk + monkeypatch.setattr( + handlers, "detect_cdm_type", lambda name, cfg: {"wv": "widevine", "pr": "playready"}.get(name[:2]) + ) + + +async def _license(monkeypatch, track, body): + session = _session(track) + + async def fake_get_session(sid, req): + return session + + monkeypatch.setattr(handlers, "get_validated_session", fake_get_session) + resp = await handlers.session_license_handler({"track_id": "vid", "mode": "server_cdm", **body}, "sess", None) + return json.loads(resp.body) + + +async def test_server_type_wins_over_client_request(env, monkeypatch): + monkeypatch.setattr(handlers, "detect_cdm_type_for_service", lambda tag, cfg: "widevine") + monkeypatch.setattr(handlers, "resolve_device_name", lambda *a, **k: "wv_dev") + monkeypatch.setattr(handlers, "require_track_pssh", lambda track, pssh, drm_type: None) + + track = SimpleNamespace(id="vid", drm=[_pr_drm(), _wv_drm()]) + body = await _license(monkeypatch, track, {"drm_type": "playready", "pssh": PR_PSSH}) + + assert body["drm_type"] == "widevine" + assert body["keys"] == {KID.hex: "key_wv"} + assert not hasattr(track, "pr_pssh"), "the discarded client PSSH must not persist on the track" + + +async def test_playready_only_track_licenses_with_widevine_server(env, monkeypatch): + """The batch route licenses a PlayReady-only header with the Widevine device; so must this one.""" + monkeypatch.setattr(handlers, "detect_cdm_type_for_service", lambda tag, cfg: "widevine") + monkeypatch.setattr(handlers, "resolve_device_name", lambda *a, **k: "wv_dev") + + track = SimpleNamespace(id="vid", drm=[_pr_drm()]) + body = await _license(monkeypatch, track, {"drm_type": "playready"}) + + assert body["drm_type"] == "widevine" + assert body["keys"] == {KID.hex: "key_wv"} + + +async def test_client_type_decides_without_server_mapping(env, monkeypatch): + """No config.cdm entry for the service: the tier's devices answer in the client's order.""" + monkeypatch.setattr(handlers.config, "cdm", {}, raising=False) + monkeypatch.setattr( + handlers, "serve_user_config", lambda key: {"devices": ["wv_dev"], "playready_devices": ["pr_dev"]} + ) + + track = SimpleNamespace(id="vid", drm=[_pr_drm(), _wv_drm()]) + body = await _license(monkeypatch, track, {"drm_type": "playready"}) + assert body["drm_type"] == "playready" + assert body["keys"] == {KID.hex: "key_pr"} + + track = SimpleNamespace(id="vid", drm=[_pr_drm(), _wv_drm()]) + body = await _license(monkeypatch, track, {"drm_type": "widevine"}) + assert body["drm_type"] == "widevine" + assert body["keys"] == {KID.hex: "key_wv"} + + +async def test_quality_keyed_mapping_resolves_to_the_device_it_loads(env, monkeypatch): + """A quality-tier mapping falls through to cdm.default on both the type probe and the device lookup. + + The type probe used to take the first tier's device (Widevine) while the device lookup + returned the global default (PlayReady), so the route loaded a PlayReady device for a + Widevine licence and failed with "is not a Widevine device". + """ + monkeypatch.setattr( + handlers.config, + "cdm", + {"PCOK": {"<=1080": "wv_tier", ">1080": {"widevine": "wv_tier", "playready": "pr_tier"}}, "default": "pr_dev"}, + raising=False, + ) + + track = SimpleNamespace(id="vid", drm=[_pr_drm(), _wv_drm()]) + body = await _license(monkeypatch, track, {"drm_type": "widevine"}) + + assert body["drm_type"] == "playready" + assert body["keys"] == {KID.hex: "key_pr"} + + +async def test_both_systems_mapped_follow_the_client(env, monkeypatch): + """A mapping with a device per system settles nothing, so the client's choice goes first.""" + monkeypatch.setattr(handlers.config, "cdm", {"PCOK": {"widevine": "wv_dev", "playready": "pr_dev"}}, raising=False) + + track = SimpleNamespace(id="vid", drm=[_pr_drm(), _wv_drm()]) + body = await _license(monkeypatch, track, {"drm_type": "widevine"}) + + assert body["drm_type"] == "widevine" + + +async def test_no_candidate_header_is_a_client_error(env, monkeypatch): + monkeypatch.setattr(handlers.config, "cdm", {"PCOK": "pr_dev"}, raising=False) + + track = SimpleNamespace(id="vid", drm=[_wv_drm()]) + with pytest.raises(APIError, match="No PSSH on track"): + await _license(monkeypatch, track, {"drm_type": "widevine"}) + + +async def test_track_preference_goes_first_when_the_server_has_that_device(env, monkeypatch): + """A track that prefers PlayReady licenses with PlayReady even on a Widevine-mapped server.""" + monkeypatch.setattr(handlers.config, "cdm", {"PCOK": {"widevine": "wv_dev", "default": "pr_dev"}}, raising=False) + + track = SimpleNamespace(id="vid", drm=[_pr_drm(), _wv_drm()], drm_preference="pr") + body = await _license(monkeypatch, track, {"drm_type": "widevine"}) + + assert body["drm_type"] == "playready" + assert body["keys"] == {KID.hex: "key_pr"} + + +async def test_track_preference_falls_back_with_a_warning(env, monkeypatch, caplog): + """With no PlayReady device the preference cannot be met: Widevine licenses and the client hears why.""" + monkeypatch.setattr(handlers.config, "cdm", {"PCOK": "wv_dev"}, raising=False) + + track = SimpleNamespace(id="vid", drm=[_pr_drm(), _wv_drm()], drm_preference="pr") + with caplog.at_level("WARNING", logger=handlers.log.name): + body = await _license(monkeypatch, track, {"drm_type": "playready"}) + + assert body["drm_type"] == "widevine" + assert "wants playready DRM but the server has no device for it" in caplog.text diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 61e5109e..ae06452b 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -2910,13 +2910,14 @@ class dl: service_session=service.session, ) - if getattr(self._remote_service, "_server_cdm", False) and self.cdm is None: - cdm_type = getattr(self._remote_service, "_server_cdm_type", "widevine") - self.cdm = cdm_type_stub(cdm_type) - self.log.info(f"Using server CDM ({cdm_type.title()}); no local CDM required") + server_cdm_type = None + if getattr(self._remote_service, "_server_cdm", False): + server_cdm_type = getattr(self._remote_service, "_server_cdm_type", "widevine") + self.cdm = cdm_type_stub(server_cdm_type) + self.log.info(f"Using server CDM ({server_cdm_type.title()}); no local CDM required") video_tracks = title.tracks.videos - if video_tracks: + if video_tracks and not server_cdm_type: highest_quality = max((track.height for track in video_tracks if track.height), default=0) if highest_quality > 0: if is_widevine_cdm(self.cdm): diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index 8bb096f0..9ac2c25f 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -13,7 +13,7 @@ from contextlib import suppress from datetime import date as date_ from http.cookiejar import CookieJar, MozillaCookieJar from pathlib import Path -from typing import Any, Dict, Iterable, List, Optional +from typing import Any, Callable, Dict, Iterable, List, Optional import click from aiohttp import web @@ -3394,18 +3394,38 @@ def resolve_server_cdm(service: str, profile: Optional[str], cdm_type: Optional[ return None -def detect_cdm_type_for_service(service: str, app_config: Any) -> Optional[str]: - """Detect the CDM type configured for a service in config.cdm.""" - cdm_name = ci_get(app_config.cdm, service) - if not cdm_name: - return None +def configured_cdm_name(service: str, drm_type: str, app_config: Any) -> Optional[str]: + """The device name config.cdm maps a service to for one DRM system; None when the tier decides. + + A dict names a device per system (``widevine`` / ``playready`` keys) or a ``default``. + Any other dict shape, such as the quality tiers ``dl`` reads, falls through to the + global ``cdm.default``: the server has no track context to pick a tier with. + """ + cdm_name = ci_get(app_config.cdm, service) if service else None if isinstance(cdm_name, dict): lower_keys = {k.lower(): v for k, v in cdm_name.items()} - if {"widevine", "playready"} & lower_keys.keys(): - return "playready" if "playready" in lower_keys else "widevine" - cdm_name = cdm_name.get("default") or next(iter(cdm_name.values()), None) + drm_key = drm_type if drm_type in ("widevine", "playready") else None + cdm_name = lower_keys.get(drm_key) or lower_keys.get("default") or ci_get(app_config.cdm, "default") if cdm_name and isinstance(cdm_name, str): - return detect_cdm_type(cdm_name, app_config) + return cdm_name + return None + + +def detect_cdm_type_for_service(service: str, app_config: Any) -> Optional[str]: + """The DRM system config.cdm settles for a service; None when the mapping leaves it open. + + A mapping that names one system gets that system. A device name gets the type of the + device ``resolve_device_name`` returns for it, so the routes plan with the device they + load. A mapping that names a device for both systems settles nothing. + """ + cdm_name = ci_get(app_config.cdm, service) + if isinstance(cdm_name, dict): + named = [system for system in ("widevine", "playready") if system in {k.lower() for k in cdm_name}] + if named: + return named[0] if len(named) == 1 else None + device_name = configured_cdm_name(service, "widevine", app_config) + if device_name: + return detect_cdm_type(device_name, app_config) return None @@ -3788,12 +3808,8 @@ def resolve_device_name(user_config: dict, drm_type: str, service_tag: str = "") """ from unshackle.core.config import config as app_config - cdm_name = ci_get(app_config.cdm, service_tag) if service_tag else None - if isinstance(cdm_name, dict): - drm_key = {"widevine": "widevine", "playready": "playready"}.get(drm_type) - lower_keys = {k.lower(): v for k, v in cdm_name.items()} - cdm_name = lower_keys.get(drm_key) or lower_keys.get("default") or ci_get(app_config.cdm, "default") - if cdm_name and isinstance(cdm_name, str): + cdm_name = configured_cdm_name(service_tag, drm_type, app_config) + if cdm_name: return cdm_name if drm_type == "playready": @@ -3807,6 +3823,65 @@ def resolve_device_name(user_config: dict, drm_type: str, service_tag: str = "") return device_name +def server_drm_candidates( + service_tag: str, + user_config: dict, + client_drm_type: str, + track: Any, + warn: Callable[[str], None] = log.warning, +) -> List[str]: + """The DRM systems the server can license a track with, in the order to try them. + + The server's config.cdm mapping goes first; with no mapping, the client's choice goes + first. A system stays only when the device the server would load for it is of that + system, so the route never plans a licence that fails on the wrong device type. The + track's own drm_preference moves its system to the front when the server has it. + """ + from unshackle.core.config import config as app_config + + server_type = detect_cdm_type_for_service(service_tag, app_config) + first = server_type or (client_drm_type if client_drm_type in ("widevine", "playready") else "widevine") + candidates: List[str] = [] + for drm_type in (first, *(system for system in ("widevine", "playready") if system != first)): + try: + device_name = resolve_device_name(user_config, drm_type, service_tag) + except APIError: + continue + if detect_cdm_type(device_name, app_config) in (None, drm_type): + candidates.append(drm_type) + + preferred = drm_preference_name(track) + if preferred: + if preferred in candidates: + candidates.remove(preferred) + candidates.insert(0, preferred) + else: + warn( + f"Track {sanitize_log(str(track.id)[:12])} wants {preferred} DRM " + "but the server has no device for it, using the configured DRM instead" + ) + return candidates + + +def pick_server_pssh(track: Any, candidates: List[str]) -> Optional[tuple[str, str]]: + """`(drm_type, pssh)` for the first candidate the track carries a header for; None when it has none. + + A track with only a PlayReady header still licenses under the Widevine device: the + Widevine DRM class converts the PlayReady PSSH on construction. + """ + for candidate in candidates: + pssh_str = extract_pssh_from_track(track, candidate) + if not pssh_str and candidate == "widevine": + pssh_str = extract_pssh_from_track(track, "playready") + if pssh_str: + log.info( + f"Track {sanitize_log(str(track.id)[:12])} has only a PlayReady PSSH, licensing it with the Widevine device" + ) + if pssh_str: + return candidate, pssh_str + return None + + def load_server_vaults(service_name: str) -> Any: """Load server vaults from config.key_vaults.""" from unshackle.core.config import config as app_config @@ -4166,16 +4241,10 @@ async def session_license_handler( ) if mode == "server_cdm" and track_ids: - from unshackle.core.config import config as app_config - api_key = request.headers.get("X-Secret-Key", "anonymous") if request else "anonymous" user_config = serve_user_config(api_key) service = session.service_instance - has_wv_device = bool(user_config.get("devices")) - has_pr_device = bool(user_config.get("playready_devices")) - service_tag = session.service_tag - config_cdm_type = detect_cdm_type_for_service(service_tag, app_config) all_keys: Dict[str, Dict[str, str]] = {} vault_keys: list[str] = [] @@ -4213,19 +4282,11 @@ async def session_license_handler( track: Any, title: Any, candidates: list ) -> tuple[Dict[str, str], Optional[str], Optional[str]]: """`(keys, drm_type, pssh)` for the track's current DRM, licensing once per unique PSSH.""" - for candidate in candidates: - pssh_str = extract_pssh_from_track(track, candidate) - if not pssh_str and candidate == "widevine": - pssh_str = extract_pssh_from_track(track, "playready") - if pssh_str: - log.info( - f"Track {sanitize_log(str(track.id)[:12])} has only a PlayReady PSSH, licensing it with the Widevine device" - ) - if pssh_str: - break - else: + picked = pick_server_pssh(track, candidates) + if not picked: warn(f"No PSSH on track {sanitize_log(str(track.id)[:12])} for {', '.join(candidates) or 'any CDM'}") return {}, None, None + candidate, pssh_str = picked cache_key = (pssh_str, pssh_set(track)) if cache_key not in keys_by_pssh: @@ -4258,28 +4319,7 @@ async def session_license_handler( continue title = find_title_for_track(tid, session) - - if config_cdm_type == "playready": - candidates = ["playready", "widevine"] - elif config_cdm_type == "widevine": - candidates = ["widevine", "playready"] - else: - candidates = [ - name - for name, has_device in (("widevine", has_wv_device), ("playready", has_pr_device)) - if has_device - ] - - preferred = drm_preference_name(track) - if preferred: - if preferred in candidates: - candidates.remove(preferred) - candidates.insert(0, preferred) - else: - warn( - f"Track {sanitize_log(tid[:12])} wants {preferred} DRM " - "but the server has no device for it, using the configured DRM instead" - ) + candidates = server_drm_candidates(service_tag, user_config, drm_type, track, warn) keys, track_drm_type, pssh_str = license_track(track, title, candidates) @@ -4341,19 +4381,44 @@ async def session_license_handler( service = session.service_instance pssh_b64 = data.get("pssh") - if pssh_b64: + if pssh_b64 or mode == "server_cdm": ensure_track_drm(track, getattr(service, "session", None)) + + if mode == "server_cdm": + # The server's config.cdm mapping decides the DRM system, as in the batch path. + # The client only knows its own local device, which the server never uses here. + api_key = request.headers.get("X-Secret-Key", "anonymous") if request else "anonymous" + candidates = server_drm_candidates(session.service_tag, serve_user_config(api_key), drm_type, track) + picked = pick_server_pssh(track, candidates) + if not picked: + raise APIError( + APIErrorCode.INVALID_INPUT, + f"No PSSH on track {sanitize_log(track_id[:12])} for {', '.join(candidates) or 'any CDM'}", + ) + server_drm_type, server_pssh = picked + if pssh_b64 and server_drm_type != drm_type: + log.info( + f"Client asked for {sanitize_log(drm_type)} on track {sanitize_log(track_id[:12])}, " + f"licensing with the server's {server_drm_type} CDM" + ) + pssh_b64 = None + if pssh_b64: + require_track_pssh(track, pssh_b64, drm_type) + if drm_type == "playready": + track.pr_pssh = pssh_b64 + key_sources: Dict[str, str] = {} + keys = handle_single_server_cdm( + service, title, track, pssh_b64 or server_pssh, server_drm_type, request, key_sources + ) + log.info(f"Server CDM resolved {len(keys)} key(s) for track {sanitize_log(track_id[:12])}") + note_served_keys(session, keys, key_sources) + return web.json_response({"keys": keys, "vault_keys": list(key_sources), "drm_type": server_drm_type}) + + if pssh_b64: require_track_pssh(track, pssh_b64, drm_type) if drm_type == "playready": track.pr_pssh = pssh_b64 - if mode == "server_cdm": - key_sources: Dict[str, str] = {} - keys = handle_single_server_cdm(service, title, track, pssh_b64, drm_type, request, key_sources) - log.info(f"Server CDM resolved {len(keys)} key(s) for track {sanitize_log(track_id[:12])}") - note_served_keys(session, keys, key_sources) - return web.json_response({"keys": keys, "vault_keys": list(key_sources)}) - return handle_proxy_license(service, title, track, challenge_b64, drm_type) except APIError: diff --git a/unshackle/core/remote_service.py b/unshackle/core/remote_service.py index 3c5a8f94..09089aa1 100644 --- a/unshackle/core/remote_service.py +++ b/unshackle/core/remote_service.py @@ -213,6 +213,17 @@ def deserialize_subtitle(data: Dict[str, Any]) -> Subtitle: ) +def track_pssh(track: Any, drm_type: str) -> Optional[str]: + """The base64 PSSH of the track's header for one DRM system; None when the track has none of it.""" + for drm_obj in track.drm or []: + drm_class = drm_obj.__class__.__name__ + if drm_type == "playready" and drm_class == "PlayReady": + return drm_obj.data["pssh_b64"] + if drm_type == "widevine" and drm_class == "Widevine": + return drm_obj.pssh.dumps() + return None + + def reconstruct_drm(drm_list: Optional[list]) -> list: """Reconstruct DRM objects from serialized API data.""" if not drm_list: @@ -1318,20 +1329,14 @@ class RemoteService: if isinstance(challenge, str): challenge = challenge.encode("utf-8") - pssh_b64 = None - if track.drm: - for drm_obj in track.drm: - drm_class = drm_obj.__class__.__name__ - if drm_type == "playready" and drm_class == "PlayReady": - pssh_b64 = drm_obj.data["pssh_b64"] - break - elif drm_type == "widevine" and drm_class == "Widevine": - pssh_b64 = drm_obj.pssh.dumps() - break - if self._server_cdm: from uuid import UUID + server_type = self._server_cdm_type + other_type = "playready" if server_type == "widevine" else "widevine" + drm_type, pssh_b64 = server_type, track_pssh(track, server_type) + if not pssh_b64: + drm_type, pssh_b64 = other_type, track_pssh(track, other_type) if pssh_b64: try: resp = self.client.post( @@ -1343,6 +1348,7 @@ class RemoteService: "pssh": pssh_b64, }, ) + self._server_cdm_type = resp.get("drm_type", server_type) keys = resp.get("keys", {}) self.note_vault_keys(keys, set(resp.get("vault_keys", []))) if keys and track.drm: @@ -1354,9 +1360,11 @@ class RemoteService: except Exception as e: self.log.warning("server_cdm license failed: %s", e) else: - self.log.warning(f"Track {track.id} has no {drm_type} PSSH to send to the server CDM") + self.log.warning(f"Track {track.id} has no PSSH to send to the server CDM") return challenge + pssh_b64 = track_pssh(track, drm_type) + payload = { "track_id": str(track.id), "challenge": base64.b64encode(challenge).decode("ascii"),