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:
imSp4rky
2026-09-22 10:54:31 -06:00
parent d239964f0b
commit 4639d9d496
9 changed files with 478 additions and 82 deletions
+2 -2
View File
@@ -1061,7 +1061,7 @@ Get the content keys for the DRM. The `mode` field selects one of two modes.
=== "Single: Response `200`" === "Single: Response `200`"
```json ```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`" === "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. | | `track_id` / `track_ids` | Single track, or a batch of tracks. |
| `challenge` | Base64 CDM challenge (proxy mode). | | `challenge` | Base64 CDM challenge (proxy mode). |
| `pssh` | Base64 PSSH (server-CDM 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`. | | `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. | | `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. | | `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, "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, "find_title_for_track", lambda tid, session: SimpleNamespace())
monkeypatch.setattr(handlers, "detect_cdm_type_for_service", lambda tag, cfg: cdm_type) 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, "handle_single_server_cdm", fake_single)
monkeypatch.setattr(handlers.config, "serve", {"users": {}}, raising=False) monkeypatch.setattr(handlers.config, "serve", {"users": {}}, raising=False)
resp = await handlers.session_license_handler( 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, "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, "find_title_for_track", lambda tid, session: SimpleNamespace())
monkeypatch.setattr(handlers, "detect_cdm_type_for_service", lambda tag, cfg: "widevine") 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, "fetch_init_segment", lambda track, sess: b"init")
monkeypatch.setattr( monkeypatch.setattr(
handlers, "drm_from_init_segment", lambda track, session=None, init_data=None: [_WV("init", [CDM_KID])] 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, "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, "find_title_for_track", lambda tid, session: SimpleNamespace())
monkeypatch.setattr(handlers, "detect_cdm_type_for_service", lambda tag, cfg: "playready") 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, "drm_preference_name", lambda track: None)
monkeypatch.setattr(handlers, "extract_pssh_from_track", lambda track, drm: track_pssh[track.id]) monkeypatch.setattr(handlers, "extract_pssh_from_track", lambda track, drm: track_pssh[track.id])
monkeypatch.setattr(handlers, "handle_single_server_cdm", fake_single) 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
+6 -5
View File
@@ -2910,13 +2910,14 @@ class dl:
service_session=service.session, service_session=service.session,
) )
if getattr(self._remote_service, "_server_cdm", False) and self.cdm is None: server_cdm_type = None
cdm_type = getattr(self._remote_service, "_server_cdm_type", "widevine") if getattr(self._remote_service, "_server_cdm", False):
self.cdm = cdm_type_stub(cdm_type) server_cdm_type = getattr(self._remote_service, "_server_cdm_type", "widevine")
self.log.info(f"Using server CDM ({cdm_type.title()}); no local CDM required") 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 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) highest_quality = max((track.height for track in video_tracks if track.height), default=0)
if highest_quality > 0: if highest_quality > 0:
if is_widevine_cdm(self.cdm): if is_widevine_cdm(self.cdm):
+128 -63
View File
@@ -13,7 +13,7 @@ from contextlib import suppress
from datetime import date as date_ from datetime import date as date_
from http.cookiejar import CookieJar, MozillaCookieJar from http.cookiejar import CookieJar, MozillaCookieJar
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional from typing import Any, Callable, Dict, Iterable, List, Optional
import click import click
from aiohttp import web from aiohttp import web
@@ -3394,18 +3394,38 @@ def resolve_server_cdm(service: str, profile: Optional[str], cdm_type: Optional[
return None return None
def detect_cdm_type_for_service(service: str, app_config: Any) -> Optional[str]: def configured_cdm_name(service: str, drm_type: str, app_config: Any) -> Optional[str]:
"""Detect the CDM type configured for a service in config.cdm.""" """The device name config.cdm maps a service to for one DRM system; None when the tier decides.
cdm_name = ci_get(app_config.cdm, service)
if not cdm_name: A dict names a device per system (``widevine`` / ``playready`` keys) or a ``default``.
return None 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): 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()}
if {"widevine", "playready"} & lower_keys.keys(): drm_key = drm_type if drm_type in ("widevine", "playready") else None
return "playready" if "playready" in lower_keys else "widevine" cdm_name = lower_keys.get(drm_key) or lower_keys.get("default") or ci_get(app_config.cdm, "default")
cdm_name = cdm_name.get("default") or next(iter(cdm_name.values()), None)
if cdm_name and isinstance(cdm_name, str): 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 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 from unshackle.core.config import config as app_config
cdm_name = ci_get(app_config.cdm, service_tag) if service_tag else None cdm_name = configured_cdm_name(service_tag, drm_type, app_config)
if isinstance(cdm_name, dict): if cdm_name:
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):
return cdm_name return cdm_name
if drm_type == "playready": if drm_type == "playready":
@@ -3807,6 +3823,65 @@ def resolve_device_name(user_config: dict, drm_type: str, service_tag: str = "")
return device_name 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: def load_server_vaults(service_name: str) -> Any:
"""Load server vaults from config.key_vaults.""" """Load server vaults from config.key_vaults."""
from unshackle.core.config import config as app_config 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: 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" api_key = request.headers.get("X-Secret-Key", "anonymous") if request else "anonymous"
user_config = serve_user_config(api_key) user_config = serve_user_config(api_key)
service = session.service_instance 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 service_tag = session.service_tag
config_cdm_type = detect_cdm_type_for_service(service_tag, app_config)
all_keys: Dict[str, Dict[str, str]] = {} all_keys: Dict[str, Dict[str, str]] = {}
vault_keys: list[str] = [] vault_keys: list[str] = []
@@ -4213,19 +4282,11 @@ async def session_license_handler(
track: Any, title: Any, candidates: list track: Any, title: Any, candidates: list
) -> tuple[Dict[str, str], Optional[str], Optional[str]]: ) -> tuple[Dict[str, str], Optional[str], Optional[str]]:
"""`(keys, drm_type, pssh)` for the track's current DRM, licensing once per unique PSSH.""" """`(keys, drm_type, pssh)` for the track's current DRM, licensing once per unique PSSH."""
for candidate in candidates: picked = pick_server_pssh(track, candidates)
pssh_str = extract_pssh_from_track(track, candidate) if not picked:
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:
warn(f"No PSSH on track {sanitize_log(str(track.id)[:12])} for {', '.join(candidates) or 'any CDM'}") warn(f"No PSSH on track {sanitize_log(str(track.id)[:12])} for {', '.join(candidates) or 'any CDM'}")
return {}, None, None return {}, None, None
candidate, pssh_str = picked
cache_key = (pssh_str, pssh_set(track)) cache_key = (pssh_str, pssh_set(track))
if cache_key not in keys_by_pssh: if cache_key not in keys_by_pssh:
@@ -4258,28 +4319,7 @@ async def session_license_handler(
continue continue
title = find_title_for_track(tid, session) title = find_title_for_track(tid, session)
candidates = server_drm_candidates(service_tag, user_config, drm_type, track, warn)
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"
)
keys, track_drm_type, pssh_str = license_track(track, title, candidates) keys, track_drm_type, pssh_str = license_track(track, title, candidates)
@@ -4341,19 +4381,44 @@ async def session_license_handler(
service = session.service_instance service = session.service_instance
pssh_b64 = data.get("pssh") pssh_b64 = data.get("pssh")
if pssh_b64: if pssh_b64 or mode == "server_cdm":
ensure_track_drm(track, getattr(service, "session", None)) 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) require_track_pssh(track, pssh_b64, drm_type)
if drm_type == "playready": if drm_type == "playready":
track.pr_pssh = pssh_b64 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) return handle_proxy_license(service, title, track, challenge_b64, drm_type)
except APIError: except APIError:
+20 -12
View File
@@ -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: def reconstruct_drm(drm_list: Optional[list]) -> list:
"""Reconstruct DRM objects from serialized API data.""" """Reconstruct DRM objects from serialized API data."""
if not drm_list: if not drm_list:
@@ -1318,20 +1329,14 @@ class RemoteService:
if isinstance(challenge, str): if isinstance(challenge, str):
challenge = challenge.encode("utf-8") 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: if self._server_cdm:
from uuid import UUID 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: if pssh_b64:
try: try:
resp = self.client.post( resp = self.client.post(
@@ -1343,6 +1348,7 @@ class RemoteService:
"pssh": pssh_b64, "pssh": pssh_b64,
}, },
) )
self._server_cdm_type = resp.get("drm_type", server_type)
keys = resp.get("keys", {}) keys = resp.get("keys", {})
self.note_vault_keys(keys, set(resp.get("vault_keys", []))) self.note_vault_keys(keys, set(resp.get("vault_keys", [])))
if keys and track.drm: if keys and track.drm:
@@ -1354,9 +1360,11 @@ class RemoteService:
except Exception as e: except Exception as e:
self.log.warning("server_cdm license failed: %s", e) self.log.warning("server_cdm license failed: %s", e)
else: 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 return challenge
pssh_b64 = track_pssh(track, drm_type)
payload = { payload = {
"track_id": str(track.id), "track_id": str(track.id),
"challenge": base64.b64encode(challenge).decode("ascii"), "challenge": base64.b64encode(challenge).decode("ascii"),