mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-14 21:15:06 +00:00
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.
3426 lines
123 KiB
Python
3426 lines
123 KiB
Python
import asyncio
|
|
import functools
|
|
import logging
|
|
import re
|
|
import time
|
|
from collections.abc import Awaitable, Callable
|
|
from typing import Any, Optional
|
|
|
|
import click
|
|
from aiohttp import web
|
|
from aiohttp_swagger3 import SwaggerDocs, SwaggerInfo, SwaggerUiSettings
|
|
|
|
from unshackle.core import __code_hash__, __version__
|
|
from unshackle.core import services as services_module
|
|
from unshackle.core.api.errors import (
|
|
APIError,
|
|
APIErrorCode,
|
|
build_error_response,
|
|
categorize_exception,
|
|
handle_api_exception,
|
|
)
|
|
from unshackle.core.api.handlers import (
|
|
CORS_HEADERS,
|
|
DASHBOARD_EVENTS_ROUTE,
|
|
DASHBOARD_PREFIX,
|
|
JOB_EVENTS_ROUTE,
|
|
cancel_download_job_handler,
|
|
clear_cache_handler,
|
|
clear_finished_download_jobs_handler,
|
|
clear_temp_handler,
|
|
dashboard_cdm_logs_handler,
|
|
dashboard_drain_start_handler,
|
|
dashboard_drain_stop_handler,
|
|
dashboard_events_handler,
|
|
dashboard_health_handler,
|
|
dashboard_jobs_handler,
|
|
dashboard_keys_handler,
|
|
dashboard_logs_handler,
|
|
dashboard_services_handler,
|
|
dashboard_session_logs_handler,
|
|
dashboard_sessions_handler,
|
|
dashboard_status_handler,
|
|
delete_history_handler,
|
|
download_handler,
|
|
download_history_handler,
|
|
download_job_events_handler,
|
|
download_job_input_handler,
|
|
env_check_handler,
|
|
get_allowed_services,
|
|
get_download_job_handler,
|
|
list_download_jobs_handler,
|
|
list_titles_handler,
|
|
list_tracks_handler,
|
|
prioritize_download_job_handler,
|
|
profiles_handler,
|
|
refresh_services_handler,
|
|
remote_login_input_handler,
|
|
remote_logins_handler,
|
|
remote_services_info,
|
|
retry_download_job_handler,
|
|
search_handler,
|
|
server_account_regions,
|
|
server_accounts_allowed,
|
|
server_config_handler,
|
|
session_bad_key_handler,
|
|
session_create_handler,
|
|
session_delete_handler,
|
|
session_info_handler,
|
|
session_license_handler,
|
|
session_logs_handler,
|
|
session_prompt_get_handler,
|
|
session_prompt_post_handler,
|
|
session_samples_handler,
|
|
session_segment_filter_handler,
|
|
session_segments_handler,
|
|
session_titles_handler,
|
|
session_tracks_handler,
|
|
tunnel_check_handler,
|
|
tunnel_handler,
|
|
)
|
|
from unshackle.core.api.sanitize import sanitize_log
|
|
from unshackle.core.drm import cbcs
|
|
from unshackle.core.services import Services
|
|
from unshackle.core.tunnel import CHECK_ROUTE as TUNNEL_CHECK_ROUTE
|
|
from unshackle.core.tunnel import ROUTE as TUNNEL_ROUTE
|
|
from unshackle.core.tunnel import close_tunnels
|
|
from unshackle.core.update_checker import UpdateChecker
|
|
from unshackle.core.utils.redact import redact_path
|
|
|
|
|
|
@web.middleware
|
|
async def cors_middleware(
|
|
request: web.Request, handler: Callable[[web.Request], Awaitable[web.StreamResponse]]
|
|
) -> web.StreamResponse:
|
|
"""Add CORS headers to all responses."""
|
|
response: web.StreamResponse
|
|
if request.method == "OPTIONS":
|
|
response = web.Response()
|
|
else:
|
|
response = await handler(request)
|
|
|
|
response.headers.update(CORS_HEADERS)
|
|
|
|
return response
|
|
|
|
|
|
@web.middleware
|
|
async def no_5xx_middleware(
|
|
request: web.Request, handler: Callable[[web.Request], Awaitable[web.StreamResponse]]
|
|
) -> web.StreamResponse:
|
|
"""Answer an uncaught exception or any unsent 5xx (a CDM subapplication's own) as a 422; see errors.py."""
|
|
try:
|
|
response = await handler(request)
|
|
except (Exception, SystemExit) as e:
|
|
if isinstance(e, web.HTTPException) and e.status < 500:
|
|
raise
|
|
# the exception text can hold a caller's key, so the answer gets a generic message
|
|
log.exception(f"Unhandled error in {sanitize_log(request.path)}")
|
|
error = categorize_exception(e) if isinstance(e, SystemExit) else None
|
|
response = build_error_response(error or APIError(APIErrorCode.INTERNAL_ERROR, "Internal server error"))
|
|
if response.status >= 500 and not response.prepared:
|
|
response.set_status(APIError.default_http_status(APIErrorCode.INTERNAL_ERROR))
|
|
return response
|
|
|
|
|
|
log = logging.getLogger("api")
|
|
|
|
# Route handler signature: takes the request, returns a response (streamed or complete).
|
|
Handler = Callable[[web.Request], Awaitable[web.StreamResponse]]
|
|
|
|
|
|
def api_handler(handler: Handler) -> Handler:
|
|
"""Wrap a route handler so any raised APIError becomes a structured error response."""
|
|
|
|
@functools.wraps(handler)
|
|
async def wrapper(request: web.Request) -> web.StreamResponse:
|
|
try:
|
|
return await handler(request)
|
|
except APIError as e:
|
|
return build_error_response(e, request.app.get("debug_api", False))
|
|
|
|
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
|
|
|
|
|
|
def heartbeat(handler: Handler) -> Handler:
|
|
"""Keep a slow request alive with a newline every ``HEARTBEAT_INTERVAL`` seconds.
|
|
|
|
A response that is ready within one interval goes out unchanged. After that the route
|
|
sends status 200 and the headers, then a newline for each interval until the JSON body is
|
|
ready. JSON parsers ignore the leading whitespace. The status is already sent, so a late
|
|
failure arrives as the usual error body with ``"status": "error"`` and a 200 status. The
|
|
work continues if the client disconnects, so the remote session state stays complete.
|
|
"""
|
|
|
|
async def guarded(request: web.Request) -> web.StreamResponse:
|
|
# a SystemExit leaving any task is re-raised into the event loop, so it must stop inside the task
|
|
try:
|
|
return await handler(request)
|
|
except SystemExit as e:
|
|
log.exception(f"Service exited in {sanitize_log(request.path)}")
|
|
raise categorize_exception(e) from e
|
|
|
|
@functools.wraps(handler)
|
|
async def wrapper(request: web.Request) -> web.StreamResponse:
|
|
task = asyncio.ensure_future(guarded(request))
|
|
done, _ = await asyncio.wait({task}, timeout=HEARTBEAT_INTERVAL)
|
|
if done:
|
|
return task.result()
|
|
|
|
headers = {"Content-Type": "application/json", "X-Accel-Buffering": "no", **CORS_HEADERS}
|
|
response = web.StreamResponse(headers=headers)
|
|
await response.prepare(request)
|
|
try:
|
|
while not done:
|
|
await response.write(b"\n")
|
|
done, _ = await asyncio.wait({task}, timeout=HEARTBEAT_INTERVAL)
|
|
except ConnectionResetError:
|
|
log.info(f"Client left {request.path} before the response was ready; the work continues")
|
|
return response
|
|
debug_mode = request.app.get("debug_api", False)
|
|
try:
|
|
result = task.result()
|
|
except APIError as e:
|
|
result = build_error_response(e, debug_mode)
|
|
except Exception as e:
|
|
log.exception(f"Error in {request.path}")
|
|
result = handle_api_exception(e, debug_mode=debug_mode)
|
|
response["late_status"] = result.status
|
|
response["error"] = result.get("error")
|
|
body = result.body if isinstance(result, web.Response) else None
|
|
await response.write(bytes(body) if isinstance(body, (bytes, bytearray)) else b"{}")
|
|
await response.write_eof()
|
|
return response
|
|
|
|
return wrapper
|
|
|
|
|
|
UPDATE_CHECK_TTL = 300.0
|
|
UPDATE_CHECK: dict[str, tuple[float, Optional[str]]] = {}
|
|
|
|
|
|
def latest_release() -> Optional[str]:
|
|
"""The newer release the update check reports, kept for ``UPDATE_CHECK_TTL`` seconds: clients poll health."""
|
|
hit = UPDATE_CHECK.get(__version__)
|
|
if hit and time.monotonic() - hit[0] < UPDATE_CHECK_TTL:
|
|
return hit[1]
|
|
latest = UpdateChecker.check_for_updates_sync(__version__)
|
|
UPDATE_CHECK[__version__] = (time.monotonic(), latest)
|
|
return latest
|
|
|
|
|
|
@api_handler
|
|
async def health(request: web.Request) -> web.Response:
|
|
"""
|
|
Health check endpoint.
|
|
---
|
|
summary: Health check
|
|
description: Get server health status, version info, and update availability
|
|
responses:
|
|
'200':
|
|
description: Health status
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
status:
|
|
type: string
|
|
example: ok
|
|
version:
|
|
type: string
|
|
example: "2.0.0"
|
|
code_hash:
|
|
type: string
|
|
nullable: true
|
|
example: "1d22a1e"
|
|
draining:
|
|
type: boolean
|
|
description: True while the server refuses new sessions before a restart
|
|
update_check:
|
|
type: object
|
|
properties:
|
|
update_available:
|
|
type: boolean
|
|
nullable: true
|
|
current_version:
|
|
type: string
|
|
latest_version:
|
|
type: string
|
|
nullable: true
|
|
"""
|
|
try:
|
|
latest_version = await asyncio.to_thread(latest_release)
|
|
update_info = {
|
|
"update_available": latest_version is not None,
|
|
"current_version": __version__,
|
|
"latest_version": latest_version,
|
|
}
|
|
except Exception as e:
|
|
log.warning(f"Failed to check for updates: {e}")
|
|
update_info = {"update_available": None, "current_version": __version__, "latest_version": None}
|
|
|
|
from unshackle.core.api.session_store import get_session_store
|
|
|
|
return web.json_response(
|
|
{
|
|
"status": "ok",
|
|
"version": __version__,
|
|
"code_hash": __code_hash__ or None,
|
|
"draining": get_session_store().draining,
|
|
"update_check": update_info,
|
|
}
|
|
)
|
|
|
|
|
|
SERVICE_DEFAULTS: dict[str, Any] = {
|
|
"aliases": [],
|
|
"geofence": [],
|
|
"geoblock": [],
|
|
"title_regex": None,
|
|
"url": None,
|
|
"help": None,
|
|
}
|
|
SERVICE_DETAILS: dict[str, tuple[Any, dict[str, Any]]] = {}
|
|
|
|
|
|
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)
|
|
hit = SERVICE_DETAILS.get(tag)
|
|
if hit and hit[0] is service_module:
|
|
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["geoblock"] = list(getattr(service_module, "GEOBLOCK", ()) or ())
|
|
|
|
if hasattr(service_module, "TITLE_RE"):
|
|
title_re = service_module.TITLE_RE
|
|
if isinstance(title_re, re.Pattern):
|
|
service_data["title_regex"] = title_re.pattern
|
|
elif isinstance(title_re, str):
|
|
service_data["title_regex"] = title_re
|
|
elif isinstance(title_re, (list, tuple)):
|
|
# Convert list/tuple of patterns to list of strings
|
|
patterns = []
|
|
for item in title_re:
|
|
if isinstance(item, re.Pattern):
|
|
patterns.append(item.pattern)
|
|
elif isinstance(item, str):
|
|
patterns.append(item)
|
|
service_data["title_regex"] = patterns if patterns else None
|
|
|
|
if hasattr(service_module, "cli") and hasattr(service_module.cli, "short_help"):
|
|
service_data["url"] = service_module.cli.short_help
|
|
|
|
if hasattr(service_module, "cli") and hasattr(service_module.cli, "params"):
|
|
cli_params = []
|
|
for param in service_module.cli.params:
|
|
param_info: dict = {"name": getattr(param, "name", None)}
|
|
if param.param_type_name == "argument":
|
|
param_info["kind"] = "argument"
|
|
param_info["required"] = param.required
|
|
else:
|
|
param_info["kind"] = "option"
|
|
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":
|
|
default = None
|
|
elif hasattr(default, "name"):
|
|
default = default.name
|
|
elif not isinstance(default, (str, int, float, bool, list)):
|
|
default = str(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["multiple"] = getattr(param, "multiple", False)
|
|
cli_params.append(param_info)
|
|
service_data["cli_params"] = cli_params
|
|
|
|
if service_module.__doc__:
|
|
service_data["help"] = service_module.__doc__.strip()
|
|
|
|
# Capability flags, derived from which Service hooks the service overrides.
|
|
from unshackle.core.service import Service as _BaseService
|
|
|
|
service_data["needs_auth"] = getattr(service_module, "authenticate", None) is not _BaseService.authenticate
|
|
client_config = getattr(service_module, "CLIENT_CONFIG", ())
|
|
if client_config:
|
|
service_data["client_config"] = list(client_config)
|
|
service_data["has_search"] = getattr(service_module, "search", None) is not _BaseService.search
|
|
service_data["anonymous_search"] = bool(getattr(service_module, "ANONYMOUS_SEARCH", False))
|
|
service_data["has_drm"] = (
|
|
getattr(service_module, "get_widevine_license", None) is not _BaseService.get_widevine_license
|
|
or getattr(service_module, "get_playready_license", None) is not _BaseService.get_playready_license
|
|
)
|
|
|
|
# Prefer the service's explicit AUTH_METHODS; otherwise infer from authenticate().
|
|
methods = []
|
|
if service_data["needs_auth"]:
|
|
declared = getattr(service_module, "AUTH_METHODS", None)
|
|
if declared:
|
|
methods = list(declared)
|
|
else:
|
|
try:
|
|
import inspect as _inspect
|
|
|
|
src_lines = _inspect.getsource(service_module.authenticate).splitlines()
|
|
start = next((i + 1 for i, ln in enumerate(src_lines) if ln.rstrip().endswith(":")), 1)
|
|
body = "\n".join(src_lines[start:])
|
|
if "cookies" in body:
|
|
methods.append("cookies")
|
|
if "credential" in body:
|
|
methods.append("credentials")
|
|
except (OSError, TypeError):
|
|
pass
|
|
if not methods:
|
|
methods = ["cookies"]
|
|
service_data["auth_methods"] = methods
|
|
|
|
SERVICE_DETAILS[tag] = (service_module, service_data)
|
|
return service_data
|
|
|
|
|
|
@api_handler
|
|
async def services(request: web.Request) -> web.Response:
|
|
"""
|
|
Show the available services.
|
|
---
|
|
summary: Show services
|
|
description: Get all available streaming services with their details
|
|
parameters:
|
|
- name: remote
|
|
in: query
|
|
required: false
|
|
schema:
|
|
type: string
|
|
description: >
|
|
A remote_services server name. Lists that server's services instead of this server's, each
|
|
with `remote` set to the name. Pass the name as `remote` to search, list-titles, list-tracks and
|
|
download to run the service through that server; the download itself still runs here.
|
|
responses:
|
|
'200':
|
|
description: List of services
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
load_errors:
|
|
type: array
|
|
description: Services skipped because they failed to import (startup or repo refresh)
|
|
items:
|
|
type: string
|
|
services:
|
|
type: array
|
|
items:
|
|
type: object
|
|
properties:
|
|
tag:
|
|
type: string
|
|
pending_update:
|
|
type: boolean
|
|
description: Present (true) while a repo refresh waits for this service's jobs to finish
|
|
server_accounts:
|
|
type: object
|
|
description: Present when the server lends its own accounts for this service (serve.server_accounts)
|
|
properties:
|
|
regions:
|
|
type: array
|
|
items:
|
|
type: string
|
|
global:
|
|
type: boolean
|
|
aliases:
|
|
type: array
|
|
items:
|
|
type: string
|
|
geofence:
|
|
type: array
|
|
items:
|
|
type: string
|
|
geoblock:
|
|
type: array
|
|
items:
|
|
type: string
|
|
title_regex:
|
|
oneOf:
|
|
- type: string
|
|
- type: array
|
|
items:
|
|
type: string
|
|
nullable: true
|
|
url:
|
|
type: string
|
|
nullable: true
|
|
description: Service URL from short_help
|
|
help:
|
|
type: string
|
|
nullable: true
|
|
description: Full service documentation
|
|
'422':
|
|
description: Server error
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
status:
|
|
type: string
|
|
example: error
|
|
error_code:
|
|
type: string
|
|
example: INTERNAL_ERROR
|
|
message:
|
|
type: string
|
|
example: An unexpected error occurred
|
|
details:
|
|
type: object
|
|
timestamp:
|
|
type: string
|
|
format: date-time
|
|
debug_info:
|
|
type: object
|
|
description: Only present when you use the --debug-api flag
|
|
"""
|
|
try:
|
|
service_tags = Services.get_tags()
|
|
allowed = get_allowed_services(request)
|
|
if allowed is not None:
|
|
service_tags = [t for t in service_tags if t in allowed]
|
|
if remote := request.query.get("remote"):
|
|
remote_info = await asyncio.to_thread(remote_services_info, remote, allowed)
|
|
return web.json_response({"services": remote_info, "load_errors": []})
|
|
services_info = []
|
|
|
|
for tag in service_tags:
|
|
service_data: dict[str, Any] = {**SERVICE_DEFAULTS, "tag": tag}
|
|
try:
|
|
service_data = {**service_details(tag)}
|
|
except Exception as e:
|
|
log.warning(f"Could not load details for service {tag}: {e}")
|
|
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:
|
|
service_data["server_accounts"] = server_accounts
|
|
|
|
services_info.append(service_data)
|
|
|
|
load_errors = [redact_path(re.sub(r" \([^()]*\)$", "", err)) for err in services_module.LOAD_ERRORS]
|
|
return web.json_response({"services": services_info, "load_errors": load_errors})
|
|
except Exception as e:
|
|
log.exception("Error listing services")
|
|
debug_mode = request.app.get("debug_api", False)
|
|
return handle_api_exception(e, context={"operation": "list_services"}, debug_mode=debug_mode)
|
|
|
|
|
|
@api_handler
|
|
async def search(request: web.Request) -> web.Response:
|
|
"""
|
|
Find titles from a service.
|
|
---
|
|
summary: Find titles
|
|
description: Find titles by query string from a service. A service that reports anonymous_search does the search with no login, and ignores credentials and cookies.
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required:
|
|
- service
|
|
- query
|
|
properties:
|
|
remote:
|
|
type: string
|
|
description: >
|
|
A remote_services server name from GET /api/config. The service runs through that
|
|
server (login, titles, tracks, licences); a download still runs on this server.
|
|
client_name:
|
|
type: string
|
|
description: Names this client on the remote server's dashboard, as serve (client_name)
|
|
service:
|
|
type: string
|
|
description: Service tag
|
|
query:
|
|
type: string
|
|
description: Search query string
|
|
profile:
|
|
type: string
|
|
description: Profile to use for credentials and cookies (default - None)
|
|
proxy:
|
|
type: string
|
|
description: Full proxy URI on a public host, a Control D resolver as controld://<resolver>@dns.controld.com, or a country code when the API key has server_proxy, or tunnel:<id> for a tunnel the same API key opened on /api/tunnel (default - None)
|
|
no_proxy:
|
|
type: boolean
|
|
description: Force disable all proxy use (default - false)
|
|
credentials:
|
|
type: object
|
|
additionalProperties: true
|
|
description: Your own login, on a --remote-only server only ({username, password, extra?})
|
|
cookies:
|
|
type: string
|
|
description: Your own cookies, on a --remote-only server only (base64 of a zlib-compressed Netscape cookie file)
|
|
cache:
|
|
type: object
|
|
additionalProperties:
|
|
type: string
|
|
description: |
|
|
Your own cache files, on a --remote-only server only: cache key to base64 of the
|
|
zlib-compressed file. The request runs on a cache directory of its own, which the
|
|
server removes when the request ends.
|
|
responses:
|
|
'200':
|
|
description: Search results
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
results:
|
|
type: array
|
|
items:
|
|
type: object
|
|
properties:
|
|
id:
|
|
type: string
|
|
description: Title ID for use with other endpoints
|
|
title:
|
|
type: string
|
|
description: Title name
|
|
description:
|
|
type: string
|
|
description: Title description
|
|
label:
|
|
type: string
|
|
description: Informative label (e.g., availability, region)
|
|
url:
|
|
type: string
|
|
description: URL to the title page
|
|
count:
|
|
type: integer
|
|
description: Number of results returned
|
|
cache:
|
|
type: object
|
|
additionalProperties:
|
|
type: string
|
|
description: |
|
|
The updated cache files, in the same form as the request field. Only on a
|
|
--remote-only server, without a server account, when you sent credentials,
|
|
cookies or cache.
|
|
'400':
|
|
description: Invalid request
|
|
"""
|
|
data = await json_body(request)
|
|
|
|
try:
|
|
return await search_handler(data, request)
|
|
except Exception as e:
|
|
log.exception("Error in search")
|
|
debug_mode = request.app.get("debug_api", False)
|
|
return handle_api_exception(e, context={"operation": "search"}, debug_mode=debug_mode)
|
|
|
|
|
|
@api_handler
|
|
async def list_titles(request: web.Request) -> web.Response:
|
|
"""
|
|
Show titles for a service and title ID.
|
|
---
|
|
summary: Show titles
|
|
description: Get available titles for a service and title ID
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required:
|
|
- service
|
|
- title_id
|
|
properties:
|
|
remote:
|
|
type: string
|
|
description: >
|
|
A remote_services server name from GET /api/config. The service runs through that
|
|
server (login, titles, tracks, licences); a download still runs on this server.
|
|
client_name:
|
|
type: string
|
|
description: Names this client on the remote server's dashboard, as serve (client_name)
|
|
service:
|
|
type: string
|
|
description: Service tag
|
|
title_id:
|
|
type: string
|
|
description: Title identifier
|
|
credentials:
|
|
type: object
|
|
additionalProperties: true
|
|
description: Your own login, on a --remote-only server only ({username, password, extra?})
|
|
cookies:
|
|
type: string
|
|
description: Your own cookies, on a --remote-only server only (base64 of a zlib-compressed Netscape cookie file)
|
|
cache:
|
|
type: object
|
|
additionalProperties:
|
|
type: string
|
|
description: |
|
|
Your own cache files, on a --remote-only server only: cache key to base64 of the
|
|
zlib-compressed file. The request runs on a cache directory of its own, which the
|
|
server removes when the request ends.
|
|
responses:
|
|
'200':
|
|
description: List of titles
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
titles:
|
|
type: array
|
|
items:
|
|
type: object
|
|
cache:
|
|
type: object
|
|
additionalProperties:
|
|
type: string
|
|
description: |
|
|
The updated cache files, in the same form as the request field. Only on a
|
|
--remote-only server, without a server account, when you sent credentials,
|
|
cookies or cache.
|
|
'400':
|
|
description: Invalid request (missing parameters, invalid service)
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
status:
|
|
type: string
|
|
example: error
|
|
error_code:
|
|
type: string
|
|
example: INVALID_INPUT
|
|
message:
|
|
type: string
|
|
example: Missing required parameter
|
|
details:
|
|
type: object
|
|
timestamp:
|
|
type: string
|
|
format: date-time
|
|
'401':
|
|
description: Authentication failed
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
status:
|
|
type: string
|
|
example: error
|
|
error_code:
|
|
type: string
|
|
example: AUTH_FAILED
|
|
message:
|
|
type: string
|
|
details:
|
|
type: object
|
|
timestamp:
|
|
type: string
|
|
format: date-time
|
|
'404':
|
|
description: Title not found
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
status:
|
|
type: string
|
|
example: error
|
|
error_code:
|
|
type: string
|
|
example: NOT_FOUND
|
|
message:
|
|
type: string
|
|
details:
|
|
type: object
|
|
timestamp:
|
|
type: string
|
|
format: date-time
|
|
'422':
|
|
description: Server error
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
status:
|
|
type: string
|
|
example: error
|
|
error_code:
|
|
type: string
|
|
example: INTERNAL_ERROR
|
|
message:
|
|
type: string
|
|
details:
|
|
type: object
|
|
timestamp:
|
|
type: string
|
|
format: date-time
|
|
"""
|
|
data = await json_body(request)
|
|
|
|
return await list_titles_handler(data, request)
|
|
|
|
|
|
@api_handler
|
|
async def list_tracks(request: web.Request) -> web.Response:
|
|
"""
|
|
Show tracks for a title, separated by type.
|
|
---
|
|
summary: Show tracks
|
|
description: Get available video, audio, and subtitle tracks for a title
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required:
|
|
- service
|
|
- title_id
|
|
properties:
|
|
remote:
|
|
type: string
|
|
description: >
|
|
A remote_services server name from GET /api/config. The service runs through that
|
|
server (login, titles, tracks, licences); a download still runs on this server.
|
|
client_name:
|
|
type: string
|
|
description: Names this client on the remote server's dashboard, as serve (client_name)
|
|
service:
|
|
type: string
|
|
description: Service tag
|
|
title_id:
|
|
type: string
|
|
description: Title identifier
|
|
wanted:
|
|
type: string
|
|
description: Specific episode/season, or song ("1-5", "2x3" for disc 2 track 3) (optional)
|
|
proxy:
|
|
type: string
|
|
description: Full proxy URI on a public host, a Control D resolver as controld://<resolver>@dns.controld.com, or a country code when the API key has server_proxy (default - None)
|
|
dl_params:
|
|
type: object
|
|
description: |
|
|
The dl track selection (lang, v_lang, a_lang, s_lang, acodec, forced_subs, exact_lang), in the same
|
|
form as on `/api/session/create`. The service reads it from `ctx.parent.params`.
|
|
credentials:
|
|
type: object
|
|
additionalProperties: true
|
|
description: Your own login, on a --remote-only server only ({username, password, extra?})
|
|
cookies:
|
|
type: string
|
|
description: Your own cookies, on a --remote-only server only (base64 of a zlib-compressed Netscape cookie file)
|
|
cache:
|
|
type: object
|
|
additionalProperties:
|
|
type: string
|
|
description: |
|
|
Your own cache files, on a --remote-only server only: cache key to base64 of the
|
|
zlib-compressed file. The request runs on a cache directory of its own, which the
|
|
server removes when the request ends.
|
|
responses:
|
|
'200':
|
|
description: Track information
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
additionalProperties: true
|
|
properties:
|
|
cache:
|
|
type: object
|
|
additionalProperties:
|
|
type: string
|
|
description: |
|
|
The updated cache files, in the same form as the request field. Only on a
|
|
--remote-only server, without a server account, when you sent credentials,
|
|
cookies or cache.
|
|
'400':
|
|
description: Invalid request
|
|
"""
|
|
data = await json_body(request)
|
|
|
|
return await list_tracks_handler(data, request)
|
|
|
|
|
|
@api_handler
|
|
async def download(request: web.Request) -> web.Response:
|
|
"""
|
|
Download a title based on the provided parameters.
|
|
---
|
|
summary: Download a title
|
|
description: >-
|
|
Download a video title based on the specified parameters.
|
|
'postscript', 'post_script' and 'post_scripts' are never accepted and return 400.
|
|
You configure post-scripts only in unshackle.yaml.
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required:
|
|
- service
|
|
- title_id
|
|
properties:
|
|
remote:
|
|
type: string
|
|
description: >
|
|
A remote_services server name from GET /api/config. The service runs through that
|
|
server (login, titles, tracks, licences); a download still runs on this server.
|
|
client_name:
|
|
type: string
|
|
description: Names this client on the remote server's dashboard, as serve (client_name)
|
|
service:
|
|
type: string
|
|
description: Service tag
|
|
title_id:
|
|
type: string
|
|
description: Title identifier
|
|
profile:
|
|
type: string
|
|
description: Profile to use for credentials and cookies (default - None)
|
|
quality:
|
|
type: array
|
|
items:
|
|
type: integer
|
|
description: >-
|
|
Download resolution(s) (default - best available). An API key with a
|
|
server_cdm_max_height for the service must pass a quality at or under that height.
|
|
vcodec:
|
|
oneOf:
|
|
- type: string
|
|
- type: array
|
|
items:
|
|
type: string
|
|
description: Video codec(s) to download (e.g., "HEVC" or ["AVC", "HEVC"]) - accepts AVC, H.264, H264, HEVC, H.265, H265, VC1, VC-1, VP8, VP9, AV1, LCEVC (default - None)
|
|
acodec:
|
|
oneOf:
|
|
- type: string
|
|
- type: array
|
|
items:
|
|
type: string
|
|
description: Audio codec(s) to download (e.g., "AAC" or ["AAC", "EC3"]) - accepts AAC, AC3, DD, EC3, DD+, EAC3, DDP, AC4, AC-4, OPUS, OGG, VORB, VORBIS, DTS, DTSX, DTS-X, DTSHD, DTS-HD, MPEGH, MPEG-H, ALAC, FLAC, MP3 (default - None)
|
|
vbitrate:
|
|
type: integer
|
|
description: Video bitrate in kbps (default - None)
|
|
abitrate:
|
|
type: integer
|
|
description: Audio bitrate in kbps (default - None)
|
|
range:
|
|
type: array
|
|
items:
|
|
type: string
|
|
description: >-
|
|
Video colour range (SDR, HDR10, HDR10+, HLG, DV, HYBRID) (default - ["SDR"]).
|
|
An API key with a server_cdm_max_height for the service cannot set HYBRID.
|
|
channels:
|
|
type: number
|
|
description: Audio channels (e.g., 2.0, 5.1, 7.1) (default - None)
|
|
no_atmos:
|
|
type: boolean
|
|
description: Exclude Atmos audio tracks (default - false)
|
|
wanted:
|
|
type: array
|
|
items:
|
|
type: string
|
|
description: Wanted episodes (e.g., ["S01E01", "S01E02"]) or music tracks (e.g., ["1-5"], ["2x3"] for disc 2 track 3) (default - all)
|
|
latest_episode:
|
|
type: boolean
|
|
description: Download only the single most recent episode (default - false)
|
|
latest_episodes:
|
|
type: integer
|
|
minimum: 1
|
|
description: Download only the N most recent episodes; overrides latest_episode (default - null)
|
|
lang:
|
|
type: array
|
|
items:
|
|
type: string
|
|
description: Language for video and audio (use 'orig' for original; a '-' prefix excludes, e.g. ["all", "-es"]) (default - ["orig"])
|
|
v_lang:
|
|
type: array
|
|
items:
|
|
type: string
|
|
description: Language for video tracks only (a '-' prefix excludes) (default - [])
|
|
a_lang:
|
|
type: array
|
|
items:
|
|
type: string
|
|
description: Language for audio tracks only (a '-' prefix excludes) (default - [])
|
|
s_lang:
|
|
type: array
|
|
items:
|
|
type: string
|
|
description: Language for subtitle tracks (a '-' prefix excludes, e.g. ["all", "-es"]) (default - ["all"])
|
|
require_audio:
|
|
type: array
|
|
items:
|
|
type: string
|
|
description: Audio languages that must exist, else the job fails (default - [])
|
|
require_video:
|
|
type: array
|
|
items:
|
|
type: string
|
|
description: Video languages that must exist, else the job fails (default - [])
|
|
require_subs:
|
|
type: array
|
|
items:
|
|
type: string
|
|
description: Required subtitle languages (default - [])
|
|
forced_subs:
|
|
type: boolean
|
|
description: Include forced subtitle tracks (default - false)
|
|
forced_subs_only:
|
|
type: boolean
|
|
description: Download forced subtitle tracks and no other subtitle tracks, implies forced_subs (default - false)
|
|
forced_s_lang:
|
|
type: array
|
|
items:
|
|
type: string
|
|
description: Languages wanted for forced subtitles, implies forced_subs (a '-' prefix excludes) (default - [])
|
|
exact_lang:
|
|
type: boolean
|
|
description: A language tag without a region never falls back to another variant; a tag with a region is always exact (default - false)
|
|
sub_format:
|
|
type: string
|
|
description: Output subtitle format such as SRT or VTT, or "original" to keep the source format (default - None)
|
|
video_only:
|
|
type: boolean
|
|
description: Only download video tracks (default - false)
|
|
audio_only:
|
|
type: boolean
|
|
description: Only download audio tracks (default - false)
|
|
subs_only:
|
|
type: boolean
|
|
description: Only download subtitle tracks (default - false)
|
|
chapters_only:
|
|
type: boolean
|
|
description: Only download chapters (default - false)
|
|
no_subs:
|
|
type: boolean
|
|
description: Do not download subtitle tracks (default - false)
|
|
no_audio:
|
|
type: boolean
|
|
description: Do not download audio tracks (default - false)
|
|
no_chapters:
|
|
type: boolean
|
|
description: Do not download chapters (default - false)
|
|
no_video:
|
|
type: boolean
|
|
description: Do not download video tracks (default - false)
|
|
no_attachments:
|
|
type: boolean
|
|
description: Do not download or mux attachments such as cover art or subtitle fonts (default - false)
|
|
audio_description:
|
|
type: boolean
|
|
description: Download audio description tracks (default - false)
|
|
audio_description_only:
|
|
type: boolean
|
|
description: Download audio description tracks and no standard audio tracks (default - false)
|
|
slow:
|
|
oneOf:
|
|
- type: boolean
|
|
- type: string
|
|
description: Add randomized delay between downloads. `true` for default 60-120s, or `"MIN-MAX"` string (e.g., `"20-40"`). Min must be >= 20 (default - null)
|
|
split_audio:
|
|
type: boolean
|
|
description: Make separate output files per audio codec instead of merging all audio (default - null)
|
|
skip_dl:
|
|
type: boolean
|
|
description: Skip downloading, only retrieve decryption keys (default - false)
|
|
all_drm:
|
|
type: boolean
|
|
description: License each track with both Widevine and PlayReady. A DRM system sends a challenge only for a KID that no vault has, unless `cdm_only` is true. Needs a CDM for each DRM system and cannot be used with `cdm` (default - false)
|
|
export:
|
|
type: boolean
|
|
description: Export manifest, DRM init data, keys, and track info to a JSON file in the exports directory (default - false)
|
|
cdm_only:
|
|
type: boolean
|
|
description: Only use CDM for content key retrieval (true) or only vaults (false) (default - None)
|
|
cdm:
|
|
type: string
|
|
description: CDM device name on the server to license with, overriding the cdm config mapping. Requires serve.cdm_overrides to allow it (default - None)
|
|
proxy:
|
|
type: string
|
|
description: Full proxy URI on a public host, a Control D resolver as controld://<resolver>@dns.controld.com, or a country code when the API key has server_proxy (default - None)
|
|
no_proxy:
|
|
type: boolean
|
|
description: Force disable all proxy use (default - false)
|
|
no_proxy_download:
|
|
type: boolean
|
|
description: Bypass proxy for all downloads. Manifest, license, and auth still use proxy (default - false)
|
|
proxy_download:
|
|
type: string
|
|
description: Proxy for the downloads only, in the same form as proxy. Manifest, license, and auth use proxy (default - None)
|
|
tag:
|
|
type: string
|
|
description: Set the group tag (default - None)
|
|
tmdb_id:
|
|
type: integer
|
|
description: Use this TMDB ID for tagging instead of a title search. Set enrich to also take its title, year and original language. Mutually exclusive with imdb_id and tvdb_id. Needs tmdb_api_key (default - None)
|
|
anilist_id:
|
|
oneOf:
|
|
- type: integer
|
|
- type: string
|
|
description: AniList ID for tagging instead of a title search, or a MyAnimeList ID as the string mal:21. Combines with one of tmdb_id, imdb_id and tvdb_id, which AniList does not know (default - None)
|
|
enrich:
|
|
type: boolean
|
|
description: Overwrite show title, year and original language with the external source's. Requires one of tmdb_id, imdb_id, tvdb_id or anilist_id (default - false)
|
|
daily:
|
|
type: boolean
|
|
description: Treat the title as daily content and fill missing air dates from TVDB. Needs enrich (default - false)
|
|
no_folder:
|
|
type: boolean
|
|
description: Disable folder creation for TV shows (default - false)
|
|
no_source:
|
|
type: boolean
|
|
description: Disable source tag from output file name (default - false)
|
|
no_mux:
|
|
type: boolean
|
|
description: Do not mux tracks into a container file (default - false)
|
|
include_sample:
|
|
type: boolean
|
|
description: Also cut a 60 second sample from each muxed file into a Sample folder next to it (default - false)
|
|
sample:
|
|
oneOf:
|
|
- type: boolean
|
|
- type: string
|
|
description: Download a short sample and write it to a Sample folder. `true` for 60 seconds from 2 minutes in, or a `"START-END"` string in seconds (e.g., `"0-60"`) (default - null)
|
|
workers:
|
|
type: integer
|
|
description: Max workers/threads per track download (default - None)
|
|
adaptive_workers:
|
|
type: boolean
|
|
description: Scale per-track segment workers to measured CDN throughput, up to the workers cap (default - false)
|
|
download_processes:
|
|
type: integer
|
|
description: Split a track's segments across this many processes. Only engages for large batches (default - 1)
|
|
continue_downloads:
|
|
type: boolean
|
|
description: Keep completed segment files across runs and resume a previously failed download (default - false)
|
|
downloads:
|
|
type: integer
|
|
description: Amount of tracks to download concurrently (default - 1)
|
|
best_available:
|
|
type: boolean
|
|
description: >-
|
|
Continue with best available if requested quality unavailable (default - false).
|
|
An API key with a server_cdm_max_height for the service cannot set it.
|
|
generate_dv:
|
|
type: boolean
|
|
description: >-
|
|
With HYBRID in range, make Dolby Vision from the HDR10+ metadata when a title has no
|
|
Dolby Vision track (default - false). Needs hdr10plus_tool on the server.
|
|
worst:
|
|
type: boolean
|
|
description: Select the lowest bitrate track within the specified quality. Requires `quality` (default - false)
|
|
repack:
|
|
type: boolean
|
|
description: Add REPACK tag to the output filename (default - false)
|
|
imdb_id:
|
|
type: string
|
|
description: Use this IMDB ID (e.g. tt1375666) for tagging instead of a title search. Set enrich to also take its title, year and original language. Mutually exclusive with tmdb_id and tvdb_id (default - None)
|
|
tvdb_id:
|
|
type: integer
|
|
description: Use this TVDB ID for tagging and episode ordering instead of a series lookup. Set enrich to also take its title, year and original language. Mutually exclusive with tmdb_id and imdb_id. Needs tvdb_api_key (default - None)
|
|
tvdb_order:
|
|
type: string
|
|
enum: [official, dvd, absolute, alternate, regional]
|
|
description: Renumber episodes to a TVDB season order (default - the tvdb_order config option)
|
|
output_dir:
|
|
type: string
|
|
description: >
|
|
Output directory for this download, relative to the server's downloads
|
|
directory. A path resolving outside it is rejected (default - None).
|
|
no_cache:
|
|
type: boolean
|
|
description: Bypass title cache for this download (default - false)
|
|
reset_cache:
|
|
type: boolean
|
|
description: Clear title cache before fetching (default - false)
|
|
responses:
|
|
'202':
|
|
description: Download job created
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
job_id:
|
|
type: string
|
|
status:
|
|
type: string
|
|
created_time:
|
|
type: string
|
|
'400':
|
|
description: Invalid request
|
|
'403':
|
|
description: >-
|
|
A gated parameter is not permitted (FORBIDDEN), or the job goes above the
|
|
server_cdm_max_height of the API key (SERVER_CDM_CAPPED)
|
|
"""
|
|
data = await json_body(request)
|
|
|
|
return await download_handler(data, request)
|
|
|
|
|
|
@api_handler
|
|
async def download_jobs(request: web.Request) -> web.Response:
|
|
"""
|
|
Show all download jobs with optional filtering and sorting.
|
|
---
|
|
summary: Show download jobs
|
|
description: Get list of all download jobs with their status, with optional filtering by status/service and sorting
|
|
parameters:
|
|
- name: status
|
|
in: query
|
|
required: false
|
|
schema:
|
|
type: string
|
|
enum: [queued, downloading, completed, failed, cancelled]
|
|
description: Filter jobs by status
|
|
- name: service
|
|
in: query
|
|
required: false
|
|
schema:
|
|
type: string
|
|
description: Filter jobs by service tag
|
|
- name: sort_by
|
|
in: query
|
|
required: false
|
|
schema:
|
|
type: string
|
|
enum: [created_time, started_time, completed_time, progress, status, service]
|
|
default: created_time
|
|
description: Field to sort by
|
|
- name: sort_order
|
|
in: query
|
|
required: false
|
|
schema:
|
|
type: string
|
|
enum: [asc, desc]
|
|
default: desc
|
|
description: Sort order (ascending or descending)
|
|
- name: full
|
|
in: query
|
|
required: false
|
|
schema:
|
|
type: string
|
|
enum: ["true", "false"]
|
|
default: "false"
|
|
description: When "true", include full job details (parameters, timestamps, output files, errors) per job
|
|
responses:
|
|
'200':
|
|
description: List of download jobs
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
jobs:
|
|
type: array
|
|
items:
|
|
type: object
|
|
properties:
|
|
job_id:
|
|
type: string
|
|
status:
|
|
type: string
|
|
created_time:
|
|
type: string
|
|
service:
|
|
type: string
|
|
title_id:
|
|
type: string
|
|
progress:
|
|
type: number
|
|
'400':
|
|
description: Invalid query parameters
|
|
'422':
|
|
description: Server error
|
|
"""
|
|
# Extract query parameters
|
|
query_params = {
|
|
"status": request.query.get("status"),
|
|
"service": request.query.get("service"),
|
|
"sort_by": request.query.get("sort_by", "created_time"),
|
|
"sort_order": request.query.get("sort_order", "desc"),
|
|
"full": request.query.get("full"),
|
|
}
|
|
return await list_download_jobs_handler(query_params, request)
|
|
|
|
|
|
@api_handler
|
|
async def download_job_detail(request: web.Request) -> web.Response:
|
|
"""
|
|
Get download job details.
|
|
---
|
|
summary: Get download job
|
|
description: Get detailed information about a specific download job
|
|
parameters:
|
|
- name: job_id
|
|
in: path
|
|
required: true
|
|
schema:
|
|
type: string
|
|
responses:
|
|
'200':
|
|
description: Download job details
|
|
'404':
|
|
description: Job not found
|
|
'422':
|
|
description: Server error
|
|
"""
|
|
job_id = request.match_info["job_id"]
|
|
return await get_download_job_handler(job_id, request)
|
|
|
|
|
|
@api_handler
|
|
async def download_job_events(request: web.Request) -> web.StreamResponse:
|
|
"""
|
|
Stream download job events.
|
|
---
|
|
summary: Stream download job events
|
|
description: >
|
|
Server-Sent Events stream of a job's progress. Emits a `snapshot` event, then
|
|
`progress` and `status` events, and closes after the terminal `completed`, `failed`
|
|
or `cancelled` event. Every event carries the same full job object that
|
|
GET /api/download/jobs/{job_id} returns. A browser EventSource can authenticate with
|
|
the `secret_key` query parameter instead of the X-Secret-Key header. When the client
|
|
sends both, the server uses the header.
|
|
parameters:
|
|
- name: job_id
|
|
in: path
|
|
required: true
|
|
schema:
|
|
type: string
|
|
- name: secret_key
|
|
in: query
|
|
required: false
|
|
schema:
|
|
type: string
|
|
responses:
|
|
'200':
|
|
description: Event stream
|
|
content:
|
|
text/event-stream:
|
|
schema:
|
|
type: string
|
|
'404':
|
|
description: Job not found
|
|
'422':
|
|
description: Server error
|
|
"""
|
|
job_id = request.match_info["job_id"]
|
|
return await download_job_events_handler(job_id, request)
|
|
|
|
|
|
@api_handler
|
|
async def cancel_download_job(request: web.Request) -> web.Response:
|
|
"""
|
|
Cancel or remove download job.
|
|
---
|
|
summary: Cancel or remove download job
|
|
description: Cancel a queued or running download job, or remove a completed/failed/cancelled job entirely
|
|
parameters:
|
|
- name: job_id
|
|
in: path
|
|
required: true
|
|
schema:
|
|
type: string
|
|
responses:
|
|
'200':
|
|
description: Job cancelled successfully
|
|
'204':
|
|
description: Terminal job removed from the manager
|
|
'400':
|
|
description: The server cannot cancel the job
|
|
'404':
|
|
description: Job not found
|
|
'422':
|
|
description: Server error
|
|
"""
|
|
job_id = request.match_info["job_id"]
|
|
return await cancel_download_job_handler(job_id, request)
|
|
|
|
|
|
@api_handler
|
|
async def clear_finished_download_jobs(request: web.Request) -> web.Response:
|
|
"""
|
|
Clear finished download jobs.
|
|
---
|
|
summary: Clear finished download jobs
|
|
description: Remove all completed, failed, and cancelled jobs from the manager
|
|
responses:
|
|
'200':
|
|
description: Finished jobs removed
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
removed:
|
|
type: integer
|
|
description: Number of jobs removed
|
|
'422':
|
|
description: Server error
|
|
"""
|
|
return await clear_finished_download_jobs_handler(request)
|
|
|
|
|
|
@api_handler
|
|
async def retry_download_job(request: web.Request) -> web.Response:
|
|
"""
|
|
Retry download job.
|
|
---
|
|
summary: Retry download job
|
|
description: Enqueue a new job reusing a completed, failed, or cancelled job's service, title, and parameters
|
|
parameters:
|
|
- name: job_id
|
|
in: path
|
|
required: true
|
|
schema:
|
|
type: string
|
|
responses:
|
|
'202':
|
|
description: New job queued
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
job_id:
|
|
type: string
|
|
status:
|
|
type: string
|
|
created_time:
|
|
type: string
|
|
'404':
|
|
description: Job not found
|
|
'409':
|
|
description: Job is not in a terminal state
|
|
'422':
|
|
description: Server error
|
|
"""
|
|
job_id = request.match_info["job_id"]
|
|
return await retry_download_job_handler(job_id, request)
|
|
|
|
|
|
@api_handler
|
|
async def prioritize_download_job(request: web.Request) -> web.Response:
|
|
"""
|
|
Prioritise download job.
|
|
---
|
|
summary: Prioritise download job
|
|
description: Move a queued job to the front of the download queue
|
|
parameters:
|
|
- name: job_id
|
|
in: path
|
|
required: true
|
|
schema:
|
|
type: string
|
|
responses:
|
|
'200':
|
|
description: Job moved to front of queue
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
job_id:
|
|
type: string
|
|
position:
|
|
type: string
|
|
example: front
|
|
'404':
|
|
description: Job not found
|
|
'409':
|
|
description: Job is not queued
|
|
'422':
|
|
description: Server error
|
|
"""
|
|
job_id = request.match_info["job_id"]
|
|
return await prioritize_download_job_handler(job_id, request)
|
|
|
|
|
|
@api_handler
|
|
async def download_job_input(request: web.Request) -> web.Response:
|
|
"""
|
|
Answer a download job prompt.
|
|
---
|
|
summary: Answer a download job prompt
|
|
description: >
|
|
Submit the answer to the prompt a running job waits on (an OTP code, a PIN, a device-code
|
|
confirmation). The job shows the prompt in its `input_prompt` field and in its SSE `progress`
|
|
events. The job fails if nobody answers within the prompt timeout. A job that waits for an
|
|
approval on another device shows the message in its `notice` field, which takes no answer.
|
|
parameters:
|
|
- name: job_id
|
|
in: path
|
|
required: true
|
|
schema:
|
|
type: string
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required:
|
|
- response
|
|
properties:
|
|
response:
|
|
type: string
|
|
description: The user's answer to the prompt
|
|
responses:
|
|
'200':
|
|
description: Answer accepted
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
status:
|
|
type: string
|
|
example: accepted
|
|
'400':
|
|
description: Invalid request body
|
|
'404':
|
|
description: Job not found
|
|
'409':
|
|
description: No prompt pending for this job
|
|
"""
|
|
job_id = request.match_info["job_id"]
|
|
data = await json_body(request)
|
|
return await download_job_input_handler(data, job_id, request)
|
|
|
|
|
|
@api_handler
|
|
async def profiles(request: web.Request) -> web.Response:
|
|
"""
|
|
Show the configured credential profiles per service.
|
|
---
|
|
summary: Show credential profiles
|
|
description: >
|
|
Enumerate named credential profiles configured per service (usable as the `profile`
|
|
parameter). This endpoint shows only the services whose credentials are a mapping of
|
|
profile-name to credential, and it includes a `default` entry if present. It ignores a
|
|
service configured with a single plain (unnamed) credential. Filtered by the caller's
|
|
service allowlist.
|
|
responses:
|
|
'200':
|
|
description: Profiles per service
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
profiles:
|
|
type: object
|
|
additionalProperties:
|
|
type: array
|
|
items:
|
|
type: string
|
|
'422':
|
|
description: Server error
|
|
"""
|
|
return await profiles_handler(request)
|
|
|
|
|
|
@api_handler
|
|
async def server_config(request: web.Request) -> web.Response:
|
|
"""
|
|
Get the redacted effective server configuration.
|
|
---
|
|
summary: Get server config
|
|
description: >
|
|
Read-only, redacted view of the effective server configuration for display in a UI
|
|
settings page. This view never includes secrets (api_secret, users, credentials,
|
|
tokens), and unshackle masks secret-looking config keys inside `dl`.
|
|
responses:
|
|
'200':
|
|
description: Redacted server configuration
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
config:
|
|
type: object
|
|
properties:
|
|
dl:
|
|
type: object
|
|
description: Default dl parameters from config (secret-looking keys masked)
|
|
serve:
|
|
type: object
|
|
properties:
|
|
max_concurrent_downloads:
|
|
type: integer
|
|
job_retention_hours:
|
|
type: integer
|
|
services:
|
|
type: array
|
|
items:
|
|
type: string
|
|
nullable: true
|
|
remote_only:
|
|
type: boolean
|
|
cdm_overrides:
|
|
nullable: true
|
|
description: List of permitted CDM device names, true, or null
|
|
allow_job_credentials:
|
|
type: boolean
|
|
remote_services:
|
|
type: array
|
|
items:
|
|
type: string
|
|
description: remote_services server names; their url and api_key stay on the server
|
|
directories:
|
|
type: object
|
|
properties:
|
|
downloads:
|
|
type: string
|
|
temp:
|
|
type: string
|
|
cache:
|
|
type: string
|
|
services:
|
|
type: array
|
|
items:
|
|
type: string
|
|
description: Available service tags (allowlist-filtered)
|
|
'422':
|
|
description: Server error
|
|
"""
|
|
return await server_config_handler(request)
|
|
|
|
|
|
@api_handler
|
|
async def download_history(request: web.Request) -> web.Response:
|
|
"""
|
|
Get persistent download history.
|
|
---
|
|
summary: Get download history
|
|
description: >
|
|
Read the persisted job history (jobs that reached a terminal state), newest first.
|
|
This endpoint ignores corrupt lines in the history file. A missing file gives no entries.
|
|
parameters:
|
|
- name: limit
|
|
in: query
|
|
required: false
|
|
schema:
|
|
type: integer
|
|
minimum: 1
|
|
default: 100
|
|
description: Maximum number of entries to return
|
|
- name: service
|
|
in: query
|
|
required: false
|
|
schema:
|
|
type: string
|
|
description: Filter entries by service tag (case-insensitive)
|
|
responses:
|
|
'200':
|
|
description: History entries, newest first
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
history:
|
|
type: array
|
|
items:
|
|
type: object
|
|
properties:
|
|
job_id:
|
|
type: string
|
|
service:
|
|
type: string
|
|
title_id:
|
|
type: string
|
|
title:
|
|
type: string
|
|
nullable: true
|
|
status:
|
|
type: string
|
|
enum: [completed, failed, cancelled]
|
|
created_time:
|
|
type: string
|
|
completed_time:
|
|
type: string
|
|
nullable: true
|
|
output_files:
|
|
type: array
|
|
items:
|
|
type: string
|
|
error_message:
|
|
type: string
|
|
nullable: true
|
|
count:
|
|
type: integer
|
|
'400':
|
|
description: Invalid query parameters
|
|
'422':
|
|
description: Server error
|
|
"""
|
|
query_params = {"limit": request.query.get("limit"), "service": request.query.get("service")}
|
|
return await download_history_handler(query_params, request)
|
|
|
|
|
|
@api_handler
|
|
async def delete_history(request: web.Request) -> web.Response:
|
|
"""
|
|
Delete a download history entry.
|
|
---
|
|
summary: Delete download history entry
|
|
description: Remove a single persisted history entry by job_id.
|
|
parameters:
|
|
- name: job_id
|
|
in: path
|
|
required: true
|
|
schema:
|
|
type: string
|
|
responses:
|
|
'204':
|
|
description: History entry removed
|
|
'404':
|
|
description: History entry not found
|
|
'422':
|
|
description: Server error
|
|
"""
|
|
return await delete_history_handler(request.match_info["job_id"], request)
|
|
|
|
|
|
@api_handler
|
|
async def maintenance_clear_cache(request: web.Request) -> web.Response:
|
|
"""
|
|
Clear the cache directory.
|
|
---
|
|
summary: Clear cache
|
|
description: Delete the contents of the cache directory (recreated empty). Freed size is best effort.
|
|
responses:
|
|
'200':
|
|
description: Cache cleared
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
cleared:
|
|
type: boolean
|
|
freed_bytes:
|
|
type: integer
|
|
'422':
|
|
description: Server error
|
|
"""
|
|
return await clear_cache_handler(request)
|
|
|
|
|
|
@api_handler
|
|
async def maintenance_clear_temp(request: web.Request) -> web.Response:
|
|
"""
|
|
Clear the temp directory.
|
|
---
|
|
summary: Clear temp
|
|
description: Delete the contents of the temp directory (recreated empty). Freed size is best effort.
|
|
responses:
|
|
'200':
|
|
description: Temp cleared
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
cleared:
|
|
type: boolean
|
|
freed_bytes:
|
|
type: integer
|
|
'422':
|
|
description: Server error
|
|
"""
|
|
return await clear_temp_handler(request)
|
|
|
|
|
|
@api_handler
|
|
async def maintenance_refresh_services(request: web.Request) -> web.Response:
|
|
"""
|
|
Refresh configured service repos.
|
|
---
|
|
summary: Refresh service repos
|
|
description: >
|
|
Force-sync (git pull) every service repo configured in directories.services and
|
|
re-import the services that changed, without a restart. `refreshed` is true when all
|
|
repos synced, or when you configured no repos. The `repos` field holds the per-repo
|
|
results: `deferred` names the services that keep their current code until their
|
|
running or queued jobs finish, and `load_errors` names the services whose new code
|
|
failed to import.
|
|
responses:
|
|
'200':
|
|
description: Refresh results
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
refreshed:
|
|
type: boolean
|
|
repos:
|
|
type: array
|
|
items:
|
|
type: object
|
|
properties:
|
|
spec:
|
|
type: string
|
|
updated:
|
|
type: boolean
|
|
changes:
|
|
type: array
|
|
items:
|
|
type: string
|
|
deferred:
|
|
type: array
|
|
description: Services staged until their running or queued jobs finish
|
|
items:
|
|
type: string
|
|
load_errors:
|
|
type: array
|
|
description: Services whose new code failed to import
|
|
items:
|
|
type: string
|
|
'422':
|
|
description: Server error
|
|
"""
|
|
return await refresh_services_handler(request)
|
|
|
|
|
|
@api_handler
|
|
async def env_check(request: web.Request) -> web.Response:
|
|
"""
|
|
Examine the environment dependencies.
|
|
---
|
|
summary: Environment check
|
|
description: Report install status of the binaries `env check` inspects, with best-effort versions.
|
|
responses:
|
|
'200':
|
|
description: Dependency check results
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
checks:
|
|
type: array
|
|
items:
|
|
type: object
|
|
properties:
|
|
name:
|
|
type: string
|
|
installed:
|
|
type: boolean
|
|
version:
|
|
type: string
|
|
nullable: true
|
|
required:
|
|
type: boolean
|
|
'422':
|
|
description: Server error
|
|
"""
|
|
return await env_check_handler(request)
|
|
|
|
|
|
@api_handler
|
|
async def session_create(request: web.Request) -> web.Response:
|
|
"""
|
|
Make a remote session.
|
|
---
|
|
summary: Make remote session
|
|
description: Authenticate with a service, get titles, tracks, and chapters in one call
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
additionalProperties: true
|
|
required:
|
|
- service
|
|
- title_id
|
|
properties:
|
|
service:
|
|
type: string
|
|
title_id:
|
|
type: string
|
|
credentials:
|
|
type: object
|
|
additionalProperties: true
|
|
cookies:
|
|
type: string
|
|
proxy:
|
|
type: string
|
|
description: As on /api/search, including tunnel:<id> for a --local-proxy client
|
|
no_proxy:
|
|
type: boolean
|
|
profile:
|
|
type: string
|
|
cache:
|
|
type: object
|
|
additionalProperties: true
|
|
client_region:
|
|
type: string
|
|
description: Two-letter country the client sits in
|
|
proxy_region:
|
|
type: string
|
|
description: Two-letter country the client resolved its proxy for; picks a server account
|
|
dl_params:
|
|
type: object
|
|
description: |
|
|
The dl track selection. The service reads it from ctx.parent.params, never as
|
|
service options. An absent or malformed value gets the dl default, and the
|
|
server drops an unknown codec name.
|
|
properties:
|
|
lang:
|
|
type: array
|
|
items:
|
|
type: string
|
|
default: ["orig"]
|
|
v_lang:
|
|
type: array
|
|
items:
|
|
type: string
|
|
default: []
|
|
acodec:
|
|
type: array
|
|
items:
|
|
type: string
|
|
default: []
|
|
description: Audio codec names, such as EC3
|
|
a_lang:
|
|
type: array
|
|
items:
|
|
type: string
|
|
default: []
|
|
s_lang:
|
|
type: array
|
|
items:
|
|
type: string
|
|
default: ["all"]
|
|
forced_subs:
|
|
type: boolean
|
|
default: false
|
|
exact_lang:
|
|
type: boolean
|
|
default: false
|
|
server_cdm:
|
|
type: boolean
|
|
description: |
|
|
False when the user picked the local CDM. With `cdm_type`, the server then builds
|
|
the service on the client's device, also for an API key with the server CDM.
|
|
A request that sets one of the service's `SERVER_DEVICE_OPTIONS` keeps the server
|
|
CDM. The server reads it from `service_params`, or from the top level when
|
|
the request has no `service_params`
|
|
cdm_type:
|
|
type: string
|
|
enum: [widevine, playready]
|
|
description: DRM system of the client's local CDM; absent when the client has none
|
|
cdm_security_level:
|
|
type: integer
|
|
description: |
|
|
Security level of the client's local CDM, in the device's own numbers
|
|
(Widevine 1 to 3, PlayReady 150 to 3000)
|
|
cdm_relay:
|
|
type: boolean
|
|
description: |
|
|
True when the client answers CDM calls, so the service sees a stand-in for its
|
|
device instead of the server's
|
|
cdm_system_id:
|
|
type: integer
|
|
description: System ID of the client's Widevine device
|
|
cdm_device_type:
|
|
type: string
|
|
enum: [CHROME, ANDROID]
|
|
description: Device type of the client's Widevine device
|
|
service_config:
|
|
type: object
|
|
additionalProperties: true
|
|
description: |
|
|
The client's own identity config for the service. The server takes only the keys
|
|
the service lists in `client_config`, and only when the client's device licenses.
|
|
client:
|
|
type: object
|
|
additionalProperties: true
|
|
description: |
|
|
Freeform client identity, shown to dashboard viewers as sent. The CLI sends
|
|
`version`, `code_hash`, `platform` and a redacted `argv`. Ignored above 4096
|
|
bytes of JSON.
|
|
responses:
|
|
'200':
|
|
description: Remote session created; authentication continues in the background
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
session_id:
|
|
type: string
|
|
service:
|
|
type: string
|
|
status:
|
|
type: string
|
|
server_account:
|
|
type: boolean
|
|
description: True when the server authenticated with one of its own accounts
|
|
server_cdm:
|
|
type: boolean
|
|
description: |
|
|
True when the server CDM licenses this remote session. False when the client's own
|
|
local CDM licenses it.
|
|
server_cdm_max_height:
|
|
type: integer
|
|
nullable: true
|
|
description: |
|
|
Tallest video track the server CDM licenses live for this API key and service;
|
|
null for no limit
|
|
server_vault:
|
|
type: boolean
|
|
description: True when the server vault serves content keys to a remote session the client's device licenses
|
|
server_device:
|
|
type: boolean
|
|
description: |
|
|
True when the service runs on the server's own device. Every content key then comes
|
|
from a vault, and the server makes no live licence.
|
|
'400':
|
|
description: Invalid request
|
|
'403':
|
|
description: |
|
|
SERVER_CDM_CAPPED when `quality` asks for more than the server CDM limit and the client
|
|
reported no local CDM
|
|
'401':
|
|
description: Authentication failed
|
|
"""
|
|
data = await json_body(request)
|
|
try:
|
|
return await session_create_handler(data, request)
|
|
except Exception as e:
|
|
log.exception("Error in session create")
|
|
return handle_api_exception(
|
|
e, context={"operation": "session_create"}, debug_mode=request.app.get("debug_api", False)
|
|
)
|
|
|
|
|
|
@api_handler
|
|
async def tunnel(request: web.Request) -> web.StreamResponse:
|
|
"""
|
|
Open a --local-proxy tunnel.
|
|
---
|
|
summary: Open tunnel
|
|
description: >-
|
|
A WebSocket that a --remote client holds open. The server sends the requests of a remote session
|
|
created with proxy tunnel:<id> through it, and the client makes them from its own connection.
|
|
The first message is {"id": <tunnel id>}.
|
|
parameters:
|
|
- name: id
|
|
in: query
|
|
required: false
|
|
description: Reattach to this tunnel after a drop
|
|
schema:
|
|
type: string
|
|
responses:
|
|
'101':
|
|
description: WebSocket open
|
|
'404':
|
|
description: No open tunnel with this id for this API key
|
|
'429':
|
|
description: The API key has the maximum number of tunnels open
|
|
"""
|
|
return await tunnel_handler(request)
|
|
|
|
|
|
@api_handler
|
|
async def tunnel_check(request: web.Request) -> web.Response:
|
|
"""
|
|
Look up the exit IP through a tunnel.
|
|
---
|
|
summary: Check tunnel
|
|
parameters:
|
|
- name: id
|
|
in: query
|
|
required: true
|
|
schema:
|
|
type: string
|
|
responses:
|
|
'200':
|
|
description: The exit IP and country through the tunnel, and the lookup time in ms
|
|
'404':
|
|
description: No open tunnel with this id for this API key
|
|
'424':
|
|
description: No IP lookup got through the tunnel, or the requests left from the server itself
|
|
"""
|
|
return await tunnel_check_handler(request)
|
|
|
|
|
|
@api_handler
|
|
@heartbeat
|
|
async def session_titles(request: web.Request) -> web.Response:
|
|
"""
|
|
Get titles for an authenticated remote session.
|
|
---
|
|
summary: Get titles
|
|
description: Fetch titles from the authenticated remote session
|
|
parameters:
|
|
- name: session_id
|
|
in: path
|
|
required: true
|
|
schema:
|
|
type: string
|
|
responses:
|
|
'200':
|
|
description: >-
|
|
List of titles.
|
|
After 30 s the route sends 200 and a newline every 30 s. A failure after that time
|
|
arrives as the error body with status 200.
|
|
'404':
|
|
description: Remote session not found
|
|
"""
|
|
session_id = request.match_info["session_id"]
|
|
try:
|
|
return await session_titles_handler(session_id, request)
|
|
except Exception as e:
|
|
log.exception("Error in session titles")
|
|
return handle_api_exception(
|
|
e, context={"operation": "session_titles"}, debug_mode=request.app.get("debug_api", False)
|
|
)
|
|
|
|
|
|
@api_handler
|
|
@heartbeat
|
|
async def session_tracks(request: web.Request) -> web.Response:
|
|
"""
|
|
Get tracks and chapters for a specific title.
|
|
---
|
|
summary: Get tracks
|
|
description: Fetch tracks and chapters for a title in the remote session
|
|
parameters:
|
|
- name: session_id
|
|
in: path
|
|
required: true
|
|
schema:
|
|
type: string
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required:
|
|
- title_id
|
|
properties:
|
|
title_id:
|
|
type: string
|
|
description: ID of the title to get tracks for
|
|
responses:
|
|
'200':
|
|
description: >-
|
|
Tracks and chapters for the title.
|
|
After 30 s the route sends 200 and a newline every 30 s. A failure after that time
|
|
arrives as the error body with status 200.
|
|
'404':
|
|
description: Remote session or title not found
|
|
"""
|
|
session_id = request.match_info["session_id"]
|
|
data = await json_body(request)
|
|
try:
|
|
return await session_tracks_handler(data, session_id, request)
|
|
except Exception as e:
|
|
log.exception("Error in session tracks")
|
|
return handle_api_exception(
|
|
e, context={"operation": "session_tracks"}, debug_mode=request.app.get("debug_api", False)
|
|
)
|
|
|
|
|
|
@api_handler
|
|
async def session_segments(request: web.Request) -> web.Response:
|
|
"""
|
|
Get segment URLs for selected tracks.
|
|
---
|
|
summary: Get segments
|
|
description: Get download URLs, DRM info, and headers for selected tracks
|
|
parameters:
|
|
- name: session_id
|
|
in: path
|
|
required: true
|
|
schema:
|
|
type: string
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required:
|
|
- track_ids
|
|
properties:
|
|
track_ids:
|
|
type: array
|
|
items:
|
|
type: string
|
|
description: List of track IDs to get segment URLs for
|
|
responses:
|
|
'200':
|
|
description: Segment URLs and DRM info for each track
|
|
'404':
|
|
description: Remote session or track not found
|
|
"""
|
|
session_id = request.match_info["session_id"]
|
|
data = await json_body(request)
|
|
try:
|
|
return await session_segments_handler(data, session_id, request)
|
|
except Exception as e:
|
|
log.exception("Error in session segments")
|
|
return handle_api_exception(
|
|
e, context={"operation": "session_segments"}, debug_mode=request.app.get("debug_api", False)
|
|
)
|
|
|
|
|
|
@api_handler
|
|
async def session_segment_filter(request: web.Request) -> web.Response:
|
|
"""
|
|
Get the unwanted HLS segments for one track.
|
|
---
|
|
summary: Get segment filter
|
|
description: >-
|
|
Run the service's HLS segment filter on the server and return the segment URIs the
|
|
client must skip. The client fetches the same media playlist itself.
|
|
parameters:
|
|
- name: session_id
|
|
in: path
|
|
required: true
|
|
schema:
|
|
type: string
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required:
|
|
- track_id
|
|
properties:
|
|
track_id:
|
|
type: string
|
|
description: Track ID to run the segment filter for
|
|
responses:
|
|
'200':
|
|
description: >-
|
|
Absolute URIs of the unwanted segments, or null when the track has no segment filter
|
|
'404':
|
|
description: Remote session or track not found
|
|
"""
|
|
session_id = request.match_info["session_id"]
|
|
data = await json_body(request)
|
|
try:
|
|
return await session_segment_filter_handler(data, session_id, request)
|
|
except Exception as e:
|
|
log.exception("Error in session segment filter")
|
|
return handle_api_exception(
|
|
e, context={"operation": "session_segment_filter"}, debug_mode=request.app.get("debug_api", False)
|
|
)
|
|
|
|
|
|
@api_handler
|
|
async def session_samples(request: web.Request) -> web.Response:
|
|
"""
|
|
Decrypt the samples of a track on the server.
|
|
---
|
|
summary: Decrypt samples
|
|
description: >-
|
|
For a track the tracks response lists in sample_decrypt_tracks. An empty body asks how the
|
|
stsd entry decrypts: the JSON answer is its content key (hex) in key, or decrypter true. A
|
|
body of samples gets their plaintexts back in the same frame: a big-endian uint32 count,
|
|
one uint32 length per sample, then the samples, each ciphertext cut to whole AES blocks. The
|
|
body is application/octet-stream and is not in this schema, so the docs page cannot send it.
|
|
Needs the server_cdm grant for the service.
|
|
parameters:
|
|
- name: session_id
|
|
in: path
|
|
required: true
|
|
schema:
|
|
type: string
|
|
- name: track_id
|
|
in: query
|
|
required: true
|
|
schema:
|
|
type: string
|
|
- name: entry
|
|
in: query
|
|
required: true
|
|
description: "1-based stsd sample entry index"
|
|
schema:
|
|
type: integer
|
|
responses:
|
|
'200':
|
|
description: "JSON key (hex) or decrypter: true, or a sample frame of plaintexts in order"
|
|
'403':
|
|
description: The API key has no server_cdm grant for the service
|
|
'404':
|
|
description: Remote session or track not found
|
|
"""
|
|
frame = await request.clone(client_max_size=2 * cbcs.SAMPLE_BATCH_BYTES).read()
|
|
return await session_samples_handler(
|
|
request.match_info["session_id"],
|
|
request.query.get("track_id", ""),
|
|
request.query.get("entry", ""),
|
|
frame,
|
|
request,
|
|
)
|
|
|
|
|
|
@api_handler
|
|
@heartbeat
|
|
async def session_license(request: web.Request) -> web.Response:
|
|
"""
|
|
Proxy DRM license through authenticated service.
|
|
---
|
|
summary: Proxy license
|
|
description: Forward a CDM challenge to the service's license endpoint
|
|
parameters:
|
|
- name: session_id
|
|
in: path
|
|
required: true
|
|
schema:
|
|
type: string
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required:
|
|
- track_id
|
|
- challenge
|
|
properties:
|
|
track_id:
|
|
type: string
|
|
description: Track ID this license is for
|
|
challenge:
|
|
type: string
|
|
description: Base64-encoded CDM challenge
|
|
drm_type:
|
|
type: string
|
|
enum: [widevine, playready]
|
|
description: DRM type (default widevine)
|
|
is_certificate:
|
|
type: boolean
|
|
description: >-
|
|
True to send the challenge to the service's Widevine service certificate hook
|
|
instead of its licence hook. `license` is then the raw certificate, or null.
|
|
responses:
|
|
'200':
|
|
description: >-
|
|
License response. A Widevine licence or certificate that the service returns as Base64
|
|
text arrives decoded, as raw bytes in Base64. In server_cdm mode `keys` maps KID to content key and `vault_keys`,
|
|
an array of KID hex strings that may be absent and may repeat a KID shared by several
|
|
tracks, lists the content keys a server vault supplied, which the client has to prove
|
|
before it trusts them. `clear_tracks`, absent when empty, lists the requested track ids
|
|
that carry no DRM and so have no keys. `capped_tracks`, absent when empty, maps each
|
|
track id the server refused to license live to the refusal details (`reason` and
|
|
`max_height`); the client licenses those tracks with its own local CDM. A `reason` of
|
|
`server_device` means the remote session runs on the server's device: the client reads
|
|
only its own vaults for that track and makes no licence.
|
|
After 30 s the route sends 200 and a newline every 30 s. A failure after that time
|
|
arrives as the error body with status 200.
|
|
'404':
|
|
description: Remote session or track not found
|
|
"""
|
|
session_id = request.match_info["session_id"]
|
|
data = await json_body(request)
|
|
try:
|
|
return await session_license_handler(data, session_id, request)
|
|
except Exception as e:
|
|
if isinstance(e, APIError) and e.error_code is APIErrorCode.SERVER_CDM_CAPPED:
|
|
log.info(f"Session licence refused: {e.message}")
|
|
else:
|
|
log.exception("Error in session license")
|
|
return handle_api_exception(
|
|
e, context={"operation": "session_license"}, debug_mode=request.app.get("debug_api", False)
|
|
)
|
|
|
|
|
|
@api_handler
|
|
async def session_bad_key(request: web.Request) -> web.Response:
|
|
"""
|
|
Flag a server-vault content key the client proved wrong.
|
|
---
|
|
summary: Report a bad content key
|
|
description: >-
|
|
The client decrypted with a content key the server took from its vault and the output did
|
|
not decode. The server flags the pair in its local vaults and reports it to the vault that
|
|
served it, so the next licence for that KID reaches the CDM. The server accepts only a pair
|
|
it served to this remote session.
|
|
parameters:
|
|
- name: session_id
|
|
in: path
|
|
required: true
|
|
schema:
|
|
type: string
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required:
|
|
- kid
|
|
- key
|
|
properties:
|
|
kid:
|
|
type: string
|
|
description: KID as hex
|
|
key:
|
|
type: string
|
|
description: Content key as hex
|
|
responses:
|
|
'200':
|
|
description: "flagged true when the pair is flagged; false when the CDM licensed it"
|
|
'400':
|
|
description: The remote session was not served that pair
|
|
'404':
|
|
description: Remote session not found
|
|
"""
|
|
session_id = request.match_info["session_id"]
|
|
data = await json_body(request)
|
|
return await session_bad_key_handler(data, session_id, request)
|
|
|
|
|
|
@api_handler
|
|
async def session_info(request: web.Request) -> web.Response:
|
|
"""
|
|
Get remote session info.
|
|
---
|
|
summary: Remote session info
|
|
description: Make sure that the remote session is valid, and get metadata
|
|
parameters:
|
|
- name: session_id
|
|
in: path
|
|
required: true
|
|
schema:
|
|
type: string
|
|
responses:
|
|
'200':
|
|
description: Remote session info
|
|
'404':
|
|
description: Remote session not found
|
|
"""
|
|
session_id = request.match_info["session_id"]
|
|
return await session_info_handler(session_id, request)
|
|
|
|
|
|
@api_handler
|
|
async def session_delete(request: web.Request) -> web.Response:
|
|
"""
|
|
Delete a remote session.
|
|
---
|
|
summary: Delete remote session
|
|
description: Clean up a remote session
|
|
parameters:
|
|
- name: session_id
|
|
in: path
|
|
required: true
|
|
schema:
|
|
type: string
|
|
responses:
|
|
'200':
|
|
description: Remote session deleted
|
|
'404':
|
|
description: Remote session not found
|
|
"""
|
|
session_id = request.match_info["session_id"]
|
|
return await session_delete_handler(session_id, request)
|
|
|
|
|
|
@api_handler
|
|
async def session_prompt_get(request: web.Request) -> web.Response:
|
|
"""
|
|
Poll for pending interactive prompts during authentication.
|
|
---
|
|
summary: Get auth prompt
|
|
description: Poll for pending interactive prompts (OTP, device code, PIN) during remote session authentication
|
|
parameters:
|
|
- name: session_id
|
|
in: path
|
|
required: true
|
|
schema:
|
|
type: string
|
|
responses:
|
|
'200':
|
|
description: Auth status and optional prompt
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
status:
|
|
type: string
|
|
enum: [authenticating, pending_input, authenticated, failed]
|
|
prompt:
|
|
type: string
|
|
description: Prompt to display to the user (only when status is pending_input)
|
|
cdm_call:
|
|
type: object
|
|
additionalProperties: true
|
|
description: |
|
|
A call the service makes on the client's CDM, in any auth state: `op` is `challenge`
|
|
(with `drm`, `init_data`, `license_type`, `privacy_mode` and `service_certificate`),
|
|
`keys` (with `session` and `license`) or `close` (with `session`). The client answers
|
|
with a JSON object in the POST `response`, or `{"error": ...}` for an op it does not know.
|
|
error:
|
|
type: string
|
|
description: Error message (only for the failed status)
|
|
'404':
|
|
description: Remote session not found
|
|
"""
|
|
session_id = request.match_info["session_id"]
|
|
try:
|
|
return await session_prompt_get_handler(session_id, request)
|
|
except Exception as e:
|
|
log.exception("Error in session prompt get")
|
|
return handle_api_exception(
|
|
e, context={"operation": "session_prompt_get"}, debug_mode=request.app.get("debug_api", False)
|
|
)
|
|
|
|
|
|
@api_handler
|
|
async def session_logs(request: web.Request) -> web.Response:
|
|
"""
|
|
Drain the remote session's service log records.
|
|
---
|
|
summary: Get session logs
|
|
description: The service's server-side log output for this remote session, newer than the given sequence number
|
|
parameters:
|
|
- name: session_id
|
|
in: path
|
|
required: true
|
|
schema:
|
|
type: string
|
|
- name: since
|
|
in: query
|
|
required: false
|
|
schema:
|
|
type: integer
|
|
default: 0
|
|
description: Return only records with a sequence number greater than this
|
|
responses:
|
|
'200':
|
|
description: Log records, oldest first
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
logs:
|
|
type: array
|
|
items:
|
|
type: object
|
|
properties:
|
|
seq:
|
|
type: integer
|
|
level:
|
|
type: string
|
|
message:
|
|
type: string
|
|
ts:
|
|
type: number
|
|
last_seq:
|
|
type: integer
|
|
'404':
|
|
description: Remote session not found
|
|
"""
|
|
session_id = request.match_info["session_id"]
|
|
try:
|
|
since = int(request.query.get("since", 0))
|
|
except ValueError:
|
|
since = 0
|
|
try:
|
|
return await session_logs_handler(session_id, since, request)
|
|
except Exception as e:
|
|
log.exception("Error in session logs")
|
|
return handle_api_exception(
|
|
e, context={"operation": "session_logs"}, debug_mode=request.app.get("debug_api", False)
|
|
)
|
|
|
|
|
|
@api_handler
|
|
async def session_prompt_submit(request: web.Request) -> web.Response:
|
|
"""
|
|
Submit a response to a pending interactive prompt.
|
|
---
|
|
summary: Submit prompt response
|
|
description: Submit user input (OTP code, PIN, device code confirmation) to unblock server authentication
|
|
parameters:
|
|
- name: session_id
|
|
in: path
|
|
required: true
|
|
schema:
|
|
type: string
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required:
|
|
- response
|
|
properties:
|
|
response:
|
|
type: string
|
|
description: User's response to the prompt
|
|
responses:
|
|
'200':
|
|
description: Response accepted
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
status:
|
|
type: string
|
|
example: accepted
|
|
'400':
|
|
description: No prompt pending or invalid request
|
|
'404':
|
|
description: Remote session not found
|
|
"""
|
|
session_id = request.match_info["session_id"]
|
|
data = await json_body(request)
|
|
try:
|
|
return await session_prompt_post_handler(data, session_id, request)
|
|
except Exception as e:
|
|
log.exception("Error in session prompt submit")
|
|
return handle_api_exception(
|
|
e, context={"operation": "session_prompt_submit"}, debug_mode=request.app.get("debug_api", False)
|
|
)
|
|
|
|
|
|
@api_handler
|
|
async def dashboard_status(request: web.Request) -> web.Response:
|
|
"""
|
|
Dashboard: server status.
|
|
---
|
|
summary: Dashboard server status
|
|
description: >
|
|
Version, uptime, bind address, mode, request counters per API key, error counts by
|
|
service and error code, and counts of services, remote sessions and jobs. Every /api/dashboard/ route needs
|
|
`serve.dashboard.key` in X-Secret-Key; a user key never passes, and the routes do not
|
|
exist without a dashboard key.
|
|
tags: [Dashboard]
|
|
responses:
|
|
'200':
|
|
description: Server status
|
|
'401':
|
|
description: Dashboard key missing or invalid
|
|
"""
|
|
return await dashboard_status_handler(request)
|
|
|
|
|
|
@api_handler
|
|
async def dashboard_drain_start(request: web.Request) -> web.Response:
|
|
"""
|
|
Dashboard: start a drain before a restart.
|
|
---
|
|
summary: Dashboard drain start
|
|
description: >
|
|
Refuses every new remote session, download job and job retry with `SERVER_DRAINING` (409)
|
|
until the server restarts or the operator cancels the drain. Open remote sessions continue, and
|
|
each one gets a warning in its service log. Poll `/api/dashboard/status` until `sessions`
|
|
and the active jobs reach 0, then restart. A second call changes nothing.
|
|
tags: [Dashboard]
|
|
responses:
|
|
'200':
|
|
description: The status body, with `draining` true
|
|
'401':
|
|
description: Dashboard key missing or invalid
|
|
"""
|
|
return await dashboard_drain_start_handler(request)
|
|
|
|
|
|
@api_handler
|
|
async def dashboard_drain_stop(request: web.Request) -> web.Response:
|
|
"""
|
|
Dashboard: cancel a drain.
|
|
---
|
|
summary: Dashboard drain cancel
|
|
description: The server accepts new remote sessions and download jobs again.
|
|
tags: [Dashboard]
|
|
responses:
|
|
'200':
|
|
description: The status body, with `draining` false
|
|
'401':
|
|
description: Dashboard key missing or invalid
|
|
"""
|
|
return await dashboard_drain_stop_handler(request)
|
|
|
|
|
|
@api_handler
|
|
async def dashboard_sessions(request: web.Request) -> web.Response:
|
|
"""
|
|
Dashboard: active remote sessions.
|
|
---
|
|
summary: Dashboard remote sessions
|
|
description: Every live remote session on the server, with the owner key masked.
|
|
tags: [Dashboard]
|
|
responses:
|
|
'200':
|
|
description: Session list
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: array
|
|
items:
|
|
type: object
|
|
properties:
|
|
id:
|
|
type: string
|
|
description: 'Remote session id'
|
|
owner:
|
|
type: string
|
|
description: 'Username, else the masked API key'
|
|
creator_ip:
|
|
type: string
|
|
nullable: true
|
|
service:
|
|
type: string
|
|
description: 'Service tag'
|
|
title_id:
|
|
type: string
|
|
nullable: true
|
|
description: 'The title the client last asked tracks for, else the first resolved title'
|
|
title:
|
|
type: string
|
|
nullable: true
|
|
description: 'Display name of that title'
|
|
titles:
|
|
type: integer
|
|
description: 'How many titles the remote session resolved'
|
|
tracks:
|
|
type: integer
|
|
auth_status:
|
|
type: string
|
|
enum: [authenticated, authenticating, pending_input, failed]
|
|
auth_error:
|
|
type: string
|
|
nullable: true
|
|
server_account:
|
|
type: string
|
|
nullable: true
|
|
description: 'Server profile lent to the remote session'
|
|
log_seq:
|
|
type: integer
|
|
description: 'Last sequence number in the remote session service log'
|
|
client:
|
|
type: object
|
|
description: |
|
|
What the client reported when it opened the remote session. The CLI sends `version`,
|
|
`code_hash`, `platform` and `argv`. `argv` is the command line the user
|
|
ran, redacted by the client: proxy and URL userinfo, secret query
|
|
parameters and credential values become `***`, home and install paths
|
|
shorten as in the logs, and the line is cut at 3000 characters. Empty
|
|
for a client too old to report anything.
|
|
properties:
|
|
version:
|
|
type: string
|
|
code_hash:
|
|
type: string
|
|
nullable: true
|
|
description: 'Commit the client runs, null when its source cannot be read'
|
|
platform:
|
|
type: string
|
|
argv:
|
|
type: string
|
|
description: 'Redacted command line the user ran'
|
|
actions:
|
|
type: array
|
|
description: 'Request log for the remote session, newest last, capped at 500'
|
|
items:
|
|
type: object
|
|
properties:
|
|
ts:
|
|
type: number
|
|
method:
|
|
type: string
|
|
action:
|
|
type: string
|
|
query:
|
|
type: string
|
|
status:
|
|
type: integer
|
|
ms:
|
|
type: number
|
|
bytes_in:
|
|
type: integer
|
|
bytes_out:
|
|
type: integer
|
|
created_at:
|
|
type: string
|
|
last_accessed:
|
|
type: string
|
|
created_ts:
|
|
type: number
|
|
description: 'Unix epoch, the same clock as log ts'
|
|
last_accessed_ts:
|
|
type: number
|
|
age_seconds:
|
|
type: integer
|
|
idle_seconds:
|
|
type: integer
|
|
'401':
|
|
description: Dashboard key missing or invalid
|
|
"""
|
|
return await dashboard_sessions_handler(request)
|
|
|
|
|
|
@api_handler
|
|
async def dashboard_jobs(request: web.Request) -> web.Response:
|
|
"""
|
|
Dashboard: download jobs.
|
|
---
|
|
summary: Dashboard download jobs
|
|
description: Every download job on the server regardless of owner. Empty in --remote-only mode.
|
|
tags: [Dashboard]
|
|
responses:
|
|
'200':
|
|
description: Job list
|
|
'401':
|
|
description: Dashboard key missing or invalid
|
|
"""
|
|
return await dashboard_jobs_handler(request)
|
|
|
|
|
|
@api_handler
|
|
async def dashboard_logs(request: web.Request) -> web.Response:
|
|
"""
|
|
Dashboard: recent log records.
|
|
---
|
|
summary: Dashboard log ring buffer
|
|
description: The last 1000 log records. Poll with `since` set to the `seq` of the last record seen.
|
|
tags: [Dashboard]
|
|
parameters:
|
|
- name: since
|
|
in: query
|
|
required: false
|
|
schema:
|
|
type: integer
|
|
description: Return only records with seq greater than this value
|
|
- name: level
|
|
in: query
|
|
required: false
|
|
schema:
|
|
type: string
|
|
description: Minimum level (DEBUG, INFO, WARNING, ERROR)
|
|
- name: logger
|
|
in: query
|
|
required: false
|
|
schema:
|
|
type: string
|
|
description: Keep only this logger and its children, e.g. serve or aiohttp.access
|
|
responses:
|
|
'200':
|
|
description: Records plus the current seq
|
|
'401':
|
|
description: Dashboard key missing or invalid
|
|
"""
|
|
return await dashboard_logs_handler(request)
|
|
|
|
|
|
@api_handler
|
|
async def dashboard_cdm_logs(request: web.Request) -> web.Response:
|
|
"""
|
|
Dashboard: recent CDM calls and open CDM sessions.
|
|
---
|
|
summary: Dashboard CDM call log
|
|
description: >
|
|
The last 5000 calls to the Widevine and PlayReady CDM device routes, one record for each
|
|
call, with a snapshot of the open sessions of each live Cdm. A record never holds an API
|
|
key, `init_data`, a challenge, a certificate, a licence body or a content key.
|
|
Poll with `since` set to the `seq` of the last reply. The server does not publish these
|
|
records on the event stream.
|
|
tags: [Dashboard]
|
|
parameters:
|
|
- name: since
|
|
in: query
|
|
required: false
|
|
schema:
|
|
type: integer
|
|
description: Return only records with seq greater than this value
|
|
responses:
|
|
'200':
|
|
description: Records plus the current seq and the open sessions
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
seq:
|
|
type: integer
|
|
records:
|
|
type: array
|
|
items:
|
|
type: object
|
|
properties:
|
|
seq:
|
|
type: integer
|
|
ts:
|
|
type: number
|
|
drm:
|
|
type: string
|
|
enum: [widevine, playready]
|
|
op:
|
|
type: string
|
|
device:
|
|
type: string
|
|
key_id:
|
|
type: string
|
|
user:
|
|
type: string
|
|
session_id:
|
|
type: string
|
|
nullable: true
|
|
status:
|
|
type: integer
|
|
ok:
|
|
type: boolean
|
|
error:
|
|
type: string
|
|
nullable: true
|
|
ms:
|
|
type: number
|
|
sessions:
|
|
type: array
|
|
items:
|
|
type: object
|
|
properties:
|
|
drm:
|
|
type: string
|
|
device:
|
|
type: string
|
|
key_id:
|
|
type: string
|
|
user:
|
|
type: string
|
|
open:
|
|
type: integer
|
|
max:
|
|
type: integer
|
|
'401':
|
|
description: Dashboard key missing or invalid
|
|
"""
|
|
return await dashboard_cdm_logs_handler(request)
|
|
|
|
|
|
@api_handler
|
|
async def dashboard_session_logs(request: web.Request) -> web.Response:
|
|
"""
|
|
Dashboard: one remote session's service log.
|
|
---
|
|
summary: Dashboard remote session log
|
|
description: >
|
|
The service's own log output for a remote session, mirrored at INFO regardless of the
|
|
server's log level. This is where the real reason for a failed authentication sits, in
|
|
full, while the remote session summary carries only a truncated `auth_error`.
|
|
Reading this does not refresh the remote session's idle timer and does not take records
|
|
from the client draining the same buffer through `/api/session/{session_id}/logs`.
|
|
Poll when the remote session summary's `log_seq` changes.
|
|
tags: [Dashboard]
|
|
parameters:
|
|
- name: session_id
|
|
in: path
|
|
required: true
|
|
schema:
|
|
type: string
|
|
- name: since
|
|
in: query
|
|
required: false
|
|
schema:
|
|
type: integer
|
|
description: Return only records with seq greater than this value
|
|
responses:
|
|
'200':
|
|
description: Log records plus the buffer's current last_seq
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
session_id:
|
|
type: string
|
|
last_seq:
|
|
type: integer
|
|
records:
|
|
type: array
|
|
items:
|
|
type: object
|
|
properties:
|
|
seq:
|
|
type: integer
|
|
ts:
|
|
type: number
|
|
level:
|
|
type: string
|
|
message:
|
|
type: string
|
|
'401':
|
|
description: Dashboard key missing or invalid
|
|
'404':
|
|
description: Remote session not found
|
|
"""
|
|
return await dashboard_session_logs_handler(request)
|
|
|
|
|
|
@api_handler
|
|
async def dashboard_keys(request: web.Request) -> web.Response:
|
|
"""
|
|
Dashboard: configured API keys, their grants and their usage.
|
|
---
|
|
summary: Dashboard API keys
|
|
description: >
|
|
Every API key in `serve.users`, plus `serve.api_secret` and the dashboard API key when
|
|
they are configured, with the grants that decide what it may do and the counters for what
|
|
it has done. An API key listed in more than one of those places still gets exactly one row.
|
|
`id` is a hash prefix, stable across restarts and carrying no API key material, so two
|
|
unnamed API keys never merge the way they do in the `requests_by_key` labels.
|
|
Every API key the server counts has a row here, so a `requests_by_key` bucket other than
|
|
`anonymous` always matches one.
|
|
`bytes_out` counts response bodies only: an SSE stream reports no `Content-Length` and
|
|
contributes nothing.
|
|
tags: [Dashboard]
|
|
responses:
|
|
'200':
|
|
description: One row per configured key
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: array
|
|
items:
|
|
type: object
|
|
properties:
|
|
id:
|
|
type: string
|
|
role:
|
|
type: string
|
|
enum: [user, admin, dashboard]
|
|
description: >
|
|
Which key this is, not what it may do: `admin` is `serve.api_secret`,
|
|
`dashboard` is `serve.dashboard.key`. The grant fields carry capability.
|
|
label:
|
|
type: string
|
|
services:
|
|
type: array
|
|
nullable: true
|
|
items:
|
|
type: string
|
|
description: >
|
|
Effective allowlist; null when nothing restricts the API key, and an
|
|
empty list when the API key reaches no service route at all, as a
|
|
dashboard API key with no `serve.users` entry does
|
|
server_cdm:
|
|
description: false, true, or the list of service tags it covers
|
|
server_cdm_max_height:
|
|
nullable: true
|
|
description: The configured live licence height limit, a map of service tag to one, or null
|
|
server_accounts:
|
|
description: false, true, or the list of service tags it covers
|
|
server_proxy:
|
|
type: boolean
|
|
tier:
|
|
type: string
|
|
nullable: true
|
|
rate_limit:
|
|
type: integer
|
|
nullable: true
|
|
description: Requests per hour; null means unlimited
|
|
window_used:
|
|
type: integer
|
|
requests:
|
|
type: integer
|
|
rejected:
|
|
type: integer
|
|
bytes_out:
|
|
type: integer
|
|
last_seen:
|
|
type: number
|
|
nullable: true
|
|
'401':
|
|
description: Dashboard key missing or invalid
|
|
"""
|
|
return await dashboard_keys_handler(request)
|
|
|
|
|
|
@api_handler
|
|
async def dashboard_services(request: web.Request) -> web.Response:
|
|
"""
|
|
Dashboard: every configured service and its load state.
|
|
---
|
|
summary: Dashboard services
|
|
description: >
|
|
Shows only the services configured on this server: the global `serve.services`
|
|
allowlist, else the union of every `serve.users` key's `services`, else every discovered
|
|
service. An API key with no `services` list does not widen the union, so the list can be
|
|
smaller than what an unrestricted API key can reach. `?all=1` lists every installed
|
|
service instead. Unlike `/api/services` it keeps the
|
|
services that failed to import, with their error. `state` is `loaded`, `staged` (an update is on
|
|
disk but a busy service blocks the re-import) or `failed`.
|
|
A staged service applies when its last job finishes; watch the `service` event on
|
|
`/api/dashboard/events` instead of polling for it.
|
|
tags: [Dashboard]
|
|
parameters:
|
|
- name: all
|
|
in: query
|
|
required: false
|
|
schema:
|
|
type: string
|
|
enum: ["1"]
|
|
description: List every installed service, not only the configured ones
|
|
responses:
|
|
'200':
|
|
description: One row per listed service
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: array
|
|
items:
|
|
type: object
|
|
properties:
|
|
tag:
|
|
type: string
|
|
state:
|
|
type: string
|
|
enum: [loaded, staged, failed]
|
|
error:
|
|
type: string
|
|
nullable: true
|
|
commit:
|
|
type: string
|
|
nullable: true
|
|
description: Repo HEAD the loaded code was imported from
|
|
staged_commit:
|
|
type: string
|
|
nullable: true
|
|
description: Repo HEAD waiting to be imported; only set while staged
|
|
staged_since:
|
|
type: number
|
|
nullable: true
|
|
sessions:
|
|
type: integer
|
|
jobs:
|
|
type: integer
|
|
aliases:
|
|
type: array
|
|
items:
|
|
type: string
|
|
geofence:
|
|
type: array
|
|
items:
|
|
type: string
|
|
geoblock:
|
|
type: array
|
|
items:
|
|
type: string
|
|
'401':
|
|
description: Dashboard key missing or invalid
|
|
"""
|
|
return await dashboard_services_handler(request)
|
|
|
|
|
|
@api_handler
|
|
async def dashboard_health(request: web.Request) -> web.Response:
|
|
"""
|
|
Dashboard: preflight checks.
|
|
---
|
|
summary: Dashboard health preflight
|
|
description: >
|
|
Whether this instance could finish a download: the binaries on PATH, the CDM device
|
|
files, each configured key vault and the proxy providers.
|
|
A panel, not a liveness probe. The result is cached for 30 seconds and every probe is
|
|
shallow, so reading it never spends a proxy session or a licence. A dependency that only
|
|
fails on first use is therefore not caught here.
|
|
tags: [Dashboard]
|
|
responses:
|
|
'200':
|
|
description: Roll-up status plus one entry per check
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
generated_at:
|
|
type: number
|
|
status:
|
|
type: string
|
|
enum: [ok, degraded, failing]
|
|
checks:
|
|
type: array
|
|
items:
|
|
type: object
|
|
properties:
|
|
id:
|
|
type: string
|
|
label:
|
|
type: string
|
|
status:
|
|
type: string
|
|
enum: [ok, warn, fail]
|
|
detail:
|
|
type: string
|
|
ms:
|
|
type: number
|
|
'401':
|
|
description: Dashboard key missing or invalid
|
|
"""
|
|
return await dashboard_health_handler(request)
|
|
|
|
|
|
@api_handler
|
|
async def dashboard_events(request: web.Request) -> web.StreamResponse:
|
|
"""
|
|
Dashboard: server-wide event stream.
|
|
---
|
|
summary: Dashboard SSE stream
|
|
description: >
|
|
Server-Sent Events: a `stats` event on connect and every 5 seconds (also the keep-alive),
|
|
plus `log`, `session` (action create/update/delete) and `job` events as they happen. A browser
|
|
EventSource can authenticate with the `secret_key` query parameter.
|
|
tags: [Dashboard]
|
|
parameters:
|
|
- name: secret_key
|
|
in: query
|
|
required: false
|
|
schema:
|
|
type: string
|
|
description: Dashboard key, for clients that cannot send headers
|
|
- name: since
|
|
in: query
|
|
required: false
|
|
schema:
|
|
type: integer
|
|
description: >
|
|
Poll mode. Return the events after this seq as one JSON burst
|
|
({seq, stats, events}) instead of opening an event stream. Use seq 0 on first call.
|
|
responses:
|
|
'200':
|
|
description: text/event-stream, or JSON for a request that carries `since`
|
|
'401':
|
|
description: Dashboard key missing or invalid
|
|
"""
|
|
return await dashboard_events_handler(request)
|
|
|
|
|
|
@api_handler
|
|
async def remote_logins(request: web.Request) -> web.Response:
|
|
"""
|
|
Remote logins in progress.
|
|
---
|
|
summary: Remote logins in progress
|
|
description: >
|
|
The caller's list calls that wait on a remote_services server's login, each with the remote
|
|
server, service and title, and `notice`: what the login shows the user while it waits on them,
|
|
such as a URL and pair code to enter on another device, or null. Poll it alongside a remote
|
|
list-titles, list-tracks or search call; a download job carries the same text as its `notice`.
|
|
responses:
|
|
'200':
|
|
description: Logins in progress
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
logins:
|
|
type: array
|
|
items:
|
|
type: object
|
|
properties:
|
|
remote:
|
|
type: string
|
|
service:
|
|
type: string
|
|
id:
|
|
type: string
|
|
description: Answer its prompt at POST /api/remote/logins/{id}/input
|
|
title_id:
|
|
type: string
|
|
prompt:
|
|
type: string
|
|
nullable: true
|
|
description: What the login asks the user (an OTP, a PIN), or null
|
|
notice:
|
|
type: string
|
|
nullable: true
|
|
"""
|
|
return await remote_logins_handler(request)
|
|
|
|
|
|
@api_handler
|
|
async def remote_login_input(request: web.Request) -> web.Response:
|
|
"""
|
|
Answer a remote login's prompt.
|
|
---
|
|
summary: Answer a remote login's prompt
|
|
description: Send the answer to the prompt (an OTP, a PIN) a remote login a list call waits on is asking.
|
|
parameters:
|
|
- name: login_id
|
|
in: path
|
|
required: true
|
|
schema:
|
|
type: string
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [response]
|
|
properties:
|
|
response:
|
|
type: string
|
|
responses:
|
|
'200':
|
|
description: Answer accepted
|
|
'404':
|
|
description: No such remote login for this API key
|
|
'409':
|
|
description: The login is not asking anything
|
|
"""
|
|
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. 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),
|
|
("POST", "/api/search", search, True),
|
|
("POST", "/api/list-titles", list_titles, False),
|
|
("POST", "/api/list-tracks", list_tracks, False),
|
|
("POST", "/api/download", download, False),
|
|
("GET", "/api/download/jobs", download_jobs, False),
|
|
("POST", "/api/download/jobs/clear-finished", clear_finished_download_jobs, False),
|
|
("GET", "/api/download/jobs/{job_id}", download_job_detail, False),
|
|
("GET", JOB_EVENTS_ROUTE, download_job_events, False),
|
|
("DELETE", "/api/download/jobs/{job_id}", cancel_download_job, False),
|
|
("POST", "/api/download/jobs/{job_id}/retry", retry_download_job, False),
|
|
("POST", "/api/download/jobs/{job_id}/priority", prioritize_download_job, False),
|
|
("POST", "/api/download/jobs/{job_id}/input", download_job_input, False),
|
|
("GET", "/api/profiles", profiles, False),
|
|
("GET", "/api/config", server_config, False),
|
|
("GET", "/api/history", download_history, False),
|
|
("DELETE", "/api/history/{job_id}", delete_history, False),
|
|
("POST", "/api/maintenance/clear-cache", maintenance_clear_cache, False),
|
|
("POST", "/api/maintenance/clear-temp", maintenance_clear_temp, False),
|
|
("POST", "/api/maintenance/refresh-services", maintenance_refresh_services, False),
|
|
("GET", "/api/env/check", env_check, False),
|
|
("GET", "/api/remote/logins", remote_logins, False),
|
|
("POST", "/api/remote/logins/{login_id}/input", remote_login_input, False),
|
|
("POST", "/api/session/create", session_create, True),
|
|
("GET", TUNNEL_ROUTE, tunnel, True),
|
|
("GET", TUNNEL_CHECK_ROUTE, tunnel_check, True),
|
|
("GET", "/api/session/{session_id}/titles", session_titles, True),
|
|
("POST", "/api/session/{session_id}/tracks", session_tracks, True),
|
|
("POST", "/api/session/{session_id}/segments", session_segments, True),
|
|
("POST", "/api/session/{session_id}/segment_filter", session_segment_filter, True),
|
|
("POST", "/api/session/{session_id}/samples", session_samples, True),
|
|
("POST", "/api/session/{session_id}/license", session_license, True),
|
|
("POST", "/api/session/{session_id}/keys/bad", session_bad_key, True),
|
|
("GET", "/api/session/{session_id}/logs", session_logs, True),
|
|
("GET", "/api/session/{session_id}/prompt", session_prompt_get, True),
|
|
("POST", "/api/session/{session_id}/prompt", session_prompt_submit, True),
|
|
("GET", "/api/session/{session_id}", session_info, True),
|
|
("DELETE", "/api/session/{session_id}", session_delete, True),
|
|
]
|
|
|
|
DASHBOARD_ROUTES: list[tuple[str, str, Handler, bool]] = [
|
|
("GET", DASHBOARD_PREFIX + "status", dashboard_status, True),
|
|
("POST", DASHBOARD_PREFIX + "drain", dashboard_drain_start, True),
|
|
("DELETE", DASHBOARD_PREFIX + "drain", dashboard_drain_stop, True),
|
|
("GET", DASHBOARD_PREFIX + "sessions", dashboard_sessions, True),
|
|
("GET", DASHBOARD_PREFIX + "jobs", dashboard_jobs, True),
|
|
("GET", DASHBOARD_PREFIX + "logs", dashboard_logs, True),
|
|
("GET", DASHBOARD_PREFIX + "cdm-logs", dashboard_cdm_logs, True),
|
|
("GET", DASHBOARD_PREFIX + "keys", dashboard_keys, True),
|
|
("GET", DASHBOARD_PREFIX + "services", dashboard_services, True),
|
|
("GET", DASHBOARD_PREFIX + "health", dashboard_health, True),
|
|
("GET", DASHBOARD_PREFIX + "sessions/{session_id}/logs", dashboard_session_logs, True),
|
|
("GET", DASHBOARD_EVENTS_ROUTE, dashboard_events, True),
|
|
]
|
|
|
|
|
|
def setup_routes(
|
|
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. 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": 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 []):
|
|
if remote_only and not remote:
|
|
continue
|
|
add[method](path, handler)
|
|
app.on_shutdown.append(close_tunnels)
|
|
|
|
|
|
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",
|
|
version=__version__,
|
|
description="REST API for Unshackle - Modular Movie, TV, and Music Archival Software",
|
|
),
|
|
)
|