Files
unshackle/unshackle/core/api/routes.py
T
imSp4rky eea4b41b70 feat(serve): log CDM calls for the dashboard
Add GET /api/dashboard/cdm-logs: one record per pywidevine/pyplayready call plus open sessions per device, with no key or licence material.
2026-10-06 14:58:08 -06:00

3114 lines
111 KiB
Python

import asyncio
import functools
import logging
import re
from collections.abc import Awaitable, Callable
from typing import Any
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, 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_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,
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_segment_filter_handler,
session_segments_handler,
session_titles_handler,
session_tracks_handler,
)
from unshackle.core.services import Services
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
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
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.
"""
@functools.wraps(handler)
async def wrapper(request: web.Request) -> web.StreamResponse:
task = asyncio.ensure_future(handler(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
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
@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"
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 UpdateChecker.check_for_updates(__version__)
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}
return web.json_response(
{"status": "ok", "version": __version__, "code_hash": __code_hash__ or None, "update_check": update_info}
)
@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
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
'500':
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]
services_info = []
for tag in service_tags:
service_data: dict[str, Any] = {
"tag": tag,
"aliases": [],
"geofence": [],
"geoblock": [],
"title_regex": None,
"url": None,
"help": None,
}
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
try:
service_module = Services.load(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["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
except Exception as e:
log.warning(f"Could not load details for service {tag}: {e}")
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
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- service
- query
properties:
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, 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)
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
"""
try:
data = await request.json()
except Exception as e:
return build_error_response(
APIError(
APIErrorCode.INVALID_INPUT,
"Invalid JSON request body",
details={"error": str(e)},
),
request.app.get("debug_api", False),
)
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:
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
'500':
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
"""
try:
data = await request.json()
except Exception as e:
return build_error_response(
APIError(
APIErrorCode.INVALID_INPUT,
"Invalid JSON request body",
details={"error": str(e)},
),
request.app.get("debug_api", False),
)
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:
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, 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, acodec, forced_subs), 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
"""
try:
data = await request.json()
except Exception as e:
return build_error_response(
APIError(
APIErrorCode.INVALID_INPUT,
"Invalid JSON request body",
details={"error": str(e)},
),
request.app.get("debug_api", False),
)
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:
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 (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, ALAC, FLAC (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: Use exact language matching (no variants) (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, 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)
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.
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)
"""
try:
data = await request.json()
except Exception as e:
return build_error_response(
APIError(
APIErrorCode.INVALID_INPUT,
"Invalid JSON request body",
details={"error": str(e)},
),
request.app.get("debug_api", False),
)
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
'500':
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
'500':
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
'500':
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
'500':
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
'500':
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
'500':
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
'500':
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.
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"]
try:
data = await request.json()
except Exception as e:
raise APIError(APIErrorCode.INVALID_INPUT, "Invalid JSON request body", details={"error": str(e)}) from e
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
'500':
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
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)
'500':
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
'500':
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
'500':
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
'500':
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
'500':
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
'500':
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
'500':
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
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: []
forced_subs:
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`, in
`service_params` or at the top level, keeps the server CDM
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
"""
try:
data = await request.json()
except Exception as e:
return build_error_response(
APIError(APIErrorCode.INVALID_INPUT, "Invalid JSON request body", details={"error": str(e)}),
request.app.get("debug_api", False),
)
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
@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"]
try:
data = await request.json()
except Exception as e:
return build_error_response(
APIError(APIErrorCode.INVALID_INPUT, "Invalid JSON request body", details={"error": str(e)}),
request.app.get("debug_api", False),
)
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"]
try:
data = await request.json()
except Exception as e:
return build_error_response(
APIError(APIErrorCode.INVALID_INPUT, "Invalid JSON request body", details={"error": str(e)}),
request.app.get("debug_api", False),
)
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"]
try:
data = await request.json()
except Exception as e:
return build_error_response(
APIError(APIErrorCode.INVALID_INPUT, "Invalid JSON request body", details={"error": str(e)}),
request.app.get("debug_api", False),
)
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
@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"]
try:
data = await request.json()
except Exception as e:
return build_error_response(
APIError(APIErrorCode.INVALID_INPUT, "Invalid JSON request body", details={"error": str(e)}),
request.app.get("debug_api", False),
)
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: The pair is flagged
'400':
description: The remote session was not served that pair
'404':
description: Remote session not found
"""
session_id = request.match_info["session_id"]
try:
data = await request.json()
except Exception as e:
return build_error_response(
APIError(APIErrorCode.INVALID_INPUT, "Invalid JSON request body", details={"error": str(e)}),
request.app.get("debug_api", False),
)
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"]
try:
data = await request.json()
except Exception as e:
return build_error_response(
APIError(APIErrorCode.INVALID_INPUT, "Invalid JSON request body", details={"error": str(e)}),
request.app.get("debug_api", False),
)
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, 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_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)
# Single source of truth for all API routes. `remote` marks endpoints exposed in
# --remote-only server mode; full mode registers every route. Both setup_routes and
# setup_swagger derive from this table so the live routes and the docs cannot drift.
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),
("POST", "/api/session/create", session_create, 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}/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),
("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) -> None:
"""Setup API routes. When remote_only=True, only the remote session endpoints operate.
When dashboard=True, this also registers the /api/dashboard/ routes."""
add: dict[str, Callable[..., Any]] = {
"GET": app.router.add_get,
"POST": app.router.add_post,
"DELETE": app.router.add_delete,
}
for method, path, handler, remote in ROUTES + (DASHBOARD_ROUTES if dashboard else []):
if remote_only and not remote:
continue
add[method](path, handler)
def setup_swagger(app: web.Application, dashboard: bool = False) -> None:
"""Setup Swagger UI documentation."""
swagger = SwaggerDocs(
app,
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",
),
)
route: dict[str, Callable[..., Any]] = {"GET": web.get, "POST": web.post, "DELETE": web.delete}
swagger.add_routes(
[route[method](path, handler) for method, path, handler, _ in ROUTES + (DASHBOARD_ROUTES if dashboard else [])]
)