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.
This commit is contained in:
imSp4rky
2026-11-03 10:51:29 -07:00
parent b710b5b0c9
commit b6429ecdfd
12 changed files with 309 additions and 68 deletions
+7 -2
View File
@@ -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'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, 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 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. - Enable with `server_cdm: true` in the server entry.
- The server tells the client which DRM type it used - 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 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 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 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). [`server_cdm_max_height`](../../reference/configuration/services.md).
### Your own device identity ### Your own device identity
+6 -1
View File
@@ -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 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 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 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 A quality key can also hold a dict of `widevine` / `playready` devices, and so can the
top-level `default` entry: top-level `default` entry:
+36 -1
View File
@@ -1,3 +1,4 @@
import logging
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any, Iterable, Optional from typing import Any, Iterable, Optional
from uuid import UUID from uuid import UUID
@@ -17,7 +18,41 @@ def remote_keys(
svc._server_cdm = server_cdm svc._server_cdm = server_cdm
svc.server_cdm_type = server_cdm_type svc.server_cdm_type = server_cdm_type
svc.client_licensed = set(client_licensed) svc.client_licensed = set(client_licensed)
svc.deferred_refusals = {}
svc.server_vault_keys = dict(server_vault_keys or {}) svc.server_vault_keys = dict(server_vault_keys or {})
svc.server_vault = ServerVault(svc) 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 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]))
@@ -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._server_device = True
svc._local_vaults_only = True svc._local_vaults_only = True
svc.client_licensed = set() svc.client_licensed = set()
svc.deferred_refusals = {}
svc.client = SimpleNamespace(post=lambda *a, **k: pytest.fail("the server refuses every key request")) svc.client = SimpleNamespace(post=lambda *a, **k: pytest.fail("the server refuses every key request"))
track = SimpleNamespace(id="vid", drm=None) track = SimpleNamespace(id="vid", drm=None)
+84 -51
View File
@@ -13,7 +13,7 @@ import pywidevine.pssh as wv_pssh_mod
from rich.table import Table from rich.table import Table
import unshackle.core.drm as drm_mod 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.commands.dl import dl
from unshackle.core.api import handlers from unshackle.core.api import handlers
from unshackle.core.api.errors import APIError, APIErrorCode from unshackle.core.api.errors import APIError, APIErrorCode
@@ -203,42 +203,15 @@ class _WV:
_WV.__name__ = "Widevine" _WV.__name__ = "Widevine"
def remote(local_cdm, answer): def capped_track():
svc = remote_keys(server_cdm_type="playready", local_cdm=local_cdm) return SimpleNamespace(id="vid", height=2160, drm=[_WV()], drm_preference=None)
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 test_a_capped_track_moves_to_the_local_device(): def test_a_capped_track_moves_to_the_local_device():
track = SimpleNamespace(id="vid", drm=[_WV()], drm_preference=None) 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 svc.client_licensed == {"vid"}
assert track.drm_preference == "widevine" 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"] 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(): def test_a_capped_track_without_a_local_device_fails_clearly():
track = SimpleNamespace(id="vid", drm=[_WV()], drm_preference=None) fails_at_the_track_licence(None, "1080p")
svc = remote(None, CAPPED)
with pytest.raises(click.ClickException, match="1080p"):
svc.resolve_server_keys(_Title(track))
def test_a_capped_track_without_the_local_drm_system_fails_clearly(): def test_a_capped_track_without_the_local_drm_system_fails_clearly():
track = SimpleNamespace(id="vid", drm=[_WV()], drm_preference=None) fails_at_the_track_licence(
svc = remote(SimpleNamespace(is_playready=True, security_level=3000), CAPPED) PR_DEVICE,
with pytest.raises(click.ClickException, match="PlayReady"): r"1080p.*the 2160p track has only Widevine DRM for your PlayReady device; pass -q 2160 ",
svc.resolve_server_keys(_Title(track)) )
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): 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) raise self.Used(cdm)
def prepare(svc, licence): def prepare(svc, licence, drm=None, track=None, cdm_for=None):
runner = dl.__new__(dl) runner = dl.__new__(dl)
runner._remote_service = svc runner._remote_service = svc
runner.cdm = "server stub" runner.cdm = "server stub"
runner.service = "EXAMPLE1" runner.service = "EXAMPLE1"
runner.debug_logger = None runner.debug_logger = None
runner.log = logging.getLogger("test-server-cdm-cap") runner.log = logging.getLogger("test-server-cdm-cap")
runner.cdm_for = cdm_for
table = Table() table = Table()
table.add_column() 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: 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] 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(): def test_a_capped_hls_track_with_no_drm_yet_moves_to_the_local_device():
track = SimpleNamespace(id="vid", drm=[], drm_preference=None) track = SimpleNamespace(id="vid", drm=[], 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 svc.client_licensed == {"vid"}
assert track.drm_preference == "widevine" assert track.drm_preference == "widevine"
def test_resolve_server_keys_forgets_the_last_titles_handovers(): def test_resolve_server_keys_forgets_the_last_titles_handovers():
track = SimpleNamespace(id="vid", drm=[_WV()], drm_preference=None) 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.client_licensed = {"vid"}
svc.resolve_server_keys(_Title(track)) svc.resolve_server_keys(one_title(track))
assert svc.client_licensed == set() assert svc.client_licensed == set()
def test_a_single_licence_refusal_hands_the_track_to_the_local_device(): def test_a_single_licence_refusal_hands_the_track_to_the_local_device():
track = SimpleNamespace(id="vid", drm=[_WV()], drm_preference=None) track = SimpleNamespace(id="vid", drm=[_WV()], drm_preference=None)
refusal = {"status": "error", "error_code": "SERVER_CDM_CAPPED", "details": CAP_DETAILS} 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.proxy_license(b"", track, "widevine") == b""
assert svc.client_licensed == {"vid"} assert svc.client_licensed == {"vid"}
@@ -665,7 +661,7 @@ def sent_create(
raise _SentCreate(data) raise _SentCreate(data)
handlers.config.cdm = cdm_map or {} handlers.config.cdm = cdm_map or {}
svc = remote(local_cdm, {}) svc = remote_session(local_cdm, {})
svc.ctx.obj.cdm_entry = cdm_entry svc.ctx.obj.cdm_entry = cdm_entry
svc._server_cdm = server_cdm svc._server_cdm = server_cdm
svc.service_tag = "EXAMPLE1" 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]) @pytest.mark.parametrize("max_height", [None, 1080])
def test_a_declined_server_cdm_that_the_server_keeps_is_reported(max_height, caplog): 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.service_tag = "EXAMPLE1"
svc._server_cdm = False svc._server_cdm = False
with caplog.at_level(logging.INFO): 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)]) @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): 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.service_tag = "EXAMPLE1"
svc.ctx.obj.cdm_entry = cdm_entry svc.ctx.obj.cdm_entry = cdm_entry
with caplog.at_level(logging.INFO): 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._server_cdm is False
assert svc._anonymous_search is True assert svc._anonymous_search is True
assert handlers.config.services == {tag: {"title_map": {"a": "b"}}} 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
@@ -225,3 +225,88 @@ async def test_track_preference_falls_back_with_a_warning(env, monkeypatch, capl
assert body["drm_type"] == "widevine" assert body["drm_type"] == "widevine"
assert "wants playready DRM but the server has no device for it" in caplog.text 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"
@@ -13,7 +13,7 @@ from uuid import UUID
import pytest import pytest
from rich.table import Table 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 from unshackle.commands.dl import dl
pytestmark = pytest.mark.unit 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"} 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(): 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.""" """The batch licence fills track.drm before prepare_drm, so dl caches it at the call site."""
vaults = FakeVaults() vaults = FakeVaults()
+5
View File
@@ -1623,6 +1623,7 @@ class dl:
proxy_providers=self.proxy_providers, proxy_providers=self.proxy_providers,
profile=self.profile, profile=self.profile,
cdm_entry=self.cdm_from_service_entry(), cdm_entry=self.cdm_from_service_entry(),
cdm_for=self.cdm_for,
) )
if repack: if repack:
@@ -5283,6 +5284,10 @@ class dl:
assert remote is not None assert remote is not None
track_cdm = remote.local_cdm track_cdm = remote.local_cdm
local_drm = track.get_drm_for_cdm(track_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: if local_drm is not None and local_drm is not drm:
assert not isinstance(local_drm, ClearKey) assert not isinstance(local_drm, ClearKey)
# a mid-download handover leaves drm on the server's DRM system, and the caller decrypts with it # a mid-download handover leaves drm on the server's DRM system, and the caller decrypts with it
+17 -2
View File
@@ -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.api.session_store import SessionStore
from unshackle.core.binaries import binary_version from unshackle.core.binaries import binary_version
from unshackle.core.cacher import Cacher 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.config import config, load_yaml
from unshackle.core.constants import AUDIO_CODEC_MAP, DYNAMIC_RANGE_MAP, VIDEO_CODEC_MAP, AnyTrack 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 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.cdm import load_cdm
from unshackle.core.config import config as app_config 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") cdm_name = ci_get(app_config.cdm, service) or ci_get(app_config.cdm, "default")
if isinstance(cdm_name, dict): if isinstance(cdm_name, dict):
lower_keys = {k.lower(): v for k, v in cdm_name.items()} 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) cdm = client_device_cdm(data, input_bridge, lend_server_device)
else: 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 # 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. # 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 service_tag = session.service_tag
config_cdm_type = detect_cdm_type_for_service(service_tag, app_config) 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( 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 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 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]: 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. """The DRM system config.cdm settles for a service; None when the mapping leaves it open.
+4 -1
View File
@@ -1957,7 +1957,10 @@ async def session_create(request: web.Request) -> web.Response:
cdm_type: cdm_type:
type: string type: string
enum: [widevine, playready] 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: cdm_security_level:
type: integer type: integer
description: | description: |
+25 -8
View File
@@ -1019,6 +1019,7 @@ class RemoteService:
self._chapters_by_title: Dict[str, list] = {} self._chapters_by_title: Dict[str, list] = {}
self._session_id: Optional[str] = None self._session_id: Optional[str] = None
self.client_licensed: set[str] = set() # track ids this machine's device licenses by challenge relay 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_keys: Dict[UUID, str] = {}
self.server_vault = ServerVault(self) self.server_vault = ServerVault(self)
self._segment_filters: Dict[str, Callable[[Any], bool]] = {} 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 Called by dl.py between track selection and download. The server
decides which CDM device to use and tells the client through decides which CDM device to use and tells the client through
server_cdm_type. We send track IDs and the server does the full 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 # track ids can repeat across titles, so a refusal for the last title must not carry over
self.client_licensed.clear() self.client_licensed.clear()
self.deferred_refusals.clear()
if not (self._server_cdm or getattr(self, "_server_vault", False)) or self.local_vaults_only: if not (self._server_cdm or getattr(self, "_server_vault", False)) or self.local_vaults_only:
return return
@@ -1863,7 +1866,13 @@ class RemoteService:
# a lent-device miss still gets the local vaults, which dl reads before asking the server again # 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)) refusal = capped.get(self.server_track_id(track.id))
if refusal is not None and refusal.get("reason") != "server_device": 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: 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. """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 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``. 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": if refusal.get("reason") == "server_device":
vaults = ( vaults = (
@@ -1893,11 +1902,17 @@ class RemoteService:
if cdm is None: if cdm is None:
raise click.ClickException(f"{why}, and {track} needs a local CDM to license it") raise click.ClickException(f"{why}, and {track} needs a local CDM to license it")
drm_class = "PlayReady" if is_playready_cdm(cdm) else "Widevine" 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)
height = getattr(track, "height", None) systems = {d.__class__.__name__ for d in track.drm or ()}
raise click.ClickException( if systems and drm_class not in systems:
f"{why}, and {track} has no {drm_class} DRM for your local CDM; pass -q {height or 'with that height'}" 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() track.drm_preference = drm_class.lower()
self.client_licensed.add(str(track.id)) self.client_licensed.add(str(track.id))
self.log.info(f"{why}; licensing {track.id} with your own {drm_class} device") 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: if self.local_vaults_only:
self.license_locally(track, {"reason": "server_device"}) self.license_locally(track, {"reason": "server_device"})
if self._server_cdm and str(track.id) not in self.client_licensed: 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 from uuid import UUID
server_type = self.server_cdm_type server_type = self.server_cdm_type
+3 -1
View File
@@ -1,7 +1,7 @@
import re import re
from datetime import date, timedelta from datetime import date, timedelta
from enum import Enum from enum import Enum
from typing import Any, Optional, Sequence, Union from typing import Any, Callable, Optional, Sequence, Union
import click import click
from click.shell_completion import CompletionItem from click.shell_completion import CompletionItem
@@ -166,6 +166,7 @@ class ContextData:
proxy_providers: list, proxy_providers: list,
profile: Optional[str] = None, profile: Optional[str] = None,
cdm_entry: bool = False, cdm_entry: bool = False,
cdm_for: Optional[Callable[[str, Optional[int]], Any]] = None,
) -> None: ) -> None:
self.config = config self.config = config
self.cdm = cdm self.cdm = cdm
@@ -173,6 +174,7 @@ class ContextData:
self.profile = profile self.profile = profile
# True when the service's own entry in the cdm config named the loaded device # True when the service's own entry in the cdm config named the loaded device
self.cdm_entry = cdm_entry self.cdm_entry = cdm_entry
self.cdm_for = cdm_for
class SeasonRange(click.ParamType): class SeasonRange(click.ParamType):