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:
imSp4rky
2026-10-01 13:21:53 -06:00
parent af17bad010
commit 9a1abd23b6
14 changed files with 292 additions and 20 deletions
+9 -1
View File
@@ -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`,
+1 -1
View File
@@ -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. |
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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.
+8
View File
@@ -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
+15
View File
@@ -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
+179 -9
View File
@@ -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
+15 -3
View File
@@ -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,
+9
View File
@@ -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",
+7
View File
@@ -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]
+11
View File
@@ -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
+23 -3
View File
@@ -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:
+3
View File
@@ -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)))
+10 -1
View File
@@ -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):