mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-11 19:45:06 +00:00
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.
This commit is contained in:
@@ -1061,7 +1061,7 @@ Get the content keys for the DRM. The `mode` field selects one of two modes.
|
||||
=== "Single: Response `200`"
|
||||
|
||||
```json
|
||||
{ "keys": { "<kid_hex>": "<key_hex>" }, "vault_keys": ["<kid_hex>"] }
|
||||
{ "keys": { "<kid_hex>": "<key_hex>" }, "vault_keys": ["<kid_hex>"], "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. |
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
@@ -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])]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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):
|
||||
|
||||
+128
-63
@@ -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:
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user