From b6429ecdfd0c0c598ce60cd1c2737d4e76c76d9a Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Tue, 3 Nov 2026 10:51:29 -0700 Subject: [PATCH] feat(remote): match the server CDM to the client's DRM and read vaults first A dual widevine/playready cdm entry now loads the device of the client's DRM system, for old clients too since they already send cdm_type. A new client hands a capped track to its own device for the track's DRM system, and raises the refusal only after its local vaults miss. --- docs/dev/rest-api/remote-sessions.md | 9 +- docs/reference/configuration/drm.md | 7 +- tests/remote/key_source.py | 37 ++++- tests/remote/unit/test_client_device_cdm.py | 1 + .../remote/unit/test_server_cdm_max_height.py | 135 +++++++++++------- .../unit/test_server_cdm_type_override.py | 85 +++++++++++ .../unit/test_server_cdm_vault_write.py | 37 ++++- unshackle/commands/dl.py | 5 + unshackle/core/api/handlers.py | 19 ++- unshackle/core/api/routes.py | 5 +- unshackle/core/remote_service.py | 33 +++-- unshackle/core/utils/click_types.py | 4 +- 12 files changed, 309 insertions(+), 68 deletions(-) diff --git a/docs/dev/rest-api/remote-sessions.md b/docs/dev/rest-api/remote-sessions.md index 85b95ee6..16971ede 100644 --- a/docs/dev/rest-api/remote-sessions.md +++ b/docs/dev/rest-api/remote-sessions.md @@ -296,7 +296,9 @@ There are two ways DRM keys get resolved, chosen by the client's `server_cdm` fl The **server's CDM** does everything. The client sends track IDs (or a PSSH), the server checks its key vaults, loads the device configured for your API key, runs the CDM flow, and returns `KID:KEY` pairs directly. The client needs no - CDM. + CDM. When the server's `cdm` entry for the service names a `widevine` and a + `playready` device, the server loads the device of your local CDM's DRM system. + See [`cdm`](../../reference/configuration/drm.md). - Enable with `server_cdm: true` in the server entry. - The server tells the client which DRM type it used @@ -321,7 +323,10 @@ There are two ways DRM keys get resolved, chosen by the client's `server_cdm` fl the limit in `server_cdm_max_height`. In a server CDM session, the batch licence response lists each track the server refused to license live under `capped_tracks`. A single-track server CDM licence answers `403 SERVER_CDM_CAPPED` for such a track. In both cases the client - licenses those tracks in proxy mode with its own local CDM. See + reads its own key vaults first, then licenses those tracks in proxy mode with its own local + CDM. When the track has no DRM of that CDM's system, the client uses the device that its own + `cdm` entry names for the track's DRM system. With no such device, the download of that track + stops. See [`server_cdm_max_height`](../../reference/configuration/services.md). ### Your own device identity diff --git a/docs/reference/configuration/drm.md b/docs/reference/configuration/drm.md index 4c95b2d1..b2322b72 100644 --- a/docs/reference/configuration/drm.md +++ b/docs/reference/configuration/drm.md @@ -50,7 +50,12 @@ height is known. If the value is still a dict after that, unshackle uses When an entry holds `widevine` and `playready`, their order sets which device unshackle loads first. A track that offers the two DRM systems licenses with that system. A track that offers only the other system uses the other device. A `widevine` / `playready` dict in a quality key -does not set the order. The server CDM of a remote session follows the same order. +does not set the order. + +On a server, an entry that holds `widevine` and `playready` gives a remote session the server CDM +of the DRM system of the client's local CDM. The service then makes its manifest for that system, +so the client's vault keys and its own device apply to the tracks. A client with no local CDM gets +the device that the entry lists first. A quality key can also hold a dict of `widevine` / `playready` devices, and so can the top-level `default` entry: diff --git a/tests/remote/key_source.py b/tests/remote/key_source.py index 396728eb..528ed9d3 100644 --- a/tests/remote/key_source.py +++ b/tests/remote/key_source.py @@ -1,3 +1,4 @@ +import logging from types import SimpleNamespace from typing import Any, Iterable, Optional from uuid import UUID @@ -17,7 +18,41 @@ def remote_keys( svc._server_cdm = server_cdm svc.server_cdm_type = server_cdm_type svc.client_licensed = set(client_licensed) + svc.deferred_refusals = {} svc.server_vault_keys = dict(server_vault_keys or {}) svc.server_vault = ServerVault(svc) - svc.ctx = SimpleNamespace(obj=SimpleNamespace(cdm=local_cdm)) + svc.ctx = SimpleNamespace(obj=SimpleNamespace(cdm=local_cdm, cdm_for=None)) return svc + + +CAPPED = {"keys": {}, "capped_tracks": {"vid": {"reason": "server_cdm_max_height", "max_height": 1080}}} +PR_DEVICE = SimpleNamespace(is_playready=True, security_level=3000) + + +def remote_session(local_cdm: Any, answer: dict) -> RemoteService: + """A server-CDM RemoteService whose every POST records its payload in ``posts`` and returns ``answer``.""" + svc = remote_keys(server_cdm_type="playready", local_cdm=local_cdm) + svc._track_alias = {} + svc._title_alias = {} + svc._session_id = "sess" + svc.log = logging.getLogger("test-remote-session") + posts: list = [] + svc.client = SimpleNamespace(post=lambda endpoint, payload, expect=(): posts.append(payload) or answer) + svc.drain_server_logs = lambda: None + svc.posts = posts + return svc + + +class OneTrack(list): + @property + def videos(self) -> list: + return list(self) + + @property + def audio(self) -> list: + return [] + + +def one_title(track: Any) -> SimpleNamespace: + """A title holding only ``track``, as a video.""" + return SimpleNamespace(tracks=OneTrack([track])) diff --git a/tests/remote/unit/test_client_device_cdm.py b/tests/remote/unit/test_client_device_cdm.py index 7e732b50..3e3d96ba 100644 --- a/tests/remote/unit/test_client_device_cdm.py +++ b/tests/remote/unit/test_client_device_cdm.py @@ -405,6 +405,7 @@ def test_a_lent_device_without_the_vault_grant_asks_the_server_for_no_key() -> N svc._server_device = True svc._local_vaults_only = True svc.client_licensed = set() + svc.deferred_refusals = {} svc.client = SimpleNamespace(post=lambda *a, **k: pytest.fail("the server refuses every key request")) track = SimpleNamespace(id="vid", drm=None) diff --git a/tests/remote/unit/test_server_cdm_max_height.py b/tests/remote/unit/test_server_cdm_max_height.py index eff0829a..68843c9a 100644 --- a/tests/remote/unit/test_server_cdm_max_height.py +++ b/tests/remote/unit/test_server_cdm_max_height.py @@ -13,7 +13,7 @@ import pywidevine.pssh as wv_pssh_mod from rich.table import Table import unshackle.core.drm as drm_mod -from tests.remote.key_source import remote_keys +from tests.remote.key_source import CAPPED, PR_DEVICE, one_title, remote_keys, remote_session from unshackle.commands.dl import dl from unshackle.core.api import handlers from unshackle.core.api.errors import APIError, APIErrorCode @@ -203,42 +203,15 @@ class _WV: _WV.__name__ = "Widevine" -def remote(local_cdm, answer): - svc = remote_keys(server_cdm_type="playready", local_cdm=local_cdm) - svc._track_alias = {} - svc._title_alias = {} - svc._session_id = "sess" - svc.log = logging.getLogger("test-server-cdm-cap") - posts: list = [] - svc.client = SimpleNamespace(post=lambda endpoint, payload, expect=(): posts.append(payload) or answer) - svc.drain_server_logs = lambda: None - svc.posts = posts - return svc - - -class _Title: - def __init__(self, track): - self.tracks = _Tracks([track]) - - -class _Tracks(list): - @property - def videos(self): - return list(self) - - @property - def audio(self): - return [] - - -CAPPED = {"keys": {}, "capped_tracks": {"vid": {"reason": "server_cdm_max_height", "max_height": 1080}}} +def capped_track(): + return SimpleNamespace(id="vid", height=2160, drm=[_WV()], drm_preference=None) def test_a_capped_track_moves_to_the_local_device(): track = SimpleNamespace(id="vid", drm=[_WV()], drm_preference=None) - svc = remote(SimpleNamespace(is_playready=False, security_level=1), CAPPED) + svc = remote_session(SimpleNamespace(is_playready=False, security_level=1), CAPPED) - svc.resolve_server_keys(_Title(track)) + svc.resolve_server_keys(one_title(track)) assert svc.client_licensed == {"vid"} assert track.drm_preference == "widevine" @@ -249,18 +222,39 @@ def test_a_capped_track_moves_to_the_local_device(): assert "mode" not in svc.posts[0] and svc.posts[0]["challenge"] +def fails_at_the_track_licence(local_cdm, match): + """The batch licence defers the refusal, so dl reads the local vaults before the track licence raises it.""" + track = capped_track() + svc = remote_session(local_cdm, CAPPED) + svc.resolve_server_keys(one_title(track)) + assert svc.client_licensed == set() + assert "vid" in svc.deferred_refusals + + svc.client.post = None + with pytest.raises(click.ClickException, match=match): + svc.proxy_license(b"", track, "widevine") + + def test_a_capped_track_without_a_local_device_fails_clearly(): - track = SimpleNamespace(id="vid", drm=[_WV()], drm_preference=None) - svc = remote(None, CAPPED) - with pytest.raises(click.ClickException, match="1080p"): - svc.resolve_server_keys(_Title(track)) + fails_at_the_track_licence(None, "1080p") def test_a_capped_track_without_the_local_drm_system_fails_clearly(): - track = SimpleNamespace(id="vid", drm=[_WV()], drm_preference=None) - svc = remote(SimpleNamespace(is_playready=True, security_level=3000), CAPPED) - with pytest.raises(click.ClickException, match="PlayReady"): - svc.resolve_server_keys(_Title(track)) + fails_at_the_track_licence( + PR_DEVICE, + r"1080p.*the 2160p track has only Widevine DRM for your PlayReady device; pass -q 2160 ", + ) + + +def test_a_server_vault_miss_without_the_local_drm_system_fails_at_once(): + """Only a server-CDM session reads the local vaults before the licence, so a server-vault session raises at once.""" + track = capped_track() + svc = remote_session(PR_DEVICE, {"keys": {}, "capped_tracks": {"vid": {}}}) + svc._server_cdm = False + svc._server_vault = True + with pytest.raises(click.ClickException, match="server vault") as ei: + svc.resolve_server_keys(one_title(track)) + assert "-q" not in ei.value.message def test_a_null_service_in_the_cap_map_falls_through_to_the_default(monkeypatch): @@ -537,18 +531,20 @@ class _LocalWidevine(Widevine): raise self.Used(cdm) -def prepare(svc, licence): +def prepare(svc, licence, drm=None, track=None, cdm_for=None): runner = dl.__new__(dl) runner._remote_service = svc runner.cdm = "server stub" runner.service = "EXAMPLE1" runner.debug_logger = None runner.log = logging.getLogger("test-server-cdm-cap") + runner.cdm_for = cdm_for table = Table() table.add_column() - track = SimpleNamespace(id="vid", drm=None, get_drm_for_cdm=lambda cdm: None) + drm = drm or _LocalWidevine() + track = track or SimpleNamespace(id="vid", drm=None, get_drm_for_cdm=lambda cdm: None) with pytest.raises(_LocalWidevine.Used) as ei: - runner.prepare_drm(_LocalWidevine(), track, None, lambda **k: None, licence, table=table, cdm_only=True) + runner.prepare_drm(drm, track, None, lambda **k: None, licence, table=table, cdm_only=True) return ei.value.args[0] @@ -611,24 +607,24 @@ def test_a_mid_download_handover_puts_the_keys_on_the_callers_drm(monkeypatch): def test_a_capped_hls_track_with_no_drm_yet_moves_to_the_local_device(): track = SimpleNamespace(id="vid", drm=[], drm_preference=None) - svc = remote(SimpleNamespace(is_playready=False, security_level=1), CAPPED) - svc.resolve_server_keys(_Title(track)) + svc = remote_session(SimpleNamespace(is_playready=False, security_level=1), CAPPED) + svc.resolve_server_keys(one_title(track)) assert svc.client_licensed == {"vid"} assert track.drm_preference == "widevine" def test_resolve_server_keys_forgets_the_last_titles_handovers(): track = SimpleNamespace(id="vid", drm=[_WV()], drm_preference=None) - svc = remote(SimpleNamespace(is_playready=False, security_level=1), {"keys": {}}) + svc = remote_session(SimpleNamespace(is_playready=False, security_level=1), {"keys": {}}) svc.client_licensed = {"vid"} - svc.resolve_server_keys(_Title(track)) + svc.resolve_server_keys(one_title(track)) assert svc.client_licensed == set() def test_a_single_licence_refusal_hands_the_track_to_the_local_device(): track = SimpleNamespace(id="vid", drm=[_WV()], drm_preference=None) refusal = {"status": "error", "error_code": "SERVER_CDM_CAPPED", "details": CAP_DETAILS} - svc = remote(SimpleNamespace(is_playready=False, security_level=1), refusal) + svc = remote_session(SimpleNamespace(is_playready=False, security_level=1), refusal) assert svc.proxy_license(b"", track, "widevine") == b"" assert svc.client_licensed == {"vid"} @@ -665,7 +661,7 @@ def sent_create( raise _SentCreate(data) handlers.config.cdm = cdm_map or {} - svc = remote(local_cdm, {}) + svc = remote_session(local_cdm, {}) svc.ctx.obj.cdm_entry = cdm_entry svc._server_cdm = server_cdm svc.service_tag = "EXAMPLE1" @@ -815,7 +811,7 @@ def test_dl_records_whether_the_services_cdm_entry_named_the_loaded_device(cdm, @pytest.mark.parametrize("max_height", [None, 1080]) def test_a_declined_server_cdm_that_the_server_keeps_is_reported(max_height, caplog): - svc = remote(SimpleNamespace(is_playready=False), {}) + svc = remote_session(SimpleNamespace(is_playready=False), {}) svc.service_tag = "EXAMPLE1" svc._server_cdm = False with caplog.at_level(logging.INFO): @@ -833,7 +829,7 @@ def test_a_declined_server_cdm_that_the_server_keeps_is_reported(max_height, cap @pytest.mark.parametrize(("cdm_entry", "level"), [(True, logging.INFO), (False, logging.WARNING)]) def test_a_refused_server_cdm_warns_only_when_no_cdm_entry_loaded_the_fallback(cdm_entry, level, caplog): - svc = remote(SimpleNamespace(is_playready=False), {}) + svc = remote_session(SimpleNamespace(is_playready=False), {}) svc.service_tag = "EXAMPLE1" svc.ctx.obj.cdm_entry = cdm_entry with caplog.at_level(logging.INFO): @@ -890,3 +886,40 @@ def test_a_per_service_entry_matches_the_tag_in_any_case(config_key, tag, monkey assert svc._server_cdm is False assert svc._anonymous_search is True assert handlers.config.services == {tag: {"title_map": {"a": "b"}}} + + +def test_a_capped_track_moves_to_the_clients_device_for_its_drm_system(): + """A client whose cdm entry also names a device of the track's system licenses with that device.""" + track = capped_track() + svc = remote_session(PR_DEVICE, CAPPED) + asked: list = [] + svc.ctx.obj.cdm_for = lambda system, quality: asked.append((system, quality)) or SimpleNamespace(is_playready=False) + + svc.resolve_server_keys(one_title(track)) + + assert asked == [("widevine", 2160)] + assert svc.client_licensed == {"vid"} + assert track.drm_preference == "widevine" + assert svc.deferred_refusals == {} + + +def test_a_capped_track_keeps_the_refusal_when_the_client_has_no_device_for_its_system(): + track = capped_track() + svc = remote_session(PR_DEVICE, CAPPED) + svc.ctx.obj.cdm_for = lambda system, quality: SimpleNamespace(is_playready=True) + + svc.resolve_server_keys(one_title(track)) + + assert svc.client_licensed == set() + assert "vid" in svc.deferred_refusals + + +def test_dl_licenses_a_handed_over_track_with_the_device_of_its_drm_system(): + svc = remote_keys(client_licensed={"vid"}, local_cdm=PR_DEVICE) + widevine_device = SimpleNamespace(is_playready=False) + drm = _LocalWidevine() + track = SimpleNamespace(id="vid", drm=[drm], get_drm_for_cdm=lambda cdm: drm) + + used = prepare(svc, lambda **k: None, drm, track, lambda system, quality: widevine_device) + + assert used is widevine_device diff --git a/tests/remote/unit/test_server_cdm_type_override.py b/tests/remote/unit/test_server_cdm_type_override.py index 67eebf8b..f210673a 100644 --- a/tests/remote/unit/test_server_cdm_type_override.py +++ b/tests/remote/unit/test_server_cdm_type_override.py @@ -225,3 +225,88 @@ async def test_track_preference_falls_back_with_a_warning(env, monkeypatch, capl assert body["drm_type"] == "widevine" assert "wants playready DRM but the server has no device for it" in caplog.text + + +BOTH = {"EXAMPLE1": {"widevine": "wv_device", "playready": "pr_device"}} + + +@pytest.mark.parametrize( + ("mapping", "server_cdm", "wanted"), + [ + (BOTH, True, "playready"), + ({"EXAMPLE1": "wv_device"}, True, None), + ({"EXAMPLE1": {"widevine": "wv_device"}}, True, None), + ({}, False, "playready"), + ], +) +def test_the_server_device_follows_the_client_only_when_both_systems_are_mapped( + monkeypatch, mapping, server_cdm, wanted +): + asked: list = [] + monkeypatch.setattr(handlers.config, "cdm", mapping, raising=False) + monkeypatch.setattr(handlers, "load_service_yaml", lambda tag: {}) + monkeypatch.setattr(handlers, "load_full_cdm", lambda service, profile, cdm_type: asked.append(cdm_type)) + monkeypatch.setattr(handlers, "build_parent_ctx", lambda *a, **k: None) + monkeypatch.setattr(handlers.Services, "load", lambda tag: object) + monkeypatch.setattr(handlers, "instantiate_service", lambda *a, **k: "service") + + handlers.create_service_instance("EXAMPLE1", "t", {"cdm_type": "playready"}, None, [], None, server_cdm=server_cdm) + + assert asked == [wanted] + + +@pytest.mark.parametrize("system", ["widevine", "playready"]) +def test_the_tracks_response_names_the_system_of_the_loaded_device(monkeypatch, system): + from unshackle.core.api import session_store as store_mod + from unshackle.core.tracks import Tracks + + monkeypatch.setattr(handlers.config, "cdm", BOTH, raising=False) + monkeypatch.setattr(handlers.config, "serve", {"users": {}}, raising=False) + monkeypatch.setattr(store_mod.bus, "publish", lambda topic, data: None) + + class StubService: + session = SimpleNamespace(headers={}, cookies=[]) + ctx = SimpleNamespace(obj=SimpleNamespace(cdm=SimpleNamespace(is_playready=system == "playready"))) + + def get_tracks(self, title): + return Tracks() + + def get_chapters(self, title): + raise NotImplementedError + + async def run(): + store = store_mod.get_session_store() + entry = await store.create( + service_tag="EXAMPLE1", service_instance=StubService(), session_id="both", owner_key=None + ) + entry.title_map = {"t1": "Title"} + entry.server_cdm = True + try: + resp = await handlers.session_tracks_handler({"title_id": "t1"}, "both") + return json.loads(resp.body) + finally: + await store.delete("both") + + assert asyncio.run(run())["server_cdm_type"] == system + + +@pytest.mark.parametrize( + ("mapping", "both"), + [ + (BOTH, True), + ({"default": {"Widevine": "wv_device", "PlayReady": "pr_device"}}, True), + ({"EXAMPLE1": {"widevine": "wv_device", "playready": None}}, False), + ({"EXAMPLE1": {"widevine": "wv_device", "default": "pr_device"}}, False), + ({"EXAMPLE1": "wv_device"}, False), + ], +) +def test_lists_both_systems_needs_a_device_for_each(monkeypatch, mapping, both): + monkeypatch.setattr(handlers.config, "cdm", mapping, raising=False) + assert handlers.lists_both_systems("EXAMPLE1", handlers.config) is both + + +@pytest.mark.parametrize("cdm_type", [5, ["playready"], {"a": 1}]) +def test_a_cdm_type_that_is_not_a_string_takes_the_first_listed_device(monkeypatch, cdm_type): + monkeypatch.setattr(handlers.config, "cdm", BOTH, raising=False) + monkeypatch.setitem(cdm_mod.__dict__, "load_cdm", lambda name, **k: name) + assert handlers.load_full_cdm("EXAMPLE1", None, cdm_type) == "wv_device" diff --git a/tests/remote/unit/test_server_cdm_vault_write.py b/tests/remote/unit/test_server_cdm_vault_write.py index db799a11..6693a680 100644 --- a/tests/remote/unit/test_server_cdm_vault_write.py +++ b/tests/remote/unit/test_server_cdm_vault_write.py @@ -13,7 +13,7 @@ from uuid import UUID import pytest from rich.table import Table -from tests.remote.key_source import remote_keys +from tests.remote.key_source import CAPPED, PR_DEVICE, one_title, remote_keys, remote_session from unshackle.commands.dl import dl pytestmark = pytest.mark.unit @@ -114,6 +114,41 @@ def test_keys_read_from_a_vault_are_not_pushed_back(): assert vaults.added == {NEW: "k_new"} +def capped_run(vaults): + """A batch licence refuses the track for a local PlayReady device, then dl prepares its Widevine DRM.""" + cmd = make_cmd(vaults) + svc = remote_session(PR_DEVICE, CAPPED) + cmd._remote_service = svc + + drm = type("Widevine", (FakeWidevine,), {})([CACHED]) + track = SimpleNamespace(id="vid", height=2160, drm=[drm], drm_preference=None) + svc.resolve_server_keys(one_title(track)) + + table = Table.grid() + table.add_row("") + cmd.prepare_drm( + track=track, + title=None, + drm=drm, + licence=lambda **kwargs: svc.proxy_license(b"", track, "widevine"), + certificate=None, + track_kid=CACHED, + table=table, + ) + return drm + + +def test_a_capped_track_takes_a_local_vault_key_before_the_refusal(): + assert capped_run(FakeVaults({CACHED: "k_cached"})).content_keys == {CACHED: "k_cached"} + + +def test_a_capped_track_no_vault_holds_raises_the_refusal(): + import click + + with pytest.raises(click.ClickException, match="only Widevine DRM for your PlayReady device"): + capped_run(FakeVaults()) + + def test_batch_resolved_keys_are_cached_before_download(): """The batch licence fills track.drm before prepare_drm, so dl caches it at the call site.""" vaults = FakeVaults() diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 899c9ba3..863d795f 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -1623,6 +1623,7 @@ class dl: proxy_providers=self.proxy_providers, profile=self.profile, cdm_entry=self.cdm_from_service_entry(), + cdm_for=self.cdm_for, ) if repack: @@ -5283,6 +5284,10 @@ class dl: assert remote is not None track_cdm = remote.local_cdm local_drm = track.get_drm_for_cdm(track_cdm) + if isinstance(local_drm, (Widevine, PlayReady)) and isinstance(local_drm, PlayReady) != is_playready_cdm( + track_cdm + ): + track_cdm = self.cdm_for("playready" if isinstance(local_drm, PlayReady) else "widevine", track_quality) if local_drm is not None and local_drm is not drm: assert not isinstance(local_drm, ClearKey) # a mid-download handover leaves drm on the server's DRM system, and the caller decrypts with it diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index 6d3913ad..ddd0db6b 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -32,7 +32,7 @@ from unshackle.core.api.session_log import SessionLogBuffer, SessionLogMirror, c from unshackle.core.api.session_store import SessionStore from unshackle.core.binaries import binary_version from unshackle.core.cacher import Cacher -from unshackle.core.cdm.detect import cdm_type_stub +from unshackle.core.cdm.detect import cdm_type_stub, is_playready_cdm from unshackle.core.config import config, load_yaml from unshackle.core.constants import AUDIO_CODEC_MAP, DYNAMIC_RANGE_MAP, VIDEO_CODEC_MAP, AnyTrack from unshackle.core.drm import BlowfishStripe, cbcs, is_null_key @@ -200,6 +200,8 @@ def load_full_cdm(service: str, profile: Optional[str], cdm_type: Optional[str] from unshackle.core.cdm import load_cdm from unshackle.core.config import config as app_config + if not isinstance(cdm_type, str): + cdm_type = None cdm_name = ci_get(app_config.cdm, service) or ci_get(app_config.cdm, "default") if isinstance(cdm_name, dict): lower_keys = {k.lower(): v for k, v in cdm_name.items()} @@ -3710,7 +3712,8 @@ def create_service_instance( cdm = client_device_cdm(data, input_bridge, lend_server_device) else: - cdm = load_full_cdm(normalized_service, profile, None if server_cdm else data.get("cdm_type")) + follows_client = not server_cdm or lists_both_systems(normalized_service, config) + cdm = load_full_cdm(normalized_service, profile, data.get("cdm_type") if follows_client else None) # Reconstruct enum track-selection params from client data so service code that reads # ctx.parent.params (Service.__init__ proxy/range/vcodec/best_available block) sees enums. @@ -4103,6 +4106,9 @@ async def session_tracks_handler( service_tag = session.service_tag config_cdm_type = detect_cdm_type_for_service(service_tag, app_config) + if not config_cdm_type and session.server_cdm and lists_both_systems(service_tag, app_config): + loaded = getattr(getattr(getattr(service_instance, "ctx", None), "obj", None), "cdm", None) + config_cdm_type = "playready" if is_playready_cdm(loaded) else "widevine" track_has_wv = any( d.__class__.__name__ == "Widevine" for t in list(tracks.videos) + list(tracks.audio) if t.drm for d in t.drm @@ -4278,6 +4284,15 @@ def configured_cdm_name(service: str, drm_type: str, app_config: Any) -> Optiona return None +def lists_both_systems(service: str, app_config: Any) -> bool: + """True when config.cdm names a device for both Widevine and PlayReady for ``service``. + + The server CDM then loads the device of the client's DRM system, so the manifest suits the client's vaults. + """ + entry = ci_get(app_config.cdm, service) or ci_get(app_config.cdm, "default") + return isinstance(entry, dict) and {"widevine", "playready"} <= {key.lower() for key, name in entry.items() if name} + + 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. diff --git a/unshackle/core/api/routes.py b/unshackle/core/api/routes.py index 29f7000a..3e7b7994 100644 --- a/unshackle/core/api/routes.py +++ b/unshackle/core/api/routes.py @@ -1957,7 +1957,10 @@ async def session_create(request: web.Request) -> web.Response: cdm_type: type: string enum: [widevine, playready] - description: DRM system of the client's local CDM; absent when the client has none + description: | + DRM system of the client's local CDM; absent when the client has none. + A server CDM session loads the device of this system when the server's + cdm entry for the service names both systems cdm_security_level: type: integer description: | diff --git a/unshackle/core/remote_service.py b/unshackle/core/remote_service.py index 4cc7450f..c21910d3 100644 --- a/unshackle/core/remote_service.py +++ b/unshackle/core/remote_service.py @@ -1019,6 +1019,7 @@ class RemoteService: self._chapters_by_title: Dict[str, list] = {} self._session_id: Optional[str] = None self.client_licensed: set[str] = set() # track ids this machine's device licenses by challenge relay + self.deferred_refusals: Dict[str, str] = {} self.server_vault_keys: Dict[UUID, str] = {} self.server_vault = ServerVault(self) self._segment_filters: Dict[str, Callable[[Any], bool]] = {} @@ -1790,10 +1791,12 @@ class RemoteService: Called by dl.py between track selection and download. The server decides which CDM device to use and tells the client through server_cdm_type. We send track IDs and the server does the full - CDM flow, returning KID:KEY pairs. + CDM flow, returning KID:KEY pairs. A capped track this machine's device cannot license + waits in ``deferred_refusals``, so dl reads the local vaults before ``proxy_license`` raises it. """ # track ids can repeat across titles, so a refusal for the last title must not carry over self.client_licensed.clear() + self.deferred_refusals.clear() if not (self._server_cdm or getattr(self, "_server_vault", False)) or self.local_vaults_only: return @@ -1863,7 +1866,13 @@ class RemoteService: # a lent-device miss still gets the local vaults, which dl reads before asking the server again refusal = capped.get(self.server_track_id(track.id)) if refusal is not None and refusal.get("reason") != "server_device": - self.license_locally(track, refusal) + try: + self.license_locally(track, refusal) + except click.ClickException as e: + if not self._server_cdm: + raise + self.deferred_refusals[str(track.id)] = e.message + self.log.debug(f"{e.message}; trying the local vaults first") def license_locally(self, track: AnyTrack, refusal: Dict[str, Any]) -> None: """Hand a track the server CDM refused to license live to this machine's own device. @@ -1871,7 +1880,7 @@ class RemoteService: dl then licenses it through the challenge relay. An HLS track with no DRM yet passes, as its media playlist names the DRM and the downloader honours ``drm_preference``. """ - from unshackle.core.cdm.detect import is_playready_cdm + from unshackle.core.cdm.detect import is_playready_cdm, is_widevine_cdm if refusal.get("reason") == "server_device": vaults = ( @@ -1893,11 +1902,17 @@ class RemoteService: if cdm is None: raise click.ClickException(f"{why}, and {track} needs a local CDM to license it") drm_class = "PlayReady" if is_playready_cdm(cdm) else "Widevine" - if track.drm and not any(d.__class__.__name__ == drm_class for d in track.drm): - height = getattr(track, "height", None) - raise click.ClickException( - f"{why}, and {track} has no {drm_class} DRM for your local CDM; pass -q {height or 'with that height'}" - ) + height = getattr(track, "height", None) + systems = {d.__class__.__name__ for d in track.drm or ()} + if systems and drm_class not in systems: + other = "Widevine" if drm_class == "PlayReady" else "PlayReady" + matches = is_widevine_cdm if other == "Widevine" else is_playready_cdm + if not (other in systems and self.ctx.obj.cdm_for and matches(self.ctx.obj.cdm_for(other.lower(), height))): + label = f"the {height}p track" if height else str(track) + hint = f"; pass -q {height} to start the session on your device" if cap and height else "" + have = " and ".join(sorted(systems)) + raise click.ClickException(f"{why}, and {label} has only {have} DRM for your {drm_class} device{hint}") + drm_class = other track.drm_preference = drm_class.lower() self.client_licensed.add(str(track.id)) self.log.info(f"{why}; licensing {track.id} with your own {drm_class} device") @@ -2027,6 +2042,8 @@ class RemoteService: if self.local_vaults_only: self.license_locally(track, {"reason": "server_device"}) if self._server_cdm and str(track.id) not in self.client_licensed: + if deferred := self.deferred_refusals.get(str(track.id)): + raise click.ClickException(deferred) from uuid import UUID server_type = self.server_cdm_type diff --git a/unshackle/core/utils/click_types.py b/unshackle/core/utils/click_types.py index 31b618a6..86ef0247 100644 --- a/unshackle/core/utils/click_types.py +++ b/unshackle/core/utils/click_types.py @@ -1,7 +1,7 @@ import re from datetime import date, timedelta from enum import Enum -from typing import Any, Optional, Sequence, Union +from typing import Any, Callable, Optional, Sequence, Union import click from click.shell_completion import CompletionItem @@ -166,6 +166,7 @@ class ContextData: proxy_providers: list, profile: Optional[str] = None, cdm_entry: bool = False, + cdm_for: Optional[Callable[[str, Optional[int]], Any]] = None, ) -> None: self.config = config self.cdm = cdm @@ -173,6 +174,7 @@ class ContextData: self.profile = profile # True when the service's own entry in the cdm config named the loaded device self.cdm_entry = cdm_entry + self.cdm_for = cdm_for class SeasonRange(click.ParamType):