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"
api_key: "your-api-key"
# Optional: let the server's CDM do the licensing instead of a local CDM
server_cdm: false
# Optional: per-service config overrides applied locally (title_map, cdm, etc.)
server_cdm: true
# Optional: per-service config overrides applied locally (title_map, cdm, server_cdm, etc.)
services:
EXAMPLE:
server_cdm: false # this service licenses with your local CDM
title_map:
"0ABCDEF": "The Show (Renamed Locally)"
```
@@ -74,8 +75,8 @@ remote_services:
|---|---|---|
| `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 |
| `server_cdm` | no | When `true`, DRM keys are resolved by the server's CDM rather than a local device (default `false`) |
| `services` | no | Per-service local config overrides, keyed by service tag |
| `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 in any case; a `server_cdm` here replaces the server entry's value for that service |
!!! warning "Port default"
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.
=== "Proxied CDM (default)"
=== "Proxied CDM (server_cdm: false)"
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
the raw license bytes come back for your local CDM to parse.
- Keeps your CDM local. The server only relays the license request.
- 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
- Used when `server_cdm` is `false`, or when it is unset and the API key has no server
CDM for the service.
- 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
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
+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,
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
starts. When `server_cdm` is unset, 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. 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).
```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
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
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
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.
`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.
name licenses the remote session, and it takes priority over `server_cdm: true`. When
`server_cdm` is unset, a [`cdm`](drm.md#cdm) entry for the service has the same effect for that
service when it names the device that unshackle loaded. The `default` 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
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.
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
with `dl --cdm`, with `server_cdm: false` in its `remote_services` entry or in the `services`
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
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():
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 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):
with caplog.at_level(logging.WARNING):
_, 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])
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"}})
svc, data = sent_create(None, local_cdm=local_cdm, cdm_map={"example1": {">=1080": "my_device"}})
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
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(
("entry", "profile", "names"),
[
@@ -751,8 +767,8 @@ def test_a_client_with_no_device_does_not_decline():
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, _ = sent_create(None, cdm_entry=True, cdm_map={"EXAMPLE1": "my_device"})
svc._server_cdm = None
svc.client = SimpleNamespace(post=lambda path, data: {"session_id": "sess", **answer})
svc.close = svc.start_keepalive = lambda: None
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):
svc.adopt_session_cdm(True, max_height)
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
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
services_config = dict(services_config)
if not apply_overrides:
services_config.pop(service_tag, None)
info = next((s for s in remote_service_list(server_name) or [] if s.get("tag") == service_tag), {})
tag = service_tag.lower()
services_config = {k: v for k, v in live.items() if not (isinstance(k, str) and k.lower() == tag)}
if isinstance(own, dict) and "server_cdm" in own:
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["_client_config"] = info.get("client_config")
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
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):
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 not None) 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"
@@ -1471,6 +1476,17 @@ class RemoteService:
"""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)
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:
"""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")
return
if self._server_cdm and not server_cdm and max_height is None:
self.log.warning(
f"{self.service_tag} is not available for server CDM licensing with this API key, "
"falling back to the local CDM"
)
self.report_local_fallback()
self._server_cdm = server_cdm
if max_height is None:
return
@@ -1840,10 +1853,7 @@ class RemoteService:
self._server_cdm = server_cdm
elif self._server_cdm and not server_cdm:
self._server_cdm = False
self.log.warning(
f"{self.service_tag} is not available for server CDM licensing with this API key, "
"falling back to the local CDM"
)
self.report_local_fallback()
self._server_cdm_type = result.get("server_cdm_type", "widevine")
self._sample_decrypt_tracks.update(result.get("sample_decrypt_tracks") or [])