diff --git a/docs/dev/rest-api/endpoints.md b/docs/dev/rest-api/endpoints.md index 30e3e4d7..f556ce51 100644 --- a/docs/dev/rest-api/endpoints.md +++ b/docs/dev/rest-api/endpoints.md @@ -17,7 +17,7 @@ Start the server with [`unshackle serve`](../../guide/cli-reference.md). By defa - **CORS.** Every response carries permissive CORS headers, and the server answers `OPTIONS` preflight requests automatically. - **Compression.** JSON responses of 256 bytes or more are gzip-compressed when the client sends `Accept-Encoding: gzip`. A [slow response](#slow-responses) is not compressed. - **Service allowlist.** The effective allowlist for your API key filters the service-facing endpoints (the intersection of the global `serve.services` list and your per-key list). The server treats services you cannot use as unknown. -- **Remote services.** A full-mode server can run a service through one of its own `remote_services` servers (see [Remote sessions](remote-sessions.md)). `GET /api/config` lists their names in `remote_services`, and `GET /api/services?remote=` lists that server's services, each with `remote` set to the name. Add `"remote": ""` to `POST /api/search`, `/api/list-titles`, `/api/list-tracks` or `/api/download`, and the server opens a remote session with its own key for that server: the remote logs in and resolves titles, tracks and licences, and a download still runs, decrypts and muxes here. The tag may be any case or an alias, and your allowlist applies to it. An unknown name is `400 INVALID_PARAMETERS`, a server that does not answer is `SERVICE_UNAVAILABLE`, and a tag it does not offer is `INVALID_SERVICE`. List calls for the same title, profile, proxy and service options share one remote session, which closes after 120 seconds unused. An optional `client_name` names your client on the remote server's dashboard as `serve ()`. A relayed search opens no remote session. The server sends its own credentials, cookies and cache for the `profile` with the search. A service that searches with no login gets none of them. A `--remote-only` server does not relay. A remote login that waits on you (a pair code or URL to approve on another device) cannot answer through a list call, so poll `GET /api/remote/logins` alongside it: each entry carries the login's `notice`, and its `prompt` when it asks a question (an OTP), which you answer with `POST /api/remote/logins/{id}/input` and `{"response": "..."}`. A remote download job shows the same text as its own `notice`, and asks a question (an OTP) through `input_prompt` as a local job does. +- **Remote services.** A full-mode server can run a service through one of its own `remote_services` servers (see [Remote sessions](remote-sessions.md)). `GET /api/config` lists their names in `remote_services`, and `GET /api/services?remote=` lists that server's services, each with `remote` set to the name. Add `"remote": ""` to `POST /api/search`, `/api/list-titles`, `/api/list-tracks` or `/api/download`, and the server opens a remote session with its own key for that server: the remote logs in and resolves titles, tracks and licences, and a download still runs, decrypts and muxes here. The tag may be any case or an alias, and your allowlist applies to it. An unknown name is `400 INVALID_PARAMETERS`, a server that does not answer is `SERVICE_UNAVAILABLE`, and a tag it does not offer is `INVALID_SERVICE`. List calls from the same API key for the same title, profile, proxy, client region and service options share one remote session, which closes after 120 seconds unused. An optional `client_name` names your client on the remote server's dashboard as `serve ()`. A relayed search opens no remote session. The server sends its own credentials, cookies and cache for the `profile` with the search. A service that searches with no login gets none of them. A `--remote-only` server does not relay. A remote login that waits on you (a pair code or URL to approve on another device) cannot answer through a list call, so poll `GET /api/remote/logins` alongside it: each entry carries the login's `notice`, and its `prompt` when it asks a question (an OTP), which you answer with `POST /api/remote/logins/{id}/input` and `{"response": "..."}`. A remote download job shows the same text as its own `notice`, and asks a question (an OTP) through `input_prompt` as a local job does. !!! warning "Error responses have two shapes" Structured application errors (raised inside a handler) return `{"status": "error", "error_code": "...", "message": "...", "timestamp": "..."}`. The authentication middleware, however, returns a **different** shape: `{"status": 401, "message": "..."}` with an integer `status` and no `error_code`. The same middleware answers `429` in that shape when an API key goes over its rate limit. Clients must accept both shapes. The [Error responses](#error-responses) section at the bottom of this page gives the full error model. diff --git a/tests/remote/unit/test_remote_relay.py b/tests/remote/unit/test_remote_relay.py index 3717e151..aee8ba71 100644 --- a/tests/remote/unit/test_remote_relay.py +++ b/tests/remote/unit/test_remote_relay.py @@ -1,6 +1,7 @@ import asyncio import json import time +from types import SimpleNamespace import pytest @@ -43,8 +44,8 @@ def pool(monkeypatch): return logins -def lease(data, **extra): - return handlers.lease_remote_service({**data, **extra}, "srv", INFO, None, "show") +def lease(data, request=None, **extra): + return handlers.lease_remote_service({**data, **extra}, "srv", INFO, None, "show", request) DATA = {"service": "EXAMPLE", "title_id": "show", "remote": "srv"} @@ -81,6 +82,25 @@ async def test_only_options_that_reach_the_remote_split_the_pool(pool): assert a.service is c.service +def keyed(key): + return SimpleNamespace(headers={"X-Secret-Key": key}) + + +async def test_each_api_key_gets_its_own_pooled_session(pool): + a = await lease(DATA, keyed("a"), proxy="us") + again = await lease(DATA, keyed("a"), proxy="us") + b = await lease(DATA, keyed("b"), proxy="us") + assert a.service is again.service + assert b.service is not a.service + assert pool == ["show", "show"] + + +async def test_client_region_splits_the_pool(pool): + us = await lease(DATA, client_region="us") + gb = await lease(DATA, client_region="gb") + assert us.service is not gb.service + + @pytest.fixture def remote_config(monkeypatch): monkeypatch.setattr(config, "remote_services", {"srv": {"url": "https://srv", "api_key": "k"}}) diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index 8e4ec080..91534d3e 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -587,7 +587,7 @@ class RemoteLease: Only the event loop touches the pool, so it needs no thread lock; blocking work runs in threads. """ - key: tuple + key: tuple = field(repr=False) # holds the caller's API key lock: asyncio.Lock = field(default_factory=asyncio.Lock) service: Any = None error: Optional[Exception] = None @@ -600,12 +600,24 @@ _remote_pool: Dict[tuple, RemoteLease] = {} _remote_sweeper: Optional["asyncio.Task[None]"] = None -def remote_pool_key(data: Dict[str, Any], server: str, info: dict, profile: Optional[str], title_id: str) -> tuple: - """What opens a remote session; requests that differ only in episode selection share one.""" +def remote_pool_key( + data: Dict[str, Any], server: str, info: dict, profile: Optional[str], title_id: str, request: Optional[web.Request] +) -> tuple: + """What opens a remote session for one API key; requests that differ only in episode selection share one.""" from unshackle.core.remote_service import remote_service_params options = json.dumps(remote_service_params(info, data), sort_keys=True, default=str) - return (server, data["service"], title_id, profile, data.get("proxy"), bool(data.get("no_proxy")), options) + return ( + caller_key(request), + server, + data["service"], + title_id, + profile, + data.get("proxy"), + data.get("client_region"), + bool(data.get("no_proxy")), + options, + ) def close_remote_services(services: List[Any]) -> None: @@ -661,7 +673,7 @@ async def lease_remote_service( global _remote_sweeper if _remote_sweeper is None or _remote_sweeper.done(): _remote_sweeper = asyncio.get_running_loop().create_task(sweep_remote_pool()) - key = remote_pool_key(data, server, info, profile, title_id) + key = remote_pool_key(data, server, info, profile, title_id, request) lease = _remote_pool.setdefault(key, RemoteLease(key)) lease.busy += 1 try: