refactor(api): register each route once and merge duplicated handler code

Swagger now owns route registration instead of adding a second dead copy of every route; add json_body, enqueue_job, serialize_tracks and client_credential, build the serve middleware list once, and scan the users once per request.
This commit is contained in:
imSp4rky
2026-11-02 15:06:52 -07:00
parent 47d9e3d3c9
commit e685710fba
6 changed files with 144 additions and 283 deletions
+10
View File
@@ -347,3 +347,13 @@ def test_setup_routes_cdm_only_keeps_health_and_dashboard_only() -> None:
assert "/api/health" in paths
assert any(path.startswith("/api/dashboard/") for path in paths)
assert all(path == "/api/health" or path.startswith("/api/dashboard/") for path in paths)
def test_setup_routes_with_docs_registers_each_route_once() -> None:
"""Swagger registers the routes itself, so docs add none twice and drop none."""
plain, documented = web.Application(), web.Application()
setup_routes(plain, dashboard=True)
setup_routes(documented, dashboard=True, docs=True)
routes_with_docs = Counter((r.method, r.resource.canonical) for r in documented.router.routes())
assert max(routes_with_docs.values()) == 1
assert {route for route in routes_with_docs if not route[1].startswith("/api/docs")} == set(collect_paths(plain))
+28 -56
View File
@@ -16,7 +16,7 @@ from aiohttp import web
import unshackle.core.config as config_module
from unshackle.core import binaries
from unshackle.core.api import cors_middleware, no_5xx_middleware, setup_routes, setup_swagger
from unshackle.core.api import cors_middleware, no_5xx_middleware, setup_routes
from unshackle.core.api.compression import compression_middleware
from unshackle.core.api.events import publish_refresh_events, publish_service_event
from unshackle.core.api.handlers import (
@@ -493,29 +493,17 @@ def serve(
serve_config, playready_config = _build_cdm_config(config.serve, api_secret, serve_widevine, serve_playready)
prd_devices = playready_config["devices"]
base_middlewares = [cors_middleware, no_5xx_middleware, stats_middleware]
if api_only:
log.info("Starting REST API server (pywidevine/pyplayready CDM disabled)")
if no_key:
app = web.Application(
middlewares=[
cors_middleware,
no_5xx_middleware,
stats_middleware,
dashboard_authentication,
compression_middleware,
]
)
else:
app = web.Application(
middlewares=[
cors_middleware,
no_5xx_middleware,
stats_middleware,
dashboard_authentication,
api_key_authentication,
compression_middleware,
]
)
app = web.Application(
middlewares=[
*base_middlewares,
dashboard_authentication,
*([] if no_key else [api_key_authentication]),
compression_middleware,
]
)
app["config"] = serve_config
app["debug_api"] = debug_api
@@ -523,9 +511,8 @@ def serve(
_install_service_refresh(app)
_install_config_reload(app, no_key=no_key, serve_widevine=serve_widevine, serve_playready=serve_playready)
setup_routes(app, remote_only=remote_only, dashboard=dashboard)
setup_routes(app, remote_only=remote_only, dashboard=dashboard, docs=not remote_only)
if not remote_only:
setup_swagger(app, dashboard=dashboard)
log.info(f"REST API endpoints available at http://{host}:{port}/api/")
log.info(f"Swagger UI available at http://{host}:{port}/api/docs/")
else:
@@ -546,18 +533,17 @@ def serve(
secret_key = request_secret_key(request)
if request.path.startswith(DASHBOARD_PREFIX):
return await handler(request)
if (
request.path != "/api/health"
and secret_key
and any(hmac.compare_digest(secret_key, k) for k in request.app["config"]["users"])
):
known = bool(secret_key) and any(
hmac.compare_digest(secret_key, k) for k in request.app["config"]["users"]
)
if request.path != "/api/health" and secret_key and known:
limited = rate_limit_response(secret_key)
if limited is not None:
return limited
if serve_playready_flag and request.path in ("/playready", "/playready/"):
response = await handler(request)
elif secret_key and not request.headers.get("X-Secret-Key"):
if not any(hmac.compare_digest(secret_key, k) for k in request.app["config"]["users"]):
if not known:
return web.json_response({"status": 401, "message": "Secret Key is Invalid."}, status=401)
response = await handler(request)
else:
@@ -574,30 +560,15 @@ def serve(
return serve_authentication
if no_key:
app = web.Application(
middlewares=[
cors_middleware,
no_5xx_middleware,
stats_middleware,
cdm_log_middleware,
dashboard_authentication,
compression_middleware,
]
)
else:
serve_auth = create_serve_authentication(serve_playready and bool(prd_devices))
app = web.Application(
middlewares=[
cors_middleware,
no_5xx_middleware,
stats_middleware,
cdm_log_middleware,
dashboard_authentication,
serve_auth,
compression_middleware,
]
)
app = web.Application(
middlewares=[
*base_middlewares,
cdm_log_middleware,
dashboard_authentication,
*([] if no_key else [create_serve_authentication(serve_playready and bool(prd_devices))]),
compression_middleware,
]
)
app["config"] = serve_config
app["debug_api"] = debug_api
@@ -637,7 +608,9 @@ def serve(
elif serve_playready:
log.info("No PlayReady devices found, skipping PlayReady CDM endpoints")
setup_routes(app, remote_only=remote_only, dashboard=dashboard, cdm_only=cdm_only)
setup_routes(
app, remote_only=remote_only, dashboard=dashboard, cdm_only=cdm_only, docs=not (remote_only or cdm_only)
)
if serve_widevine:
log.info(f"Widevine CDM endpoints available at http://{host}:{port}/{{device}}/open")
@@ -646,7 +619,6 @@ def serve(
elif remote_only:
log.info(f"Remote service endpoints available at http://{host}:{port}/api/session/")
else:
setup_swagger(app, dashboard=dashboard)
log.info(f"REST API endpoints available at http://{host}:{port}/api/")
log.info(f"Swagger UI available at http://{host}:{port}/api/docs/")
log.info("(Press CTRL+C to quit)")
+2 -2
View File
@@ -1,3 +1,3 @@
from unshackle.core.api.routes import cors_middleware, no_5xx_middleware, setup_routes, setup_swagger
from unshackle.core.api.routes import cors_middleware, no_5xx_middleware, setup_routes
__all__ = ["setup_routes", "setup_swagger", "cors_middleware", "no_5xx_middleware"]
__all__ = ["setup_routes", "cors_middleware", "no_5xx_middleware"]
+1 -1
View File
@@ -913,7 +913,7 @@ class DownloadQueueManager:
jobs_to_remove = []
for job_id, job in self._jobs.items():
if job.status in [JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED]:
if job.status in TERMINAL_STATUSES:
if job.completed_time and job.completed_time < cutoff_time:
jobs_to_remove.append(job_id)
elif not job.completed_time and job.created_time < cutoff_time:
+65 -108
View File
@@ -312,24 +312,29 @@ def server_login_material(
"""
from unshackle.commands.dl import dl
from unshackle.core.api.stats import stats
from unshackle.core.credential import Credential
if account:
return server_account_cookies(normalized_service, profile), dl.get_credentials(normalized_service, profile)
if stats.mode != "remote_only":
return dl.get_cookie_jar(normalized_service, profile), dl.get_credentials(normalized_service, profile)
credential = None
return load_client_cookies(data.get("cookies")), client_credential(data)
def client_credential(data: Dict[str, Any]) -> Any:
"""The Credential a client sent as ``credentials``, or None when it sent no username."""
from unshackle.core.credential import Credential
cred_data = data.get("credentials")
if isinstance(cred_data, dict) and cred_data.get("username") is not None:
credential = Credential(
username=cred_data["username"], password=cred_data.get("password") or "", extra=cred_data.get("extra")
)
return load_client_cookies(data.get("cookies")), credential
if not isinstance(cred_data, dict) or cred_data.get("username") is None:
return None
return Credential(
username=cred_data["username"], password=cred_data.get("password") or "", extra=cred_data.get("extra")
)
def api_key_namespace(request: Optional[web.Request]) -> str:
"""Name of the caller's cache directories, derived from its API key so no caller can guess another's."""
return key_namespace(request.headers.get("X-Secret-Key", "anonymous") if request else "anonymous")
return key_namespace(caller_key(request))
@functools.lru_cache(maxsize=256)
@@ -1266,19 +1271,7 @@ def server_accounts_allowed(request: Optional[web.Request] = None, service: Opti
full access. Combine with ``server_account_spec``: the server treats an API key without the
opt-in as a client-managed remote session for that service.
"""
if not request:
return True
user_config = config.serve.get("users", {}).get(request_secret_key(request))
if user_config is None:
return True
allowed = user_config.get("server_accounts", False)
if isinstance(allowed, str):
allowed = [allowed]
if isinstance(allowed, (list, tuple, set)):
return service is not None and Services.get_tag(service).upper() in {
Services.get_tag(s).upper() for s in allowed
}
return bool(allowed)
return key_grant_allows(request, service, "server_accounts")
def resolve_server_account(request: Optional[web.Request], service: str, region: Any) -> Optional[str]:
@@ -1781,6 +1774,20 @@ def serialize_track_common(track: Any) -> Dict[str, Any]:
}
def serialize_tracks(tracks: Tracks, title: Any, include_url: bool = False) -> Dict[str, List[Dict[str, Any]]]:
"""The ``video``, ``audio`` and ``subtitles`` lists of a title's tracks, video and audio highest bitrate first."""
video_tracks = sorted(tracks.videos, key=lambda t: t.bitrate or 0, reverse=True)
audio_tracks = sorted(tracks.audio, key=lambda t: t.bitrate or 0, reverse=True)
original_ids = original_audio_ids(audio_tracks, title)
return {
"video": [serialize_video_track(t, include_url=include_url) for t in video_tracks],
"audio": [
serialize_audio_track(t, include_url=include_url, is_original=t.id in original_ids) for t in audio_tracks
],
"subtitles": [serialize_subtitle_track(t, include_url=include_url) for t in tracks.subtitles],
}
def serialize_video_track(track: Video, include_url: bool = False) -> Dict[str, Any]:
"""Convert video track to JSON-serializable dict."""
codec_name = enum_name(track.codec)
@@ -2102,19 +2109,7 @@ async def list_tracks_handler(data: Dict[str, Any], request: Optional[web.Reques
for title in sorted_titles:
try:
tracks = await call(probed_tracks, service_instance, title)
video_tracks = sorted(tracks.videos, key=lambda t: t.bitrate or 0, reverse=True)
audio_tracks = sorted(tracks.audio, key=lambda t: t.bitrate or 0, reverse=True)
original_ids = original_audio_ids(audio_tracks, title)
episode_data = {
"title": serialize_title(title),
"video": [serialize_video_track(t) for t in video_tracks],
"audio": [
serialize_audio_track(t, is_original=t.id in original_ids) for t in audio_tracks
],
"subtitles": [serialize_subtitle_track(t) for t in tracks.subtitles],
}
episodes_data.append(episode_data)
episodes_data.append({"title": serialize_title(title), **serialize_tracks(tracks, title)})
log.debug(f"Successfully got tracks for {title.season}x{title.number}")
except SystemExit:
# Service calls sys.exit() for unavailable episodes - catch and skip
@@ -2149,17 +2144,7 @@ async def list_tracks_handler(data: Dict[str, Any], request: Optional[web.Reques
first_title = titles
tracks = await call(probed_tracks, service_instance, first_title)
video_tracks = sorted(tracks.videos, key=lambda t: t.bitrate or 0, reverse=True)
audio_tracks = sorted(tracks.audio, key=lambda t: t.bitrate or 0, reverse=True)
original_ids = original_audio_ids(audio_tracks, first_title)
response = {
"title": serialize_title(first_title),
"video": [serialize_video_track(t) for t in video_tracks],
"audio": [serialize_audio_track(t, is_original=t.id in original_ids) for t in audio_tracks],
"subtitles": [serialize_subtitle_track(t) for t in tracks.subtitles],
}
response = {"title": serialize_title(first_title), **serialize_tracks(tracks, first_title)}
return await request_cache.json_response(response)
@@ -2476,9 +2461,28 @@ def service_dl_defaults(service: str) -> Dict[str, Any]:
return result
async def enqueue_job(
service: str, title_id: str, params: Dict[str, Any], request: Optional[web.Request], gate_service: str
) -> web.Response:
"""Check the download gates for ``gate_service``, then queue a job with ``params`` and answer 202.
``server_proxy`` is always stamped here, so a client-sent value cannot grant the server's proxy providers.
"""
from unshackle.core.api.download_manager import get_download_manager
await asyncio.to_thread(enforce_download_gates, {**params, "service": gate_service}, request)
manager = get_download_manager()
await manager.start_workers()
job = manager.create_job(
service, title_id, owner_key=caller_key(request), **{**params, "server_proxy": server_proxy_allowed(request)}
)
return web.json_response(
{"job_id": job.job_id, "status": job.status.value, "created_time": job.created_time.isoformat()}, status=202
)
async def download_handler(data: Dict[str, Any], request: Optional[web.Request] = None) -> web.Response:
"""Answer the download request: make a download job and add it to the queue."""
from unshackle.core.api.download_manager import get_download_manager
require_fields(data, "service", "title_id")
service_tag = data["service"]
@@ -2538,19 +2542,10 @@ async def download_handler(data: Dict[str, Any], request: Optional[web.Request]
}
if service_params is not None:
params_with_defaults["service_params"] = service_params
await asyncio.to_thread(enforce_download_gates, {**params_with_defaults, "service": service_tag}, request)
manager = get_download_manager()
await manager.start_workers()
# The download worker reads this to decide whether dl may load the server's proxy
# providers. Always stamped server-side so a client-sent value cannot grant it.
params_with_defaults["server_proxy"] = server_proxy_allowed(request)
params_with_defaults["client_keys"] = sorted(params_with_defaults if server else filtered_params)
job = manager.create_job(normalized_service, title_id, owner_key=caller_key(request), **params_with_defaults)
return web.json_response(
{"job_id": job.job_id, "status": job.status.value, "created_time": job.created_time.isoformat()}, status=202
params_with_defaults["client_keys"] = sorted(
[*params_with_defaults, "server_proxy"] if server else filtered_params
)
return await enqueue_job(normalized_service, title_id, params_with_defaults, request, gate_service=service_tag)
except APIError:
raise
@@ -3309,26 +3304,8 @@ async def retry_download_job_handler(job_id: str, request: Optional[web.Request]
f"Invalid or unavailable service: {job.service}",
details={"service": job.service},
)
await asyncio.to_thread(enforce_download_gates, {**job.parameters, "service": job.service}, request)
await manager.start_workers()
# Reuse the raw in-memory parameters; redaction only ever applies to serialized copies.
new_job = manager.create_job(
job.service,
job.title_id,
owner_key=caller_key(request),
**{**job.parameters, "server_proxy": server_proxy_allowed(request)},
)
return web.json_response(
{
"job_id": new_job.job_id,
"status": new_job.status.value,
"created_time": new_job.created_time.isoformat(),
},
status=202,
)
return await enqueue_job(job.service, job.title_id, job.parameters, request, gate_service=job.service)
except APIError:
raise
@@ -3819,7 +3796,6 @@ def create_service_instance(
own accounts.
"""
from unshackle.commands.dl import dl
from unshackle.core.credential import Credential
from unshackle.core.tracks import Video
server_config = service_config = load_service_yaml(normalized_service)
@@ -3872,14 +3848,7 @@ def create_service_instance(
cookies = server_account_cookies(normalized_service, profile)
credential = dl.get_credentials(normalized_service, profile)
else:
credential = None
cred_data = data.get("credentials")
if cred_data and isinstance(cred_data, dict):
credential = Credential(
username=cred_data["username"],
password=cred_data["password"],
extra=cred_data.get("extra"),
)
credential = client_credential(data)
cookies = load_client_cookies(data.get("cookies"))
extra_params["cookies_supplied"] = cookies is not None
@@ -3939,7 +3908,7 @@ async def session_create_handler(data: Dict[str, Any], request: Optional[web.Req
import uuid as uuid_mod
session_id = str(uuid_mod.uuid4())
api_key = request.headers.get("X-Secret-Key", "anonymous") if request else "anonymous"
api_key = caller_key(request)
session_cache_dir = f"_sessions/{api_key_namespace(request)}/{session_id}/{normalized_service}"
session_cache_tag: Optional[str] = session_cache_dir
@@ -4240,7 +4209,7 @@ async def session_tracks_handler(
from unshackle.core.config import config as app_config
api_key = request.headers.get("X-Secret-Key", "anonymous") if request else "anonymous"
api_key = caller_key(request)
user_cfg = serve_user_config(api_key)
has_wv = bool(user_cfg.get("devices"))
has_pr = bool(user_cfg.get("playready_devices"))
@@ -4271,13 +4240,11 @@ async def session_tracks_handler(
def serialize() -> tuple[list, list, list, list]:
"""The track and attachment lists, off the event loop: an attachment can be a 20 MB file."""
original_ids = original_audio_ids(audio_tracks, title)
video = [serialize_video_track(t, include_url=True) for t in video_tracks]
audio = [serialize_audio_track(t, include_url=True, is_original=t.id in original_ids) for t in audio_tracks]
subtitles = [serialize_subtitle_track(t, include_url=True) for t in tracks.subtitles]
for d in video + audio + subtitles:
lists = serialize_tracks(tracks, title, include_url=True)
for d in lists["video"] + lists["audio"] + lists["subtitles"]:
d["data"] = scrub_track_data(d.get("data"), session)
return video, audio, subtitles, [a for a in map(serialize_attachment, tracks.attachments) if a]
attachments = [a for a in map(serialize_attachment, tracks.attachments) if a]
return lists["video"], lists["audio"], lists["subtitles"], attachments
video, audio, subtitles, attachments = await asyncio.to_thread(serialize)
@@ -5149,24 +5116,14 @@ def handle_single_server_cdm(
raise APIError(APIErrorCode.INVALID_INPUT, "No PSSH available for server_cdm licensing")
if vault_only:
from unshackle.core.drm import PlayReady, Widevine
if drm_type == "playready":
from pyplayready.system.pssh import PSSH as PlayReadyPSSH
kids = PlayReady(pssh=PlayReadyPSSH(base64.b64decode(pssh_b64)), pssh_b64=pssh_b64).kids
else:
from pywidevine.pssh import PSSH as WvPSSH
kids = Widevine(pssh=WvPSSH(pssh_b64)).kids
vault_hit = check_vaults(kids, service_name, shared=shared)
vault_hit = check_vaults(list(pssh_kids(pssh_b64, drm_type)), service_name, shared=shared)
if vault_hit:
if sources is not None:
sources.update(vault_hit[1])
return vault_hit[0]
raise refusal or APIError(APIErrorCode.NO_CONTENT, "The server vault holds no content key for this track")
api_key = request.headers.get("X-Secret-Key", "anonymous") if request else "anonymous"
api_key = caller_key(request)
user_config = serve_user_config(api_key)
if drm_type == "playready":
@@ -5448,7 +5405,7 @@ def license_response(
)
if mode == "server_cdm" and track_ids:
api_key = request.headers.get("X-Secret-Key", "anonymous") if request else "anonymous"
api_key = caller_key(request)
user_config = serve_user_config(api_key)
service = session.service_instance
service_tag = session.service_tag
@@ -5629,7 +5586,7 @@ def license_response(
if mode == "server_cdm":
# The server's config.cdm mapping decides the DRM system, as in the batch path.
# The client only knows its own local device, which the server never uses here.
api_key = request.headers.get("X-Secret-Key", "anonymous") if request else "anonymous"
api_key = caller_key(request)
candidates = (
vault_candidates(drm_type)
if vault_only
+38 -116
View File
@@ -142,6 +142,14 @@ def api_handler(handler: Handler) -> Handler:
return wrapper
async def json_body(request: web.Request) -> Any:
"""The request's JSON body; raises INVALID_INPUT when it does not parse."""
try:
return await request.json()
except Exception as e:
raise APIError(APIErrorCode.INVALID_INPUT, "Invalid JSON request body", details={"error": str(e)}) from e
HEARTBEAT_INTERVAL = 30.0
@@ -624,17 +632,7 @@ async def search(request: web.Request) -> web.Response:
'400':
description: Invalid request
"""
try:
data = await request.json()
except Exception as e:
return build_error_response(
APIError(
APIErrorCode.INVALID_INPUT,
"Invalid JSON request body",
details={"error": str(e)},
),
request.app.get("debug_api", False),
)
data = await json_body(request)
try:
return await search_handler(data, request)
@@ -792,17 +790,7 @@ async def list_titles(request: web.Request) -> web.Response:
type: string
format: date-time
"""
try:
data = await request.json()
except Exception as e:
return build_error_response(
APIError(
APIErrorCode.INVALID_INPUT,
"Invalid JSON request body",
details={"error": str(e)},
),
request.app.get("debug_api", False),
)
data = await json_body(request)
return await list_titles_handler(data, request)
@@ -884,17 +872,7 @@ async def list_tracks(request: web.Request) -> web.Response:
'400':
description: Invalid request
"""
try:
data = await request.json()
except Exception as e:
return build_error_response(
APIError(
APIErrorCode.INVALID_INPUT,
"Invalid JSON request body",
details={"error": str(e)},
),
request.app.get("debug_api", False),
)
data = await json_body(request)
return await list_tracks_handler(data, request)
@@ -1215,17 +1193,7 @@ async def download(request: web.Request) -> web.Response:
A gated parameter is not permitted (FORBIDDEN), or the job goes above the
server_cdm_max_height of the API key (SERVER_CDM_CAPPED)
"""
try:
data = await request.json()
except Exception as e:
return build_error_response(
APIError(
APIErrorCode.INVALID_INPUT,
"Invalid JSON request body",
details={"error": str(e)},
),
request.app.get("debug_api", False),
)
data = await json_body(request)
return await download_handler(data, request)
@@ -1557,10 +1525,7 @@ async def download_job_input(request: web.Request) -> web.Response:
description: No prompt pending for this job
"""
job_id = request.match_info["job_id"]
try:
data = await request.json()
except Exception as e:
raise APIError(APIErrorCode.INVALID_INPUT, "Invalid JSON request body", details={"error": str(e)}) from e
data = await json_body(request)
return await download_job_input_handler(data, job_id, request)
@@ -2066,13 +2031,7 @@ async def session_create(request: web.Request) -> web.Response:
'401':
description: Authentication failed
"""
try:
data = await request.json()
except Exception as e:
return build_error_response(
APIError(APIErrorCode.INVALID_INPUT, "Invalid JSON request body", details={"error": str(e)}),
request.app.get("debug_api", False),
)
data = await json_body(request)
try:
return await session_create_handler(data, request)
except Exception as e:
@@ -2202,13 +2161,7 @@ async def session_tracks(request: web.Request) -> web.Response:
description: Remote session or title not found
"""
session_id = request.match_info["session_id"]
try:
data = await request.json()
except Exception as e:
return build_error_response(
APIError(APIErrorCode.INVALID_INPUT, "Invalid JSON request body", details={"error": str(e)}),
request.app.get("debug_api", False),
)
data = await json_body(request)
try:
return await session_tracks_handler(data, session_id, request)
except Exception as e:
@@ -2252,13 +2205,7 @@ async def session_segments(request: web.Request) -> web.Response:
description: Remote session or track not found
"""
session_id = request.match_info["session_id"]
try:
data = await request.json()
except Exception as e:
return build_error_response(
APIError(APIErrorCode.INVALID_INPUT, "Invalid JSON request body", details={"error": str(e)}),
request.app.get("debug_api", False),
)
data = await json_body(request)
try:
return await session_segments_handler(data, session_id, request)
except Exception as e:
@@ -2303,13 +2250,7 @@ async def session_segment_filter(request: web.Request) -> web.Response:
description: Remote session or track not found
"""
session_id = request.match_info["session_id"]
try:
data = await request.json()
except Exception as e:
return build_error_response(
APIError(APIErrorCode.INVALID_INPUT, "Invalid JSON request body", details={"error": str(e)}),
request.app.get("debug_api", False),
)
data = await json_body(request)
try:
return await session_segment_filter_handler(data, session_id, request)
except Exception as e:
@@ -2425,13 +2366,7 @@ async def session_license(request: web.Request) -> web.Response:
description: Remote session or track not found
"""
session_id = request.match_info["session_id"]
try:
data = await request.json()
except Exception as e:
return build_error_response(
APIError(APIErrorCode.INVALID_INPUT, "Invalid JSON request body", details={"error": str(e)}),
request.app.get("debug_api", False),
)
data = await json_body(request)
try:
return await session_license_handler(data, session_id, request)
except Exception as e:
@@ -2486,13 +2421,7 @@ async def session_bad_key(request: web.Request) -> web.Response:
description: Remote session not found
"""
session_id = request.match_info["session_id"]
try:
data = await request.json()
except Exception as e:
return build_error_response(
APIError(APIErrorCode.INVALID_INPUT, "Invalid JSON request body", details={"error": str(e)}),
request.app.get("debug_api", False),
)
data = await json_body(request)
return await session_bad_key_handler(data, session_id, request)
@@ -2695,13 +2624,7 @@ async def session_prompt_submit(request: web.Request) -> web.Response:
description: Remote session not found
"""
session_id = request.match_info["session_id"]
try:
data = await request.json()
except Exception as e:
return build_error_response(
APIError(APIErrorCode.INVALID_INPUT, "Invalid JSON request body", details={"error": str(e)}),
request.app.get("debug_api", False),
)
data = await json_body(request)
try:
return await session_prompt_post_handler(data, session_id, request)
except Exception as e:
@@ -3398,16 +3321,13 @@ async def remote_login_input(request: web.Request) -> web.Response:
'409':
description: The login is not asking anything
"""
try:
data = await request.json()
except Exception as e:
raise APIError(APIErrorCode.INVALID_INPUT, "Invalid JSON request body", details={"error": str(e)}) from e
data = await json_body(request)
return await remote_login_input_handler(request.match_info["login_id"], data, request)
# Single source of truth for all API routes. `remote` marks endpoints exposed in
# --remote-only server mode; full mode registers every route. Both setup_routes and
# setup_swagger derive from this table so the live routes and the docs cannot drift.
# --remote-only server mode; full mode registers every route. setup_routes registers the
# routes and their docs from this table, so the live routes and the docs cannot drift.
ROUTES: list[tuple[str, str, Handler, bool]] = [
("GET", "/api/health", health, True),
("GET", "/api/services", services, True),
@@ -3467,15 +3387,21 @@ DASHBOARD_ROUTES: list[tuple[str, str, Handler, bool]] = [
def setup_routes(
app: web.Application, remote_only: bool = False, dashboard: bool = False, cdm_only: bool = False
app: web.Application,
remote_only: bool = False,
dashboard: bool = False,
cdm_only: bool = False,
docs: bool = False,
) -> None:
"""Setup API routes. When remote_only=True, only the remote session endpoints operate.
When cdm_only=True, only /api/health operates. When dashboard=True, this also registers
the /api/dashboard/ routes."""
the /api/dashboard/ routes. When docs=True, each route goes through Swagger so it is
documented at /api/docs/; Swagger request validation stays off."""
router: Any = swagger_docs(app) if docs else app.router
add: dict[str, Callable[..., Any]] = {
"GET": app.router.add_get,
"POST": app.router.add_post,
"DELETE": app.router.add_delete,
"GET": router.add_get,
"POST": router.add_post,
"DELETE": router.add_delete,
}
api_routes = [r for r in ROUTES if r[1] == "/api/health"] if cdm_only else ROUTES
for method, path, handler, remote in api_routes + (DASHBOARD_ROUTES if dashboard else []):
@@ -3485,10 +3411,11 @@ def setup_routes(
app.on_shutdown.append(close_tunnels)
def setup_swagger(app: web.Application, dashboard: bool = False) -> None:
"""Setup Swagger UI documentation."""
swagger = SwaggerDocs(
def swagger_docs(app: web.Application) -> SwaggerDocs:
"""The Swagger UI at /api/docs/, which registers the routes added through it."""
return SwaggerDocs(
app,
validate=False,
swagger_ui_settings=SwaggerUiSettings(path="/api/docs/"),
info=SwaggerInfo(
title="Unshackle REST API",
@@ -3496,8 +3423,3 @@ def setup_swagger(app: web.Application, dashboard: bool = False) -> None:
description="REST API for Unshackle - Modular Movie, TV, and Music Archival Software",
),
)
route: dict[str, Callable[..., Any]] = {"GET": web.get, "POST": web.post, "DELETE": web.delete}
swagger.add_routes(
[route[method](path, handler) for method, path, handler, _ in ROUTES + (DASHBOARD_ROUTES if dashboard else [])]
)