mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-11 19:45:06 +00:00
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:
@@ -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. |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user