mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-11 19:45:06 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}}}
|
||||
|
||||
@@ -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 [])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user