feat(remote): cap server CDM licensing by height per API key and service

A session above the cap licenses with the client's own device, and a track above it in an uncapped -q run is handed back to the client. Also names the fallback in the ccextractor crash warning.
This commit is contained in:
imSp4rky
2026-09-24 16:50:26 -06:00
parent 91db1fa0b1
commit 7d31153c7f
21 changed files with 1087 additions and 47 deletions
+4 -3
View File
@@ -44,7 +44,7 @@ All are `GET`.
| Path | Returns |
|------|---------|
| `/api/dashboard/status` | Version, `code_hash`, bind host and port, `mode` (`full`, `api_only`, `remote_only`), uptime, `requests_total`, `requests_rejected` (401s), `requests_by_key` (label → count), loaded service count, session count, `max_sessions` (`null` when the operator configured no limit), `session_ttl`, job counts by status. The service count includes services that failed to import - use `/api/dashboard/services` for the number that loaded. |
| `/api/dashboard/sessions` | Every live remote session: `id`, `owner` (username or masked key), `creator_ip`, `service`, `title_id`, `titles`, `tracks`, `title` (the display name of the title the client is on: the last one it asked tracks for, else the first resolved title), `auth_status`, `auth_error`, `server_account` (the server profile lent to the remote session, else `null`), `client`, `actions`, `created_at` and `last_accessed` (ISO 8601) with `created_ts` and `last_accessed_ts` (Unix epoch, the same clock as log `ts`), `age_seconds`, `idle_seconds`, `log_seq`. `client` is whatever the remote client sent as `client` in its session create request. The CLI sends `version`, `code_hash` (the commit the client runs, `null` when its source cannot be read), `platform` and `argv`. `argv` is the command line the user ran: proxy and URL userinfo, secret query parameters and credential values become `***`, home and install paths shorten as in the logs, and the line is cut at 3000 characters. An older client sends less or nothing and the field is `{}`. `log_seq` is the last sequence number in the remote session's service log - poll `/api/dashboard/sessions/{id}/logs` when it changes. `actions` is the session's request log, newest last, capped at 500: `ts`, `method`, `action` (`titles`, `tracks`, `segments`, `license`, `prompt`, …), `query`, `status`, `ms`, `bytes_in`, `bytes_out`. |
| `/api/dashboard/sessions` | Every live remote session: `id`, `owner` (username or masked key), `creator_ip`, `service`, `title_id`, `titles`, `tracks`, `title` (the display name of the title the client is on: the last one it asked tracks for, else the first resolved title), `auth_status`, `auth_error`, `server_account` (the server profile lent to the remote session, else `null`), `server_cdm` (`false` when the client's own device licenses the remote session), `server_cdm_max_height` (the tallest video the server CDM licenses live, else `null`), `client`, `actions`, `created_at` and `last_accessed` (ISO 8601) with `created_ts` and `last_accessed_ts` (Unix epoch, the same clock as log `ts`), `age_seconds`, `idle_seconds`, `log_seq`. `client` is whatever the remote client sent as `client` in its session create request. The CLI sends `version`, `code_hash` (the commit the client runs, `null` when its source cannot be read), `platform` and `argv`. `argv` is the command line the user ran: proxy and URL userinfo, secret query parameters and credential values become `***`, home and install paths shorten as in the logs, and the line is cut at 3000 characters. An older client sends less or nothing and the field is `{}`. `log_seq` is the last sequence number in the remote session's service log - poll `/api/dashboard/sessions/{id}/logs` when it changes. `actions` is the session's request log, newest last, capped at 500: `ts`, `method`, `action` (`titles`, `tracks`, `segments`, `license`, `prompt`, …), `query`, `status`, `ms`, `bytes_in`, `bytes_out`. |
| `/api/dashboard/jobs` | Every download job with full detail, regardless of owner. Empty in `--remote-only` mode. |
| `/api/dashboard/logs` | `{"seq": N, "records": [...]}`: the last 1000 log records. `?since=<seq>` returns only newer records; `?level=WARNING` sets the minimum level; `?logger=serve` keeps one logger and its children (`aiohttp.access` is the noisiest). `ts` is a Unix epoch in seconds. |
| `/api/dashboard/sessions/{id}/logs` | One remote session's service log: `{"session_id", "last_seq", "records": [...]}`. Each record has `seq`, `ts`, `level`, `message`. `?since=<seq>` returns only newer records. 404 when the remote session is unknown. |
@@ -118,11 +118,11 @@ never keeps it alive or makes it look active.
```
GET /api/dashboard/keys
[{"id": "3ed054be9371", "role": "user", "label": "tier1", "services": ["EXAMPLE", "DEMO"],
"server_cdm": false, "server_accounts": false, "server_proxy": false,
"server_cdm": false, "server_cdm_max_height": null, "server_accounts": false, "server_proxy": false,
"tier": "bot", "rate_limit": 600, "window_used": 412,
"requests": 41300, "rejected": 4, "bytes_out": 103079215104, "last_seen": 1756909188.2},
{"id": "7c1d0f52a884", "role": "dashboard", "label": "dash…", "services": [],
"server_cdm": false, "server_accounts": false, "server_proxy": false,
"server_cdm": false, "server_cdm_max_height": null, "server_accounts": false, "server_proxy": false,
"tier": null, "rate_limit": null, "window_used": 0,
"requests": 8140, "rejected": 0, "bytes_out": 41200311, "last_seen": 1756909350.9}]
```
@@ -136,6 +136,7 @@ here, so use `id` as the identity and `label` only for display.
| `role` | Which key this is: `user` (a `serve.users` entry), `admin` (`serve.api_secret`) or `dashboard` (`serve.dashboard.key`). Identity, not capability - read the grant fields for what the API key may do. |
| `services` | The effective allowlist, the global list intersected with the API key's. `null` means unrestricted; `[]` means the API key reaches no service route at all. |
| `server_cdm`, `server_accounts` | `false`, `true`, or the list of service tags the grant covers. |
| `server_cdm_max_height` | The configured limit on live server CDM licences: a height, a map of service tag to height, or `null` for no limit. |
| `server_proxy` | Boolean. Only a literal `true` grants it, and an API key with no `users` entry does not get it. |
| `rate_limit` | Requests per hour, from the API key's own value or its tier's. `null` means no limit. |
| `window_used` | Requests counted in the current hourly window. |
+3
View File
@@ -506,6 +506,7 @@ profile, never a service's own `--profile` option.
| `400` | `INVALID_PARAMETERS` | A parameter failed validation. |
| `400` | `INVALID_SERVICE` | Unknown or disallowed service. |
| `403` | `FORBIDDEN` | A gated parameter is not permitted. |
| `403` | `SERVER_CDM_CAPPED` | The API key has a `server_cdm_max_height` for the service, and the job has no `quality` at or under it, sets `best_available`, or has `HYBRID` in `range`. |
### `GET /api/download/jobs`
@@ -690,6 +691,7 @@ Enqueue a **new** job that reuses a terminal job's service, title, and parameter
| `409` | `CONFLICT` | Job is not in a terminal state. |
| `400` | `INVALID_SERVICE` | Service is no longer allowed. |
| `403` | `FORBIDDEN` | A gated parameter is no longer permitted. |
| `403` | `SERVER_CDM_CAPPED` | The job is no longer inside the `server_cdm_max_height` of the API key. |
### `POST /api/download/jobs/{job_id}/priority`
@@ -1220,6 +1222,7 @@ The server serialises every structured error raised inside a handler to the same
| `INVALID_PARAMETERS` | 400 |
| `AUTH_FAILED` | 401 |
| `FORBIDDEN` | 403 |
| `SERVER_CDM_CAPPED` | 403 |
| `GEOFENCE` | 403 |
| `NOT_FOUND` | 404 |
| `NO_CONTENT` | 404 |
+1
View File
@@ -100,6 +100,7 @@ but the defaults hold for the overwhelming majority of responses.
| `INVALID_PARAMETERS` | 400 | One or more download or query parameters failed validation (bad codec, bitrate, sort field, and so on). |
| `AUTH_FAILED` | 401 | Authentication with the streaming service failed (bad credentials or cookies). |
| `FORBIDDEN` | 403 | The action is not allowed. Raised by server-side gates (per-key restrictions on CDM or credential overrides) and by remote session IP binding. |
| `SERVER_CDM_CAPPED` | 403 | The server CDM does not license live above the `server_cdm_max_height` of the API key. `details.reason` is `server_cdm_max_height` (with `details.max_height`) or `client_device`, for a remote session where the client's own device licenses. A remote client licenses the track with its own device. |
| `GEOFENCE` | 403 | The title is not available in the applicable region. |
| `NOT_FOUND` | 404 | A requested resource (title, history entry, and so on) does not exist. |
| `NO_CONTENT` | 404 | The request was valid but produced nothing: no matching titles, tracks, episodes, or keys. |
+9
View File
@@ -268,6 +268,15 @@ There are two ways DRM keys get resolved, chosen by the client's `server_cdm` fl
already answered. The client proves it, and a server key waits as the next
candidate.
!!! note "A height limit on the server CDM"
An API key with `server_cdm_max_height` gets the server CDM only up to that height. The
session create response tells the client who licenses the session in `server_cdm` and gives
the limit in `server_cdm_max_height`. In a server CDM session, the batch licence response
lists each track the server refused to license live under `capped_tracks`. A single-track
server CDM licence answers `403 SERVER_CDM_CAPPED` for such a track. In both cases the client
licenses those tracks in proxy mode with its own local CDM. See
[`server_cdm_max_height`](../../reference/configuration/services.md).
---
## Remote session lifecycle and expiry
+27
View File
@@ -154,6 +154,32 @@ client that asks anyway gets a `FORBIDDEN` error. Because a download job always
an API key without `server_cdm` for that service also cannot submit or retry `/api/download`
jobs. Keys that have no `users` entry, such as `api_secret`, keep server CDM access.
`server_cdm_max_height` limits the live licences the server's device makes for that API key.
Set it to a height in pixels for every service, or to a map of service tag to height with an
optional `default` entry. A service the map does not name or sets to `null` gets the `default`, and
with no `default` it has no limit. The server compares the limit with the lowest `-q` that selects
the track: the track height, or the 16:9 height from the width when that is lower. The server reads
the remote client's `-q` when the remote session starts:
- With no `-q`, or a `-q` at or below the limit, the server CDM licenses the remote session. The server
refuses a live licence for a video track taller than the limit, and the client licenses that
track with its own local CDM.
- With a `-q` above the limit, the client's own local CDM licenses every track of the remote session.
The service sees the DRM system and security level of the client's device. A client with no
local CDM gets a `SERVER_CDM_CAPPED` error.
- A download job must pass a `quality` at or below the limit, and must not set `best_available` or
a `HYBRID` range. `HYBRID` always keeps the lowest Dolby Vision track, at any quality. A job with
no `quality`, with a higher one, with `best_available`, or with `HYBRID` gets a `SERVER_CDM_CAPPED`
error. The server checks the values the job runs with, after it applies the service defaults
and the `serve:` download defaults.
The limit protects the server's device, not the content keys. For a Widevine track, a server vault
still supplies a content key at every height. A PlayReady track never reads the vault, so above
the limit it always goes to the client's device. A licence for a lower track can carry the
content key of a higher track when the service shares one content key between them. With server
accounts enabled, a client that reports a stronger device than it holds makes the server's
account request those manifests; the licence server still refuses its challenge.
`admin` is a boolean that lets the API key run the maintenance endpoints (clear-cache, clear-temp, refresh-services). It is `false` unless the entry sets it. Keys that have no `users` entry, such as `api_secret`, keep that access.
A `tier` names an entry under `serve.tiers`, which holds the settings that several API keys
@@ -242,6 +268,7 @@ serve:
tier: bot # 600 requests/hour; 429 with Retry-After once over
e5f6a7b8:
server_cdm: true # this key may have the server do the licensing
server_cdm_max_height: 1080 # above 1080p the client licenses with its own device
c9d0e1f2:
services: [EXAMPLE1, EXAMPLE2]
server_cdm: [EXAMPLE1] # the server licenses only EXAMPLE1; EXAMPLE2 needs a local CDM
@@ -83,19 +83,22 @@ def test_to_dict_full_details_redacts_error_fields_and_parameters():
class _PastGate(Exception):
"""Raised by the stubbed Services.load to prove a request got past the gate into the try block."""
"""Raised by the stubbed download manager to prove a request got past the gate."""
@pytest.fixture
def stub_handler(monkeypatch):
"""Make the service valid and make the first call after the gate (Services.load) explode, so a
forbidden request raises APIError *before* the try block and an allowed one is caught inside it."""
"""Make the service valid and make the first call after the gate (get_download_manager) explode, so a
forbidden request raises APIError and the handler returns an allowed one as an error response."""
from unshackle.core.api import download_manager
monkeypatch.setattr(handlers, "validate_service", lambda tag, request=None: tag)
monkeypatch.setattr(handlers.Services, "load", lambda tag: object())
def boom(*_args, **_kwargs):
raise _PastGate()
monkeypatch.setattr(handlers.Services, "load", boom)
monkeypatch.setattr(download_manager, "get_download_manager", boom)
return monkeypatch
+1
View File
@@ -32,6 +32,7 @@ def test_api_error_default_http_status_per_code() -> None:
APIErrorCode.AUTH_FAILED: 401,
APIErrorCode.FORBIDDEN: 403,
APIErrorCode.GEOFENCE: 403,
APIErrorCode.SERVER_CDM_CAPPED: 403,
APIErrorCode.NOT_FOUND: 404,
APIErrorCode.NO_CONTENT: 404,
APIErrorCode.JOB_NOT_FOUND: 404,
@@ -55,7 +55,7 @@ async def _run_batch(monkeypatch, tracks, cdm_type, calls):
async def fake_get_session(session_id, request):
return session
def fake_single(service, title, track, pssh_str, drm_type, request, sources=None):
def fake_single(service, title, track, pssh_str, drm_type, request, sources=None, refusal=None):
calls.append((pssh_str, drm_type))
return {"00" * 16: "key"}
@@ -40,7 +40,7 @@ class _Client:
self.answer = answer
self.posts: list = []
def post(self, endpoint, payload):
def post(self, endpoint, payload, expect=()):
self.posts.append(payload)
return self.answer
@@ -49,6 +49,7 @@ def _service(server_type, answer):
svc = RemoteService.__new__(RemoteService)
svc._server_cdm = True
svc._server_cdm_type = server_type
svc.client_licensed = set()
svc._session_id = "sess"
svc.server_vault_keys = {}
svc.log = logging.getLogger("test-remote-license")
@@ -28,6 +28,7 @@ def remote_service() -> RemoteService:
svc.server_vault_keys = {}
svc.server_vault = ServerVault(svc)
svc._session_id = "sess"
svc.client_licensed = set()
svc.log = SimpleNamespace(warning=lambda *a, **k: None)
return svc
@@ -106,7 +107,7 @@ async def test_batch_reports_vault_keys_from_the_init_segment_pssh(monkeypatch):
async def validated(sid, req):
return session
def fake_single(service, title, track, pssh_str, drm_type, request, sources=None):
def fake_single(service, title, track, pssh_str, drm_type, request, sources=None, refusal=None):
if pssh_str == "init":
sources[CDM_KID.hex] = "sqlite"
return {CDM_KID.hex: "v"}
@@ -0,0 +1,638 @@
"""A per-key height cap on the server CDM hands licensing above it to the client's own device."""
import json
import logging
from types import SimpleNamespace
from uuid import UUID
import click
import pyplayready.system.pssh as pr_pssh_mod
import pytest
import pywidevine.pssh as wv_pssh_mod
from rich.table import Table
import unshackle.core.drm as drm_mod
from unshackle.commands.dl import dl
from unshackle.core.api import handlers
from unshackle.core.api.errors import APIError, APIErrorCode
from unshackle.core.api.session_store import SessionEntry
from unshackle.core.drm import Widevine
from unshackle.core.remote_service import RemoteClient, RemoteService
from unshackle.core.tracks import Audio, Video
pytestmark = pytest.mark.unit
USERS = {
"flat": {"server_cdm": True, "server_cdm_max_height": 1080},
"mapped": {"server_cdm": True, "server_cdm_max_height": {"example1": 720, "default": 1080}},
"partial": {"server_cdm": True, "server_cdm_max_height": {"EXAMPLE1": 1080}},
"open": {"server_cdm": True},
"none": {},
}
@pytest.fixture(autouse=True)
def serve_users(monkeypatch):
monkeypatch.setattr(handlers.config, "serve", {"users": USERS}, raising=False)
monkeypatch.setattr(handlers, "request_secret_key", lambda request: request.headers.get("X-Secret-Key"))
def request(key):
return SimpleNamespace(headers={"X-Secret-Key": key} if key else {})
def video(height, width=None):
track = Video.__new__(Video)
track.height = height
track.width = width
return track
@pytest.mark.parametrize(
("key", "service", "cap"),
[
("flat", "EXAMPLE1", 1080),
("mapped", "EXAMPLE1", 720),
("mapped", "EXAMPLE2", 1080),
("partial", "EXAMPLE2", None),
("open", "EXAMPLE1", None),
("admin", "EXAMPLE1", None),
],
)
def test_cap_per_key_and_service(key, service, cap):
assert handlers.server_cdm_max_height(request(key), service) == cap
@pytest.mark.parametrize(
("key", "data", "expected"),
[
("open", {"quality": [2160]}, (True, None)),
("flat", {"quality": [1080]}, (True, 1080)),
("flat", {}, (True, 1080)), # no -q keeps the server CDM
("flat", {"quality": [2160, 1080], "cdm_type": "widevine"}, (False, 1080)),
("flat", {"quality": ["2160p"], "cdm_type": "widevine"}, (False, 1080)),
("none", {"quality": [2160], "cdm_type": "widevine"}, (False, None)),
],
)
def test_session_mode(key, data, expected):
assert handlers.choose_session_cdm(request(key), "EXAMPLE1", data) == expected
def test_above_the_cap_without_a_device_is_refused_at_create():
with pytest.raises(APIError) as ei:
handlers.choose_session_cdm(request("flat"), "EXAMPLE1", {"quality": [2160]})
assert ei.value.error_code is APIErrorCode.SERVER_CDM_CAPPED
assert ei.value.details == {"reason": "server_cdm_max_height", "max_height": 1080}
def test_bad_quality_is_invalid_input():
with pytest.raises(APIError) as ei:
handlers.choose_session_cdm(request("flat"), "EXAMPLE1", {"quality": ["best"]})
assert ei.value.error_code is APIErrorCode.INVALID_INPUT
@pytest.mark.parametrize(
("session", "track", "refused"),
[
(SimpleNamespace(server_cdm=True, server_cdm_max_height=1080), video(2160), True),
(SimpleNamespace(server_cdm=True, server_cdm_max_height=1080), video(1080), False),
(SimpleNamespace(server_cdm=True, server_cdm_max_height=1080), video(None), False),
(SimpleNamespace(server_cdm=True, server_cdm_max_height=1080), Audio.__new__(Audio), False),
(SimpleNamespace(server_cdm=True, server_cdm_max_height=None), video(2160), False),
(SimpleNamespace(server_cdm=False, server_cdm_max_height=1080), video(720), True),
(SimpleNamespace(), video(2160), False), # a session from before the cap existed
],
)
def test_live_licence_refusal(session, track, refused):
assert (handlers.live_licence_refusal(session, track) is not None) is refused
@pytest.fixture
def widevine_env(monkeypatch):
class _FakeWidevine:
def __init__(self, pssh=None, **kwargs):
self.kids = ["11" * 16]
monkeypatch.setattr(wv_pssh_mod, "PSSH", lambda b64: b64)
monkeypatch.setattr(drm_mod, "Widevine", _FakeWidevine)
monkeypatch.setattr(handlers, "ensure_track_drm", lambda track, session=None, init_data=None: None)
monkeypatch.setattr(handlers, "resolve_device_name", lambda *a, **k: "dev")
def no_device(*a, **k):
raise AssertionError("a refused track must not load the server's device")
monkeypatch.setattr("unshackle.core.cdm.load_cdm", no_device)
def single(refusal):
return handlers.handle_single_server_cdm(
SimpleNamespace(), SimpleNamespace(), video(2160), "cHNzaA==", "widevine", None, {}, refusal
)
def test_a_vault_hit_is_served_above_the_cap(widevine_env, monkeypatch):
monkeypatch.setattr(handlers, "check_vaults", lambda kids, name: ({"11" * 16: "key"}, {"11" * 16: "sqlite"}))
refusal = handlers.live_licence_refusal(SimpleNamespace(server_cdm_max_height=1080), video(2160))
assert single(refusal) == {"11" * 16: "key"}
def test_a_vault_miss_raises_the_refusal_before_the_device_loads(widevine_env, monkeypatch):
monkeypatch.setattr(handlers, "check_vaults", lambda kids, name: None)
refusal = handlers.live_licence_refusal(SimpleNamespace(server_cdm_max_height=1080), video(2160))
with pytest.raises(APIError) as ei:
single(refusal)
assert ei.value is refusal
@pytest.mark.parametrize(("quality", "allowed"), [([1080], True), ([720, 1080], True), ([2160], False), ([], False)])
def test_download_jobs_stay_inside_the_cap(quality, allowed):
params = {"service": "EXAMPLE1", "quality": quality}
if allowed:
handlers.enforce_download_gates(params, request("flat"))
else:
with pytest.raises(APIError) as ei:
handlers.enforce_download_gates(params, request("flat"))
assert ei.value.error_code is APIErrorCode.SERVER_CDM_CAPPED
@pytest.mark.parametrize(
("value", "ok"),
[
(None, True),
(1080, True),
({"EXAMPLE1": 720, "default": None}, True),
(True, False),
("1080", False),
(-1, False),
],
)
def test_config_shape(value, ok):
assert (handlers.server_cdm_max_height_error("serve.users.x", value) is None) is ok
class _WV:
pssh = SimpleNamespace(dumps=lambda: "WV")
_WV.__name__ = "Widevine"
def remote(local_cdm, answer):
svc = RemoteService.__new__(RemoteService)
svc._server_cdm = True
svc._server_cdm_type = "playready"
svc._session_id = "sess"
svc.server_vault_keys = {}
svc.client_licensed = set()
svc.log = logging.getLogger("test-server-cdm-cap")
svc.ctx = SimpleNamespace(obj=SimpleNamespace(cdm=local_cdm))
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():
track = SimpleNamespace(id="vid", drm=[_WV()], drm_preference=None)
svc = remote(SimpleNamespace(is_playready=False, security_level=1), CAPPED)
svc.resolve_server_keys(_Title(track))
assert svc.client_licensed == {"vid"}
assert track.drm_preference == "widevine"
svc.posts.clear()
svc.client.post = lambda endpoint, payload: svc.posts.append(payload) or {"license": "bGlj"}
assert svc.proxy_license(b"challenge", track, "widevine") == b"lic"
assert "mode" not in svc.posts[0] and svc.posts[0]["challenge"]
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))
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))
def test_a_null_service_in_the_cap_map_falls_through_to_the_default(monkeypatch):
users = {"k": {"server_cdm": True, "server_cdm_max_height": {"EXAMPLE1": None, "default": 1080}}}
monkeypatch.setattr(handlers.config, "serve", {"users": users}, raising=False)
assert handlers.server_cdm_max_height(request("k"), "EXAMPLE1") == 1080
def test_an_ultrawide_track_is_measured_as_the_smallest_quality_that_selects_it():
"""-q matches a height or a 16:9 width, so a track passes the cap when either one is inside it."""
session = SimpleNamespace(server_cdm=True, server_cdm_max_height=1080)
assert handlers.live_licence_refusal(session, video(1600, 3840)) is not None
assert handlers.live_licence_refusal(session, video(None, 3840)) is not None
assert handlers.live_licence_refusal(session, video(1080, 2560)) is None # -q 1080 selects it
assert handlers.live_licence_refusal(session, video(1080, 1920)) is None
@pytest.mark.parametrize("quality", [[-1], [0], [1080, 0]])
def test_a_height_of_zero_or_less_is_invalid_input(quality):
with pytest.raises(APIError) as ei:
handlers.enforce_download_gates({"service": "EXAMPLE1", "quality": quality}, request("flat"))
assert ei.value.error_code is APIErrorCode.INVALID_INPUT
def test_a_capped_download_job_cannot_set_best_available():
with pytest.raises(APIError) as ei:
handlers.enforce_download_gates(
{"service": "EXAMPLE1", "quality": [1080], "best_available": True}, request("flat")
)
assert ei.value.error_code is APIErrorCode.SERVER_CDM_CAPPED
def test_the_download_gate_reads_the_serve_dl_defaults(monkeypatch):
serve = {"users": USERS, "quality": [1080], "best_available": True}
monkeypatch.setattr(handlers.config, "serve", serve, raising=False)
with pytest.raises(APIError) as ei:
handlers.enforce_download_gates({"service": "EXAMPLE1"}, request("flat"))
assert ei.value.error_code is APIErrorCode.SERVER_CDM_CAPPED
serve["best_available"] = False
handlers.enforce_download_gates({"service": "EXAMPLE1"}, request("flat"))
# the job's own value wins over the serve default
handlers.enforce_download_gates({"service": "EXAMPLE1", "best_available": False}, request("flat"))
@pytest.mark.parametrize("hdr_range", ["HYBRID", ["SDR", "hybrid"], [Video.Range.HYBRID]])
def test_a_capped_download_job_cannot_ask_for_hybrid(hdr_range):
"""HYBRID always keeps the lowest DV track, whatever the quality."""
with pytest.raises(APIError) as ei:
handlers.enforce_download_gates({"service": "EXAMPLE1", "quality": [1080], "range": hdr_range}, request("flat"))
assert ei.value.error_code is APIErrorCode.SERVER_CDM_CAPPED
handlers.enforce_download_gates({"service": "EXAMPLE1", "quality": [1080], "range": ["HDR10"]}, request("flat"))
def test_the_download_gate_reads_the_service_click_defaults(monkeypatch):
"""A service's own click default overrides the serve default, and the gate sees what the job runs with."""
import asyncio
from unshackle.core.api import download_manager as dm
option = click.Option(["--best-available"], is_flag=True, default=True)
monkeypatch.setattr(handlers, "validate_service", lambda service, request=None: "EXAMPLE1")
monkeypatch.setattr(
handlers.Services, "load", lambda service: SimpleNamespace(cli=SimpleNamespace(params=[option]))
)
def no_job():
raise AssertionError("a refused job must not reach the download manager")
monkeypatch.setattr(dm, "get_download_manager", no_job)
body = {"service": "EXAMPLE1", "title_id": "t1", "quality": [1080]}
with pytest.raises(APIError) as ei:
asyncio.run(handlers.download_handler(body, request("flat")))
assert ei.value.error_code is APIErrorCode.SERVER_CDM_CAPPED
@pytest.fixture
def playready_env(monkeypatch):
class _FakePlayReady:
def __init__(self, **kwargs):
self.kids = []
monkeypatch.setattr(pr_pssh_mod, "PSSH", lambda data: data)
monkeypatch.setattr(drm_mod, "PlayReady", _FakePlayReady)
monkeypatch.setattr(handlers, "ensure_track_drm", lambda track, session=None, init_data=None: None)
monkeypatch.setattr(handlers, "resolve_device_name", lambda *a, **k: "dev")
def no_device(*a, **k):
raise AssertionError("a refused track must not load the server's device")
monkeypatch.setattr("unshackle.core.cdm.load_cdm", no_device)
def test_playready_raises_the_refusal_before_the_device_loads(playready_env):
refusal = handlers.live_licence_refusal(SimpleNamespace(server_cdm_max_height=1080), video(2160))
with pytest.raises(APIError) as ei:
handlers.handle_single_server_cdm(
SimpleNamespace(), SimpleNamespace(), video(2160), "cHNzaA==", "playready", None, {}, refusal
)
assert ei.value is refusal
KID = UUID("161219ec3df64e0eadbb3827c0ccc46d")
INIT_KID = UUID("75982fac2cd8412a990a59c0ef2d4bb1")
CAP_DETAILS = {"reason": "server_cdm_max_height", "max_height": 1080}
class _PSSHDrm:
def __init__(self, b64, kids):
self._pssh = SimpleNamespace(dumps=lambda: b64)
self.kids = kids
_PSSHDrm.__name__ = "Widevine"
def media(kind, tid, drm, **attrs):
track = kind.__new__(kind)
track.id = tid
track.drm = drm
track.drm_preference = None
for name, value in attrs.items():
setattr(track, name, value)
return track
@pytest.fixture
def licence_env(monkeypatch):
"""A capped remote session and a stand-in server licence that honours the refusal like the real one."""
session = SessionEntry(session_id="sess", service_tag="EXAMPLE1", service_instance=SimpleNamespace())
session.server_cdm = True
session.server_cdm_max_height = 1080
calls: list = []
vault = {}
def fake_single(service, title, track, pssh_str, drm_type, request, sources=None, refusal=None):
calls.append((track.id, pssh_str))
if pssh_str in vault:
return dict(vault[pssh_str])
if refusal:
raise refusal
return {KID.hex: "k"}
monkeypatch.setattr(handlers, "server_cdm_allowed", lambda request, tag: True)
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: None)
monkeypatch.setattr(handlers, "handle_single_server_cdm", fake_single)
return SimpleNamespace(session=session, calls=calls, vault=vault)
def batch(env, *tracks):
env.session.tracks = {t.id: t for t in tracks}
data = {"mode": "server_cdm", "track_ids": [t.id for t in tracks], "drm_type": "widevine"}
return json.loads(handlers.license_response(data, env.session, "sess", None).body)
def test_batch_hands_back_a_capped_track_and_still_licenses_its_pssh_sibling(licence_env):
shared = [_PSSHDrm("manifest", [KID])]
payload = batch(licence_env, media(Video, "vid", shared, height=2160, width=3840), media(Audio, "aud", shared))
assert payload["capped_tracks"] == {"vid": CAP_DETAILS}
assert payload["keys"] == {"aud": {KID.hex: "k"}}
assert licence_env.calls == [("vid", "manifest"), ("aud", "manifest")]
@pytest.fixture
def init_segment(monkeypatch):
init_drm = [_PSSHDrm("init", [INIT_KID])]
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: init_drm)
def test_batch_restores_the_manifest_drm_on_a_track_refused_twice(licence_env, init_segment):
manifest = [_PSSHDrm("manifest", [KID])]
track = media(Video, "vid", manifest, height=2160, width=3840, get_key_id=lambda init: INIT_KID)
payload = batch(licence_env, track)
assert payload["capped_tracks"] == {"vid": CAP_DETAILS}
assert track.drm is manifest
assert licence_env.calls == [("vid", "manifest"), ("vid", "init")]
def test_batch_serves_a_capped_track_from_a_vault_hit_on_its_init_pssh(licence_env, init_segment):
licence_env.vault["init"] = {INIT_KID.hex: "v"}
track = media(Video, "vid", [_PSSHDrm("manifest", [KID])], height=2160, get_key_id=lambda init: INIT_KID)
payload = batch(licence_env, track)
assert "capped_tracks" not in payload
assert payload["keys"] == {"vid": {INIT_KID.hex: "v"}}
def test_single_licence_refuses_a_capped_track_with_details(licence_env):
track = media(Video, "vid", [_PSSHDrm("manifest", [KID])], height=2160)
licence_env.session.tracks = {"vid": track}
data = {"mode": "server_cdm", "track_id": "vid", "drm_type": "widevine"}
with pytest.raises(APIError) as ei:
handlers.license_response(data, licence_env.session, "sess", None)
assert ei.value.error_code is APIErrorCode.SERVER_CDM_CAPPED
assert ei.value.details == CAP_DETAILS
def test_a_client_device_session_builds_the_service_on_a_stub_of_that_device(monkeypatch):
built: dict = {}
def no_server_device(*a, **k):
raise AssertionError("a client-device session must not load the server's device")
monkeypatch.setattr(handlers, "load_service_yaml", lambda tag: {})
monkeypatch.setattr(handlers, "load_full_cdm", no_server_device)
monkeypatch.setattr(handlers, "build_parent_ctx", lambda profile, cdm, *a, **k: built.update(cdm=cdm))
monkeypatch.setattr(handlers.Services, "load", lambda tag: object)
monkeypatch.setattr(handlers, "instantiate_service", lambda *a, **k: "service")
data = {"cdm_type": "playready", "cdm_security_level": 3000}
handlers.create_service_instance("EXAMPLE1", "t", data, None, [], None, client_device=True)
assert built["cdm"].is_playready is True
assert built["cdm"].security_level == 3000
def test_the_tracks_response_echoes_the_session_choice(monkeypatch):
import asyncio
from unshackle.core.api import session_store as store_mod
from unshackle.core.tracks import Tracks
monkeypatch.setattr(handlers, "server_cdm_allowed", lambda request, tag: True)
monkeypatch.setattr(store_mod.bus, "publish", lambda topic, data: None)
class StubService:
session = SimpleNamespace(headers={}, cookies=[])
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="capped", owner_key=None
)
entry.title_map = {"t1": "Title"}
entry.server_cdm = False
try:
resp = await handlers.session_tracks_handler({"title_id": "t1"}, "capped")
return json.loads(resp.body)
finally:
await store.delete("capped")
assert asyncio.run(run())["server_cdm"] is False
class _LocalWidevine(Widevine):
"""Stops at the licence call and reports the device dl handed it."""
class Used(Exception):
pass
def __init__(self):
self._pssh = SimpleNamespace(dumps=lambda: "AAAA", key_ids=[UUID(int=0xCA9)])
self.content_keys = {}
def get_content_keys(self, cdm, licence, certificate):
raise self.Used(cdm)
def prepare(svc, licence):
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")
table = Table()
table.add_column()
track = 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)
return ei.value.args[0]
def test_dl_licenses_a_client_licensed_track_with_the_local_device():
svc = SimpleNamespace(_server_cdm=True, client_licensed={"vid"}, local_cdm="local device")
server_calls: list = []
assert prepare(svc, lambda **kwargs: server_calls.append(kwargs)) == "local device"
assert server_calls == []
def test_dl_takes_the_local_path_when_the_server_hands_the_track_over():
svc = SimpleNamespace(_server_cdm=True, client_licensed=set(), local_cdm="local device")
assert prepare(svc, lambda **kwargs: svc.client_licensed.add("vid")) == "local device"
class _KeyedWidevine(_LocalWidevine):
"""Licenses one content key with whatever device dl hands it."""
def get_content_keys(self, cdm, licence, certificate):
self.content_keys[UUID(int=0xCA9)] = "ab" * 16
class PlayReady:
kids = [UUID(int=0xCA9)]
def __init__(self):
self.content_keys = {}
def test_a_mid_download_handover_puts_the_keys_on_the_callers_drm(monkeypatch):
"""The caller decrypts with the DRM object it passed in, so the local licence must fill that one."""
monkeypatch.setattr(dl, "LICENSE_KEY_CACHE", {})
svc = SimpleNamespace(_server_cdm=True, client_licensed=set(), local_cdm="local device", server_vault_keys={})
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.flush_vault_writes = lambda pending: None
table = Table()
table.add_column()
server_drm, local_drm = PlayReady(), _KeyedWidevine()
track = SimpleNamespace(id="vid", drm=[server_drm, local_drm], get_drm_for_cdm=lambda cdm: local_drm)
runner.prepare_drm(
server_drm,
track,
None,
lambda **k: None,
lambda **k: svc.client_licensed.add("vid"),
table=table,
cdm_only=True,
)
assert server_drm.content_keys == {UUID(int=0xCA9): "ab" * 16}
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))
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.client_licensed = {"vid"}
svc.resolve_server_keys(_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)
assert svc.proxy_license(b"", track, "widevine") == b""
assert svc.client_licensed == {"vid"}
assert track.drm_preference == "widevine"
class _Response:
def __init__(self, status, body):
self.status_code, self.body, self.text = status, body, json.dumps(body)
def json(self):
return self.body
def test_the_client_returns_only_an_expected_error_body():
client = RemoteClient("http://server", "key")
body = {"status": "error", "error_code": "SERVER_CDM_CAPPED", "message": "capped"}
client._session = SimpleNamespace(post=lambda url, json, timeout: _Response(403, body))
assert client.post("/x", {}, expect=("SERVER_CDM_CAPPED",)) == body
with pytest.raises(SystemExit):
client.post("/x", {})
with pytest.raises(SystemExit):
client.post("/x", {}, expect=("FORBIDDEN",))
def test_cdm_with_an_uncapped_server_cdm_key_warns_that_it_is_ignored(caplog):
svc = remote(SimpleNamespace(is_playready=False), {})
svc.service_tag = "EXAMPLE1"
svc.ctx.parent = SimpleNamespace(params={"cdm_name": "my_device"})
with caplog.at_level(logging.WARNING):
svc.adopt_session_cdm(True, None)
assert "--cdm is ignored" in caplog.text
caplog.clear()
svc.ctx.parent.params = {}
with caplog.at_level(logging.WARNING):
svc.adopt_session_cdm(True, None)
assert "--cdm is ignored" not in caplog.text
@@ -133,7 +133,7 @@ async def test_batch_shares_full_bundle_per_pssh(monkeypatch):
bundle_a = {REAL.hex: "k_real", PSSH_KID.hex: "k_pssh", OTHER.hex: "k_other"}
bundle_b = {"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": "k_b"}
def fake_single(service, title, track, pssh_str, drm_type, request, sources=None):
def fake_single(service, title, track, pssh_str, drm_type, request, sources=None, refusal=None):
calls.append(pssh_str)
return dict(bundle_a) if pssh_str == pssh_a else dict(bundle_b)
+33 -3
View File
@@ -1574,8 +1574,11 @@ class dl:
self.service_anime = bool(getattr(service, "ANIME", False))
self.service_daily = bool(getattr(service, "DAILY", False))
self.server_cdm = getattr(service, "_server_cdm", False)
if self.server_cdm and self.cdm_override:
self.log.warning("--cdm is ignored: this remote service licenses with the server CDM")
from unshackle.core.import_service import ImportService
# a remote session sends --cdm to the server, which can hand it the tracks above its cap
if self.cdm_override and isinstance(service, ImportService):
self.log.warning("--cdm is ignored: an import uses the keys in the export")
self._remote_service = service if hasattr(service, "_server_cdm") else None
start_time = time.time()
@@ -4466,8 +4469,10 @@ class dl:
svc_for_cdm = getattr(self, "_remote_service", None)
server_cdm = getattr(svc_for_cdm, "_server_cdm", getattr(self, "server_cdm", False))
client_licensed = getattr(svc_for_cdm, "client_licensed", ())
licensed_locally = bool(client_licensed) and str(track.id) in client_licensed
if server_cdm:
if server_cdm and not licensed_locally:
with self.drm_lock(drm):
pending_vault_writes: list[Callable[[], Any]] = []
vault_kids = list(getattr(drm, "kids", None) or [])
@@ -4513,6 +4518,9 @@ class dl:
pending_vault_writes.append(partial(self.cache_keys_to_vaults, new_keys))
self.flush_vault_writes(pending_vault_writes)
# the server can refuse the licence above its cap and hand the track to this machine
licensed_locally = bool(client_licensed) and str(track.id) in client_licensed
if server_cdm and not licensed_locally:
if not drm.content_keys:
self.log.warning("Server CDM did not resolve any keys for this track")
return
@@ -4576,6 +4584,28 @@ class dl:
track_quality = max((v.height for v in title.tracks.videos if v.height), default=None)
track_cdm = self.cdm
if licensed_locally:
track_cdm = getattr(svc_for_cdm, "local_cdm", None)
local_drm = track.get_drm_for_cdm(track_cdm)
if local_drm is not None and local_drm is not drm:
# a mid-download handover leaves drm on the server's DRM system, and the caller decrypts with it
self.prepare_drm(
local_drm,
track,
title,
certificate,
licence,
clearkey_licence,
track_kid,
table,
cdm_only,
vaults_only,
export,
service_session,
)
with self.drm_lock(drm):
drm.content_keys.update(local_drm.content_keys)
return
licence = partial(licence, drm_system="playready" if isinstance(drm, PlayReady) else "widevine")
+5
View File
@@ -22,6 +22,7 @@ from unshackle.core.api.handlers import (
rate_limit_response,
request_secret_key,
server_account_regions,
server_cdm_max_height_error,
validate_server_accounts,
)
from unshackle.core.api.stats import key_rate_limit, rate_limit_error, ring, stats, stats_middleware
@@ -295,6 +296,10 @@ def serve(
if isinstance(user_cfg, dict):
if problem := rate_limit_error(f"serve.users.{username}", user_cfg.get("rate_limit")):
raise click.ClickException(problem)
if problem := server_cdm_max_height_error(
f"serve.users.{username}", user_cfg.get("server_cdm_max_height")
):
raise click.ClickException(problem)
limit = key_rate_limit(user_key)
if limit:
log.info(f"User '{username}' rate limited to {limit} requests/hour")
+2
View File
@@ -27,6 +27,7 @@ class APIErrorCode(str, Enum):
AUTH_FAILED = "AUTH_FAILED"
FORBIDDEN = "FORBIDDEN"
SERVER_CDM_CAPPED = "SERVER_CDM_CAPPED"
GEOFENCE = "GEOFENCE"
NOT_FOUND = "NOT_FOUND"
@@ -85,6 +86,7 @@ class APIError(Exception):
APIErrorCode.INVALID_PARAMETERS: 400,
APIErrorCode.AUTH_FAILED: 401,
APIErrorCode.FORBIDDEN: 403,
APIErrorCode.SERVER_CDM_CAPPED: 403,
APIErrorCode.GEOFENCE: 403,
APIErrorCode.NOT_FOUND: 404,
APIErrorCode.NO_CONTENT: 404,
+202 -14
View File
@@ -608,6 +608,106 @@ def server_cdm_allowed(request: Optional[web.Request] = None, service: Optional[
return bool(allowed)
def is_whole_number(value: Any) -> bool:
"""True for a non-negative int that is not a bool."""
return isinstance(value, int) and not isinstance(value, bool) and value >= 0
def server_cdm_max_height_error(where: str, value: Any) -> Optional[str]:
"""Why *value* cannot serve as ``server_cdm_max_height``, or None when it can (unset included)."""
def bad(height: Any) -> bool:
return height is not None and not is_whole_number(height)
if isinstance(value, dict):
if any(bad(height) for height in value.values()):
return f"{where}.server_cdm_max_height values must be whole numbers of pixels, not {value!r}"
return None
if bad(value):
return (
f"{where}.server_cdm_max_height must be a whole number of pixels or a map of service to one, not {value!r}"
)
return None
def server_cdm_max_height(request: Optional[web.Request], service: Optional[str]) -> Optional[int]:
"""The tallest video track the server CDM licenses live for this API key on ``service``; None for no cap.
The setting is one height, or a map of service tag to height where ``default`` covers a missing or null tag.
"""
if not request:
return None
cap = serve_user_config(request_secret_key(request) or "").get("server_cdm_max_height")
if isinstance(cap, dict):
own = ci_get(cap, Services.get_tag(service)) if service else None
cap = own if own is not None else ci_get(cap, "default")
return cap if is_whole_number(cap) else None
def capped_error(message: str, cap: Optional[int] = None) -> APIError:
"""The SERVER_CDM_CAPPED refusal; a client reads ``details`` to license the track itself."""
details = {"reason": "server_cdm_max_height", "max_height": cap} if cap is not None else {"reason": "client_device"}
return APIError(APIErrorCode.SERVER_CDM_CAPPED, message, details=details)
def requested_height(quality: Any) -> Optional[int]:
"""The tallest height a client asked for with ``-q``; None when it asked for the best there is."""
from unshackle.core.utils.click_types import QUALITY_LIST
try:
heights = QUALITY_LIST.convert(quality)
except click.BadParameter as e:
raise APIError(APIErrorCode.INVALID_INPUT, f"quality: {e.message}")
if any(height <= 0 for height in heights):
raise APIError(APIErrorCode.INVALID_INPUT, f"quality: every height must be above 0, not {quality!r}")
return max(heights) if heights else None
def choose_session_cdm(
request: Optional[web.Request], service: str, data: Dict[str, Any]
) -> tuple[bool, Optional[int]]:
"""``(server_cdm, max_height)`` for a new remote session.
The service picks its manifest from the CDM it sees, so it decides once, before any tracks.
When ``-q`` asks for more than the cap, the client's own device licenses, and a client with no device gets a refusal.
"""
if not server_cdm_allowed(request, service):
return False, None
cap = server_cdm_max_height(request, service)
wanted = requested_height(data.get("quality"))
if cap is None or wanted is None or wanted <= cap:
return True, cap
if data.get("cdm_type"):
return False, cap
raise capped_error(
f"The server CDM licenses {service} up to {cap}p for this API key. "
f"Set up a local CDM to license {wanted}p with your own device, or pass -q {cap}.",
cap,
)
def live_licence_refusal(session: Any, track: Any) -> Optional[APIError]:
"""Why the server CDM must not make a live licence for ``track`` in ``session``; None when it may.
Only a live licence spends the server's device, so a vault hit always passes. A track measures as the
smallest ``-q`` that selects it: the lower of its height and the 16:9 height of its width.
"""
if getattr(session, "server_cdm", None) is False:
return capped_error("This session licenses with your own device, so the server CDM makes no licence for it.")
cap = getattr(session, "server_cdm_max_height", None)
height = getattr(track, "height", None) if isinstance(track, Video) else None
width = getattr(track, "width", None) if isinstance(track, Video) else None
measures = [m for m in (height, int(width * 9 / 16) if width else None) if m]
height = min(measures) if measures else None
# ponytail: a track with no size passes; a parser that fills sizes in closes that gap
if cap is None or not height or height <= cap:
return None
return capped_error(
f"The server CDM licenses up to {cap}p for this API key; license this {height}p track with your own device.",
cap,
)
_account_counters: Dict[str, int] = {}
@@ -1822,13 +1922,44 @@ def enforce_download_gates(params: Dict[str, Any], request: Optional[web.Request
``server_cdm`` cannot submit or retry jobs. A job can also spend the server's proxy providers,
through an explicit ``proxy`` country code or through the geofence auto-proxy, so the
``server_proxy`` policy from :func:`resolve_handler_proxy` applies here too: without the opt-in
the job must carry a full proxy URI or no proxy at all.
the job must carry a full proxy URI or no proxy at all. Under a ``server_cdm_max_height`` the job
must set a quality at or under it, no ``best_available`` and no HYBRID range, as a job has no client device
to fall back to. Pass the job's merged parameters, so the gate sees the values the job runs with.
"""
if not server_cdm_allowed(request, params.get("service")):
raise APIError(
APIErrorCode.FORBIDDEN,
"Download jobs license with the server CDM, which is not enabled for this key on this service.",
)
cap = server_cdm_max_height(request, params.get("service"))
if cap is not None:
serve_defaults = config.serve or {}
def effective(key: str) -> Any:
"""The job's own value for this parameter, or the ``serve:`` dl default that the job falls back to."""
return params[key] if key in params else serve_defaults.get(key)
wanted = requested_height(effective("quality"))
if wanted is None or wanted > cap:
raise capped_error(
f"Download jobs license with the server CDM, which covers up to {cap}p for this API key. "
f"Pass a quality of {cap} or lower.",
cap,
)
if effective("best_available"):
raise capped_error(
f"Download jobs license with the server CDM, which covers up to {cap}p for this API key. "
"Turn off best_available, because it can fall back to a track above that height.",
cap,
)
ranges = effective("range") or []
ranges = [ranges] if isinstance(ranges, str) else ranges
if any(str(getattr(r, "value", r)).upper() == "HYBRID" for r in ranges):
raise capped_error(
f"Download jobs license with the server CDM, which covers up to {cap}p for this API key. "
"Remove HYBRID from range, because it always keeps the lowest DV track, whatever the quality.",
cap,
)
if params.get("proxy_download") is not None and not isinstance(params["proxy_download"], str):
raise APIError(APIErrorCode.INVALID_INPUT, "proxy_download must be a string.")
@@ -1886,8 +2017,6 @@ async def download_handler(data: Dict[str, Any], request: Optional[web.Request]
details={"service": normalized_service, "title_id": title_id},
)
await asyncio.to_thread(enforce_download_gates, data, request)
try:
service_module = Services.load(normalized_service)
service_specific_defaults = {}
@@ -1900,9 +2029,6 @@ async def download_handler(data: Dict[str, Any], request: Optional[web.Request]
if hasattr(param, "name") and param.default is not None and not isinstance(param.default, enum.Enum):
service_specific_defaults[param.name] = param.default
manager = get_download_manager()
await manager.start_workers()
filtered_params = {k: v for k, v in data.items() if k not in ["service", "title_id"]}
# Overlay any dl-relevant keys from `serve:` config (e.g. downloads, workers) so the API
# respects server-side defaults without each client having to send them.
@@ -1915,6 +2041,10 @@ async def download_handler(data: Dict[str, Any], request: Optional[web.Request]
**service_specific_defaults,
**filtered_params,
}
await asyncio.to_thread(enforce_download_gates, {**params_with_defaults, "service": service_tag}, request)
manager = get_download_manager()
await manager.start_workers()
# The download worker reads this to decide whether dl may load the server's proxy
# providers. Always stamped server-side so a client-sent value cannot grant it.
params_with_defaults["server_proxy"] = server_proxy_allowed(request)
@@ -2194,6 +2324,7 @@ async def dashboard_keys_handler(request: web.Request) -> web.Response:
"label": mask_key(secret_key),
"services": allowed_services_for_key(secret_key) if api_access else [],
"server_cdm": user_grant(secret_key, "server_cdm") if api_access else False,
"server_cdm_max_height": serve_user_config(secret_key).get("server_cdm_max_height") if api_access else None,
"server_accounts": user_grant(secret_key, "server_accounts") if api_access else False,
"server_proxy": user_grant(secret_key, "server_proxy") is True if api_access else False,
"tier": key_tier(secret_key),
@@ -2965,6 +3096,7 @@ SESSION_TRANSPORT_KEYS = {
"client_region",
"proxy_region",
"cdm_type",
"cdm_security_level",
"range_",
"vcodec",
"quality",
@@ -3019,9 +3151,12 @@ def create_service_instance(
profile: Optional[str],
server_account: bool = False,
server_cdm: bool = False,
client_device: bool = False,
) -> Any:
"""Make a service instance and resolve its credentials and cookies.
With ``client_device`` the service sees a stand-in with the client's DRM system and security level.
With ``server_account`` the service takes the server's own credential and cookie file for
``profile`` and drops anything the client sent. Otherwise only client-sent data counts: a
client that sends nothing authenticates with nothing, because the server never lends its
@@ -3032,7 +3167,12 @@ def create_service_instance(
from unshackle.core.tracks import Video
service_config = load_service_yaml(normalized_service)
cdm = load_full_cdm(normalized_service, profile, None if server_cdm else data.get("cdm_type"))
cdm: Any
if client_device:
level = data.get("cdm_security_level")
cdm = cdm_type_stub(str(data.get("cdm_type")), level if is_whole_number(level) else None)
else:
cdm = load_full_cdm(normalized_service, profile, None if server_cdm else data.get("cdm_type"))
# 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.
@@ -3138,6 +3278,8 @@ async def session_create_handler(data: Dict[str, Any], request: Optional[web.Req
profile = next_server_profile(normalized_service, region)
log.info(f"Using server account '{sanitize_log(profile or 'default')}' for {normalized_service}")
use_server_cdm, max_height = choose_session_cdm(request, normalized_service, data)
client_device = max_height is not None and not use_server_cdm
log_buffer = None if server_account else SessionLogBuffer()
service_class_name = getattr(Services.load(normalized_service), "__name__", normalized_service)
@@ -3151,7 +3293,8 @@ async def session_create_handler(data: Dict[str, Any], request: Optional[web.Req
proxy_providers,
profile,
server_account=server_account,
server_cdm=server_cdm_allowed(request, normalized_service),
server_cdm=use_server_cdm,
client_device=client_device,
)
service_instance, cookies, credential = await asyncio.to_thread(build_service)
@@ -3181,6 +3324,8 @@ async def session_create_handler(data: Dict[str, Any], request: Optional[web.Req
server_account=(profile or "default") if server_account else None,
)
session.cache_tag = session_cache_tag
session.server_cdm = use_server_cdm
session.server_cdm_max_height = max_height
session.client_auth = not server_account and (
cookies is not None or credential is not None or bool(cache_data and session_cache_tag)
)
@@ -3215,6 +3360,8 @@ async def session_create_handler(data: Dict[str, Any], request: Optional[web.Req
"service": normalized_service,
"status": "authenticating",
"server_account": server_account,
"server_cdm": use_server_cdm,
"server_cdm_max_height": max_height,
}
)
@@ -3434,7 +3581,7 @@ async def session_tracks_handler(
"track_manifests": track_manifests,
"session_headers": session_headers,
"session_cookies": session_cookies,
"server_cdm": server_cdm_allowed(request, service_tag),
"server_cdm": session.server_cdm,
"server_cdm_type": server_cdm_type,
}
)
@@ -4186,11 +4333,13 @@ def handle_single_server_cdm(
drm_type: str,
request: Optional[web.Request],
sources: Optional[Dict[str, str]] = None,
refusal: Optional[APIError] = None,
) -> Dict[str, str]:
"""Do the single-track server_cdm licensing with the DRM class get_content_keys() flow.
``sources`` is filled with the vault name for every returned content key a server vault
supplied. A content key the CDM licensed gets no entry.
supplied. A content key the CDM licensed gets no entry. It raises ``refusal`` before a live licence:
after a Widevine vault miss, or at once for PlayReady, whose licence can carry keys its header does not name.
"""
import base64
@@ -4222,6 +4371,9 @@ def handle_single_server_cdm(
# Gate on the caller's CDM device first: no device, no keys from the vault or CDM.
device_name = resolve_device_name(user_config, drm_type, service.__class__.__name__)
if refusal:
raise refusal
cdm = load_cdm(device_name, service_name=service.__class__.__name__)
if not is_playready_cdm(cdm):
raise APIError(APIErrorCode.INVALID_INPUT, f"CDM device '{device_name}' is not a PlayReady device")
@@ -4252,6 +4404,8 @@ def handle_single_server_cdm(
if sources is not None:
sources.update(vault_sources)
return vault_keys
if refusal:
raise refusal
cdm = load_cdm(device_name, service_name=service.__class__.__name__)
if not is_widevine_cdm(cdm):
@@ -4429,6 +4583,7 @@ def license_response(
sources_by_pssh: Dict[tuple, Dict[str, str]] = {}
drm_type_by_pssh: Dict[tuple, str] = {}
actual_drm_type: Optional[str] = None
capped_tracks: Dict[str, Dict[str, Any]] = {}
def pssh_set(track: Any) -> tuple:
"""Every PSSH and KID the track carries, as the rest of the cache key.
@@ -4456,7 +4611,11 @@ def license_response(
def license_track(
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."""
"""`(keys, drm_type, pssh)` for the track's current DRM, licensing once per unique PSSH.
A track the server must not license live goes into ``capped_tracks`` with no keys, and its PSSH
stays uncached, as a track under the cap can share it.
"""
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'}")
@@ -4469,7 +4628,14 @@ def license_response(
sources_by_pssh[cache_key] = {}
try:
keys = handle_single_server_cdm(
service, title, track, pssh_str, candidate, request, sources_by_pssh[cache_key]
service,
title,
track,
pssh_str,
candidate,
request,
sources_by_pssh[cache_key],
live_licence_refusal(session, track),
)
if keys:
keys_by_pssh[cache_key] = keys
@@ -4477,6 +4643,10 @@ def license_response(
except SystemExit:
warn(f"Service exited while resolving keys for track {sanitize_log(str(track.id)[:12])}, skipping")
except (Exception, SystemExit) as e:
if isinstance(e, APIError) and e.error_code is APIErrorCode.SERVER_CDM_CAPPED:
del keys_by_pssh[cache_key], sources_by_pssh[cache_key]
capped_tracks[str(track.id)] = e.details
return {}, None, pssh_str
warn(f"Failed to resolve keys for track {sanitize_log(str(track.id)[:12])}: {redact_all(str(e))}")
return keys_by_pssh[cache_key], drm_type_by_pssh.get(cache_key), pssh_str
@@ -4497,6 +4667,8 @@ def license_response(
candidates = server_drm_candidates(service_tag, user_config, drm_type, track, warn)
keys, track_drm_type, pssh_str = license_track(track, title, candidates)
# a refused track still tries its init segment PSSH, where a server vault can hold the key
refusal = capped_tracks.pop(tid, None)
track_kid = None
if init_data:
@@ -4508,6 +4680,7 @@ def license_response(
manifest_drm = track.drm
track.drm = drm_from_init_segment(track, init_data=init_data) or manifest_drm
init_keys, init_drm_type, init_pssh = license_track(track, title, candidates)
init_refusal = capped_tracks.pop(tid, None)
if init_pssh != pssh_str:
log.info(
f"The manifest PSSH gave no content key for KID {track_kid.hex} of track "
@@ -4515,9 +4688,15 @@ def license_response(
)
if track_kid.hex in init_keys:
keys, track_drm_type, pssh_str = init_keys, init_drm_type, init_pssh
refusal = None
else:
track.drm = manifest_drm
warn(f"No content key for KID {track_kid.hex} of track {sanitize_log(tid[:12])}")
refusal = refusal or init_refusal
if not refusal:
warn(f"No content key for KID {track_kid.hex} of track {sanitize_log(tid[:12])}")
if refusal:
capped_tracks[tid] = refusal
continue
if keys:
all_keys[tid] = keys
@@ -4538,6 +4717,8 @@ def license_response(
response["drm_type"] = actual_drm_type
if drm_types:
response["drm_types"] = drm_types
if capped_tracks:
response["capped_tracks"] = capped_tracks
return web.json_response(response)
if not track_id:
@@ -4583,7 +4764,14 @@ def license_response(
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
service,
title,
track,
pssh_b64 or server_pssh,
server_drm_type,
request,
key_sources,
live_licence_refusal(session, track),
)
log.info(f"Server CDM resolved {len(keys)} key(s) for track {sanitize_log(track_id[:12])}")
note_served_keys(session, keys, key_sources)
+47 -5
View File
@@ -805,7 +805,9 @@ async def download(request: web.Request) -> web.Response:
type: array
items:
type: integer
description: Download resolution(s) (default - best available)
description: >-
Download resolution(s) (default - best available). An API key with a
server_cdm_max_height for the service must pass a quality at or under that height.
vcodec:
oneOf:
- type: string
@@ -830,7 +832,9 @@ async def download(request: web.Request) -> web.Response:
type: array
items:
type: string
description: Video colour range (SDR, HDR10, HDR10+, HLG, DV, HYBRID) (default - ["SDR"])
description: >-
Video colour range (SDR, HDR10, HDR10+, HLG, DV, HYBRID) (default - ["SDR"]).
An API key with a server_cdm_max_height for the service cannot set HYBRID.
channels:
type: number
description: Audio channels (e.g., 2.0, 5.1, 7.1) (default - None)
@@ -999,7 +1003,9 @@ async def download(request: web.Request) -> web.Response:
description: Amount of tracks to download concurrently (default - 1)
best_available:
type: boolean
description: Continue with best available if requested quality unavailable (default - false)
description: >-
Continue with best available if requested quality unavailable (default - false).
An API key with a server_cdm_max_height for the service cannot set it.
worst:
type: boolean
description: Select the lowest bitrate track within the specified quality. Requires `quality` (default - false)
@@ -1043,6 +1049,10 @@ async def download(request: web.Request) -> web.Response:
type: string
'400':
description: Invalid request
'403':
description: >-
A gated parameter is not permitted (FORBIDDEN), or the job goes above the
server_cdm_max_height of the API key (SERVER_CDM_CAPPED)
"""
try:
data = await request.json()
@@ -1739,6 +1749,15 @@ async def session_create(request: web.Request) -> web.Response:
forced_subs:
type: boolean
default: false
cdm_type:
type: string
enum: [widevine, playready]
description: DRM system of the client's local CDM; absent when the client has none
cdm_security_level:
type: integer
description: |
Security level of the client's local CDM, in the device's own numbers
(Widevine 1 to 3, PlayReady 150 to 3000)
client:
type: object
additionalProperties: true
@@ -1763,8 +1782,23 @@ async def session_create(request: web.Request) -> web.Response:
server_account:
type: boolean
description: True when the server authenticated with one of its own accounts
server_cdm:
type: boolean
description: |
True when the server CDM licenses this remote session. False when the client's own
local CDM licenses it.
server_cdm_max_height:
type: integer
nullable: true
description: |
Tallest video track the server CDM licenses live for this API key and service;
null for no limit
'400':
description: Invalid request
'403':
description: |
SERVER_CDM_CAPPED when `quality` asks for more than the server CDM limit and the client
reported no local CDM
'401':
description: Authentication failed
"""
@@ -2011,7 +2045,9 @@ async def session_license(request: web.Request) -> web.Response:
an array of KID hex strings that may be absent and may repeat a KID shared by several
tracks, lists the content keys a server vault supplied, which the client has to prove
before it trusts them. `clear_tracks`, absent when empty, lists the requested track ids
that carry no DRM and so have no keys.
that carry no DRM and so have no keys. `capped_tracks`, absent when empty, maps each
track id the server refused to license live to the refusal details (`reason` and
`max_height`); the client licenses those tracks with its own local CDM.
After 30 s the route sends 200 and a newline every 30 s. A failure after that time
arrives as the error body with status 200.
'404':
@@ -2028,7 +2064,10 @@ async def session_license(request: web.Request) -> web.Response:
try:
return await session_license_handler(data, session_id, request)
except Exception as e:
log.exception("Error in session license")
if isinstance(e, APIError) and e.error_code is APIErrorCode.SERVER_CDM_CAPPED:
log.info(f"Session licence refused: {e.message}")
else:
log.exception("Error in session license")
return handle_api_exception(
e, context={"operation": "session_license"}, debug_mode=request.app.get("debug_api", False)
)
@@ -2592,6 +2631,9 @@ async def dashboard_keys(request: web.Request) -> web.Response:
dashboard API key with no `serve.users` entry does
server_cdm:
description: false, true, or the list of service tags it covers
server_cdm_max_height:
nullable: true
description: The configured live licence height limit, a map of service tag to one, or null
server_accounts:
description: false, true, or the list of service tags it covers
server_proxy:
+4
View File
@@ -46,6 +46,8 @@ class SessionEntry:
owner_key: Optional[str] = None # X-Secret-Key that owns this session
cache_tag: Optional[str] = None
server_account: Optional[str] = None # profile name when the server lent its own account
server_cdm: Optional[bool] = None # False: the client's own device licenses this session
server_cdm_max_height: Optional[int] = None
client_auth: bool = (
False # the client sent its own cookies, credentials, or cache files, or answered a login prompt
)
@@ -92,6 +94,8 @@ class SessionEntry:
"auth_status": self.auth_status.value,
"auth_error": self.auth_error,
"server_account": self.server_account,
"server_cdm": self.server_cdm,
"server_cdm_max_height": self.server_cdm_max_height,
"log_seq": self.log_buffer.last_seq if self.log_buffer else 0,
"client": self.client,
"actions": list(self.actions),
+9 -4
View File
@@ -1,14 +1,19 @@
from __future__ import annotations
from types import SimpleNamespace
from typing import Any
from typing import Any, Optional
def cdm_type_stub(cdm_type: str) -> SimpleNamespace:
def cdm_type_stub(cdm_type: str, security_level: Optional[int] = None) -> SimpleNamespace:
"""Type-only CDM stand-in so is_widevine_cdm()/is_playready_cdm() (and thus
get_drm_for_cdm) route correctly without loading a real device. Used for
server_cdm mode, where the server holds the device and returns the keys."""
return SimpleNamespace(is_playready=cdm_type == "playready")
server_cdm mode, where the server holds the device and returns the keys.
``security_level`` lets a service pick a robustness tier; the attribute is absent when unknown."""
stub = SimpleNamespace(is_playready=cdm_type == "playready")
if security_level is not None:
stub.security_level = security_level
return stub
def is_remote_cdm(cdm: Any) -> bool:
+85 -6
View File
@@ -98,8 +98,14 @@ class RemoteClient:
return True
def request(
self, method: str, endpoint: str, data: Optional[Dict[str, Any]] = None, optional: bool = False
self,
method: str,
endpoint: str,
data: Optional[Dict[str, Any]] = None,
optional: bool = False,
expect: tuple[str, ...] = (),
) -> Dict[str, Any]:
"""Send one request and return its JSON body; an error outside ``expect`` exits, one inside it returns."""
url = f"{self.server_url}{endpoint}"
while True:
try:
@@ -136,6 +142,8 @@ class RemoteClient:
fallback = f"HTTP {resp.status_code}: {page_title.group(1).strip()}" if page_title else resp.text
error_msg = redact_secrets(str(detail.get("message", fallback)), data)
error_code = detail.get("error_code", "UNKNOWN")
if error_code in expect:
return detail
if optional:
log.debug(f"Optional endpoint {endpoint} unavailable [{error_code}]: {error_msg}")
return {}
@@ -143,8 +151,8 @@ class RemoteClient:
raise SystemExit(1)
return detail
def post(self, endpoint: str, data: Dict[str, Any]) -> Dict[str, Any]:
return self.request("post", endpoint, data)
def post(self, endpoint: str, data: Dict[str, Any], expect: tuple[str, ...] = ()) -> Dict[str, Any]:
return self.request("post", endpoint, data, expect=expect)
def post_optional(self, endpoint: str, data: Dict[str, Any]) -> Dict[str, Any]:
"""POST to an endpoint that an older server may not have. Empty dict when the route is missing."""
@@ -880,6 +888,7 @@ class RemoteService:
self._chapters_by_title: Dict[str, list] = {}
self._session_id: Optional[str] = None
self._server_cdm_type: str = "widevine"
self.client_licensed: set[str] = set() # track ids this machine's device licenses by challenge relay
self.server_vault_keys: Dict[UUID, str] = {}
self.server_vault = ServerVault(self)
self._segment_filters: Dict[str, tuple[set[str], set[str]]] = {}
@@ -1040,11 +1049,15 @@ class RemoteService:
create_data.update(self._service_params)
create_data["service_params"] = self._service_params
cdm = self.ctx.obj.cdm if self.ctx.obj and not self._server_cdm else None
# sent with the server CDM too: a capped server hands the tracks above its cap to this device
cdm = self.local_cdm
if cdm is not None:
from unshackle.core.cdm.detect import is_playready_cdm
create_data["cdm_type"] = "playready" if is_playready_cdm(cdm) else "widevine"
level = getattr(cdm, "security_level", None)
if isinstance(level, int):
create_data["cdm_security_level"] = level
cache_data = self.load_cache_files(profile) if self._server_accounts is None else None
if cache_data:
@@ -1064,6 +1077,9 @@ class RemoteService:
result = self.client.post("/api/session/create", create_data)
self._session_id = result["session_id"]
atexit.register(self.close)
# an older server omits the field, and the tracks response settles it
if "server_cdm" in result:
self.adopt_session_cdm(bool(result["server_cdm"]), result.get("server_cdm_max_height"))
status = result.get("status", "authenticated")
if status == "authenticating":
@@ -1071,6 +1087,30 @@ class RemoteService:
self.drain_server_logs()
self.start_keepalive()
@property
def local_cdm(self) -> Any:
"""The CDM device dl loaded on this machine, or None. dl may later swap its own copy for a stub."""
return self.ctx.obj.cdm if self.ctx.obj else None
def adopt_session_cdm(self, server_cdm: bool, max_height: Optional[int]) -> None:
"""Take the server's choice of who licenses this remote session, and say why when a cap made it."""
if self._server_cdm and not server_cdm and max_height is None:
self.log.warning(
f"{self.service_tag} is not available for server CDM licensing with this API key, "
"falling back to the local CDM"
)
self._server_cdm = server_cdm
if max_height is None:
if server_cdm and self.ctx.parent and self.ctx.parent.params.get("cdm_name"):
self.log.warning("--cdm is ignored: the server CDM licenses this remote session")
return
message = f"The server CDM licenses up to {max_height}p for this API key"
if not server_cdm:
message += "; licensing with your own device"
elif self.local_cdm is not None:
message += "; pass a higher -q to license above it with your own device"
self.log.info(message)
def start_keepalive(self) -> None:
"""Send keep-alive requests so that a long download or mux operation does not outlive the server's idle TTL.
@@ -1256,6 +1296,8 @@ class RemoteService:
server_cdm_type. We send track IDs and the server does the full
CDM flow, returning KID:KEY pairs.
"""
# track ids can repeat across titles, so a refusal for the last title must not carry over
self.client_licensed.clear()
if not self._server_cdm:
return
@@ -1268,6 +1310,7 @@ class RemoteService:
drm_type = getattr(self, "_server_cdm_type", "widevine")
self.log.debug(f"Requesting server CDM keys (server_cdm_type={drm_type})")
capped: Dict[str, Any] = {}
try:
with console.status("Retrieving Remote License...", spinner="dots"):
resp = self.client.post(
@@ -1286,13 +1329,14 @@ class RemoteService:
self.note_vault_keys(track_keys, vault_kids)
server_drm_type = resp.get("drm_type", drm_type)
drm_types_by_track = resp.get("drm_types", {})
capped = resp.get("capped_tracks") or {}
self._server_cdm_type = server_drm_type
self.log.debug(f"Server responded with drm_type={server_drm_type}, keys for {len(keys_by_track)} track(s)")
for track in title.tracks:
track_keys = keys_by_track.get(str(track.id), {})
if not track_keys:
if str(track.id) in track_ids and str(track.id) not in clear_tracks:
if str(track.id) in track_ids and str(track.id) not in clear_tracks | set(capped):
self.log.warning(f"Server CDM returned no content keys for track {track.id}")
continue
@@ -1311,6 +1355,34 @@ class RemoteService:
except Exception as e:
self.log.warning("Failed to resolve server CDM keys: %s", e)
for track in title.tracks:
if str(track.id) in capped:
self.license_locally(track, capped[str(track.id)])
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.
dl then licenses it through the challenge relay. An HLS track with no DRM yet passes, as its
media playlist names the DRM and the downloader honours ``drm_preference``.
"""
from unshackle.core.cdm.detect import is_playready_cdm
cap = refusal.get("max_height")
why = f"The server CDM licenses up to {cap}p for this key" if cap else "The server CDM refused this track"
cdm = self.local_cdm
if cdm is None:
raise click.ClickException(f"{why}, and {track} needs a local CDM to license it with your own device.")
drm_class = "PlayReady" if is_playready_cdm(cdm) else "Widevine"
if track.drm and not any(d.__class__.__name__ == drm_class for d in track.drm):
height = getattr(track, "height", None)
raise click.ClickException(
f"{why}, and {track} carries no {drm_class} DRM for your local device to license. "
f"Pass -q {height or 'with that height'} so the session starts on your own device."
)
track.drm_preference = drm_class.lower()
self.client_licensed.add(str(track.id))
self.log.info(f"{why}; licensing {track.id} with your own {drm_class} device")
def note_vault_keys(self, keys: Dict[str, str], vault_kids: set[str]) -> None:
"""Remember which of the served keys a server vault supplied, and forget the rest."""
for kid_hex, key in keys.items():
@@ -1421,7 +1493,7 @@ class RemoteService:
if isinstance(challenge, str):
challenge = challenge.encode("utf-8")
if self._server_cdm:
if self._server_cdm and str(track.id) not in self.client_licensed:
from uuid import UUID
server_type = self._server_cdm_type
@@ -1429,6 +1501,7 @@ class RemoteService:
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)
refusal: Optional[Dict[str, Any]] = None
if pssh_b64:
try:
resp = self.client.post(
@@ -1439,7 +1512,10 @@ class RemoteService:
"mode": "server_cdm",
"pssh": pssh_b64,
},
expect=("SERVER_CDM_CAPPED",),
)
if resp.get("error_code") == "SERVER_CDM_CAPPED":
refusal = resp.get("details") or {}
self._server_cdm_type = resp.get("drm_type", server_type)
keys = resp.get("keys", {})
self.note_vault_keys(keys, set(resp.get("vault_keys", [])))
@@ -1453,6 +1529,9 @@ class RemoteService:
self.log.warning("server_cdm license failed: %s", e)
else:
self.log.warning(f"Track {track.id} has no PSSH to send to the server CDM")
if refusal is not None:
# dl sees the track in client_licensed and calls again with this device's challenge
self.license_locally(track, refusal)
return challenge
pssh_b64 = track_pssh(track, drm_type)
+4 -4
View File
@@ -541,7 +541,7 @@ class Video(Track):
out_path = Path(out_path)
def run_ccextractor() -> bool:
def run_ccextractor(fallback: str) -> bool:
cc_start = time.monotonic()
try:
subprocess.run(
@@ -560,7 +560,7 @@ class Video(Track):
)
if e.returncode < 0:
logging.getLogger("Video").warning(
f"ccextractor crashed (signal {-e.returncode}) on {self.path.name}; skipping CC extraction"
f"ccextractor crashed (signal {-e.returncode}) on {self.path.name}; {fallback}"
)
return out_path.exists()
if e.returncode != 10: # 10 = No captions found
@@ -576,9 +576,9 @@ class Video(Track):
# Try on the original file first (preserves container-level CC data like c608 boxes),
# then fall back to repacked file (ccextractor can fail on some container formats).
if not run_ccextractor():
if not run_ccextractor("retrying on a repackaged copy"):
self.repackage()
run_ccextractor()
run_ccextractor("skipping CC extraction")
if out_path.exists():
cc_track = Subtitle(