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