diff --git a/docs/dev/rest-api/dashboard.md b/docs/dev/rest-api/dashboard.md index 830a932e..002f3f73 100644 --- a/docs/dev/rest-api/dashboard.md +++ b/docs/dev/rest-api/dashboard.md @@ -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=` 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=` 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. | diff --git a/docs/dev/rest-api/endpoints.md b/docs/dev/rest-api/endpoints.md index b40c9a6d..b07b30f0 100644 --- a/docs/dev/rest-api/endpoints.md +++ b/docs/dev/rest-api/endpoints.md @@ -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 | diff --git a/docs/dev/rest-api/errors.md b/docs/dev/rest-api/errors.md index 3c8e9cd4..f07306b3 100644 --- a/docs/dev/rest-api/errors.md +++ b/docs/dev/rest-api/errors.md @@ -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. | diff --git a/docs/dev/rest-api/remote-sessions.md b/docs/dev/rest-api/remote-sessions.md index ebf5f7f3..86ff2367 100644 --- a/docs/dev/rest-api/remote-sessions.md +++ b/docs/dev/rest-api/remote-sessions.md @@ -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 diff --git a/docs/reference/configuration/services.md b/docs/reference/configuration/services.md index 7be6f346..24005000 100644 --- a/docs/reference/configuration/services.md +++ b/docs/reference/configuration/services.md @@ -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 diff --git a/tests/remote/unit/test_download_gates_redaction.py b/tests/remote/unit/test_download_gates_redaction.py index b17a5ae9..1b433f20 100644 --- a/tests/remote/unit/test_download_gates_redaction.py +++ b/tests/remote/unit/test_download_gates_redaction.py @@ -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 diff --git a/tests/remote/unit/test_errors.py b/tests/remote/unit/test_errors.py index 10e185d3..4b56ed10 100644 --- a/tests/remote/unit/test_errors.py +++ b/tests/remote/unit/test_errors.py @@ -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, diff --git a/tests/remote/unit/test_extract_pssh_strict.py b/tests/remote/unit/test_extract_pssh_strict.py index 2f371ef1..30071f7c 100644 --- a/tests/remote/unit/test_extract_pssh_strict.py +++ b/tests/remote/unit/test_extract_pssh_strict.py @@ -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"} diff --git a/tests/remote/unit/test_remote_service_server_cdm_license.py b/tests/remote/unit/test_remote_service_server_cdm_license.py index 73013845..186a503a 100644 --- a/tests/remote/unit/test_remote_service_server_cdm_license.py +++ b/tests/remote/unit/test_remote_service_server_cdm_license.py @@ -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") diff --git a/tests/remote/unit/test_server_cdm_key_verification.py b/tests/remote/unit/test_server_cdm_key_verification.py index b8d04ca1..15913c50 100644 --- a/tests/remote/unit/test_server_cdm_key_verification.py +++ b/tests/remote/unit/test_server_cdm_key_verification.py @@ -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"} diff --git a/tests/remote/unit/test_server_cdm_max_height.py b/tests/remote/unit/test_server_cdm_max_height.py new file mode 100644 index 00000000..4bda331b --- /dev/null +++ b/tests/remote/unit/test_server_cdm_max_height.py @@ -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 diff --git a/tests/remote/unit/test_server_cdm_multikey.py b/tests/remote/unit/test_server_cdm_multikey.py index 02f8b209..58209d2c 100644 --- a/tests/remote/unit/test_server_cdm_multikey.py +++ b/tests/remote/unit/test_server_cdm_multikey.py @@ -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) diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index eb0abe93..eb2d6702 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -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") diff --git a/unshackle/commands/serve.py b/unshackle/commands/serve.py index 4e8d8a57..541e3296 100644 --- a/unshackle/commands/serve.py +++ b/unshackle/commands/serve.py @@ -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") diff --git a/unshackle/core/api/errors.py b/unshackle/core/api/errors.py index 6a9f9c58..3f64eb25 100644 --- a/unshackle/core/api/errors.py +++ b/unshackle/core/api/errors.py @@ -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, diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index 305475ed..9edac18b 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -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) diff --git a/unshackle/core/api/routes.py b/unshackle/core/api/routes.py index 3cd5ff18..6725450d 100644 --- a/unshackle/core/api/routes.py +++ b/unshackle/core/api/routes.py @@ -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: diff --git a/unshackle/core/api/session_store.py b/unshackle/core/api/session_store.py index 5ea1ae14..dfed87b2 100644 --- a/unshackle/core/api/session_store.py +++ b/unshackle/core/api/session_store.py @@ -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), diff --git a/unshackle/core/cdm/detect.py b/unshackle/core/cdm/detect.py index 00e296c8..921847ad 100644 --- a/unshackle/core/cdm/detect.py +++ b/unshackle/core/cdm/detect.py @@ -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: diff --git a/unshackle/core/remote_service.py b/unshackle/core/remote_service.py index b90c8781..2c4c881b 100644 --- a/unshackle/core/remote_service.py +++ b/unshackle/core/remote_service.py @@ -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) diff --git a/unshackle/core/tracks/video.py b/unshackle/core/tracks/video.py index 4285eb4b..e17f78c8 100644 --- a/unshackle/core/tracks/video.py +++ b/unshackle/core/tracks/video.py @@ -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(