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