fix(api): keep a pooled remote session to the api key that opened it

The pool key now includes the caller's API key and client_region, so a hit never skips the server_proxy and region checks that a fresh session runs.
This commit is contained in:
imSp4rky
2026-11-01 19:47:19 -07:00
parent 2bbb2d65b8
commit e509db618e
3 changed files with 40 additions and 8 deletions
+1 -1
View File
@@ -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=<name>` lists that server's services, each with `remote` set to the name. Add `"remote": "<name>"` 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 (<client_name>)`. 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=<name>` lists that server's services, each with `remote` set to the name. Add `"remote": "<name>"` 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 (<client_name>)`. 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.
+22 -2
View File
@@ -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"}})
+17 -5
View File
@@ -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: