From b3d586a590325b30b146217d0db49cdc045944b2 Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Tue, 3 Nov 2026 18:27:49 -0700 Subject: [PATCH] feat(api): carry the service catalogue on /api/dashboard/services Rows now include the /api/services fields (help, cli_params, auth_methods, ...) and server_accounts. Both routes build the catalogue off the event loop, and an enum choice or a non-JSON list default no longer fails the whole route. --- docs/dev/rest-api/dashboard.md | 16 ++++++- tests/remote/unit/test_dashboard.py | 65 +++++++++++++++++++++++++++ unshackle/core/api/handlers.py | 12 +++-- unshackle/core/api/routes.py | 68 +++++++++++++++++++++-------- 4 files changed, 133 insertions(+), 28 deletions(-) diff --git a/docs/dev/rest-api/dashboard.md b/docs/dev/rest-api/dashboard.md index a0da8981..c9d24ceb 100644 --- a/docs/dev/rest-api/dashboard.md +++ b/docs/dev/rest-api/dashboard.md @@ -53,7 +53,7 @@ All are `GET`, except the two `drain` routes. | `/api/dashboard/cdm-logs` | `{"seq": N, "records": [...], "sessions": [...]}`: the last 5000 calls to the Widevine and PlayReady CDM device routes, and the open sessions of each device. `?since=` returns only newer records. See [CDM calls](#cdm-calls). | | `/api/dashboard/sessions/{id}/logs` | One remote session's service log: `{"session_id", "last_seq", "records": [...]}`. Each record has `seq`, `ts`, `level`, `message`. `?since=` returns only newer records. 404 when the remote session is unknown. | | `/api/dashboard/keys` | Every configured API key: what it may do and what it has done. One row per key in `serve.users`, plus `serve.api_secret` when it is set. | -| `/api/dashboard/services` | Every service configured on this server and its load state, including the ones that failed to import. See [Services](#services) for what configured means. | +| `/api/dashboard/services` | Every service configured on this server, its load state and its catalogue entry, including the services that failed to import. See [Services](#services) for what configured means. | | `/api/dashboard/health` | Preflight: whether this instance could finish a download. Cached 30 seconds. | | `/api/dashboard/events` | Server-Sent Events stream. With `?since=` it returns `{"seq", "stats", "events": [...]}` instead: every event after `seq` (the server keeps the last 20000, log records included, so a poll every few seconds is safe) as one JSON burst, so a UI that polls every few seconds still sees a remote session that opened and closed in between. | @@ -238,7 +238,10 @@ GET /api/dashboard/services [{"tag": "EXAMPLE", "state": "staged", "error": null, "commit": "9cc230c98f13b83f6c6fe831595b9702d3ba2786", "staged_commit": "5f0aa17...", "staged_since": 1756908900.0, "sessions": 1, "jobs": 1, - "aliases": ["EXMPL"], "geofence": ["US"], "geoblock": []}] + "aliases": ["EXMPL"], "geofence": ["US"], "geoblock": [], + "help": "...", "url": "https://www.service.example", "title_regex": "...", "cli_params": [...], + "needs_auth": true, "auth_methods": ["cookies"], "has_search": true, "anonymous_search": false, + "has_drm": true, "server_accounts": {"regions": ["gb", "us"], "global": false}}] ``` The list holds only the services configured on this server: @@ -269,6 +272,15 @@ for a service from a plain local directory rather than a git repo. Watch the `service` event rather than polling for the swap. +Each row also carries the service's catalogue entry: the fields that +[`GET /api/services`](endpoints.md#get-apiservices) sends, with the same meaning. A service with no loaded +module, or whose catalogue entry fails, gets only `help`, `url` and `title_regex`, all `null`. +A service whose re-import failed keeps the entry of the module that still runs. + +`server_accounts` shows when `serve.server_accounts` names the service. No API key grant +applies to it here: `/api/services` sends it only to an API key with the `server_accounts` +grant, but the dashboard key sees it for every service. + ## Health ``` diff --git a/tests/remote/unit/test_dashboard.py b/tests/remote/unit/test_dashboard.py index b9e9e529..548a7129 100644 --- a/tests/remote/unit/test_dashboard.py +++ b/tests/remote/unit/test_dashboard.py @@ -682,3 +682,68 @@ async def test_health_probe_timeout_reports_what_finished(aiohttp_client, dashbo # The timeout is cached for the full TTL, so callers cannot stack up more stalled threads. assert (await client.get("/api/dashboard/health", headers={"X-Secret-Key": "dash-secret"})).status == 200 assert handlers._health_cache["payload"]["status"] == "failing" + + +async def test_dashboard_services_carry_the_catalogue(aiohttp_client, dashboard_cfg, monkeypatch) -> None: + import enum + from pathlib import Path + + import click + + from unshackle.core import services as services_module + from unshackle.core.api import routes + from unshackle.core.service import Service + + class Mode(enum.Enum): + FAST = 1 + + class Loaded(Service): + """How to use AAA.""" + + cli = click.Command( + "AAA", + params=[ + click.Argument(["title"]), + click.Option(["--mode"], type=click.Choice(Mode)), + click.Option(["--dir"], multiple=True, default=[Path("/x")]), + ], + short_help="https://aaa.example", + ) + + class NoGeofence(Service): + """How to use DDD.""" + + GEOFENCE = None + + real_details = routes.service_details + + def details(tag: str) -> dict: + if tag == "CCC": + raise RuntimeError("bad module") + return real_details(tag) + + tags = ("AAA", "BBB", "CCC", "DDD") + monkeypatch.setattr(services_module, "SERVICES", [Path(f"/svc/{t}/__init__.py") for t in tags]) + monkeypatch.setattr(services_module, "MODULES", {"AAA": Loaded, "CCC": Loaded, "DDD": NoGeofence}) + monkeypatch.setattr(services_module, "LOAD_ERRORS", ["BBB: failed to import - ImportError: boom"]) + monkeypatch.setattr(services_module, "PENDING", {"DDD"}) + monkeypatch.setattr(routes, "SERVICE_DETAILS", {}) + monkeypatch.setattr(routes, "service_details", details) + monkeypatch.setitem(config.serve, "server_accounts", {"aaa": {"default": ["us", "gb"]}}) + + client = await aiohttp_client(make_app()) + resp = await client.get("/api/dashboard/services", headers={"X-Secret-Key": "dash-secret"}) + assert resp.status == 200 + rows = {r["tag"]: r for r in await resp.json()} + + assert rows["AAA"]["help"] == "How to use AAA." and rows["AAA"]["url"] == "https://aaa.example" + title, mode, folder = rows["AAA"]["cli_params"] + assert title == {"name": "title", "kind": "argument", "required": True} + assert mode["choices"] == ["FAST"] and folder["default"] == ["x"] + assert rows["AAA"]["state"] == "loaded" + assert rows["AAA"]["server_accounts"] == {"regions": ["gb", "us"], "global": False} + for tag in ("BBB", "CCC"): + assert rows[tag]["help"] is None and "cli_params" not in rows[tag] and "server_accounts" not in rows[tag] + assert rows["BBB"]["state"] == "failed" + assert rows["DDD"]["state"] == "staged" and rows["DDD"]["help"] == "How to use DDD." + assert rows["DDD"]["geofence"] == [] diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index 36d3981a..92a48f8d 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -2821,6 +2821,7 @@ async def dashboard_services_handler(request: web.Request) -> web.Response: """Every service configured on this server, or every installed one with ``?all=1``, and its load state.""" from unshackle.core import services as services_module from unshackle.core.api.download_manager import TERMINAL_STATUSES, get_download_manager + from unshackle.core.api.routes import SERVICE_DEFAULTS, service_catalogue # lazy: routes imports this module from unshackle.core.api.session_store import get_session_store from unshackle.core.service_repo import head @@ -2830,11 +2831,13 @@ async def dashboard_services_handler(request: web.Request) -> web.Response: staged_commits: Dict[Any, Optional[str]] = {} tags = Services.get_tags() if request.query.get("all") == "1" else configured_service_tags() + catalogue = await asyncio.to_thread(service_catalogue, [tag for tag in tags if tag in services_module.MODULES]) rows = [] for tag in tags: error = errors.get(tag) staged = tag in services_module.PENDING row: Dict[str, Any] = { + **catalogue.get(tag, SERVICE_DEFAULTS), "tag": tag, "state": "failed" if error else "staged" if staged else "loaded", "error": error, @@ -2843,9 +2846,6 @@ async def dashboard_services_handler(request: web.Request) -> web.Response: "staged_since": services_module.PENDING_SINCE.get(tag), "sessions": sessions.get(tag, 0), "jobs": jobs.get(tag, 0), - "aliases": list(services_module.ALIASES.get(tag, ())), - "geofence": [], - "geoblock": [], } if staged: # Only staged tags need a git call; the working tree already holds the new commit. @@ -2853,10 +2853,8 @@ async def dashboard_services_handler(request: web.Request) -> web.Response: if source is not None and source not in staged_commits: staged_commits[source] = head(source) if (source / ".git").exists() else None row["staged_commit"] = staged_commits.get(source) - module = services_module.MODULES.get(tag) - if module is not None: - row["geofence"] = list(getattr(module, "GEOFENCE", ()) or ()) - row["geoblock"] = list(getattr(module, "GEOBLOCK", ()) or ()) + if (accounts := server_account_regions(tag)) is not None: + row["server_accounts"] = accounts rows.append(row) return web.json_response(rows) diff --git a/unshackle/core/api/routes.py b/unshackle/core/api/routes.py index c9cd781a..c78bf60a 100644 --- a/unshackle/core/api/routes.py +++ b/unshackle/core/api/routes.py @@ -46,6 +46,7 @@ from unshackle.core.api.handlers import ( download_history_handler, download_job_events_handler, download_job_input_handler, + enum_name, env_check_handler, get_allowed_services, get_download_job_handler, @@ -296,6 +297,25 @@ SERVICE_DEFAULTS: dict[str, Any] = { SERVICE_DETAILS: dict[str, tuple[Any, dict[str, Any]]] = {} +def json_value(value: Any) -> Any: + """A click default or choice as JSON: a named object such as an enum member by its name, else text.""" + if value is None or (isinstance(value, (str, int, float, bool)) and not hasattr(value, "name")): + return value + return enum_name(value) + + +def service_catalogue(tags: list[str]) -> dict[str, dict[str, Any]]: + """The catalogue entry of each tag, or the defaults for one that fails; slow on the first call after a reload.""" + entries: dict[str, dict[str, Any]] = {} + for tag in tags: + try: + entries[tag] = service_details(tag) + except Exception as e: + log.warning(f"Could not load details for service {tag}: {e}") + entries[tag] = {**SERVICE_DEFAULTS, "tag": tag} + return entries + + def service_details(tag: str) -> dict[str, Any]: """The catalogue entry of one service, built again only when its service module reloads.""" service_module = Services.load(tag) @@ -304,11 +324,8 @@ def service_details(tag: str) -> dict[str, Any]: return hit[1] service_data: dict[str, Any] = {**SERVICE_DEFAULTS, "tag": tag} - if hasattr(service_module, "ALIASES"): - service_data["aliases"] = list(service_module.ALIASES) - - if hasattr(service_module, "GEOFENCE"): - service_data["geofence"] = list(service_module.GEOFENCE) + service_data["aliases"] = list(getattr(service_module, "ALIASES", ()) or ()) + service_data["geofence"] = list(getattr(service_module, "GEOFENCE", ()) or ()) service_data["geoblock"] = list(getattr(service_module, "GEOBLOCK", ()) or ()) if hasattr(service_module, "TITLE_RE"): @@ -342,19 +359,17 @@ def service_details(tag: str) -> dict[str, Any]: param_info["opts"] = list(param.opts) if hasattr(param, "opts") else [] param_info["is_flag"] = getattr(param, "is_flag", False) default = param.default - if default is None: - pass - elif callable(default) or type(default).__name__ == "Sentinel": + if callable(default) or type(default).__name__ == "Sentinel": default = None - elif hasattr(default, "name"): - default = default.name - elif not isinstance(default, (str, int, float, bool, list)): - default = str(default) + elif isinstance(default, list): + default = [json_value(item) for item in default] + else: + default = json_value(default) param_info["default"] = default param_info["help"] = getattr(param, "help", None) param_info["type"] = param.type.name if hasattr(param.type, "name") else str(param.type) if isinstance(param.type, click.Choice): - param_info["choices"] = list(param.type.choices) + param_info["choices"] = [json_value(choice) for choice in param.type.choices] param_info["multiple"] = getattr(param, "multiple", False) cli_params.append(param_info) service_data["cli_params"] = cli_params @@ -514,13 +529,10 @@ async def services(request: web.Request) -> web.Response: remote_info = await asyncio.to_thread(remote_services_info, remote, allowed) return web.json_response({"services": remote_info, "load_errors": []}) services_info = [] + catalogue = await asyncio.to_thread(service_catalogue, service_tags) for tag in service_tags: - try: - service_data: dict[str, Any] = {**service_details(tag)} - except Exception as e: - log.warning(f"Could not load details for service {tag}: {e}") - service_data = {**SERVICE_DEFAULTS, "tag": tag} + service_data: dict[str, Any] = {**catalogue[tag]} if tag in services_module.PENDING: service_data["pending_update"] = True if server_accounts_allowed(request, tag) and (server_accounts := server_account_regions(tag)) is not None: @@ -3102,7 +3114,7 @@ async def dashboard_keys(request: web.Request) -> web.Response: @api_handler async def dashboard_services(request: web.Request) -> web.Response: """ - Dashboard: every configured service and its load state. + Dashboard: every configured service, its load state and its catalogue entry. --- summary: Dashboard services description: > @@ -3169,6 +3181,24 @@ async def dashboard_services(request: web.Request) -> web.Response: type: array items: type: string + server_accounts: + type: object + description: > + Regions the server's own accounts cover. Only when `serve.server_accounts` names + the service. No API key grant applies here, unlike `/api/services`. + properties: + regions: + type: array + items: + type: string + global: + type: boolean + additionalProperties: + description: > + The `/api/services` catalogue fields (help, url, title_regex, cli_params, + needs_auth, auth_methods, has_search, anonymous_search, has_drm, client_config). + A service with no loaded module, or whose catalogue entry fails, gets only + help, url and title_regex, all null. '401': description: Dashboard key missing or invalid """