feat(remote)!: let server_cdm outrank a service's cdm entry and set it per service

server_cdm: true on a remote_services entry now licenses remote runs with the server CDM even when the service has its own cdm entry, which stays the local-run device and the fallback. A services.<TAG>.server_cdm replaces the server value for one service, per-service entries match the tag in any case, and the configured fallback logs at info.

BREAKING CHANGE: with server_cdm: true, a per-service cdm entry no longer makes your own device license remote sessions; use dl --cdm or services.<TAG>.server_cdm: false.
This commit is contained in:
imSp4rky
2026-10-29 10:18:15 -06:00
parent d12a943d96
commit 7b927934e0
5 changed files with 147 additions and 37 deletions
+10 -8
View File
@@ -62,10 +62,11 @@ remote_services:
url: "https://my-box.example:8786" url: "https://my-box.example:8786"
api_key: "your-api-key" api_key: "your-api-key"
# Optional: let the server's CDM do the licensing instead of a local CDM # Optional: let the server's CDM do the licensing instead of a local CDM
server_cdm: false server_cdm: true
# Optional: per-service config overrides applied locally (title_map, cdm, etc.) # Optional: per-service config overrides applied locally (title_map, cdm, server_cdm, etc.)
services: services:
EXAMPLE: EXAMPLE:
server_cdm: false # this service licenses with your local CDM
title_map: title_map:
"0ABCDEF": "The Show (Renamed Locally)" "0ABCDEF": "The Show (Renamed Locally)"
``` ```
@@ -74,8 +75,8 @@ remote_services:
|---|---|---| |---|---|---|
| `url` | yes | Base URL of the `unshackle serve` instance (no trailing slash needed) | | `url` | yes | Base URL of the `unshackle serve` instance (no trailing slash needed) |
| `api_key` | no | Sent as the `X-Secret-Key` header on every request | | `api_key` | no | Sent as the `X-Secret-Key` header on every request |
| `server_cdm` | no | When `true`, DRM keys are resolved by the server's CDM rather than a local device (default `false`) | | `server_cdm` | no | `true` asks for the server's CDM, `false` uses your local device; unset follows the API key's server CDM grant |
| `services` | no | Per-service local config overrides, keyed by service tag | | `services` | no | Per-service local config overrides, keyed by service tag in any case; a `server_cdm` here replaces the server entry's value for that service |
!!! warning "Port default" !!! warning "Port default"
The `unshackle serve` default port is **`8786`** (not `8080`). Use the port The `unshackle serve` default port is **`8786`** (not `8080`). Use the port
@@ -267,16 +268,17 @@ hood the client walks a remote session through its lifecycle.
There are two ways DRM keys get resolved, chosen by the client's `server_cdm` flag. There are two ways DRM keys get resolved, chosen by the client's `server_cdm` flag.
=== "Proxied CDM (default)" === "Proxied CDM (server_cdm: false)"
Your **local CDM** builds the license challenge. The client sends that challenge Your **local CDM** builds the license challenge. The client sends that challenge
to `POST /api/session/{id}/license`, the server forwards it to the service, and to `POST /api/session/{id}/license`, the server forwards it to the service, and
the raw license bytes come back for your local CDM to parse. the raw license bytes come back for your local CDM to parse.
- 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`, or when it is unset and the API key has no server
- Also used for a run with `dl --cdm <name>`, and for a service whose own entry in CDM for the service.
your `cdm` config named the loaded device. In each case the client sends - Also used for a run with `dl --cdm <name>`, and, when `server_cdm` is unset, 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 `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. 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 - A service option that the service lists in `SERVER_DEVICE_OPTIONS` keeps the server
+4 -2
View File
@@ -17,8 +17,10 @@ Over `--remote`, an override also makes your device license the remote session,
CDM. A per-service entry does the same when it names a device without a quality: a device name, 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 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 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. starts. When `server_cdm` is unset, unshackle then shows a warning, and the server decides who
With only the top-level `default` entry, the server CDM licenses when the API key has it. See licenses the remote session.
With only the top-level `default` entry, the server CDM licenses when the API key has it. Set
`server_cdm: true` on the server entry to keep a per-service entry for local runs only. See
[`remote_services`](services.md#remote_services). [`remote_services`](services.md#remote_services).
```yaml ```yaml
+26 -7
View File
@@ -89,16 +89,35 @@ Definitions of remote unshackle service servers, used by the `--remote` mode. Ea
named by you (pick it with `--server`, or do not write that flag when you configure only one) and gives named by you (pick it with `--server`, or do not write that flag when you configure only one) and gives
the server's `url` (required), an optional `api_key`, an optional `auth_headers` list, an the server's `url` (required), an optional `api_key`, an optional `auth_headers` list, an
optional `server_cdm` boolean, an optional `timeout` in seconds, and an optional `services` sub-dict of per-service local optional `server_cdm` boolean, an optional `timeout` in seconds, and an optional `services` sub-dict of per-service local
overrides such as `title_map`. overrides such as `title_map`, keyed by service tag in any case. A `server_cdm` in a `services` entry replaces the `server_cdm` of
the server entry for that service.
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 `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 name licenses the remote session, and it takes priority over `server_cdm: true`. When
effect for that service when it names the device that unshackle loaded. Both take priority over `server_cdm` is unset, a [`cdm`](drm.md#cdm) entry for the service has the same effect for that
`server_cdm: true`. Remove the entry to use the server CDM for that service. The `default` service when it names the device that unshackle loaded. The `default` entry does not count.
entry does not count. With `server_cdm: true`, the server CDM licenses the remote session, and the device of the entry
licenses only when the server refuses. Thus the entry applies to local runs and the server CDM
applies to remote runs:
```yaml
cdm:
EXAMPLE1: my_device # local runs of EXAMPLE1
remote_services:
my_server:
url: https://example.com
server_cdm: true # remote runs of EXAMPLE1 use the server CDM
services:
EXAMPLE2:
server_cdm: false # remote runs of EXAMPLE2 use your own device
```
When the server refuses the server CDM for a service whose `cdm` entry loaded the device,
unshackle logs the fallback to that device as info, not as a warning.
The server has the last word. When it keeps the server CDM for a remote session that your 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 device was to license, unshackle shows a warning. This occurs with a server that does not read
@@ -180,8 +199,8 @@ an API key without `server_cdm` for that service also cannot submit or retry `/a
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 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 with `dl --cdm`, with `server_cdm: false` in its `remote_services` entry or in the `services`
`remote_services` entry. The server CDM makes no licence for that remote session, with or without entry for the service, or with a `cdm` entry for the service when `server_cdm` is unset. 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 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-identity flag, keeps the server CDM.
@@ -698,11 +698,20 @@ def test_the_client_declines_the_server_cdm_when_the_user_picked_a_device(server
def test_a_device_loaded_from_the_services_cdm_entry_declines_the_server_cdm(): 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"}) svc, data = sent_create(None, cdm_entry=True, cdm_map={"EXAMPLE1": "my_device"})
assert data["server_cdm"] is False assert data["server_cdm"] is False
assert svc._server_cdm is False assert svc._server_cdm is False
def test_server_cdm_true_outranks_the_services_cdm_entry(caplog):
with caplog.at_level(logging.WARNING):
svc, data = sent_create(True, cdm_entry=True, cdm_map={"EXAMPLE1": "my_device"})
assert "server_cdm" not in data
assert data["cdm_relay"] is True
assert svc._server_cdm is True
assert not caplog.text
def test_cdm_over_an_existing_cdm_entry_declines_without_a_warning(caplog): def test_cdm_over_an_existing_cdm_entry_declines_without_a_warning(caplog):
with caplog.at_level(logging.WARNING): with caplog.at_level(logging.WARNING):
_, data = sent_create(True, "my_device", cdm_map={"EXAMPLE1": {">=1080": "other"}}) _, data = sent_create(True, "my_device", cdm_map={"EXAMPLE1": {">=1080": "other"}})
@@ -721,12 +730,19 @@ def test_a_device_loaded_from_the_default_cdm_entry_does_not_decline(caplog):
@pytest.mark.parametrize("local_cdm", [SimpleNamespace(is_playready=False), None]) @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): 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): with caplog.at_level(logging.WARNING):
svc, data = sent_create(True, local_cdm=local_cdm, cdm_map={"example1": {">=1080": "my_device"}}) svc, data = sent_create(None, local_cdm=local_cdm, cdm_map={"example1": {">=1080": "my_device"}})
assert "server_cdm" not in data assert "server_cdm" not in data
assert svc._server_cdm is True assert svc._server_cdm is None
assert "cdm entry for EXAMPLE1" in caplog.text assert "cdm entry for EXAMPLE1" in caplog.text
def test_server_cdm_true_does_not_warn_about_a_cdm_entry_that_loaded_no_device(caplog):
with caplog.at_level(logging.WARNING):
_, data = sent_create(True, cdm_map={"example1": {">=1080": "my_device"}})
assert "server_cdm" not in data
assert not caplog.text
@pytest.mark.parametrize( @pytest.mark.parametrize(
("entry", "profile", "names"), ("entry", "profile", "names"),
[ [
@@ -751,8 +767,8 @@ def test_a_client_with_no_device_does_not_decline():
def created(answer): def created(answer):
"""Run ``authenticate()`` to its end for a device the service's cdm entry loaded.""" """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, _ = sent_create(None, cdm_entry=True, cdm_map={"EXAMPLE1": "my_device"})
svc._server_cdm = True svc._server_cdm = None
svc.client = SimpleNamespace(post=lambda path, data: {"session_id": "sess", **answer}) svc.client = SimpleNamespace(post=lambda path, data: {"session_id": "sess", **answer})
svc.close = svc.start_keepalive = lambda: None svc.close = svc.start_keepalive = lambda: None
svc.authenticate() svc.authenticate()
@@ -808,3 +824,64 @@ def test_a_declined_server_cdm_that_the_server_keeps_is_reported(max_height, cap
with caplog.at_level(logging.WARNING): with caplog.at_level(logging.WARNING):
svc.adopt_session_cdm(True, max_height) svc.adopt_session_cdm(True, max_height)
assert "your own device is not used" not in caplog.text assert "your own device is not used" not in caplog.text
@pytest.mark.parametrize(("cdm_entry", "level"), [(True, logging.INFO), (False, logging.WARNING)])
def test_a_refused_server_cdm_warns_only_when_no_cdm_entry_loaded_the_fallback(cdm_entry, level, caplog):
svc = remote(SimpleNamespace(is_playready=False), {})
svc.service_tag = "EXAMPLE1"
svc.ctx.obj.cdm_entry = cdm_entry
with caplog.at_level(logging.INFO):
svc.adopt_session_cdm(False, None)
assert svc._server_cdm is False
[record] = [r for r in caplog.records if "falling back to the local CDM" in r.getMessage()]
assert record.levelno == level
@pytest.mark.parametrize("apply_overrides", [True, False])
@pytest.mark.parametrize(
("server_level", "service_entry", "expected"),
[
(True, {"server_cdm": False}, False),
(False, {"server_cdm": True}, True),
(True, {}, True),
(None, {}, None),
],
)
def test_a_per_service_server_cdm_overrides_the_server_entry(
server_level, service_entry, expected, apply_overrides, monkeypatch
):
import unshackle.core.remote_service as remote_mod
live = {"EXAMPLE1": {**service_entry, "title_map": {"a": "b"}}, "_server_cdm": server_level}
monkeypatch.setattr(remote_mod, "resolve_server", lambda name: ("http://127.0.0.1:1", "", live))
monkeypatch.setattr(remote_mod, "remote_service_list", lambda name: [])
monkeypatch.setattr(handlers.config, "services", {})
name = RemoteService.__name__
try:
svc = remote_mod.make_remote_service(
SimpleNamespace(obj=None), None, "EXAMPLE1", "t1", {}, apply_overrides=apply_overrides
)
finally:
RemoteService.__name__ = name # the constructor renames the class
assert svc._server_cdm is expected
assert handlers.config.services.get("EXAMPLE1") == ({"title_map": {"a": "b"}} if apply_overrides else None)
assert live["EXAMPLE1"] == {**service_entry, "title_map": {"a": "b"}}
@pytest.mark.parametrize(("config_key", "tag"), [("example1", "EXAMPLE1"), ("EXAMPLE1", "example1")])
def test_a_per_service_entry_matches_the_tag_in_any_case(config_key, tag, monkeypatch):
import unshackle.core.remote_service as remote_mod
live = {config_key: {"server_cdm": False, "title_map": {"a": "b"}}, "_server_cdm": True}
monkeypatch.setattr(remote_mod, "resolve_server", lambda name: ("http://127.0.0.1:1", "", live))
monkeypatch.setattr(remote_mod, "remote_service_list", lambda name: [{"tag": "EXAMPLE1", "anonymous_search": True}])
monkeypatch.setattr(handlers.config, "services", {})
name = RemoteService.__name__
try:
svc = remote_mod.make_remote_service(SimpleNamespace(obj=None), None, tag, "t1", {})
finally:
RemoteService.__name__ = name # the constructor renames the class
assert svc._server_cdm is False
assert svc._anonymous_search is True
assert handlers.config.services == {tag: {"title_map": {"a": "b"}}}
+25 -15
View File
@@ -897,12 +897,17 @@ def make_remote_service(
``apply_overrides`` False keeps the per-service ``remote_services.<name>.services`` overrides out of this ``apply_overrides`` False keeps the per-service ``remote_services.<name>.services`` overrides out of this
process's global config. process's global config.
""" """
url, api_key, services_config = resolve_server(server_name) url, api_key, live = resolve_server(server_name)
own = ci_get(live, service_tag)
# resolve_server hands back the live config dict; concurrent requests for other tags must not share it # resolve_server hands back the live config dict; concurrent requests for other tags must not share it
services_config = dict(services_config) tag = service_tag.lower()
if not apply_overrides: services_config = {k: v for k, v in live.items() if not (isinstance(k, str) and k.lower() == tag)}
services_config.pop(service_tag, None) if isinstance(own, dict) and "server_cdm" in own:
info = next((s for s in remote_service_list(server_name) or [] if s.get("tag") == service_tag), {}) own = dict(own)
services_config["_server_cdm"] = own.pop("server_cdm")
if apply_overrides and own is not None:
services_config[service_tag] = own
info = next((s for s in remote_service_list(server_name) or [] if str(s.get("tag", "")).lower() == tag), {})
services_config["_server_accounts"] = info.get("server_accounts") services_config["_server_accounts"] = info.get("server_accounts")
services_config["_client_config"] = info.get("client_config") services_config["_client_config"] = info.get("client_config")
services_config["_anonymous_search"] = info.get("anonymous_search") services_config["_anonymous_search"] = info.get("anonymous_search")
@@ -1387,8 +1392,8 @@ 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_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 cdm_entry = cdm is not None and self.ctx.obj.cdm_entry and self._server_cdm is None
if not (cdm_entry or cdm_name or self._server_cdm is False) and ci_get(config.cdm, self.service_tag): if not (cdm_entry or cdm_name or self._server_cdm is not None) and ci_get(config.cdm, self.service_tag):
self.log.warning( self.log.warning(
f"The cdm entry for {self.service_tag} did not load a device for this remote session, " f"The cdm entry for {self.service_tag} did not load a device for this remote session, "
"so the server decides who licenses it" "so the server decides who licenses it"
@@ -1471,6 +1476,17 @@ class RemoteService:
"""True for a remote session on the server's device whose API key cannot read the server vault.""" """True for a remote session on the server's device whose API key cannot read the server vault."""
return getattr(self, "_local_vaults_only", False) return getattr(self, "_local_vaults_only", False)
def report_local_fallback(self) -> None:
"""Say the local CDM licenses because the server CDM is not granted.
Only info when the service's own cdm entry loaded that device, since the entry is the configured fallback.
"""
log = self.log.info if getattr(self.ctx.obj, "cdm_entry", False) else self.log.warning
log(
f"{self.service_tag} is not available for server CDM licensing with this API key, "
"falling back to the local CDM"
)
def adopt_session_cdm(self, server_cdm: bool, max_height: Optional[int]) -> 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. """Take the server's choice of who licenses this remote session, and say why when a cap made it.
@@ -1486,10 +1502,7 @@ class RemoteService:
self.log.warning("The server CDM licenses this remote session, so your own device is not used") self.log.warning("The server CDM licenses this remote session, so your own device is not used")
return 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.report_local_fallback()
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 self._server_cdm = server_cdm
if max_height is None: if max_height is None:
return return
@@ -1840,10 +1853,7 @@ class RemoteService:
self._server_cdm = server_cdm self._server_cdm = server_cdm
elif self._server_cdm and not server_cdm: elif self._server_cdm and not server_cdm:
self._server_cdm = False self._server_cdm = False
self.log.warning( self.report_local_fallback()
f"{self.service_tag} is not available for server CDM licensing with this API key, "
"falling back to the local CDM"
)
self._server_cdm_type = result.get("server_cdm_type", "widevine") self._server_cdm_type = result.get("server_cdm_type", "widevine")
self._sample_decrypt_tracks.update(result.get("sample_decrypt_tracks") or []) self._sample_decrypt_tracks.update(result.get("sample_decrypt_tracks") or [])