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.
This commit is contained in:
imSp4rky
2026-11-03 18:27:49 -07:00
parent 893c81f428
commit b3d586a590
4 changed files with 133 additions and 28 deletions
+14 -2
View File
@@ -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=<seq>` 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=<seq>` 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=<seq>` 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
```
+65
View File
@@ -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"] == []
+5 -7
View File
@@ -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)
+49 -19
View File
@@ -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
"""