mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 20:15:10 +00:00
feat(remote): license with the client's device when the user picked one
A remote client that passes --cdm, has a cdm entry for the service that named the loaded device, or sets server_cdm: false now declines the server CDM at session create, and warns when the server keeps it. A service option listed in SERVER_DEVICE_OPTIONS keeps the server CDM.
This commit is contained in:
@@ -246,6 +246,14 @@ There are two ways DRM keys get resolved, chosen by the client's `server_cdm` fl
|
|||||||
|
|
||||||
- Keeps your CDM local. The server only relays the license request.
|
- Keeps your CDM local. The server only relays the license request.
|
||||||
- Used when `server_cdm` is `false` (the default).
|
- Used when `server_cdm` is `false` (the default).
|
||||||
|
- Also used for a run with `dl --cdm <name>`, and for a service whose own entry in
|
||||||
|
your `cdm` config named the loaded device. In each case the client sends
|
||||||
|
`server_cdm: false` when it creates the remote session, and the server builds the
|
||||||
|
service on your device even when the API key has the server CDM.
|
||||||
|
- A service option that the service lists in `SERVER_DEVICE_OPTIONS` keeps the server
|
||||||
|
CDM. The server reads the option from `service_params`, then from the top level of
|
||||||
|
the request. The create response gives the result in `server_cdm`, and the client
|
||||||
|
shows a warning when the server did not apply its choice.
|
||||||
- The service on the server sees your device, not the server's: its DRM system,
|
- The service on the server sees your device, not the server's: its DRM system,
|
||||||
security level, system ID and device type. A CDM call that the service makes
|
security level, system ID and device type. A CDM call that the service makes
|
||||||
(for example a session-key exchange at login) goes to your client as a `cdm_call`,
|
(for example a session-key exchange at login) goes to your client as a `cdm_call`,
|
||||||
@@ -412,7 +420,7 @@ The server mounts all these routes, even in `--remote-only` mode. Paths use the
|
|||||||
`POST /api/session/create` requires `service` and `title_id`. It also accepts
|
`POST /api/session/create` requires `service` and `title_id`. It also accepts
|
||||||
`credentials`, `cookies` (base64 of zlib-compressed Netscape cookie file), `proxy`,
|
`credentials`, `cookies` (base64 of zlib-compressed Netscape cookie file), `proxy`,
|
||||||
`no_proxy`, `profile`, `cache` (a map of `filename → base64(zlib(bytes))`),
|
`no_proxy`, `profile`, `cache` (a map of `filename → base64(zlib(bytes))`),
|
||||||
`client_region`, `cdm_type`, `cdm_security_level`, `cdm_relay` (`true` when the client
|
`client_region`, `server_cdm` (`false` when the user picked the local CDM), `cdm_type`, `cdm_security_level`, `cdm_relay` (`true` when the client
|
||||||
answers CDM calls), `cdm_system_id` and `cdm_device_type` (Widevine only), `service_config`
|
answers CDM calls), `cdm_system_id` and `cdm_device_type` (Widevine only), `service_config`
|
||||||
(the identity config keys the service lists in `client_config`), `client` (a freeform identity
|
(the identity config keys the service lists in `client_config`), `client` (a freeform identity
|
||||||
object the dashboard shows as sent), and the track-selection hints `range_`, `vcodec`,
|
object the dashboard shows as sent), and the track-selection hints `range_`, `vcodec`,
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ order the service used, then renumbers the episodes into the order you asked for
|
|||||||
| Flag | Description |
|
| Flag | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `--cdm-only` / `--vaults-only` | Use only the CDM, or only Key Vaults, for key acquisition. |
|
| `--cdm-only` / `--vaults-only` | Use only the CDM, or only Key Vaults, for key acquisition. |
|
||||||
| `--cdm <name>` | Use the named CDM device from the `cdm` config mapping for this run, ignoring the service/default mapping. |
|
| `--cdm <name>` | Use the named CDM device from the `cdm` config mapping for this run, ignoring the service/default mapping. Over `--remote`, that device licenses the remote session, not the server CDM. |
|
||||||
| `--skip-dl` | Skip the download but still retrieve keys. |
|
| `--skip-dl` | Skip the download but still retrieve keys. |
|
||||||
| `--export` | Export track info and keys to a `mediaexport` JSON file in the exports directory. |
|
| `--export` | Export track info and keys to a `mediaexport` JSON file in the exports directory. |
|
||||||
|
|
||||||
|
|||||||
@@ -777,7 +777,7 @@ By default, unshackle checks your **key vaults** first and only asks a **CDM** t
|
|||||||
a content key when the vault misses. You can force one side or the other:
|
a content key when the vault misses. You can force one side or the other:
|
||||||
|
|
||||||
- `--cdm-only`: only use the CDM (skip vaults). Over a `server_cdm` remote session the flag does not reach the server, whose vault shortcut still runs; it only skips the client's decode check on a server key.
|
- `--cdm-only`: only use the CDM (skip vaults). Over a `server_cdm` remote session the flag does not reach the server, whose vault shortcut still runs; it only skips the client's decode check on a server key.
|
||||||
- `--cdm <name>`: use the named CDM device for this run, ignoring the `cdm` config mapping (including its quality and Widevine/PlayReady sub-entries).
|
- `--cdm <name>`: use the named CDM device for this run, ignoring the `cdm` config mapping (including its quality and Widevine/PlayReady sub-entries). Over `--remote`, that device licenses the remote session, not the server CDM.
|
||||||
- `--vaults-only`: only use key vaults and never license through the CDM. A missing
|
- `--vaults-only`: only use key vaults and never license through the CDM. A missing
|
||||||
content key fails.
|
content key fails.
|
||||||
|
|
||||||
|
|||||||
@@ -501,7 +501,7 @@ control this trade-off on the `dl` command:
|
|||||||
|------|--------|
|
|------|--------|
|
||||||
| `--vaults-only` | Never send a license request; use only cached/vault keys. Fails if a content key is missing. |
|
| `--vaults-only` | Never send a license request; use only cached/vault keys. Fails if a content key is missing. |
|
||||||
| `--cdm-only` | Ignore vaults for sourcing keys and always perform a fresh license request. Over a `server_cdm` remote session the server still reads its own vaults; the flag only skips the client's decode check on the key it gets back. |
|
| `--cdm-only` | Ignore vaults for sourcing keys and always perform a fresh license request. Over a `server_cdm` remote session the server still reads its own vaults; the flag only skips the client's decode check on the key it gets back. |
|
||||||
| `--cdm <name>` | Use the named CDM device for this run instead of the one the `cdm` config maps for the service. |
|
| `--cdm <name>` | Use the named CDM device for this run instead of the one the `cdm` config maps for the service. Over `--remote`, that device licenses the remote session, not the server CDM. |
|
||||||
|
|
||||||
See [key vaults](vaults.md) for how to configure vault backends.
|
See [key vaults](vaults.md) for how to configure vault backends.
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ Resolution is case-insensitive: an override (`dl --cdm <name>`, or the `cdm` fie
|
|||||||
job) wins, then the per-service entry, then `default`. An override pins one device for the whole
|
job) wins, then the per-service entry, then `default`. An override pins one device for the whole
|
||||||
run and skips any quality or Widevine/PlayReady sub-entries.
|
run and skips any quality or Widevine/PlayReady sub-entries.
|
||||||
|
|
||||||
|
Over `--remote`, an override also makes your device license the remote session, not the server
|
||||||
|
CDM. A per-service entry does the same when it names a device without a quality: a device name,
|
||||||
|
a `widevine` or `playready` sub-entry, or a profile or `default` sub-entry. A per-service entry
|
||||||
|
that selects only by quality, or only for a different profile, loads no device when the run
|
||||||
|
starts. unshackle then shows a warning, and the server decides who licenses the remote session.
|
||||||
|
With only the top-level `default` entry, the server CDM licenses when the API key has it. See
|
||||||
|
[`remote_services`](services.md#remote_services).
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
cdm:
|
cdm:
|
||||||
default: chromecdm_l3
|
default: chromecdm_l3
|
||||||
|
|||||||
@@ -89,6 +89,15 @@ overrides such as `title_map`.
|
|||||||
Leave `server_cdm` unset and the client follows the server: it uses the server CDM for each
|
Leave `server_cdm` unset and the client follows the server: it uses the server CDM for each
|
||||||
service the API key has it on, and the local CDM for the others. `true` asks for the server CDM
|
service the API key has it on, and the local CDM for the others. `true` asks for the server CDM
|
||||||
and falls back to the local CDM when the server refuses; `false` always uses the local CDM.
|
and falls back to the local CDM when the server refuses; `false` always uses the local CDM.
|
||||||
|
`dl --cdm <name>` on a remote run has the same effect as `false` for that run: the device you
|
||||||
|
name licenses the remote session. A [`cdm`](drm.md#cdm) entry for the service has the same
|
||||||
|
effect for that service when it names the device that unshackle loaded. Both take priority over
|
||||||
|
`server_cdm: true`. Remove the entry to use the server CDM for that service. The `default`
|
||||||
|
entry does not count.
|
||||||
|
|
||||||
|
The server has the last word. When it keeps the server CDM for a remote session that your
|
||||||
|
device was to license, unshackle shows a warning. This occurs with a server that does not read
|
||||||
|
your choice, and with a service option that runs the remote session on the server's device.
|
||||||
|
|
||||||
`auth_headers` lists extra header names to send the API key in, tried before the defaults
|
`auth_headers` lists extra header names to send the API key in, tried before the defaults
|
||||||
`X-Secret-Key` and `X-Api-Key`, which unshackle always appends as fallbacks. It sends the
|
`X-Secret-Key` and `X-Api-Key`, which unshackle always appends as fallbacks. It sends the
|
||||||
@@ -154,6 +163,12 @@ 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`
|
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.
|
jobs. Keys that have no `users` entry, such as `api_secret`, keep server CDM access.
|
||||||
|
|
||||||
|
A remote client that picks its own device licenses the remote session with that device. It picks
|
||||||
|
with `dl --cdm`, with a `cdm` entry for the service, or with `server_cdm: false` in its
|
||||||
|
`remote_services` entry. The server CDM makes no licence for that remote session, with or without
|
||||||
|
a limit. A service option that runs the remote session on the server's device, such as a
|
||||||
|
server-identity flag, keeps the server CDM.
|
||||||
|
|
||||||
`server_cdm_max_height` limits the live licences the server's device makes for that API key.
|
`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
|
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
|
optional `default` entry. A service the map does not name or sets to `null` gets the `default`, and
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from unshackle.commands.dl import dl
|
|||||||
from unshackle.core.api import handlers
|
from unshackle.core.api import handlers
|
||||||
from unshackle.core.api.errors import APIError, APIErrorCode
|
from unshackle.core.api.errors import APIError, APIErrorCode
|
||||||
from unshackle.core.api.session_store import SessionEntry
|
from unshackle.core.api.session_store import SessionEntry
|
||||||
|
from unshackle.core.config import cdm_entry_names_device
|
||||||
from unshackle.core.drm import Widevine
|
from unshackle.core.drm import Widevine
|
||||||
from unshackle.core.remote_service import RemoteClient, RemoteService
|
from unshackle.core.remote_service import RemoteClient, RemoteService
|
||||||
from unshackle.core.tracks import Audio, Video
|
from unshackle.core.tracks import Audio, Video
|
||||||
@@ -33,6 +34,7 @@ USERS = {
|
|||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def serve_users(monkeypatch):
|
def serve_users(monkeypatch):
|
||||||
|
monkeypatch.setattr(handlers.config, "cdm", {}, raising=False)
|
||||||
monkeypatch.setattr(handlers.config, "serve", {"users": USERS}, raising=False)
|
monkeypatch.setattr(handlers.config, "serve", {"users": USERS}, raising=False)
|
||||||
monkeypatch.setattr(handlers, "request_secret_key", lambda request: request.headers.get("X-Secret-Key"))
|
monkeypatch.setattr(handlers, "request_secret_key", lambda request: request.headers.get("X-Secret-Key"))
|
||||||
|
|
||||||
@@ -72,12 +74,34 @@ def test_cap_per_key_and_service(key, service, cap):
|
|||||||
("flat", {"quality": [2160, 1080], "cdm_type": "widevine"}, (False, 1080)),
|
("flat", {"quality": [2160, 1080], "cdm_type": "widevine"}, (False, 1080)),
|
||||||
("flat", {"quality": ["2160p"], "cdm_type": "widevine"}, (False, 1080)),
|
("flat", {"quality": ["2160p"], "cdm_type": "widevine"}, (False, 1080)),
|
||||||
("none", {"quality": [2160], "cdm_type": "widevine"}, (False, None)),
|
("none", {"quality": [2160], "cdm_type": "widevine"}, (False, None)),
|
||||||
|
("open", {"cdm_type": "widevine", "server_cdm": False}, (False, None)), # the client picked its own device
|
||||||
|
("flat", {"quality": [1080], "cdm_type": "widevine", "server_cdm": False}, (False, None)),
|
||||||
|
("open", {"server_cdm": False}, (True, None)), # no device to hand the licensing to
|
||||||
|
("open", {"cdm_type": "widevine", "server_cdm": None}, (True, None)), # only a literal false declines
|
||||||
|
("open", {"cdm_type": "widevine", "server_cdm": 0}, (True, None)),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_session_mode(key, data, expected):
|
def test_session_mode(key, data, expected, monkeypatch):
|
||||||
|
monkeypatch.setattr(handlers.Services, "load", lambda tag: type("Svc", (), {}))
|
||||||
assert handlers.choose_session_cdm(request(key), "EXAMPLE1", data) == expected
|
assert handlers.choose_session_cdm(request(key), "EXAMPLE1", data) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_server_device_option_keeps_the_server_cdm_against_the_clients_choice(monkeypatch):
|
||||||
|
svc = type("Svc", (), {"SERVER_DEVICE_OPTIONS": ("server_side",)})
|
||||||
|
monkeypatch.setattr(handlers.Services, "load", lambda tag: svc)
|
||||||
|
data = {"cdm_type": "widevine", "server_cdm": False, "server_side": True}
|
||||||
|
assert handlers.choose_session_cdm(request("flat"), "EXAMPLE1", data) == (True, 1080)
|
||||||
|
assert handlers.choose_session_cdm(request("flat"), "EXAMPLE1", {**data, "quality": [2160]}) == (False, 1080)
|
||||||
|
assert handlers.choose_session_cdm(request("flat"), "EXAMPLE1", {**data, "server_side": False}) == (False, None)
|
||||||
|
assert handlers.choose_session_cdm(request("flat"), "EXAMPLE1", {**data, "server_side": "yes"}) == (True, 1080)
|
||||||
|
|
||||||
|
nested = {"cdm_type": "widevine", "server_cdm": False, "service_params": {"server_side": True}}
|
||||||
|
assert handlers.choose_session_cdm(request("flat"), "EXAMPLE1", nested) == (True, 1080)
|
||||||
|
# service_params wins over the flat key, as it does when the service is built
|
||||||
|
nested["server_side"], nested["service_params"] = True, {"server_side": False}
|
||||||
|
assert handlers.choose_session_cdm(request("flat"), "EXAMPLE1", nested) == (False, None)
|
||||||
|
|
||||||
|
|
||||||
def test_above_the_cap_without_a_device_is_refused_at_create():
|
def test_above_the_cap_without_a_device_is_refused_at_create():
|
||||||
with pytest.raises(APIError) as ei:
|
with pytest.raises(APIError) as ei:
|
||||||
handlers.choose_session_cdm(request("flat"), "EXAMPLE1", {"quality": [2160]})
|
handlers.choose_session_cdm(request("flat"), "EXAMPLE1", {"quality": [2160]})
|
||||||
@@ -623,16 +647,162 @@ def test_the_client_returns_only_an_expected_error_body():
|
|||||||
client.post("/x", {}, expect=("FORBIDDEN",))
|
client.post("/x", {}, expect=("FORBIDDEN",))
|
||||||
|
|
||||||
|
|
||||||
def test_cdm_with_an_uncapped_server_cdm_key_warns_that_it_is_ignored(caplog):
|
class _SentCreate(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def sent_create(
|
||||||
|
server_cdm, cdm_name=None, local_cdm=SimpleNamespace(is_playready=False), cdm_entry=False, cdm_map=None
|
||||||
|
):
|
||||||
|
def post(path, data):
|
||||||
|
raise _SentCreate(data)
|
||||||
|
|
||||||
|
handlers.config.cdm = cdm_map or {}
|
||||||
|
svc = remote(local_cdm, {})
|
||||||
|
svc.ctx.obj.cdm_entry = cdm_entry
|
||||||
|
svc._server_cdm = server_cdm
|
||||||
|
svc.service_tag = "EXAMPLE1"
|
||||||
|
svc.title_id = "t1"
|
||||||
|
svc._server_accounts = {"global": True}
|
||||||
|
svc._service_params = {}
|
||||||
|
svc._client_config = None
|
||||||
|
svc.ctx.parent = SimpleNamespace(params={"no_proxy": True, "cdm_name": cdm_name})
|
||||||
|
svc.client = SimpleNamespace(post=post)
|
||||||
|
with pytest.raises(_SentCreate) as exc:
|
||||||
|
svc.authenticate()
|
||||||
|
return svc, exc.value.args[0]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("server_cdm", "cdm_name", "declines"),
|
||||||
|
[
|
||||||
|
(True, "my_device", True), # --cdm wins over server_cdm: true
|
||||||
|
(None, "my_device", True),
|
||||||
|
(False, None, True),
|
||||||
|
(True, None, False),
|
||||||
|
(None, None, False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_the_client_declines_the_server_cdm_when_the_user_picked_a_device(server_cdm, cdm_name, declines):
|
||||||
|
svc, data = sent_create(server_cdm, cdm_name)
|
||||||
|
assert (data.get("server_cdm") is False) is declines
|
||||||
|
assert data["cdm_type"] == "widevine"
|
||||||
|
|
||||||
|
assert svc._server_cdm is (False if declines else server_cdm)
|
||||||
|
|
||||||
|
# the server built the service, so its answer is final
|
||||||
|
svc.adopt_session_cdm(True, None)
|
||||||
|
assert svc._server_cdm is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_device_loaded_from_the_services_cdm_entry_declines_the_server_cdm():
|
||||||
|
svc, data = sent_create(True, cdm_entry=True, cdm_map={"EXAMPLE1": "my_device"})
|
||||||
|
assert data["server_cdm"] is False
|
||||||
|
assert svc._server_cdm is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_cdm_over_an_existing_cdm_entry_declines_without_a_warning(caplog):
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
_, data = sent_create(True, "my_device", cdm_map={"EXAMPLE1": {">=1080": "other"}})
|
||||||
|
assert data["server_cdm"] is False
|
||||||
|
assert not caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_device_loaded_from_the_default_cdm_entry_does_not_decline(caplog):
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
svc, data = sent_create(True, cdm_map={"default": "my_device", "EXAMPLE2": "other"})
|
||||||
|
assert "server_cdm" not in data
|
||||||
|
assert svc._server_cdm is True
|
||||||
|
assert not caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("local_cdm", [SimpleNamespace(is_playready=False), None])
|
||||||
|
def test_a_cdm_entry_that_did_not_load_the_device_warns_and_does_not_decline(local_cdm, caplog):
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
svc, data = sent_create(True, local_cdm=local_cdm, cdm_map={"example1": {">=1080": "my_device"}})
|
||||||
|
assert "server_cdm" not in data
|
||||||
|
assert svc._server_cdm is True
|
||||||
|
assert "cdm entry for EXAMPLE1" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("entry", "profile", "names"),
|
||||||
|
[
|
||||||
|
("my_device", None, True),
|
||||||
|
({"widevine": "my_device"}, None, True),
|
||||||
|
({"PlayReady": "my_device"}, None, True),
|
||||||
|
({"default": "my_device", ">=1080": "other"}, None, True),
|
||||||
|
({"john": "my_device"}, "john", True),
|
||||||
|
({"john": "my_device"}, "jane", False),
|
||||||
|
({">=1080": "my_device", "<1080": "other"}, None, False), # a quality alone selects nothing at the start
|
||||||
|
(None, None, False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_which_cdm_entries_name_a_device_with_no_quality(entry, profile, names):
|
||||||
|
assert cdm_entry_names_device(entry, profile) is names
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_client_with_no_device_does_not_decline():
|
||||||
|
_, data = sent_create(False, None, local_cdm=None)
|
||||||
|
assert "server_cdm" not in data
|
||||||
|
|
||||||
|
|
||||||
|
def created(answer):
|
||||||
|
"""Run ``authenticate()`` to its end for a device the service's cdm entry loaded."""
|
||||||
|
svc, _ = sent_create(True, cdm_entry=True, cdm_map={"EXAMPLE1": "my_device"})
|
||||||
|
svc._server_cdm = True
|
||||||
|
svc.client = SimpleNamespace(post=lambda path, data: {"session_id": "sess", **answer})
|
||||||
|
svc.close = svc.start_keepalive = lambda: None
|
||||||
|
svc.authenticate()
|
||||||
|
return svc
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_client_says_when_a_cdm_entry_gave_the_licensing_to_its_device(caplog):
|
||||||
|
with caplog.at_level(logging.INFO):
|
||||||
|
svc = created({"server_cdm": False})
|
||||||
|
assert svc._server_cdm is False
|
||||||
|
assert "cdm config has an entry for EXAMPLE1" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_client_does_not_claim_its_device_when_the_server_keeps_the_server_cdm(caplog):
|
||||||
|
with caplog.at_level(logging.INFO):
|
||||||
|
svc = created({"server_cdm": True, "server_cdm_max_height": 1080})
|
||||||
|
assert svc._server_cdm is True
|
||||||
|
assert "your own device is not used" in caplog.text
|
||||||
|
assert "cdm config has an entry" not in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("cdm", "override", "cdm_map", "expected"),
|
||||||
|
[
|
||||||
|
(object(), None, {"EXAMPLE1": "my_device"}, True),
|
||||||
|
(object(), None, {"example1": {"widevine": "my_device"}}, True),
|
||||||
|
(object(), "my_device", {"EXAMPLE1": "my_device"}, False), # --cdm is its own reason
|
||||||
|
(object(), None, {"default": "my_device"}, False),
|
||||||
|
(object(), None, {"EXAMPLE1": {">=1080": "other"}, "default": "my_device"}, False),
|
||||||
|
(None, None, {"EXAMPLE1": {">=1080": "other"}}, False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_dl_records_whether_the_services_cdm_entry_named_the_loaded_device(cdm, override, cdm_map, expected):
|
||||||
|
handlers.config.cdm = cdm_map
|
||||||
|
runner = dl.__new__(dl)
|
||||||
|
runner.cdm, runner.cdm_override, runner.service, runner.profile = cdm, override, "EXAMPLE1", None
|
||||||
|
assert runner.cdm_from_service_entry() is expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("max_height", [None, 1080])
|
||||||
|
def test_a_declined_server_cdm_that_the_server_keeps_is_reported(max_height, caplog):
|
||||||
svc = remote(SimpleNamespace(is_playready=False), {})
|
svc = remote(SimpleNamespace(is_playready=False), {})
|
||||||
svc.service_tag = "EXAMPLE1"
|
svc.service_tag = "EXAMPLE1"
|
||||||
svc.ctx.parent = SimpleNamespace(params={"cdm_name": "my_device"})
|
svc._server_cdm = False
|
||||||
with caplog.at_level(logging.WARNING):
|
with caplog.at_level(logging.INFO):
|
||||||
svc.adopt_session_cdm(True, None)
|
svc.adopt_session_cdm(True, max_height)
|
||||||
assert "--cdm is ignored" in caplog.text
|
assert svc._server_cdm is True
|
||||||
|
assert "your own device is not used" in caplog.text
|
||||||
|
assert "pass a higher -q" not in caplog.text
|
||||||
|
|
||||||
caplog.clear()
|
caplog.clear()
|
||||||
svc.ctx.parent.params = {}
|
svc._server_cdm = True
|
||||||
with caplog.at_level(logging.WARNING):
|
with caplog.at_level(logging.WARNING):
|
||||||
svc.adopt_session_cdm(True, None)
|
svc.adopt_session_cdm(True, max_height)
|
||||||
assert "--cdm is ignored" not in caplog.text
|
assert "your own device is not used" not in caplog.text
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ from rich.tree import Tree
|
|||||||
from unshackle.core import __version__, binaries, providers
|
from unshackle.core import __version__, binaries, providers
|
||||||
from unshackle.core.cdm import DecryptLabsRemoteCDM
|
from unshackle.core.cdm import DecryptLabsRemoteCDM
|
||||||
from unshackle.core.cdm.detect import cdm_type_stub, is_playready_cdm, is_widevine_cdm
|
from unshackle.core.cdm.detect import cdm_type_stub, is_playready_cdm, is_widevine_cdm
|
||||||
from unshackle.core.config import config, resolve_cdm_name, resolve_decryption
|
from unshackle.core.config import cdm_entry_names_device, config, resolve_cdm_name, resolve_decryption
|
||||||
from unshackle.core.console import GradientPulseBarColumn, SyncLive, console, listing_panel
|
from unshackle.core.console import GradientPulseBarColumn, SyncLive, console, listing_panel
|
||||||
from unshackle.core.constants import DOWNLOAD_CANCELLED, DOWNLOAD_LICENCE_ONLY, AnyTrack, context_settings
|
from unshackle.core.constants import DOWNLOAD_CANCELLED, DOWNLOAD_LICENCE_ONLY, AnyTrack, context_settings
|
||||||
from unshackle.core.credential import Credential
|
from unshackle.core.credential import Credential
|
||||||
@@ -877,7 +877,7 @@ class dl:
|
|||||||
"cdm_name",
|
"cdm_name",
|
||||||
type=str,
|
type=str,
|
||||||
default=None,
|
default=None,
|
||||||
help="Use this CDM device for the run, overriding the cdm config mapping.",
|
help="Use this CDM device for the run, overriding the cdm config mapping and, over --remote, the server CDM.",
|
||||||
)
|
)
|
||||||
@click.option("--no-proxy", is_flag=True, default=False, help="Force disable all proxy use.")
|
@click.option("--no-proxy", is_flag=True, default=False, help="Force disable all proxy use.")
|
||||||
@click.option(
|
@click.option(
|
||||||
@@ -1418,7 +1418,11 @@ class dl:
|
|||||||
raise click.UsageError("That proxy is on your machine, so --remote cannot use it.")
|
raise click.UsageError("That proxy is on your machine, so --remote cannot use it.")
|
||||||
|
|
||||||
ctx.obj = ContextData(
|
ctx.obj = ContextData(
|
||||||
config=self.service_config, cdm=self.cdm, proxy_providers=self.proxy_providers, profile=self.profile
|
config=self.service_config,
|
||||||
|
cdm=self.cdm,
|
||||||
|
proxy_providers=self.proxy_providers,
|
||||||
|
profile=self.profile,
|
||||||
|
cdm_entry=self.cdm_from_service_entry(),
|
||||||
)
|
)
|
||||||
|
|
||||||
if repack:
|
if repack:
|
||||||
@@ -5187,6 +5191,14 @@ class dl:
|
|||||||
return Credential(*credentials)
|
return Credential(*credentials)
|
||||||
return Credential.loads(credentials) # type: ignore
|
return Credential.loads(credentials) # type: ignore
|
||||||
|
|
||||||
|
def cdm_from_service_entry(self) -> bool:
|
||||||
|
"""Whether the service's own ``cdm`` entry named the loaded device, not ``--cdm`` or the ``default`` entry."""
|
||||||
|
return (
|
||||||
|
self.cdm is not None
|
||||||
|
and not self.cdm_override
|
||||||
|
and cdm_entry_names_device(ci_get(config.cdm, self.service), self.profile)
|
||||||
|
)
|
||||||
|
|
||||||
def get_cdm(
|
def get_cdm(
|
||||||
self,
|
self,
|
||||||
service: str,
|
service: str,
|
||||||
|
|||||||
@@ -682,10 +682,18 @@ def choose_session_cdm(
|
|||||||
"""``(server_cdm, max_height)`` for a new remote session.
|
"""``(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.
|
The service picks its manifest from the CDM it sees, so it decides once, before any tracks.
|
||||||
|
A client that sends ``server_cdm: false`` with a device licenses with that device, unless it also set
|
||||||
|
one of the service's ``SERVER_DEVICE_OPTIONS``.
|
||||||
When ``-q`` asks for more than the cap, the client's own device licenses, and a client with no device gets a refusal.
|
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):
|
if not server_cdm_allowed(request, service):
|
||||||
return False, None
|
return False, None
|
||||||
|
if data.get("server_cdm") is False and data.get("cdm_type"):
|
||||||
|
server_device_options = getattr(Services.load(service), "SERVER_DEVICE_OPTIONS", ())
|
||||||
|
service_params = data.get("service_params")
|
||||||
|
options = {**data, **service_params} if isinstance(service_params, dict) else data
|
||||||
|
if not any(options.get(option) for option in server_device_options):
|
||||||
|
return False, None
|
||||||
cap = server_cdm_max_height(request, service)
|
cap = server_cdm_max_height(request, service)
|
||||||
wanted = requested_height(data.get("quality"))
|
wanted = requested_height(data.get("quality"))
|
||||||
if cap is None or wanted is None or wanted <= cap:
|
if cap is None or wanted is None or wanted <= cap:
|
||||||
@@ -3172,6 +3180,7 @@ SESSION_TRANSPORT_KEYS = {
|
|||||||
"cache",
|
"cache",
|
||||||
"client_region",
|
"client_region",
|
||||||
"proxy_region",
|
"proxy_region",
|
||||||
|
"server_cdm",
|
||||||
"cdm_type",
|
"cdm_type",
|
||||||
"cdm_security_level",
|
"cdm_security_level",
|
||||||
"cdm_relay",
|
"cdm_relay",
|
||||||
|
|||||||
@@ -1811,6 +1811,13 @@ async def session_create(request: web.Request) -> web.Response:
|
|||||||
forced_subs:
|
forced_subs:
|
||||||
type: boolean
|
type: boolean
|
||||||
default: false
|
default: false
|
||||||
|
server_cdm:
|
||||||
|
type: boolean
|
||||||
|
description: |
|
||||||
|
False when the user picked the local CDM. With `cdm_type`, the server then builds
|
||||||
|
the service on the client's device, also for an API key with the server CDM.
|
||||||
|
A request that sets one of the service's `SERVER_DEVICE_OPTIONS`, in
|
||||||
|
`service_params` or at the top level, keeps the server CDM
|
||||||
cdm_type:
|
cdm_type:
|
||||||
type: string
|
type: string
|
||||||
enum: [widevine, playready]
|
enum: [widevine, playready]
|
||||||
|
|||||||
@@ -70,6 +70,17 @@ def resolve_cdm_name(cdm: dict, service: str, override: Any = None) -> Any:
|
|||||||
return override or ci_get(cdm, service) or ci_get(cdm, "default")
|
return override or ci_get(cdm, service) or ci_get(cdm, "default")
|
||||||
|
|
||||||
|
|
||||||
|
def cdm_entry_names_device(entry: Any, profile: Optional[str] = None) -> bool:
|
||||||
|
"""Whether a per-service ``cdm`` entry names a device when no quality is known.
|
||||||
|
|
||||||
|
An entry that selects only by quality, or by a different profile, names none.
|
||||||
|
"""
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
return bool(entry)
|
||||||
|
systems = {"widevine", "playready"} & {str(key).lower() for key in entry}
|
||||||
|
return bool(systems or entry.get(profile) or entry.get("default"))
|
||||||
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
class _Directories:
|
class _Directories:
|
||||||
# default directories, do not modify here, set via config
|
# default directories, do not modify here, set via config
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ from unshackle.core.titles.music import Album, Song
|
|||||||
from unshackle.core.tracks import Audio, Chapter, Chapters, Subtitle, Tracks, Video
|
from unshackle.core.tracks import Audio, Chapter, Chapters, Subtitle, Tracks, Video
|
||||||
from unshackle.core.tracks.attachment import Attachment
|
from unshackle.core.tracks.attachment import Attachment
|
||||||
from unshackle.core.tracks.track import Track
|
from unshackle.core.tracks.track import Track
|
||||||
|
from unshackle.core.utils.collections import ci_get
|
||||||
from unshackle.core.utils.redact import redact_path, redact_text, safe_display_url
|
from unshackle.core.utils.redact import redact_path, redact_text, safe_display_url
|
||||||
from unshackle.core.vault import Vault
|
from unshackle.core.vault import Vault
|
||||||
|
|
||||||
@@ -1064,9 +1065,20 @@ class RemoteService:
|
|||||||
|
|
||||||
# sent with the server CDM too: a capped server hands the tracks above its cap to this device
|
# sent with the server CDM too: a capped server hands the tracks above its cap to this device
|
||||||
cdm = self.local_cdm
|
cdm = self.local_cdm
|
||||||
|
cdm_name = self.ctx.parent and self.ctx.parent.params.get("cdm_name")
|
||||||
|
cdm_entry = cdm is not None and self.ctx.obj.cdm_entry and self._server_cdm is not False
|
||||||
|
if not (cdm_entry or cdm_name or self._server_cdm is False) and ci_get(config.cdm, self.service_tag):
|
||||||
|
self.log.warning(
|
||||||
|
f"The cdm entry for {self.service_tag} did not load a device for this remote session, "
|
||||||
|
"so the server decides who licenses it"
|
||||||
|
)
|
||||||
if cdm is not None:
|
if cdm is not None:
|
||||||
from unshackle.core.cdm.detect import is_playready_cdm
|
from unshackle.core.cdm.detect import is_playready_cdm
|
||||||
|
|
||||||
|
if cdm_entry or cdm_name:
|
||||||
|
self._server_cdm = False
|
||||||
|
if self._server_cdm is False:
|
||||||
|
create_data["server_cdm"] = False
|
||||||
create_data["cdm_type"] = "playready" if is_playready_cdm(cdm) else "widevine"
|
create_data["cdm_type"] = "playready" if is_playready_cdm(cdm) else "widevine"
|
||||||
level = getattr(cdm, "security_level", None)
|
level = getattr(cdm, "security_level", None)
|
||||||
if isinstance(level, int):
|
if isinstance(level, int):
|
||||||
@@ -1103,6 +1115,11 @@ class RemoteService:
|
|||||||
if "server_cdm" in result:
|
if "server_cdm" in result:
|
||||||
self.adopt_session_cdm(bool(result["server_cdm"]), result.get("server_cdm_max_height"))
|
self.adopt_session_cdm(bool(result["server_cdm"]), result.get("server_cdm_max_height"))
|
||||||
self._server_device = bool(result.get("server_device"))
|
self._server_device = bool(result.get("server_device"))
|
||||||
|
if cdm_entry and self._server_cdm is False and not self._server_device:
|
||||||
|
self.log.info(
|
||||||
|
f"The cdm config has an entry for {self.service_tag}, so your own device licenses "
|
||||||
|
"this remote session and not the server CDM"
|
||||||
|
)
|
||||||
vault = bool(result.get("server_vault"))
|
vault = bool(result.get("server_vault"))
|
||||||
if self._server_device:
|
if self._server_device:
|
||||||
self._server_cdm = True
|
self._server_cdm = True
|
||||||
@@ -1134,11 +1151,16 @@ class RemoteService:
|
|||||||
"""Take the server's choice of who licenses this remote session, and say why when a cap made it.
|
"""Take the server's choice of who licenses this remote session, and say why when a cap made it.
|
||||||
|
|
||||||
A remote session on the server's lent device keeps the server-CDM flow: dl checks the local vaults, then asks the
|
A remote session on the server's lent device keeps the server-CDM flow: dl checks the local vaults, then asks the
|
||||||
server, which answers only from its vault.
|
server, which answers only from its vault. ``_server_cdm`` is False on entry when the user picked the local
|
||||||
|
device, so a server that keeps its own CDM gets a warning.
|
||||||
"""
|
"""
|
||||||
if getattr(self, "_server_device", False):
|
if getattr(self, "_server_device", False):
|
||||||
self._server_cdm = True
|
self._server_cdm = True
|
||||||
return
|
return
|
||||||
|
if self._server_cdm is False and server_cdm:
|
||||||
|
self._server_cdm = True
|
||||||
|
self.log.warning("The server CDM licenses this remote session, so your own device is not used")
|
||||||
|
return
|
||||||
if self._server_cdm and not server_cdm and max_height is None:
|
if self._server_cdm and not server_cdm and max_height is None:
|
||||||
self.log.warning(
|
self.log.warning(
|
||||||
f"{self.service_tag} is not available for server CDM licensing with this API key, "
|
f"{self.service_tag} is not available for server CDM licensing with this API key, "
|
||||||
@@ -1146,8 +1168,6 @@ class RemoteService:
|
|||||||
)
|
)
|
||||||
self._server_cdm = server_cdm
|
self._server_cdm = server_cdm
|
||||||
if max_height is None:
|
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
|
return
|
||||||
message = f"The server CDM licenses up to {max_height}p for this API key"
|
message = f"The server CDM licenses up to {max_height}p for this API key"
|
||||||
if not server_cdm:
|
if not server_cdm:
|
||||||
|
|||||||
@@ -162,6 +162,9 @@ class Service(metaclass=ABCMeta):
|
|||||||
# Config keys a remote client may supply for a session its own device licenses (a device identity
|
# Config keys a remote client may supply for a session its own device licenses (a device identity
|
||||||
# such as an ESN); the server's own values for these keys are withheld from that session.
|
# such as an ESN); the server's own values for these keys are withheld from that session.
|
||||||
CLIENT_CONFIG: tuple[str, ...] = ()
|
CLIENT_CONFIG: tuple[str, ...] = ()
|
||||||
|
# Every service option that makes the service call ``cdm.lend_server_device()``. A remote session with
|
||||||
|
# one of them set keeps the server CDM even when the remote client picked its own device.
|
||||||
|
SERVER_DEVICE_OPTIONS: tuple[str, ...] = ()
|
||||||
|
|
||||||
def __init__(self, ctx: click.Context):
|
def __init__(self, ctx: click.Context):
|
||||||
console.print(Padding(Rule(f"[rule.text]Service: {self.__class__.__name__}"), (1, 2)))
|
console.print(Padding(Rule(f"[rule.text]Service: {self.__class__.__name__}"), (1, 2)))
|
||||||
|
|||||||
@@ -156,11 +156,20 @@ class SubtitleCodecChoice(click.Choice):
|
|||||||
|
|
||||||
|
|
||||||
class ContextData:
|
class ContextData:
|
||||||
def __init__(self, config: dict, cdm: WidevineCdm, proxy_providers: list, profile: Optional[str] = None):
|
def __init__(
|
||||||
|
self,
|
||||||
|
config: dict,
|
||||||
|
cdm: WidevineCdm,
|
||||||
|
proxy_providers: list,
|
||||||
|
profile: Optional[str] = None,
|
||||||
|
cdm_entry: bool = False,
|
||||||
|
):
|
||||||
self.config = config
|
self.config = config
|
||||||
self.cdm = cdm
|
self.cdm = cdm
|
||||||
self.proxy_providers = proxy_providers
|
self.proxy_providers = proxy_providers
|
||||||
self.profile = profile
|
self.profile = profile
|
||||||
|
# True when the service's own entry in the cdm config named the loaded device
|
||||||
|
self.cdm_entry = cdm_entry
|
||||||
|
|
||||||
|
|
||||||
class SeasonRange(click.ParamType):
|
class SeasonRange(click.ParamType):
|
||||||
|
|||||||
Reference in New Issue
Block a user