From 7b927934e0f8536168df2872e2b707eeab0c1f90 Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Thu, 29 Oct 2026 10:18:15 -0600 Subject: [PATCH] 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..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..server_cdm: false. --- docs/dev/rest-api/remote-sessions.md | 18 ++-- docs/reference/configuration/drm.md | 6 +- docs/reference/configuration/services.md | 33 +++++-- .../remote/unit/test_server_cdm_max_height.py | 87 +++++++++++++++++-- unshackle/core/remote_service.py | 40 +++++---- 5 files changed, 147 insertions(+), 37 deletions(-) diff --git a/docs/dev/rest-api/remote-sessions.md b/docs/dev/rest-api/remote-sessions.md index 7097e522..c6b8765d 100644 --- a/docs/dev/rest-api/remote-sessions.md +++ b/docs/dev/rest-api/remote-sessions.md @@ -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 `, 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 `, 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 diff --git a/docs/reference/configuration/drm.md b/docs/reference/configuration/drm.md index 1d53575f..4c95b2d1 100644 --- a/docs/reference/configuration/drm.md +++ b/docs/reference/configuration/drm.md @@ -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 diff --git a/docs/reference/configuration/services.md b/docs/reference/configuration/services.md index f20447e3..b99533cd 100644 --- a/docs/reference/configuration/services.md +++ b/docs/reference/configuration/services.md @@ -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 ` 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. diff --git a/tests/remote/unit/test_server_cdm_max_height.py b/tests/remote/unit/test_server_cdm_max_height.py index 81b857dc..18128166 100644 --- a/tests/remote/unit/test_server_cdm_max_height.py +++ b/tests/remote/unit/test_server_cdm_max_height.py @@ -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"}}} diff --git a/unshackle/core/remote_service.py b/unshackle/core/remote_service.py index 608379a2..f7c5dd62 100644 --- a/unshackle/core/remote_service.py +++ b/unshackle/core/remote_service.py @@ -897,12 +897,17 @@ def make_remote_service( ``apply_overrides`` False keeps the per-service ``remote_services..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 [])