mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 20:15:10 +00:00
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:
@@ -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]))
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user