Files
unshackle/unshackle/core/api/handlers.py
T
imSp4rky 9a1abd23b6 feat(remote): license with the client's device when the user picked one
A remote client that passes --cdm, has a cdm entry for the service that named the loaded device, or sets server_cdm: false now declines the server CDM at session create, and warns when the server keeps it. A service option listed in SERVER_DEVICE_OPTIONS keeps the server CDM.
2026-10-01 13:21:53 -06:00

5158 lines
213 KiB
Python

import asyncio
import base64
import enum
import json
import logging
import os
import re
import tempfile
import time
import zlib
from collections import Counter
from contextlib import suppress
from datetime import date as date_
from http.cookiejar import CookieJar, MozillaCookieJar
from pathlib import Path
from typing import Any, Callable, Dict, Iterable, List, Optional, cast
import click
from aiohttp import web
from unshackle.core.api.compression import safe_inflate
from unshackle.core.api.errors import APIError, APIErrorCode, categorize_exception, handle_api_exception
from unshackle.core.api.input_bridge import AuthStatus, InputBridge
from unshackle.core.api.sanitize import MAX_SESSION_CACHE_KEYS, safe_cache_key, sanitize_log
from unshackle.core.api.session_log import SessionLogBuffer, SessionLogMirror, capture_service_logs
from unshackle.core.api.session_store import SessionStore
from unshackle.core.cacher import Cacher
from unshackle.core.cdm.detect import cdm_type_stub
from unshackle.core.config import config
from unshackle.core.constants import AUDIO_CODEC_MAP, DYNAMIC_RANGE_MAP, VIDEO_CODEC_MAP
from unshackle.core.providers.anilist import parse_anilist_ref
from unshackle.core.proxies.resolve import initialize_proxy_providers, resolve_proxy
from unshackle.core.services import Services
from unshackle.core.titles import Episode, Movie, Song, Title_T
from unshackle.core.tracks import Audio, Subtitle, Tracks, Video
from unshackle.core.utilities import declared_kwargs
from unshackle.core.utils.click_types import AUDIO_CODEC_LIST, SUBTITLE_CODEC, VIDEO_CODEC_LIST
from unshackle.core.utils.collections import ci_get
from unshackle.core.utils.redact import REDACTED, URL_USERINFO_RE, redact_all, redact_secrets, redact_text
log = logging.getLogger("api")
DEFAULT_DOWNLOAD_PARAMS = {
"profile": None,
"quality": [],
"vcodec": None,
"acodec": None,
"vbitrate": None,
"abitrate": None,
"vbitrate_range": None,
"abitrate_range": None,
"range": ["SDR"],
"channels": None,
"no_atmos": False,
"wanted": [],
"latest_episode": False,
"latest_episodes": None,
"lang": ["orig"],
"v_lang": [],
"a_lang": [],
"s_lang": ["all"],
"require_audio": [],
"require_video": [],
"require_subs": [],
"forced_subs": False,
"forced_s_lang": [],
"exact_lang": False,
"sub_format": None,
"video_only": False,
"audio_only": False,
"subs_only": False,
"chapters_only": False,
"no_subs": False,
"no_audio": False,
"no_chapters": False,
"no_video": False,
"no_attachments": False,
"audio_description": False,
"slow": None,
"split_audio": None,
"skip_dl": False,
"export": False,
"cdm_only": None,
"proxy": None,
"no_proxy": False,
"no_proxy_download": False,
"proxy_download": None,
"no_folder": False,
"no_source": False,
"no_mux": False,
"workers": None,
"adaptive_workers": False,
"download_processes": 1,
"continue_downloads": False,
"downloads": 1,
"worst": False,
"best_available": False,
"repack": False,
"tag": None,
"tmdb_id": None,
"imdb_id": None,
"tvdb_id": None,
"tvdb_order": None,
"anilist_id": None,
"enrich": False,
"daily": False,
"output_dir": None,
"no_cache": False,
"reset_cache": False,
}
# Keys that are part of the API transport envelope, not service.cli options.
# Used by instantiate_service to avoid passing them as kwargs to a service.
LIST_HANDLER_TRANSPORT_KEYS = {
"service",
"title_id",
"profile",
"season",
"episode",
"part",
"wanted",
"proxy",
"no_proxy",
"query",
"service_params",
"cache",
"dl_params",
}
def load_full_cdm(service: str, profile: Optional[str], cdm_type: Optional[str] = None) -> Optional[Any]:
"""Load a real CDM object for the given service.
Services often touch ``ctx.obj.cdm.security_level`` / ``.device_type`` / ``.system_id``
inside ``__init__``, so the lightweight ``resolve_server_cdm`` stub is not enough
for list_titles / list_tracks / search. Mirrors ``dl.get_cdm`` selection logic but
skips the quality-tier shortcuts (no track context yet) and falls back to the stub
if the configuration has no device, or if loading fails.
"""
from unshackle.core.cdm import load_cdm
from unshackle.core.config import config as app_config
cdm_name = ci_get(app_config.cdm, service) or ci_get(app_config.cdm, "default")
if isinstance(cdm_name, dict):
lower_keys = {k.lower(): v for k, v in cdm_name.items()}
if {"widevine", "playready"} & lower_keys.keys():
drm_key = None
if cdm_type:
drm_key = {"wv": "widevine", "widevine": "widevine", "pr": "playready", "playready": "playready"}.get(
cdm_type.lower()
)
cdm_name = lower_keys.get(drm_key or "widevine") or lower_keys.get("playready")
else:
cdm_name = cdm_name.get(profile) or cdm_name.get("default") or ci_get(app_config.cdm, "default")
if not cdm_name or not isinstance(cdm_name, str):
return resolve_server_cdm(service, profile, cdm_type)
if cdm_type:
wanted = {"wv": "widevine", "widevine": "widevine", "pr": "playready", "playready": "playready"}.get(
cdm_type.lower()
)
if wanted and detect_cdm_type(cdm_name, app_config) not in (None, wanted):
return cdm_type_stub(wanted)
try:
return load_cdm(cdm_name, service_name=service)
except Exception as exc: # noqa: BLE001 - fall back to stub on load failure
log.warning(
f"load_cdm({sanitize_log(cdm_name)!r}) failed for {sanitize_log(service)}: {exc}; using lightweight stub"
)
return resolve_server_cdm(service, profile, cdm_type)
def load_service_yaml(normalized_service: str) -> dict:
"""Load a service's config.yaml and merge it with the global override block."""
import yaml
from unshackle.core.utils.collections import merge_dict
service_config_path = Services.get_path(normalized_service) / config.filenames.config
if service_config_path.exists():
service_config = yaml.safe_load(service_config_path.read_text(encoding="utf8")) or {}
else:
service_config = {}
merge_dict(config.services.get(normalized_service), service_config)
return service_config
def build_parent_ctx(
profile: Optional[str],
cdm: Any,
proxy_param: Optional[str],
no_proxy: bool,
proxy_providers: list,
service_config: dict,
extra_params: Optional[Dict[str, Any]] = None,
) -> Any:
"""Assemble a parent click Context for invoking a service.cli through ctx.invoke().
The service's CLI callback uses ``ctx.parent.params`` (proxy, range_, vcodec, and the
other dl options) and ``ctx.obj`` (ContextData). Both flow through Click's parent chain.
``extra_params`` carries ``cookies_supplied``: True when a cookie jar goes to
``authenticate()`` later in this request. A service that picks an authentication path in
``__init__`` must read that flag, because a client-sent jar is in the request body, not in
the server's ``cookies/`` directory, so a file check answers False for it.
"""
import click
from unshackle.core.utils.click_types import ContextData
@click.command()
@click.pass_context
def dummy(ctx: click.Context) -> None:
pass
parent = click.Context(dummy)
parent.obj = ContextData(config=service_config, cdm=cdm, proxy_providers=proxy_providers, profile=profile)
params: Dict[str, Any] = {"proxy": proxy_param, "no_proxy": no_proxy, "served": True}
if extra_params:
params.update(extra_params)
# dl defaults -q to [], and services iterate it
params["quality"] = params.get("quality") or []
parent.params = params
return parent
def instantiate_service(
parent_ctx: Any,
service_module: Any,
title: str,
data: Optional[Dict[str, Any]] = None,
transport_keys: Optional[set] = None,
) -> Any:
"""Instantiate a service by invoking its click cli through Click.
Click fills option defaults through ``param.get_default()`` and runs type coercion,
so we no longer have to inspect ``__init__`` or stitch defaults by hand. This function
pulls extra kwargs from the ``data`` entries whose names match a cli option name
and are not in the transport-key blocklist.
"""
cli_params = getattr(getattr(service_module, "cli", None), "params", []) or []
cli_param_names = {p.name for p in cli_params if hasattr(p, "name") and p.name}
transport_keys = transport_keys or set()
extras: Dict[str, Any] = {}
if data:
for k, v in data.items():
if k in cli_param_names and k not in transport_keys and k != "title":
extras[k] = v
service_params = data.get("service_params")
if isinstance(service_params, dict):
for k, v in service_params.items():
if k in cli_param_names and k != "title":
extras[k] = v
elif service_params is None:
for k in cli_param_names & transport_keys & set(data):
if k not in ("service", "title_id", "proxy", "no_proxy") and data.get(k) is not None:
log.warning(
f"Ignoring flat '{sanitize_log(k)}' as a service option (it names a transport field); "
"update the client to send service options under 'service_params'"
)
return parent_ctx.invoke(service_module.cli, title=title, **extras)
def server_login_material(
data: Dict[str, Any], normalized_service: str, account: Optional[str], profile: Optional[str]
) -> tuple:
"""(cookies, credential) for a list or search call.
A lent account takes the server's files for that account. Otherwise the full-mode server,
which the operator's own UI drives, uses its own profiles; a ``--remote-only`` server uses only
what the client sent, the same rule as a remote session.
"""
from unshackle.commands.dl import dl
from unshackle.core.api.stats import stats
from unshackle.core.credential import Credential
if account:
return server_account_cookies(normalized_service, profile), dl.get_credentials(normalized_service, profile)
if stats.mode != "remote_only":
return dl.get_cookie_jar(normalized_service, profile), dl.get_credentials(normalized_service, profile)
credential = None
cred_data = data.get("credentials")
if isinstance(cred_data, dict) and cred_data.get("username") is not None:
credential = Credential(
username=cred_data["username"], password=cred_data.get("password"), extra=cred_data.get("extra")
)
return load_client_cookies(data.get("cookies")), credential
def api_key_namespace(request: Optional[web.Request]) -> str:
"""Name of the caller's cache directories, derived from its API key so no caller can guess another's."""
import hashlib
api_key = request.headers.get("X-Secret-Key", "anonymous") if request else "anonymous"
return hashlib.pbkdf2_hmac("sha256", api_key.encode(), b"unshackle-session-ns", 100_000).hex()[:12]
def write_client_cache(cache_data: Any, cache_tag: str) -> None:
"""Write a client-sent ``cache`` map into the cache directory ``cache_tag``.
Each cache key is a file path relative to that directory, and each value is the file as
base64 of zlib-compressed bytes. The function skips an unsafe key or a file it cannot write,
and raises INVALID_INPUT for a map that is not an object, has too many entries, or holds a
value that is not a string.
"""
if not isinstance(cache_data, dict) or len(cache_data) > MAX_SESSION_CACHE_KEYS:
raise APIError(APIErrorCode.INVALID_INPUT, f"cache must hold at most {MAX_SESSION_CACHE_KEYS} entries")
cache_dir = config.directories.cache / cache_tag
cache_dir.mkdir(parents=True, exist_ok=True)
for key, content in cache_data.items():
safe_name = safe_cache_key(key)
if not safe_name:
log.warning(f"Rejecting unsafe session cache key: {sanitize_log(key)}")
continue
if not isinstance(content, str):
raise APIError(APIErrorCode.INVALID_INPUT, "cache values must be base64 strings")
decompressed = safe_inflate(base64.b64decode(content)).decode("utf-8")
target = cache_dir / f"{safe_name}.json"
try:
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(decompressed, encoding="utf-8")
except OSError as e:
log.warning(f"Skipping session cache key {sanitize_log(key)}: {e}")
def collect_cache_files(cache_tag: str) -> Dict[str, str]:
"""Read the cache directory ``cache_tag`` back as a ``cache`` map for the client.
The map has the same form that :func:`write_client_cache` reads. It leaves out the
``titles_`` files, because they hold no login state.
"""
cache_data: Dict[str, str] = {}
cache_dir = config.directories.cache / cache_tag
if cache_dir.is_dir():
for f in sorted(cache_dir.rglob("*.json")):
key = f.relative_to(cache_dir).as_posix()[: -len(".json")]
if f.stem.startswith("titles_") or not safe_cache_key(key):
continue
try:
cache_data[key] = base64.b64encode(zlib.compress(f.read_bytes())).decode("ascii")
except OSError:
pass
return cache_data
class RequestCache:
"""The service cache of one list or search request on a ``--remote-only`` server.
Such a server keeps nothing that a client sends, so the request runs on a cache directory
of its own that the handler removes when the request ends, on success and on error. Before
that, a client that logged in with its own cookies, credentials or cache gets the updated
files back, the same as when a remote session ends.
"""
def __init__(self) -> None:
self.tag: Optional[str] = None
self.client_auth = False
def attach(
self,
service_instance: Any,
data: Dict[str, Any],
normalized_service: str,
request: Optional[web.Request],
client_login: bool,
) -> None:
"""Give the service a new cache directory, seeded from the client's ``cache``.
Does nothing on a full-mode server, where the operator's own cache stays in use.
"""
import uuid
from unshackle.core.api.stats import stats
if stats.mode != "remote_only":
return
self.tag = f"_requests/{api_key_namespace(request)}/{uuid.uuid4()}/{normalized_service}"
service_instance.cache = Cacher(self.tag)
cache_data = data.get("cache")
if cache_data:
write_client_cache(cache_data, self.tag)
self.client_auth = client_login or bool(cache_data)
def json_response(self, payload: Dict[str, Any]) -> web.Response:
"""Answer with ``payload``, plus the updated ``cache`` for a client that logged in itself."""
if self.tag and self.client_auth:
cache_data = collect_cache_files(self.tag)
if cache_data:
payload["cache"] = cache_data
return web.json_response(payload)
def cleanup(self) -> None:
SessionStore.cleanup_cache_dir(self.tag)
def setup_list_service(
data: Dict[str, Any],
normalized_service: str,
profile: Optional[str],
title_id: str,
request: Optional[web.Request] = None,
request_cache: Optional[RequestCache] = None,
) -> Any:
"""Assemble and authenticate a service instance for list_titles / list_tracks.
Runs the shared preamble: load yaml → get proxy → load CDM → assemble ctx →
instantiate → authenticate. Raises APIError on proxy failure.
"""
service_config = load_service_yaml(normalized_service)
no_proxy = data.get("no_proxy", False)
proxy_param, proxy_providers = resolve_handler_proxy(data, normalized_service, request)
account = resolve_server_account(request, normalized_service, data.get("client_region"))
if account:
profile = None if account == "default" else account
cdm = load_full_cdm(normalized_service, profile, data.get("cdm_type"))
cookies, credential = server_login_material(data, normalized_service, account, profile)
parent_ctx = build_parent_ctx(
profile,
cdm,
proxy_param,
no_proxy,
proxy_providers,
service_config,
extra_params={"cookies_supplied": cookies is not None, **forwarded_dl_params(data)},
)
service_module = Services.load(normalized_service)
service_instance = instantiate_service(parent_ctx, service_module, title_id, data, LIST_HANDLER_TRANSPORT_KEYS)
if account:
service_instance.cache = Cacher(f"_accounts/{normalized_service}/{account}")
elif request_cache is not None:
client_login = cookies is not None or credential is not None
request_cache.attach(service_instance, data, normalized_service, request, client_login)
service_instance.authenticate(cookies, credential)
return service_instance
def run_service_search(
data: Dict[str, Any],
normalized_service: str,
query: str,
request: Optional[web.Request] = None,
request_cache: Optional[RequestCache] = None,
) -> List[Dict[str, Any]]:
"""Assemble and authenticate a service instance, then run its search.
The same preamble as :func:`setup_list_service`, without the service option extras,
plus the search itself. Every step blocks on disk or network, so the caller runs the
whole function in one worker thread and keeps the event loop free for other clients.
"""
profile = client_profile(data)
no_proxy = data.get("no_proxy", False)
service_config = load_service_yaml(normalized_service)
proxy_param, proxy_providers = resolve_handler_proxy(data, normalized_service, request)
account = resolve_server_account(request, normalized_service, data.get("client_region"))
if account:
profile = None if account == "default" else account
cdm = load_full_cdm(normalized_service, profile, data.get("cdm_type"))
cookies, credential = server_login_material(data, normalized_service, account, profile)
parent_ctx = build_parent_ctx(
profile,
cdm,
proxy_param,
no_proxy,
proxy_providers,
service_config,
extra_params={"cookies_supplied": cookies is not None},
)
service_module = Services.load(normalized_service)
from click import ClickException
try:
service_instance = instantiate_service(parent_ctx, service_module, query)
except (ConnectionError, ClickException) as exc:
raise categorize_exception(exc, {"service": normalized_service}) from exc
except Exception as exc:
raise APIError(
APIErrorCode.SERVICE_ERROR,
f"Failed to initialize service: {exc}",
details={"service": normalized_service},
)
if account:
service_instance.cache = Cacher(f"_accounts/{normalized_service}/{account}")
elif request_cache is not None:
client_login = cookies is not None or credential is not None
request_cache.attach(service_instance, data, normalized_service, request, client_login)
service_instance.authenticate(cookies, credential)
results: List[Dict[str, Any]] = []
try:
for result in service_instance.search():
results.append(
{
"id": result.id,
"title": result.title,
"description": result.description,
"label": result.label,
"url": result.url,
}
)
except NotImplementedError:
raise APIError(
APIErrorCode.SERVICE_ERROR,
f"Search is not supported by {normalized_service}",
details={"service": normalized_service},
)
return results
def allowed_services_for_key(secret_key: Optional[str]) -> Optional[List[str]]:
"""Effective service allowlist for an API key: the global list intersected with the key's.
Returns None when nothing restricts the services.
"""
global_allowed = config.serve.get("services")
global_set: Optional[set[str]] = None
if global_allowed:
global_set = {Services.get_tag(s) for s in global_allowed}
key_set: Optional[set[str]] = None
if secret_key:
users = config.serve.get("users", {})
user_config = users.get(secret_key, {})
if isinstance(user_config, dict) and user_config.get("services"):
key_set = {Services.get_tag(s) for s in user_config["services"]}
if global_set and key_set:
result = global_set & key_set
elif global_set:
result = global_set
elif key_set:
result = key_set
else:
return None
return list(result)
def get_allowed_services(request: Optional[web.Request] = None) -> Optional[List[str]]:
"""The effective service allowlist for a request's API key, or None when unrestricted."""
return allowed_services_for_key(request_secret_key(request) if request else None)
def is_admin(request: Optional[web.Request]) -> bool:
"""Whether the calling key is the admin secret (no ``users`` entry) or has ``admin: true``."""
if not request:
return True
user_config = (config.serve or {}).get("users", {}).get(request_secret_key(request))
return user_config is None or (isinstance(user_config, dict) and user_config.get("admin") is True)
def require_admin(request: Optional[web.Request]) -> None:
"""FORBIDDEN unless the calling key is the admin secret or has ``admin: true`` in ``serve.users``."""
if is_admin(request):
return
raise APIError(APIErrorCode.FORBIDDEN, "This API key may not run server maintenance.")
def serve_user_config(api_key: str) -> Dict[str, Any]:
"""The ``serve.users`` entry for an API key.
An API key absent from ``serve.users`` (the admin secret, or any caller in no-key mode) keeps
full access, so it gets every CDM device the server loaded.
"""
user_config = ((config.serve or {}).get("users") or {}).get(api_key)
if isinstance(user_config, dict):
return user_config
serve_cfg = config.serve or {}
return {
kind: [d.stem if hasattr(d, "stem") else str(d) for d in (serve_cfg.get(kind) or [])]
for kind in ("devices", "playready_devices")
}
def server_cdm_allowed(request: Optional[web.Request] = None, service: Optional[str] = None) -> bool:
"""Whether the calling API key may have the server operate the CDM licensing for ``service``."""
return key_grant_allows(request, service, "server_cdm")
def server_vault_allowed(request: Optional[web.Request] = None, service: Optional[str] = None) -> bool:
"""Whether the calling API key may read the server vault in a remote session its own device licenses."""
return key_grant_allows(request, service, "server_vault") or server_cdm_allowed(request, service)
def key_grant_allows(request: Optional[web.Request], service: Optional[str], name: str) -> bool:
"""Whether the calling API key holds the ``name`` grant for ``service``.
Configured API keys opt in with ``<name>: true`` for every service, or with a list of
service tags for only those. An API key absent from ``serve.users`` (the admin secret) keeps
full access.
"""
if not request:
return True
secret_key = request_secret_key(request)
user_config = config.serve.get("users", {}).get(secret_key)
if user_config is None:
return True
allowed = user_config.get(name, False)
if isinstance(allowed, str):
allowed = [allowed]
if isinstance(allowed, (list, tuple, set)):
return service is not None and Services.get_tag(service).upper() in {
Services.get_tag(s).upper() for s in allowed
}
return bool(allowed)
def is_whole_number(value: Any) -> bool:
"""True for a non-negative int that is not a bool."""
return isinstance(value, int) and not isinstance(value, bool) and value >= 0
def server_cdm_max_height_error(where: str, value: Any) -> Optional[str]:
"""Why *value* cannot serve as ``server_cdm_max_height``, or None when it can (unset included)."""
def bad(height: Any) -> bool:
return height is not None and not is_whole_number(height)
if isinstance(value, dict):
if any(bad(height) for height in value.values()):
return f"{where}.server_cdm_max_height values must be whole numbers of pixels, not {value!r}"
return None
if bad(value):
return (
f"{where}.server_cdm_max_height must be a whole number of pixels or a map of service to one, not {value!r}"
)
return None
def server_cdm_max_height(request: Optional[web.Request], service: Optional[str]) -> Optional[int]:
"""The tallest video track the server CDM licenses live for this API key on ``service``; None for no cap.
The setting is one height, or a map of service tag to height where ``default`` covers a missing or null tag.
"""
if not request:
return None
cap = serve_user_config(request_secret_key(request) or "").get("server_cdm_max_height")
if isinstance(cap, dict):
own = ci_get(cap, Services.get_tag(service)) if service else None
cap = own if own is not None else ci_get(cap, "default")
return cap if is_whole_number(cap) else None
def capped_error(message: str, cap: Optional[int] = None) -> APIError:
"""The SERVER_CDM_CAPPED refusal; a client reads ``details`` to license the track itself."""
details = {"reason": "server_cdm_max_height", "max_height": cap} if cap is not None else {"reason": "client_device"}
return APIError(APIErrorCode.SERVER_CDM_CAPPED, message, details=details)
def requested_height(quality: Any) -> Optional[int]:
"""The tallest height a client asked for with ``-q``; None when it asked for the best there is."""
from unshackle.core.utils.click_types import QUALITY_LIST
try:
heights = QUALITY_LIST.convert(quality)
except click.BadParameter as e:
raise APIError(APIErrorCode.INVALID_INPUT, f"quality: {e.message}")
if any(height <= 0 for height in heights):
raise APIError(APIErrorCode.INVALID_INPUT, f"quality: every height must be above 0, not {quality!r}")
return max(heights) if heights else None
def choose_session_cdm(
request: Optional[web.Request], service: str, data: Dict[str, Any]
) -> tuple[bool, Optional[int]]:
"""``(server_cdm, max_height)`` for a new remote session.
The service picks its manifest from the CDM it sees, so it decides once, before any tracks.
A client that sends ``server_cdm: false`` with a device licenses with that device, unless it also set
one of the service's ``SERVER_DEVICE_OPTIONS``.
When ``-q`` asks for more than the cap, the client's own device licenses, and a client with no device gets a refusal.
"""
if not server_cdm_allowed(request, service):
return False, None
if data.get("server_cdm") is False and data.get("cdm_type"):
server_device_options = getattr(Services.load(service), "SERVER_DEVICE_OPTIONS", ())
service_params = data.get("service_params")
options = {**data, **service_params} if isinstance(service_params, dict) else data
if not any(options.get(option) for option in server_device_options):
return False, None
cap = server_cdm_max_height(request, service)
wanted = requested_height(data.get("quality"))
if cap is None or wanted is None or wanted <= cap:
return True, cap
if data.get("cdm_type"):
return False, cap
raise capped_error(
f"The server CDM licenses {service} up to {cap}p for this API key. "
f"Set up a local CDM to license {wanted}p with your own device, or pass -q {cap}.",
cap,
)
def live_licence_refusal(session: Any, track: Any) -> Optional[APIError]:
"""Why the server CDM must not make a live licence for ``track`` in ``session``; None when it may.
Only a live licence spends the server's device, so a vault hit always passes. A track measures as the
smallest ``-q`` that selects it: the lower of its height and the 16:9 height of its width.
"""
if getattr(session, "server_device", False):
return APIError(
APIErrorCode.SERVER_CDM_CAPPED,
"This session runs on the server's device, so its keys come only from a vault.",
details={"reason": "server_device"},
)
if getattr(session, "server_cdm", None) is False:
return capped_error("This session licenses with your own device, so the server CDM makes no licence for it.")
cap = getattr(session, "server_cdm_max_height", None)
height = getattr(track, "height", None) if isinstance(track, Video) else None
width = getattr(track, "width", None) if isinstance(track, Video) else None
measures = [m for m in (height, int(width * 9 / 16) if width else None) if m]
height = min(measures) if measures else None
if cap is None or not height or height <= cap:
return None
return capped_error(
f"The server CDM licenses up to {cap}p for this API key; license this {height}p track with your own device.",
cap,
)
_account_counters: Dict[str, int] = {}
def validate_server_accounts() -> Dict[str, Any]:
"""Check ``serve.server_accounts`` at startup; raise ValueError on a bad shape or unknown profile."""
accounts = config.serve.get("server_accounts") or {}
if not isinstance(accounts, dict):
raise ValueError("serve.server_accounts must be a mapping of service tag to accounts")
for tag, spec in accounts.items():
if spec is True:
continue
if not isinstance(spec, dict) or not spec:
raise ValueError(f"serve.server_accounts.{tag}: expected true or a mapping of profile to regions")
creds = config.credentials.get(Services.get_tag(str(tag)))
for profile, regions in spec.items():
members = [regions] if isinstance(regions, str) else regions if isinstance(regions, list) else []
if not members or not all(
isinstance(m, str) and (m.lower() == "global" or re.fullmatch(r"[A-Za-z]{2}", m)) for m in members
):
raise ValueError(
f"serve.server_accounts.{tag}.{profile}: expected a two-letter country code, a list of them, "
"or global (quote codes like 'no')"
)
cookie_file = config.directories.cookies / Services.get_tag(str(tag)) / f"{profile}.txt"
if not (isinstance(creds, dict) and profile in creds) and not cookie_file.exists():
raise ValueError(
f"serve.server_accounts.{tag}.{profile}: no such profile under credentials.{tag} "
f"and no cookie file at {cookie_file}"
)
return accounts
def server_account_spec(service: str) -> Any:
"""The ``serve.server_accounts`` entry for ``service`` (``True`` or a profile->regions map), else None."""
accounts = config.serve.get("server_accounts") or {}
tag = Services.get_tag(service).upper()
spec = next((v for k, v in accounts.items() if Services.get_tag(str(k)).upper() == tag), None)
return spec if spec is True or (isinstance(spec, dict) and spec) else None
def _regions(value: Any) -> set[str]:
values = [value] if isinstance(value, str) else list(value) if isinstance(value, (list, tuple)) else []
return {str(v).lower() for v in values}
def server_account_cookies(service: str, profile: Optional[str]) -> Any:
"""Cookies for a server account: the profile's own file only, so a bare ``cookies/{service}.txt`` cannot shadow it."""
from unshackle.commands.dl import dl
if profile is None:
return dl.get_cookie_jar(service, None)
path = config.directories.cookies / service / f"{profile}.txt"
return dl.load_cookie_file(path) if path.exists() else None
def load_client_cookies(cookie_text: Any) -> Optional[CookieJar]:
"""Read the cookie jar a client sent with its request, or None when it sent none.
The client sends a Netscape cookie file compressed and base64 encoded, and
``MozillaCookieJar`` reads from a path only, so the text goes through a temp file.
"""
if not cookie_text or not isinstance(cookie_text, str):
return None
try:
cookie_str = safe_inflate(base64.b64decode(cookie_text)).decode("utf-8")
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False, encoding="utf-8") as f:
f.write(cookie_str)
tmp_path = f.name
try:
jar = MozillaCookieJar(tmp_path)
jar.load(ignore_discard=True, ignore_expires=True)
return jar
finally:
with suppress(OSError):
os.unlink(tmp_path)
except (ValueError, zlib.error, OSError) as e:
raise APIError(APIErrorCode.INVALID_INPUT, f"cookies is not a compressed Netscape cookie file: {e}")
def server_account_regions(service: str) -> Optional[Dict[str, Any]]:
"""What ``GET /api/services`` advertises: the union of regions the server's accounts cover."""
spec = server_account_spec(service)
if not spec:
return None
if spec is True:
return {"regions": [], "global": True}
union: set[str] = set()
for value in spec.values():
union |= _regions(value)
return {"regions": sorted(union - {"global"}), "global": "global" in union}
def server_region() -> Optional[str]:
"""The server's own country code, from the cached IP lookup; None when unknown."""
try:
from unshackle.core.utils.ip_info import get_ip_info
info = get_ip_info(None, cached=True)
return (info.get("country") or "").lower() or None if info else None
except Exception:
return None
def server_account_pool(service: str, region: Optional[str]) -> Optional[list[Optional[str]]]:
"""Profiles of the server's own accounts usable in ``region``; None when the service is client-managed."""
spec = server_account_spec(service)
if not spec:
return None
creds = config.credentials.get(service)
if spec is True:
return sorted(creds) if isinstance(creds, dict) else [None]
region = (region or "").lower()
return sorted(p for p, v in spec.items() if {"global", region} & _regions(v))
def next_server_profile(service: str, region: Optional[str]) -> Optional[str]:
"""Round-robin the next server account profile for ``region``."""
pool = server_account_pool(service, region) or []
if not pool:
covered = server_account_regions(service) or {}
regions = ", ".join(covered.get("regions") or []) or "none"
raise APIError(
APIErrorCode.INVALID_INPUT,
f"No server account for {service} in region '{region or 'unknown'}'. "
f"Its accounts cover: {regions}. Pass --proxy with one of those country codes.",
details={"service": service, "region": region, "regions": covered.get("regions") or []},
)
key = f"{service}:{','.join(p or '' for p in pool)}"
n = _account_counters.get(key, 0)
_account_counters[key] = n + 1
return pool[n % len(pool)]
def server_accounts_allowed(request: Optional[web.Request] = None, service: Optional[str] = None) -> bool:
"""Whether the calling API key may authenticate ``service`` with one of the server's own accounts.
Same shape as ``server_cdm``: ``server_accounts: true`` on the ``serve.users`` entry for every
service, or a list of service tags. Off unless set. The admin secret (no ``users`` entry) keeps
full access. Combine with ``server_account_spec``: the server treats an API key without the
opt-in as a client-managed remote session for that service.
"""
if not request:
return True
user_config = config.serve.get("users", {}).get(request_secret_key(request))
if user_config is None:
return True
allowed = user_config.get("server_accounts", False)
if isinstance(allowed, str):
allowed = [allowed]
if isinstance(allowed, (list, tuple, set)):
return service is not None and Services.get_tag(service).upper() in {
Services.get_tag(s).upper() for s in allowed
}
return bool(allowed)
def resolve_server_account(request: Optional[web.Request], service: str, region: Any) -> Optional[str]:
"""Profile of the server account a search or list call runs on; None when the service is client-managed.
Raises FORBIDDEN for an API key without the ``server_accounts`` opt-in.
"""
if server_account_spec(service) is None:
return None
if not server_accounts_allowed(request, service):
raise APIError(
APIErrorCode.FORBIDDEN,
f"Your API key may not use the server's accounts for {service}.",
details={"service": service},
)
return next_server_profile(service, region if isinstance(region, str) else None) or "default"
def server_account_for(request: Optional[web.Request], service: str) -> bool:
"""True when this request's remote session for ``service`` should run on a server account."""
return server_account_spec(service) is not None and server_accounts_allowed(request, service)
def server_proxy_allowed(request: Optional[web.Request] = None) -> bool:
"""Whether the calling API key may use the server's own configured proxy providers.
Configured API keys opt in with ``server_proxy: true``, a real yaml boolean, so a
quoted ``"true"`` or ``"no"`` does not count. It is a plain bool, not a per-service
list, because a proxy is not tied to a service. No API key has implicit access: an
API key absent from ``serve.users`` gets none, and neither does a call without a
request, so a handler that does not pass ``request`` through fails closed.
"""
if not request:
return False
secret_key = request_secret_key(request)
user_config = (config.serve or {}).get("users", {}).get(secret_key)
if not isinstance(user_config, dict):
return False
return user_config.get("server_proxy") is True
JOB_EVENTS_ROUTE = "/api/download/jobs/{job_id}/events"
DASHBOARD_PREFIX = "/api/dashboard/"
DASHBOARD_EVENTS_ROUTE = DASHBOARD_PREFIX + "events"
SSE_QUERY_KEY_ROUTES = {JOB_EVENTS_ROUTE, DASHBOARD_EVENTS_ROUTE}
CORS_HEADERS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, X-Secret-Key, Authorization",
"Access-Control-Max-Age": "3600",
}
def request_secret_key(request: web.Request) -> Optional[str]:
"""The caller's API key: the X-Secret-Key header, or on SSE routes the secret_key query param."""
key = request.headers.get("X-Secret-Key")
if not key:
resource = request.match_info.route.resource
if resource is not None and resource.canonical in SSE_QUERY_KEY_ROUTES:
key = request.query.get("secret_key")
return key
def dashboard_key() -> Optional[str]:
"""The developer dashboard key from serve.dashboard.key, or None when the dashboard is off."""
dashboard = (config.serve or {}).get("dashboard")
key = dashboard.get("key") if isinstance(dashboard, dict) else None
return str(key) if key else None
@web.middleware
async def dashboard_authentication(request: web.Request, handler: Any) -> web.StreamResponse:
"""Gate /api/dashboard/ behind serve.dashboard.key only; user and tier keys never pass.
This middleware runs before the user-key middleware, so it answers the dashboard routes
first and no other route ever accepts the dashboard key.
"""
import hmac
if not request.path.startswith(DASHBOARD_PREFIX):
return await handler(request)
expected = dashboard_key()
if not expected:
return await handler(request) # routes are unregistered, so this 404s
provided = request_secret_key(request)
if not provided or not hmac.compare_digest(provided, expected):
return web.json_response({"status": 401, "message": "Dashboard key is invalid."}, status=401)
return await handler(request)
def rate_limit_response(secret_key: str) -> Optional[web.Response]:
"""A 429 when *secret_key* is over its hourly limit, else None after counting the request.
Call only once the API key is known to be valid, so an invalid API key cannot burn someone else's
window. Both auth middlewares go through here: the limit has to hold in every server mode.
"""
from unshackle.core.api.stats import stats
retry_after = stats.check_rate_limit(secret_key)
if retry_after is None:
return None
return web.json_response(
{"status": 429, "message": "Rate limit exceeded."},
status=429,
headers={"Retry-After": str(retry_after)},
)
@web.middleware
async def api_key_authentication(request: web.Request, handler: Any) -> web.StreamResponse:
"""Gate every non-dashboard route behind an API key in ``app["config"]["users"]``, then rate limit it.
Runs after :func:`dashboard_authentication`, which has already answered the dashboard
routes, so the dashboard key never reaches the limiter.
"""
import hmac
if request.path == "/api/health" or request.path.startswith(DASHBOARD_PREFIX):
return await handler(request)
secret_key = request_secret_key(request)
if not secret_key:
return web.json_response({"status": 401, "message": "Secret Key is Empty."}, status=401)
# Constant-time compare against every configured key so a valid key can't be
# recovered byte-by-byte from response timing.
if not any(hmac.compare_digest(secret_key, k) for k in request.app["config"]["users"]):
return web.json_response({"status": 401, "message": "Secret Key is Invalid."}, status=401)
return rate_limit_response(secret_key) or await handler(request)
def caller_key(request: Optional[web.Request] = None) -> str:
"""The authenticating API key for a request, or 'anonymous' when unauthenticated."""
if not request:
return "anonymous"
return request_secret_key(request) or "anonymous"
def owns_job(job: Any, request: Optional[web.Request] = None) -> bool:
"""Whether the calling API key owns this job.
Jobs created without an owner (no-key mode / legacy) stay shared. Otherwise, only the
API key that created the job can see it (constant-time compare).
"""
import hmac
owner = getattr(job, "owner_key", None)
if owner is None:
return True
return hmac.compare_digest(owner, caller_key(request))
def validate_service(service_tag: str, request: Optional[web.Request] = None) -> Optional[str]:
"""Validate and normalise the service tag, and examine the allowlist."""
if not isinstance(service_tag, str):
return None
try:
normalized = Services.get_tag(service_tag)
service_path = Services.get_path(normalized)
if not service_path.exists():
return None
allowed = get_allowed_services(request)
if allowed is not None and normalized not in allowed:
return None
return normalized
except KeyError:
return None
PROFILE_RE = re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
def client_profile(data: Dict[str, Any]) -> Optional[str]:
"""The request's ``profile``, or an INVALID_INPUT error when it could name a path."""
profile = data.get("profile")
if profile is None or profile == "":
return None
if not isinstance(profile, str) or not PROFILE_RE.fullmatch(profile) or profile in (".", ".."):
raise APIError(APIErrorCode.INVALID_INPUT, "profile must be a plain name: letters, digits, '_', '-' or '.'.")
return profile
def require_fields(data: Dict[str, Any], *names: str) -> None:
"""Raise INVALID_INPUT for the first missing/falsy required field."""
for name in names:
if not data.get(name):
raise APIError(
APIErrorCode.INVALID_INPUT,
f"Missing required parameter: {name}",
details={"missing_parameter": name},
)
def part_key_suffix(part: Optional[int]) -> str:
"""`.2` selection-syntax suffix for a part-ful episode, empty otherwise."""
return f".{part}" if part is not None else ""
def listed_titles(titles: Any) -> list[Any]:
"""The result of a service's get_titles() as a list."""
if titles is None:
raise APIError(APIErrorCode.SERVICE_ERROR, "The service returned no titles.")
return list(titles) if hasattr(titles, "__iter__") and not isinstance(titles, str) else [titles]
def serialize_title(title: Title_T) -> Dict[str, Any]:
"""Convert a title object to JSON-serializable dict."""
title_language = str(title.language) if hasattr(title, "language") and title.language else None
# Optional display metadata a service may provide: a synopsis (title.description) and a
# release/air date or poster image URL stashed in title.data. Surfaced so a client can show
# a richer listing without re-fetching the page.
description = getattr(title, "description", None) or None
_data = getattr(title, "data", None)
date = _data.get("date") if isinstance(_data, dict) else None
cover_url = _data.get("cover_url") if isinstance(_data, dict) else None
is_episode = isinstance(title, Episode)
is_song = isinstance(title, Song)
episode_part = title.part if isinstance(title, Episode) else None
if is_episode:
# no part suffix here: remote_service.build_title rebuilds the Episode from this dict, so a
# suffixed name plus the structural `part` below would render the part twice in the filename
name = title.name if title.name else f"Episode {title.number:02d}"
else:
name = str(title.name) if hasattr(title, "name") else str(title)
result = {
"type": "episode" if is_episode else "movie" if isinstance(title, Movie) else "song" if is_song else "other",
"name": name,
"id": str(title.id) if hasattr(title, "id") else None,
"language": title_language,
"description": description,
"date": date,
"cover_url": cover_url,
}
# "other" titles carry no year; only Episode/Movie/Song do.
if isinstance(title, (Episode, Movie, Song)):
result["year"] = title.year
if isinstance(title, Episode):
result["series_title"] = str(title.title)
result["season"] = title.season
result["number"] = title.number
# every key below is conditional, so JSON for a title without them is unchanged
if episode_part is not None:
result["part"] = episode_part
if title.air_date is not None:
result["air_date"] = (
title.air_date.isoformat() if isinstance(title.air_date, date_) else str(title.air_date)
)
if title.absolute is not None:
result["absolute"] = title.absolute
if getattr(title, "daily", None) is not None:
result["daily"] = title.daily
if is_song:
for field in (
"artist",
"album",
"track",
"disc",
"album_artist",
"release_type",
"total_tracks",
"total_discs",
"genre",
"explicit",
"isrc",
"upc",
"copyright",
"label",
"lyrics",
"artwork_url",
):
result[field] = getattr(title, field, None)
if isinstance(title, (Episode, Movie)) and getattr(title, "anime", None) is not None:
result["anime"] = title.anime
return result
def stamp_service_flags(serialized: Dict[str, Any], service_instance: Any) -> Dict[str, Any]:
"""Fill anime/daily from the service class for titles that set neither.
The client rebuilds titles from this JSON against a synthetic service class, so a
class-level ANIME/DAILY would otherwise be lost on the way across.
"""
for key, attr in (("anime", "ANIME"), ("daily", "DAILY")):
if key == "daily" and serialized.get("type") != "episode":
continue
if serialized.get(key) is None and getattr(type(service_instance), attr, False):
serialized[key] = True
return serialized
def extract_manifests(tracks) -> List[Dict[str, Any]]:
"""Extract manifest data from tracks for client-side re-parsing.
Serializes DASH and ISM manifest XML as zlib-compressed base64 strings
so the client can reconstruct track.data locally. HLS tracks download
directly from their URL, so this function does not serialise their manifest.
"""
import base64
import zlib
from lxml import etree
from unshackle.core.config import config as app_config
compression_level = app_config.serve.get("compression_level", 1)
seen: set[str] = set()
manifests: List[Dict[str, Any]] = []
for track in list(tracks.videos) + list(tracks.audio) + list(tracks.subtitles):
manifest_url = str(track.url) if track.url else None
if not manifest_url or manifest_url in seen:
continue
if track.data.get("dash") and track.data["dash"].get("manifest") is not None:
seen.add(manifest_url)
xml_bytes = etree.tostring(track.data["dash"]["manifest"], xml_declaration=True, encoding="UTF-8")
compressed = zlib.compress(xml_bytes, compression_level) if compression_level else xml_bytes
manifests.append(
{
"type": "dash",
"url": manifest_url,
"data": base64.b64encode(compressed).decode("ascii"),
}
)
elif track.data.get("ism") and track.data["ism"].get("manifest") is not None:
seen.add(manifest_url)
xml_bytes = etree.tostring(track.data["ism"]["manifest"], xml_declaration=True, encoding="UTF-8")
compressed = zlib.compress(xml_bytes, compression_level) if compression_level else xml_bytes
manifests.append(
{
"type": "ism",
"url": manifest_url,
"data": base64.b64encode(compressed).decode("ascii"),
}
)
return manifests
def extract_track_manifests(tracks) -> List[Dict[str, Any]]:
"""Build a one-AdaptationSet MPD for each track whose AdaptationSet the served manifest does not contain.
A service can build an AdaptationSet of its own, out of merged segment timelines or
copied nodes, that never joins the manifest tree the server serialises. The client's
re-parse of that manifest cannot find it, so each of those tracks gets its own MPD.
"""
import base64
import zlib
from copy import deepcopy
from lxml import etree
from unshackle.core.config import config as app_config
compression_level = app_config.serve.get("compression_level", 1)
fragments: List[Dict[str, Any]] = []
for track in list(tracks.videos) + list(tracks.audio) + list(tracks.subtitles):
dash = (getattr(track, "data", None) or {}).get("dash") or {}
manifest = dash.get("manifest")
adaptation_set = dash.get("adaptation_set")
if manifest is None or adaptation_set is None:
continue
if adaptation_set.getroottree().getroot() is manifest:
continue
root = etree.Element("MPD", dict(manifest.attrib))
for child in manifest:
if child.tag != "Period":
root.append(deepcopy(child))
period = dash.get("period")
new_period = etree.SubElement(root, "Period", dict(period.attrib) if period is not None else {})
for child in period if period is not None else []:
if child.tag != "AdaptationSet":
new_period.append(deepcopy(child))
new_adaptation_set = deepcopy(adaptation_set)
new_period.append(new_adaptation_set)
representation = dash.get("representation")
if representation is not None:
for sibling in new_adaptation_set.findall("Representation"):
new_adaptation_set.remove(sibling)
new_adaptation_set.append(deepcopy(representation))
xml_bytes = etree.tostring(root, xml_declaration=True, encoding="UTF-8")
compressed = zlib.compress(xml_bytes, compression_level) if compression_level else xml_bytes
fragments.append(
{
"track_id": str(track.id),
"type": "dash",
"url": str(track.url) if track.url else None,
"data": base64.b64encode(compressed).decode("ascii"),
}
)
return fragments
def serialize_drm(drm_list) -> Optional[List[Dict[str, Any]]]:
"""Serialise DRM objects to JSON-serializable list."""
if not drm_list:
return None
if not isinstance(drm_list, list):
drm_list = [drm_list]
result = []
for drm in drm_list:
drm_info = {}
drm_class = drm.__class__.__name__
drm_info["type"] = drm_class.lower()
# pywidevine exposes dumps(); pyplayready's PSSH does not, so PlayReady falls back
# to the base64 it was built from. Without it the client drops every PlayReady
# entry and licenses the title with the wrong CDM.
pssh_obj = getattr(drm, "_pssh", None)
pssh_b64 = (getattr(drm, "data", None) or {}).get("pssh_b64")
if pssh_obj is not None and hasattr(pssh_obj, "dumps"):
try:
drm_info["pssh"] = pssh_obj.dumps()
except (ValueError, TypeError, KeyError):
log.warning(
"Failed to serialize PSSH for DRM type=%s pssh_type=%s",
drm_class,
type(pssh_obj).__name__,
exc_info=True,
)
if not drm_info.get("pssh") and pssh_b64:
drm_info["pssh"] = pssh_b64
if hasattr(drm, "kids") and drm.kids:
drm_info["kids"] = [str(kid) for kid in drm.kids]
if hasattr(drm, "content_keys") and drm.content_keys:
drm_info["content_keys"] = {str(k): v for k, v in drm.content_keys.items()}
# Get license URL - essential for remote licensing
if hasattr(drm, "license_url") and drm.license_url:
drm_info["license_url"] = str(drm.license_url)
elif hasattr(drm, "_license_url") and drm._license_url:
drm_info["license_url"] = str(drm._license_url)
result.append(drm_info)
return result if result else None
def enum_name(value: Any) -> str:
"""Return an enum-like value's .name, falling back to str()."""
return value.name if hasattr(value, "name") else str(value)
def descriptor_name(track: Any) -> Optional[str]:
"""Manifest descriptor (HLS/DASH/URL) name for a track, or None."""
descriptor = getattr(track, "descriptor", None)
return enum_name(descriptor) if descriptor else None
def drm_preference_name(track: Any) -> Optional[str]:
"""The track's drm_preference as a full DRM system name, "widevine" or "playready"; None when unset."""
preference = getattr(track, "drm_preference", None)
if not preference:
return None
return {"wv": "widevine", "pr": "playready"}.get(preference, preference)
MANIFEST_DATA_KEYS = ("hls", "dash", "ism")
def serialize_track_data(track: Any) -> Optional[Dict[str, Any]]:
"""The JSON-safe part of a service-set ``track.data``.
This drops the manifest parsers' own keys: the client rebuilds those from the
``manifests`` payload, and they hold live lxml and m3u8 objects that JSON cannot carry.
"""
data = getattr(track, "data", None)
if not isinstance(data, dict):
return None
safe: Dict[str, Any] = {}
for key, value in data.items():
if key in MANIFEST_DATA_KEYS:
continue
try:
json.dumps(value)
except (TypeError, ValueError):
continue
safe[key] = value
return safe or None
def serialize_track_common(track: Any) -> Dict[str, Any]:
"""The Track fields every track type shares, a superset of ``Track.to_dict()``."""
return {
"id": str(track.id),
"language": str(track.language) if track.language else None,
"is_original_lang": bool(getattr(track, "is_original_lang", False)),
"name": getattr(track, "name", None),
"needs_repack": bool(getattr(track, "needs_repack", False)),
"edition": list(getattr(track, "edition", None) or []),
"descriptor": descriptor_name(track),
"drm": serialize_drm(track.drm) if getattr(track, "drm", None) else None,
"drm_preference": getattr(track, "drm_preference", None),
"data": serialize_track_data(track),
}
def serialize_video_track(track: Video, include_url: bool = False) -> Dict[str, Any]:
"""Convert video track to JSON-serializable dict."""
codec_name = enum_name(track.codec)
range_name = enum_name(track.range)
result = {
"id": str(track.id),
"codec": codec_name,
"codec_display": VIDEO_CODEC_MAP.get(codec_name, codec_name),
"bitrate": int(track.bitrate / 1000) if track.bitrate else None,
"bitrate_bps": track.bitrate or None,
"width": track.width,
"height": track.height,
"resolution": f"{track.width}x{track.height}" if track.width and track.height else None,
"fps": track.fps if track.fps else None,
"range": range_name,
"range_display": DYNAMIC_RANGE_MAP.get(range_name, range_name),
"scan_type": enum_name(track.scan_type) if track.scan_type else None,
"closed_captions": list(getattr(track, "closed_captions", None) or []),
"dv_compatible_bitstream": bool(getattr(track, "dv_compatible_bitstream", False)),
}
result.update(serialize_track_common(track))
if include_url and hasattr(track, "url") and track.url:
result["url"] = str(track.url)
return result
def original_audio_ids(tracks: List[Audio], title: Title_T) -> set:
"""Return the ids of the audio tracks 'orig' resolves to, empty when the title has no language.
This defers to Tracks.by_language so the flag agrees with the downloader. It asks
exact mode first because CLDR rates a base tag and its paradigm regional variant as
the same language, and only the RFC 4647 preference picks one ('en' over 'en-US'
when both exist, 'pt-BR' over 'pt-PT' for a 'pt' title). The fuzzy fallback then
catches the non-paradigm regionals exact mode drops, such as an 'es' title that
carries only 'es-419'.
"""
language = getattr(title, "language", None)
if not language:
return set()
matches = Tracks.by_language(tracks, [str(language)], exact_match=True) or Tracks.by_language(
tracks, [str(language)]
)
return {t.id for t in matches}
def serialize_audio_track(track: Audio, include_url: bool = False, is_original: bool = False) -> Dict[str, Any]:
"""Convert audio track to JSON-serializable dict.
Get is_original from original_audio_ids so the flag always agrees with the
track 'orig' would download.
"""
codec_name = enum_name(track.codec)
result = {
"id": str(track.id),
"codec": codec_name,
"codec_display": AUDIO_CODEC_MAP.get(codec_name, codec_name),
"bitrate": int(track.bitrate / 1000) if track.bitrate else None,
"bitrate_bps": track.bitrate or None,
"channels": track.channels if track.channels else None,
"is_original": is_original,
"atmos": track.atmos if hasattr(track, "atmos") else False,
"joc": getattr(track, "joc", 0),
"descriptive": track.descriptive if hasattr(track, "descriptive") else False,
}
result.update(serialize_track_common(track))
if include_url and hasattr(track, "url") and track.url:
result["url"] = str(track.url)
return result
def serialize_subtitle_track(track: Subtitle, include_url: bool = False) -> Dict[str, Any]:
"""Convert subtitle track to JSON-serializable dict."""
result = {
"id": str(track.id),
"codec": enum_name(track.codec),
"forced": track.forced if hasattr(track, "forced") else False,
"sdh": track.sdh if hasattr(track, "sdh") else False,
"cc": track.cc if hasattr(track, "cc") else False,
}
result.update(serialize_track_common(track))
if include_url and hasattr(track, "url") and track.url:
result["url"] = str(track.url)
return result
ATTACHMENT_INLINE_LIMIT = 20 * 1024 * 1024
def serialize_attachment(attachment: Any) -> Optional[Dict[str, Any]]:
"""One attachment as JSON, with a path-only attachment carried as base64 file bytes.
A service can attach a file it already wrote to disk (a font, a poster). The client
has no access to the server's filesystem, so those bytes travel inline.
"""
result: Dict[str, Any] = {
"url": attachment.url,
"name": attachment.name,
"mime_type": attachment.mime_type,
"description": attachment.description,
}
if attachment.url:
return result
path = getattr(attachment, "path", None)
if not path:
return None
path = Path(path)
try:
size = path.stat().st_size
if size > ATTACHMENT_INLINE_LIMIT:
log.warning(
"Skipping attachment %s: %d bytes is over the %d byte inline limit",
sanitize_log(path.name),
size,
ATTACHMENT_INLINE_LIMIT,
)
return None
result["content"] = base64.b64encode(path.read_bytes()).decode("ascii")
except OSError as e:
log.warning("Skipping attachment %s: %s", sanitize_log(path.name), e)
return None
result["file_name"] = path.name
return result
async def search_handler(data: Dict[str, Any], request: Optional[web.Request] = None) -> web.Response:
"""Answer the request to find titles."""
service_tag = data.get("service")
query = data.get("query")
if not service_tag:
raise APIError(APIErrorCode.INVALID_INPUT, "Missing required 'service' field")
if not query:
raise APIError(APIErrorCode.INVALID_PARAMETERS, "Missing required 'query' field")
# get_tag echoes an unknown tag back, so it can never be falsy; validate_service
# is the check that actually resolves the service directory and the allowlist.
normalized_service = validate_service(service_tag, request)
if not normalized_service:
raise APIError(
APIErrorCode.INVALID_SERVICE,
f"Service '{service_tag}' not found",
details={"service": service_tag},
)
request_cache = RequestCache()
try:
results = await asyncio.to_thread(run_service_search, data, normalized_service, query, request, request_cache)
return request_cache.json_response({"results": results, "count": len(results)})
finally:
request_cache.cleanup()
async def list_titles_handler(data: Dict[str, Any], request: Optional[web.Request] = None) -> web.Response:
"""Answer the list-titles request."""
require_fields(data, "service", "title_id")
service_tag = data.get("service")
title_id = data.get("title_id")
profile = client_profile(data)
normalized_service = validate_service(service_tag, request)
if not normalized_service:
raise APIError(
APIErrorCode.INVALID_SERVICE,
f"Invalid or unavailable service: {service_tag}",
details={"service": service_tag},
)
request_cache = RequestCache()
try:
service_instance = await asyncio.to_thread(
setup_list_service, data, normalized_service, profile, title_id, request, request_cache
)
titles = await asyncio.to_thread(service_instance.get_titles)
title_list = [stamp_service_flags(serialize_title(t), service_instance) for t in listed_titles(titles)]
return request_cache.json_response({"titles": title_list})
except APIError:
raise
except (Exception, SystemExit) as e:
log.exception("Error listing titles")
debug_mode = request.app.get("debug_api", False) if request else False
return handle_api_exception(
e,
context={"operation": "list_titles", "service": normalized_service, "title_id": title_id},
debug_mode=debug_mode,
)
finally:
request_cache.cleanup()
async def list_tracks_handler(data: Dict[str, Any], request: Optional[web.Request] = None) -> web.Response:
"""Answer the list-tracks request."""
require_fields(data, "service", "title_id")
service_tag = data.get("service")
title_id = data.get("title_id")
profile = client_profile(data)
normalized_service = validate_service(service_tag, request)
if not normalized_service:
raise APIError(
APIErrorCode.INVALID_SERVICE,
f"Invalid or unavailable service: {service_tag}",
details={"service": service_tag},
)
request_cache = RequestCache()
try:
service_instance = await asyncio.to_thread(
setup_list_service, data, normalized_service, profile, title_id, request, request_cache
)
titles = await asyncio.to_thread(service_instance.get_titles)
wanted_param = data.get("wanted")
season = data.get("season")
episode = data.get("episode")
part = data.get("part")
if hasattr(titles, "__iter__") and not isinstance(titles, str):
titles_list = list(titles)
wanted = None
if wanted_param:
from unshackle.core.utils.click_types import SeasonRange
try:
season_range = SeasonRange()
if isinstance(wanted_param, list):
wanted = season_range.parse_tokens(*wanted_param)
else:
# convert(), not parse_tokens(), so a comma-separated string splits
# into tokens the way the CLI splits it
wanted = season_range.convert(wanted_param)
log.debug(
f"Parsed wanted '{sanitize_log(wanted_param)}' into {len(wanted)} episodes: {wanted[:10]}..."
)
except (Exception, SystemExit) as e:
raise APIError(
APIErrorCode.INVALID_PARAMETERS,
f"Invalid wanted parameter: {e}",
details={"wanted": wanted_param, "service": normalized_service},
)
elif season is not None and episode is not None:
wanted = [f"{season}x{episode}{part_key_suffix(part)}"]
if wanted:
# Filter titles based on wanted episodes, similar to how dl.py does it
matching_titles: list[Any] = []
log.debug(f"Filtering {len(titles_list)} titles with {len(wanted)} wanted episodes")
for title in titles_list:
if isinstance(title, Episode):
episode_key = f"{title.season}x{title.number}{part_key_suffix(title.part)}"
if title.matches_wanted(wanted):
log.debug(f"Episode {episode_key} matches wanted list")
matching_titles.append(title)
else:
log.debug(f"Episode {episode_key} not in wanted list")
elif isinstance(title, Song):
song_key = f"{title.disc}x{title.track}"
if title.matches_wanted(wanted):
log.debug(f"Song {song_key} matches wanted list")
matching_titles.append(title)
else:
log.debug(f"Song {song_key} not in wanted list")
else:
matching_titles.append(title)
log.debug(f"Found {len(matching_titles)} matching titles")
if not matching_titles:
raise APIError(
APIErrorCode.NO_CONTENT,
"No titles found matching wanted criteria",
details={
"service": normalized_service,
"title_id": title_id,
"wanted": wanted_param or wanted[0],
},
)
# If multiple episodes match, return tracks for all episodes
if len(matching_titles) > 1 and all(isinstance(t, Episode) for t in matching_titles):
episodes_data = []
failed_episodes = []
# Sort matching titles by season and episode number for consistent ordering
sorted_titles = sorted(matching_titles, key=lambda t: (t.season, t.number, t.part or 0))
for title in sorted_titles:
try:
tracks = await asyncio.to_thread(service_instance.get_tracks, title)
video_tracks = sorted(tracks.videos, key=lambda t: t.bitrate or 0, reverse=True)
audio_tracks = sorted(tracks.audio, key=lambda t: t.bitrate or 0, reverse=True)
original_ids = original_audio_ids(audio_tracks, title)
episode_data = {
"title": serialize_title(title),
"video": [serialize_video_track(t) for t in video_tracks],
"audio": [
serialize_audio_track(t, is_original=t.id in original_ids) for t in audio_tracks
],
"subtitles": [serialize_subtitle_track(t) for t in tracks.subtitles],
}
episodes_data.append(episode_data)
log.debug(f"Successfully got tracks for {title.season}x{title.number}")
except SystemExit:
# Service calls sys.exit() for unavailable episodes - catch and skip
failed_episodes.append(f"S{title.season}E{title.number:02d}{part_key_suffix(title.part)}")
log.debug(f"Episode {title.season}x{title.number} not available, skipping")
continue
except (Exception, SystemExit) as e:
failed_episodes.append(f"S{title.season}E{title.number:02d}{part_key_suffix(title.part)}")
log.debug(f"Error getting tracks for {title.season}x{title.number}: {e}")
continue
if episodes_data:
response = {"episodes": episodes_data}
if failed_episodes:
response["unavailable_episodes"] = failed_episodes
return request_cache.json_response(response)
else:
raise APIError(
APIErrorCode.NO_CONTENT,
f"No available episodes found. Unavailable: {', '.join(failed_episodes)}",
details={
"service": normalized_service,
"title_id": title_id,
"unavailable_episodes": failed_episodes,
},
)
else:
first_title = matching_titles[0]
else:
first_title = titles_list[0]
else:
first_title = titles
tracks = await asyncio.to_thread(service_instance.get_tracks, first_title)
video_tracks = sorted(tracks.videos, key=lambda t: t.bitrate or 0, reverse=True)
audio_tracks = sorted(tracks.audio, key=lambda t: t.bitrate or 0, reverse=True)
original_ids = original_audio_ids(audio_tracks, first_title)
response = {
"title": serialize_title(first_title),
"video": [serialize_video_track(t) for t in video_tracks],
"audio": [serialize_audio_track(t, is_original=t.id in original_ids) for t in audio_tracks],
"subtitles": [serialize_subtitle_track(t) for t in tracks.subtitles],
}
return request_cache.json_response(response)
except APIError:
raise
except (Exception, SystemExit) as e:
log.exception("Error listing tracks")
debug_mode = request.app.get("debug_api", False) if request else False
return handle_api_exception(
e,
context={"operation": "list_tracks", "service": normalized_service, "title_id": title_id},
debug_mode=debug_mode,
)
finally:
request_cache.cleanup()
VALID_VCODECS = [choice.upper() for choice in VIDEO_CODEC_LIST.choices]
VALID_ACODECS = [choice.upper() for choice in AUDIO_CODEC_LIST.choices]
VALID_SUB_FORMATS = [choice.upper() for choice in SUBTITLE_CODEC.choices]
def resolve_vcodec(value: Any) -> Optional[list]:
"""Map a client's vcodec field to codec enums.
Session routes do not run validate_download_parameters, so this must answer junk with a 400
instead of letting click's UsageError surface as a 500.
"""
if not value:
return None
try:
return VIDEO_CODEC_LIST.convert(value) or None
except click.UsageError as e:
raise APIError(APIErrorCode.INVALID_INPUT, f"Invalid vcodec: {e.format_message()}")
def check_codec(value: Any, allowed: List[str], name: str) -> Optional[str]:
"""Validate a comma-string or list of codec tokens against `allowed` (case-insensitive)."""
if isinstance(value, str):
tokens = [v.strip() for v in value.split(",") if v.strip()]
elif isinstance(value, list):
tokens = [str(v).strip() for v in value if str(v).strip()]
else:
return f"{name} must be a string or list"
invalid = [token for token in tokens if token.upper() not in allowed]
if invalid:
return f"Invalid {name}: {', '.join(invalid)}. Must be one of: {', '.join(allowed)}"
return None
def validate_download_parameters(data: Dict[str, Any]) -> Optional[str]:
"""
Validate download parameters and return error message if invalid.
Returns:
None if valid, error message string if invalid
"""
for banned in ("postscript", "post_script", "post_scripts"):
if data.get(banned):
return f"'{banned}' is not accepted over the API. Define post_scripts in unshackle.yaml."
# The worker reads cookies/{service}/{profile}.txt, so a profile must not be able to name a path.
try:
client_profile(data)
except APIError as e:
return e.message
# The serve user would create and write any directory a client names; keep it under downloads.
if data.get("output_dir"):
root = config.directories.downloads.resolve()
raw = str(data["output_dir"])
if "\x00" in raw:
return "output_dir is not a usable path."
try:
target = (root / raw).resolve()
except (OSError, ValueError):
return "output_dir is not a usable path."
if not target.is_relative_to(root):
return "output_dir must be a path under the server's downloads directory."
data["output_dir"] = str(target)
if "vcodec" in data and data["vcodec"]:
err = check_codec(data["vcodec"], VALID_VCODECS, "vcodec")
if err:
return err
if "acodec" in data and data["acodec"]:
err = check_codec(data["acodec"], VALID_ACODECS, "acodec")
if err:
return err
if "sub_format" in data and data["sub_format"]:
if str(data["sub_format"]).upper() not in VALID_SUB_FORMATS:
return f"Invalid sub_format: {data['sub_format']}. Must be one of: {', '.join(VALID_SUB_FORMATS)}"
if "vbitrate" in data and data["vbitrate"] is not None:
if not isinstance(data["vbitrate"], int) or data["vbitrate"] <= 0:
return "vbitrate must be a positive integer"
if "abitrate" in data and data["abitrate"] is not None:
if not isinstance(data["abitrate"], int) or data["abitrate"] <= 0:
return "abitrate must be a positive integer"
if "vbitrate_range" in data and data["vbitrate_range"] is not None:
if not isinstance(data["vbitrate_range"], str) or "-" not in data["vbitrate_range"]:
return "vbitrate_range must be a string in 'MIN-MAX' format (e.g., '6000-7000')"
if "abitrate_range" in data and data["abitrate_range"] is not None:
if not isinstance(data["abitrate_range"], str) or "-" not in data["abitrate_range"]:
return "abitrate_range must be a string in 'MIN-MAX' format (e.g., '128-256')"
if "channels" in data and data["channels"] is not None:
if not isinstance(data["channels"], (int, float)) or data["channels"] <= 0:
return "channels must be a positive number"
if "workers" in data and data["workers"] is not None:
if not isinstance(data["workers"], int) or data["workers"] <= 0:
return "workers must be a positive integer"
if "download_processes" in data and data["download_processes"] is not None:
if not isinstance(data["download_processes"], int) or data["download_processes"] <= 0:
return "download_processes must be a positive integer"
if "downloads" in data and data["downloads"] is not None:
if not isinstance(data["downloads"], int) or data["downloads"] <= 0:
return "downloads must be a positive integer"
for name in ("tmdb_id", "tvdb_id", "latest_episodes"):
if data.get(name) is not None:
value = data[name]
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
return f"{name} must be a positive integer"
if data.get("imdb_id") is not None:
if not isinstance(data["imdb_id"], str) or not re.fullmatch(r"tt\d+", data["imdb_id"]):
return "imdb_id must be an IMDB ID like 'tt1375666'"
if data.get("anilist_id") is not None:
value = data["anilist_id"]
# same parser as --anilist so both surfaces accept and reject the same inputs
valid = not isinstance(value, bool) and isinstance(value, (int, str)) and parse_anilist_ref(value)
if not valid:
return "anilist_id must be a positive integer, or a MyAnimeList ID like 'mal:21'"
supplied_ids = [name for name in ("tmdb_id", "imdb_id", "tvdb_id") if data.get(name)]
if len(supplied_ids) > 1:
return (
f"Cannot use multiple external IDs: {', '.join(supplied_ids)}. "
"Give one ID and unshackle resolves the others from it."
)
exclusive_flags = []
if data.get("video_only"):
exclusive_flags.append("video_only")
if data.get("audio_only"):
exclusive_flags.append("audio_only")
if data.get("subs_only"):
exclusive_flags.append("subs_only")
if data.get("chapters_only"):
exclusive_flags.append("chapters_only")
if len(exclusive_flags) > 1:
return f"Cannot use multiple exclusive flags: {', '.join(exclusive_flags)}"
if data.get("no_subs") and data.get("subs_only"):
return "Cannot use both no_subs and subs_only"
if data.get("no_audio") and data.get("audio_only"):
return "Cannot use both no_audio and audio_only"
for key in ("require_audio", "require_video", "require_subs"):
value = data.get(key)
if value is not None and (not isinstance(value, list) or not all(isinstance(v, str) for v in value)):
return f"{key} must be an array of language strings"
if "range" in data and data["range"]:
# "HDR10P" is the canonical range value ("+" is awkward in scripts); "HDR10+" stays valid.
valid_ranges = ["SDR", "HDR10", "HDR10P", "DV", "HLG", "HYBRID"]
accepted = {*valid_ranges, "HDR10+"}
values = data["range"] if isinstance(data["range"], list) else [data["range"]]
for r in values:
if r.upper() not in accepted:
return f"Invalid range value: {r}. Must be one of: {', '.join(valid_ranges)}"
return None
def enforce_download_gates(params: Dict[str, Any], request: Optional[web.Request] = None) -> None:
"""Enforce serve-config gates on per-job cdm overrides and client-supplied credentials.
A per-request `cdm` selects a server-side device, so this function gates it here rather than
honour it blindly. `serve.cdm_overrides` opts in: a list permits only those device names, or `true`
permits any (for a single trusted client). Unset/false rejects every override.
A per-request `credential` (or `credentials` map) authenticates the job with client-supplied
secrets instead of the server-side credentials. Gate it behind `serve.allow_job_credentials`
(default off) so a default deployment stays locked to its own credentials. This mirrors the
CDM gate. A download job licenses DRM in-process with the server's own CDM, so an API key without
``server_cdm`` cannot submit or retry jobs. A job can also spend the server's proxy providers,
through an explicit ``proxy`` country code or through the geofence auto-proxy, so the
``server_proxy`` policy from :func:`resolve_handler_proxy` applies here too: without the opt-in
the job must carry a full proxy URI or no proxy at all. Under a ``server_cdm_max_height`` the job
must set a quality at or under it, no ``best_available`` and no HYBRID range, as a job has no client device
to fall back to. Pass the job's merged parameters, so the gate sees the values the job runs with.
"""
if not server_cdm_allowed(request, params.get("service")):
raise APIError(
APIErrorCode.FORBIDDEN,
"Download jobs license with the server CDM, which is not enabled for this key on this service.",
)
cap = server_cdm_max_height(request, params.get("service"))
if cap is not None:
serve_defaults = config.serve or {}
def effective(key: str) -> Any:
"""The job's own value for this parameter, or the ``serve:`` dl default that the job falls back to."""
return params[key] if key in params else serve_defaults.get(key)
wanted = requested_height(effective("quality"))
if wanted is None or wanted > cap:
raise capped_error(
f"Download jobs license with the server CDM, which covers up to {cap}p for this API key. "
f"Pass a quality of {cap} or lower.",
cap,
)
if effective("best_available"):
raise capped_error(
f"Download jobs license with the server CDM, which covers up to {cap}p for this API key. "
"Turn off best_available, because it can fall back to a track above that height.",
cap,
)
ranges = effective("range") or []
ranges = [ranges] if isinstance(ranges, str) else ranges
if any(str(getattr(r, "value", r)).upper() == "HYBRID" for r in ranges):
raise capped_error(
f"Download jobs license with the server CDM, which covers up to {cap}p for this API key. "
"Remove HYBRID from range, because it always keeps the lowest DV track, whatever the quality.",
cap,
)
if params.get("proxy_download") is not None and not isinstance(params["proxy_download"], str):
raise APIError(APIErrorCode.INVALID_INPUT, "proxy_download must be a string.")
if not server_proxy_allowed(request):
resolve_handler_proxy(params, params.get("service") or "", request)
if params.get("proxy_download"):
# the download proxy is gated like the main one: a full URI or nothing, never a server provider
resolve_handler_proxy(
{**params, "proxy": params["proxy_download"], "client_region": None},
params.get("service") or "",
request,
)
requested_cdm = params.get("cdm")
if requested_cdm:
allowed = (config.serve or {}).get("cdm_overrides")
permitted = allowed is True or (isinstance(allowed, (list, tuple, set)) and requested_cdm in allowed)
if not permitted:
raise APIError(
APIErrorCode.FORBIDDEN,
"The requested CDM is not permitted for API downloads.",
details={"cdm": requested_cdm},
)
if params.get("credential") or params.get("credentials"):
if not (config.serve or {}).get("allow_job_credentials"):
raise APIError(
APIErrorCode.FORBIDDEN,
"Per-request credentials are not permitted for API downloads.",
)
async def download_handler(data: Dict[str, Any], request: Optional[web.Request] = None) -> web.Response:
"""Answer the download request: make a download job and add it to the queue."""
from unshackle.core.api.download_manager import get_download_manager
require_fields(data, "service", "title_id")
service_tag = data.get("service")
title_id = data.get("title_id")
normalized_service = validate_service(service_tag, request)
if not normalized_service:
raise APIError(
APIErrorCode.INVALID_SERVICE,
f"Invalid or unavailable service: {service_tag}",
details={"service": service_tag},
)
validation_error = validate_download_parameters(data)
if validation_error:
raise APIError(
APIErrorCode.INVALID_PARAMETERS,
validation_error,
details={"service": normalized_service, "title_id": title_id},
)
try:
service_module = Services.load(normalized_service)
service_specific_defaults = {}
# Skip None defaults here: this dict overlays into job params; injecting
# None for keys like `profile` would clobber serve-config overrides.
# Missing required __init__ params are handled in download_manager.perform_download.
if hasattr(service_module, "cli") and hasattr(service_module.cli, "params"):
for param in service_module.cli.params:
if hasattr(param, "name") and param.default is not None and not isinstance(param.default, enum.Enum):
service_specific_defaults[param.name] = param.default
filtered_params = {k: v for k, v in data.items() if k not in ["service", "title_id"]}
# Overlay any dl-relevant keys from `serve:` config (e.g. downloads, workers) so the API
# respects server-side defaults without each client having to send them.
serve_overrides = {
k: v for k, v in (config.serve or {}).items() if k in DEFAULT_DOWNLOAD_PARAMS and v is not None
}
params_with_defaults = {
**DEFAULT_DOWNLOAD_PARAMS,
**serve_overrides,
**service_specific_defaults,
**filtered_params,
}
await asyncio.to_thread(enforce_download_gates, {**params_with_defaults, "service": service_tag}, request)
manager = get_download_manager()
await manager.start_workers()
# The download worker reads this to decide whether dl may load the server's proxy
# providers. Always stamped server-side so a client-sent value cannot grant it.
params_with_defaults["server_proxy"] = server_proxy_allowed(request)
job = manager.create_job(normalized_service, title_id, owner_key=caller_key(request), **params_with_defaults)
return web.json_response(
{"job_id": job.job_id, "status": job.status.value, "created_time": job.created_time.isoformat()}, status=202
)
except APIError:
raise
except (Exception, SystemExit) as e:
log.exception("Error creating download job")
debug_mode = request.app.get("debug_api", False) if request else False
return handle_api_exception(
e,
context={"operation": "create_download_job", "service": normalized_service, "title_id": title_id},
debug_mode=debug_mode,
)
async def list_download_jobs_handler(data: Dict[str, Any], request: Optional[web.Request] = None) -> web.Response:
"""Answer the request to show download jobs, with optional filtering and sorting."""
from unshackle.core.api.download_manager import get_download_manager
try:
manager = get_download_manager()
jobs = [job for job in manager.list_jobs() if owns_job(job, request)]
status_filter = data.get("status")
if status_filter:
jobs = [job for job in jobs if job.status.value == status_filter]
service_filter = data.get("service")
if service_filter:
jobs = [job for job in jobs if job.service == service_filter]
sort_by = data.get("sort_by", "created_time")
sort_order = data.get("sort_order", "desc")
valid_sort_fields = ["created_time", "started_time", "completed_time", "progress", "status", "service"]
if sort_by not in valid_sort_fields:
raise APIError(
APIErrorCode.INVALID_PARAMETERS,
f"Invalid sort_by: {sort_by}. Must be one of: {', '.join(valid_sort_fields)}",
details={"sort_by": sort_by, "valid_values": valid_sort_fields},
)
if sort_order not in ["asc", "desc"]:
raise APIError(
APIErrorCode.INVALID_PARAMETERS,
"Invalid sort_order: must be 'asc' or 'desc'",
details={"sort_order": sort_order, "valid_values": ["asc", "desc"]},
)
reverse = sort_order == "desc"
def get_sort_key(job):
"""Get the value to sort on, and give a None value a default."""
value = getattr(job, sort_by, None)
if value is None:
if sort_by in ["created_time", "started_time", "completed_time"]:
from datetime import datetime
return datetime.min if not reverse else datetime.max
elif sort_by == "progress":
return 0
elif sort_by in ["status", "service"]:
return ""
return value
jobs = sorted(jobs, key=get_sort_key, reverse=reverse)
include_full = str(data.get("full") or "").lower() == "true"
job_list = [client_job_view(job.to_dict(include_full_details=include_full), request) for job in jobs]
return web.json_response({"jobs": job_list})
except APIError:
raise
except (Exception, SystemExit) as e:
log.exception("Error listing download jobs")
debug_mode = request.app.get("debug_api", False) if request else False
return handle_api_exception(
e,
context={"operation": "list_download_jobs"},
debug_mode=debug_mode,
)
def client_job_view(job: Dict[str, Any], request: Optional[web.Request]) -> Dict[str, Any]:
"""Drop a job's traceback and stderr for API clients unless the server runs with ``--debug-api``.
Redaction masks only the job's own secrets, so these fields can still carry server-side values.
"""
if request is not None and request.app.get("debug_api", False):
return job
return {k: v for k, v in job.items() if k not in ("error_traceback", "worker_stderr")}
async def get_download_job_handler(job_id: str, request: Optional[web.Request] = None) -> web.Response:
"""Answer the request for one specific download job."""
from unshackle.core.api.download_manager import get_download_manager
try:
manager = get_download_manager()
job = manager.get_job(job_id)
if not job or not owns_job(job, request):
raise APIError(
APIErrorCode.JOB_NOT_FOUND,
"Job not found",
details={"job_id": job_id},
)
return web.json_response(client_job_view(job.to_dict(include_full_details=True), request))
except APIError:
raise
except (Exception, SystemExit) as e:
log.exception(f"Error getting download job {sanitize_log(job_id)}")
debug_mode = request.app.get("debug_api", False) if request else False
return handle_api_exception(
e,
context={"operation": "get_download_job", "job_id": job_id},
debug_mode=debug_mode,
)
async def download_job_events_handler(job_id: str, request: web.Request) -> web.StreamResponse:
"""Stream a download job's progress to the caller as Server-Sent Events."""
import json
from unshackle.core.api.download_manager import TERMINAL_STATUSES, get_download_manager
manager = get_download_manager()
job = manager.get_job(job_id)
if not job or not owns_job(job, request):
raise APIError(
APIErrorCode.JOB_NOT_FOUND,
"Job not found",
details={"job_id": job_id},
)
response = web.StreamResponse(
headers={
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
# Stops nginx from buffering the stream.
"X-Accel-Buffering": "no",
**CORS_HEADERS,
}
)
await response.prepare(request)
async def send(event: str, data: Dict[str, Any]) -> None:
payload = json.dumps(client_job_view(data, request), separators=(",", ":"), default=str)
await response.write(f"event: {event}\ndata: {payload}\n\n".encode())
queue: Optional[asyncio.Queue] = None
get_task: Optional[asyncio.Task] = None
try:
await send("snapshot", job.to_dict(include_full_details=True))
if job.status not in TERMINAL_STATUSES:
queue = manager.subscribe(job_id)
if queue is None or job.status in TERMINAL_STATUSES:
await send(job.status.value, job.to_dict(include_full_details=True))
return response
while True:
if get_task is None:
get_task = asyncio.create_task(queue.get())
done, _ = await asyncio.wait({get_task}, timeout=15)
if not done:
await response.write(b": keep-alive\n\n")
continue
item = get_task.result()
get_task = None
if item is None:
break
await send(item["event"], item["data"])
except (ConnectionResetError, asyncio.CancelledError):
log.debug(f"SSE client disconnected from job {sanitize_log(job_id)}")
finally:
if get_task is not None:
get_task.cancel()
if queue is not None:
manager.unsubscribe(job_id, queue)
return response
def configured_service_tags() -> List[str]:
"""The installed services this server names in its config, for the dashboard.
The global ``serve.services`` list, else the union of every ``serve.users`` key's ``services``, else every
installed service. An API key with no ``services`` list does not widen the union, and a malformed entry is skipped.
"""
tags = Services.get_tags()
allowed = allowed_services_for_key(None)
if allowed is None:
users = config.serve.get("users")
lists = [u.get("services") for u in users.values() if isinstance(u, dict)] if isinstance(users, dict) else []
allowed = [Services.get_tag(s) for lst in lists if isinstance(lst, list) for s in lst if isinstance(s, str)]
if not allowed:
return tags
named = set(allowed)
return [tag for tag in tags if tag in named]
async def dashboard_status_handler(request: web.Request) -> web.Response:
"""Server-wide stats for the developer dashboard."""
from unshackle.core.api.download_manager import get_download_manager
from unshackle.core.api.session_store import get_session_store
from unshackle.core.api.stats import stats
store = get_session_store()
jobs = Counter(job.status.value for job in get_download_manager().list_jobs())
return web.json_response(
{
**stats.to_dict(),
"services": len(configured_service_tags()),
"sessions": store.session_count,
"max_sessions": store.max_sessions,
"session_ttl": store.ttl,
"jobs": dict(jobs),
}
)
async def dashboard_sessions_handler(request: web.Request) -> web.Response:
"""Every live remote session, not owner-scoped."""
from unshackle.core.api.session_store import get_session_store
return web.json_response([entry.summary() for entry in get_session_store().list()])
async def dashboard_jobs_handler(request: web.Request) -> web.Response:
"""Every download job, not owner-scoped."""
from unshackle.core.api.download_manager import get_download_manager
return web.json_response([job.to_dict(include_full_details=True) for job in get_download_manager().list_jobs()])
def _poll_cursor(before: int, items: List[Dict[str, Any]]) -> int:
"""Cursor for the next poll, from the buffer's seq read *before* its ``since()`` call.
This poll or the next one returns a record added between the two reads; no poll skips it.
"""
return max(before, items[-1]["seq"]) if items else before
async def dashboard_logs_handler(request: web.Request) -> web.Response:
"""Recent log records; `since` (seq) and `level` filter the ring buffer."""
from unshackle.core.api.stats import ring
try:
since = int(request.query.get("since", 0))
except ValueError:
since = 0
before = ring.seq
records = ring.since(since, request.query.get("level"), request.query.get("logger"))
return web.json_response({"seq": _poll_cursor(before, records), "records": records})
async def dashboard_session_logs_handler(request: web.Request) -> web.Response:
"""One remote session's mirrored service log, for an operator watching a failing auth.
Reads through ``peek``: a dashboard poll must not refresh the session's idle timer, and
``SessionLogBuffer.since`` is a cursor read, so this never takes records from the client
draining the same buffer through ``/api/session/{id}/logs``.
"""
from unshackle.core.api.session_store import get_session_store
session_id = request.match_info["session_id"]
session = get_session_store().peek(session_id)
if session is None:
raise APIError(APIErrorCode.SESSION_NOT_FOUND, f"Remote session not found: {session_id}")
try:
since = int(request.query.get("since", 0))
except ValueError:
since = 0
buffer = session.log_buffer
before = buffer.last_seq if buffer else 0
records = buffer.since(since) if buffer else []
return web.json_response({"session_id": session_id, "records": records, "last_seq": _poll_cursor(before, records)})
async def dashboard_keys_handler(request: web.Request) -> web.Response:
"""Every configured API key: which key it is, what it may do, and what it has done.
Every API key that ``configured_key`` counts gets a row here, so each ``requests_by_key``
bucket except ``anonymous`` has a row to attribute it to. Without the dashboard key's own
row, a dashboard would render its own polling as traffic from an unknown caller.
"""
from unshackle.core.api.stats import key_id, key_rate_limit, key_tier, mask_key, stats
users = config.serve.get("users") or {}
api_secret = str(config.serve.get("api_secret") or "")
dashboard = dashboard_key() or ""
def row(secret_key: str, role: str) -> Dict[str, Any]:
entry = stats.keys.get(key_id(secret_key))
api_access = role != "dashboard" or secret_key in users
return {
"id": key_id(secret_key),
"role": role,
"label": mask_key(secret_key),
"services": allowed_services_for_key(secret_key) if api_access else [],
"server_cdm": user_grant(secret_key, "server_cdm") if api_access else False,
"server_cdm_max_height": serve_user_config(secret_key).get("server_cdm_max_height") if api_access else None,
"server_accounts": user_grant(secret_key, "server_accounts") if api_access else False,
"server_proxy": user_grant(secret_key, "server_proxy") is True if api_access else False,
"tier": key_tier(secret_key),
"rate_limit": key_rate_limit(secret_key),
"window_used": entry.window_used if entry else 0,
"requests": entry.requests if entry else 0,
"rejected": entry.rejected if entry else 0,
"bytes_out": entry.bytes_out if entry else 0,
"last_seen": entry.last_seen if entry else None,
}
def role_of(secret_key: str) -> str:
"""Which key this is. Identity, not capability: the grant fields carry that."""
if dashboard and secret_key == dashboard:
return "dashboard"
return "admin" if secret_key == api_secret else "user"
keys = [str(k) for k in users]
for extra in (api_secret, dashboard):
if extra and extra not in keys:
keys.append(extra)
return web.json_response([row(k, role_of(k)) for k in keys])
def user_grant(secret_key: str, name: str) -> Any:
"""A per-key grant as configured: False, True, or the list of service tags it covers.
An API key with no ``serve.users`` entry keeps implicit access, matching the resolvers.
"""
user_config = (config.serve.get("users") or {}).get(secret_key)
if not isinstance(user_config, dict):
return name != "server_proxy"
return user_config.get(name, False)
async def dashboard_services_handler(request: web.Request) -> web.Response:
"""Every service configured on this server, or every installed one with ``?all=1``, and its load state."""
from unshackle.core import services as services_module
from unshackle.core.api.download_manager import TERMINAL_STATUSES, get_download_manager
from unshackle.core.api.session_store import get_session_store
from unshackle.core.service_repo import head
sessions = Counter(entry.service_tag for entry in get_session_store().list())
jobs = Counter(job.service for job in get_download_manager().list_jobs() if job.status not in TERMINAL_STATUSES)
errors = {err.split(":", 1)[0]: err for err in services_module.LOAD_ERRORS if ":" in err}
staged_commits: Dict[Any, Optional[str]] = {}
tags = Services.get_tags() if request.query.get("all") == "1" else configured_service_tags()
rows = []
for tag in tags:
error = errors.get(tag)
staged = tag in services_module.PENDING
row: Dict[str, Any] = {
"tag": tag,
"state": "failed" if error else "staged" if staged else "loaded",
"error": error,
"commit": services_module.LOADED_COMMITS.get(tag),
"staged_commit": None,
"staged_since": services_module.PENDING_SINCE.get(tag),
"sessions": sessions.get(tag, 0),
"jobs": jobs.get(tag, 0),
"aliases": list(services_module.ALIASES.get(tag, ())),
"geofence": [],
"geoblock": [],
}
if staged:
# Only staged tags need a git call; the working tree already holds the new commit.
source = services_module.service_source_dir(tag)
if source is not None and source not in staged_commits:
staged_commits[source] = head(source) if (source / ".git").exists() else None
row["staged_commit"] = staged_commits.get(source)
module = services_module.MODULES.get(tag)
if module is not None:
row["geofence"] = list(getattr(module, "GEOFENCE", ()) or ())
row["geoblock"] = list(getattr(module, "GEOBLOCK", ()) or ())
rows.append(row)
return web.json_response(rows)
HEALTH_CACHE_TTL = 30.0
HEALTH_PROBE_KID = "00000000000000000000000000000000"
HEALTH_DETAIL_MAX = 256
HEALTH_PROBE_TIMEOUT = 15.0
HEALTH_SECRET_MIN = 4
SECRET_KEYS = ("password", "passwd", "pwd", "token", "api_key", "apikey", "secret", "auth", "ssl_key_password")
_health_cache: Dict[str, Any] = {}
_health_lock: Optional[asyncio.Lock] = None
async def dashboard_health_handler(request: web.Request) -> web.Response:
"""Preflight: can this instance finish a download?
A panel, not a liveness probe - the result is cached for 30 s and every probe is shallow,
so reading it never costs the operator a proxy session or a licence.
"""
global _health_lock
if _health_lock is None:
_health_lock = asyncio.Lock()
async with _health_lock:
now = time.time()
cached = _health_cache.get("payload")
if cached and now - _health_cache.get("at", 0.0) < HEALTH_CACHE_TTL:
return web.json_response(cached)
checks: List[Dict[str, Any]] = []
try:
await asyncio.wait_for(asyncio.to_thread(run_health_checks, checks), HEALTH_PROBE_TIMEOUT)
except asyncio.TimeoutError:
checks = list(checks)
checks.append(
{
"id": "probe",
"label": "health probe",
"status": "fail",
"detail": f"timed out after {HEALTH_PROBE_TIMEOUT:.0f}s with {len(checks)} checks done",
"ms": round(HEALTH_PROBE_TIMEOUT * 1000, 1),
}
)
statuses = {c["status"] for c in checks}
payload = {
"generated_at": now,
"status": "failing" if "fail" in statuses else "degraded" if "warn" in statuses else "ok",
"checks": checks,
}
_health_cache.update(at=now, payload=payload)
return web.json_response(payload)
def config_secrets(section: Any, nested: bool = False) -> List[str]:
"""Values in a vault or proxy config that must never reach a health detail.
Name-based at the top level, and every scalar below it: a nested mapping is driver
arguments unshackle cannot name in advance (an API vault's ``headers``, a MySQL vault's
``ssl``, a proxy provider's own block), so it over-collects there on purpose. Short values
are skipped, because masking them would garble the detail without hiding anything.
"""
if isinstance(section, dict):
items: Iterable[tuple[Any, Any]] = section.items()
elif isinstance(section, (list, tuple)):
items = [(None, value) for value in section]
else:
return [str(section)] if nested and section is not None else []
found: List[str] = []
for key, value in items:
if isinstance(value, (dict, list, tuple)):
found += config_secrets(value, nested=True)
elif value is not None and (nested or (isinstance(key, str) and key.lower() in SECRET_KEYS)):
found.append(str(value))
return [secret for secret in found if len(secret) >= HEALTH_SECRET_MIN]
def health_check(check_id: str, label: str, probe: Any, secrets: Iterable[str] = ()) -> Dict[str, Any]:
"""Time one health probe and turn any exception into a failed check.
*secrets* are masked out of the detail: a driver's error string can echo the DSN or URL
it was given. The detail is then capped, because redaction only hides what it recognises
and a driver can raise with a whole response body inside it. Cap after masking, never
before: a cut through a secret would leave the tail of it unreplaced.
"""
started = time.perf_counter()
try:
status, detail = probe()
except Exception as e:
status, detail = "fail", f"{type(e).__name__}: {e}"
masked = redact_text(detail, secrets) or ""
if len(masked) > HEALTH_DETAIL_MAX:
masked = masked[:HEALTH_DETAIL_MAX] + "…"
return {
"id": check_id,
"label": label,
"status": status,
"detail": masked,
"ms": round((time.perf_counter() - started) * 1000, 1),
}
def run_health_checks(checks: Optional[List[Dict[str, Any]]] = None) -> List[Dict[str, Any]]:
"""Probe binaries, CDM devices, key vaults and proxy providers. Blocking; run in a thread.
Appends to *checks* as each probe finishes rather than returning only at the end, so a
caller that gives up on the deadline still has the results that did complete.
"""
from unshackle.commands.env import get_dependencies
from unshackle.core.proxies.resolve import initialize_proxy_providers
if checks is None:
checks = []
for dep in (d for d in get_dependencies() if d["cat"] != "Player"):
binary = dep["binary"]
def probe(binary: Any = binary, dep: Any = dep) -> tuple[str, str]:
if not binary:
return ("fail" if dep["required"] else "warn", "not found on PATH")
version = binary_version(binary) or "unknown version"
return "ok", f"{version} · {binary}"
checks.append(health_check(dep["name"].lower(), dep["name"], probe))
def cdm_probe() -> tuple[str, str]:
wvds = list(config.directories.wvds.glob("*.wvd"))
prds = list(config.directories.prds.glob("*.prd"))
if not wvds and not prds:
return ("fail" if config.cdm else "warn", "no .wvd or .prd device files found")
return "ok", f"{len(wvds)} Widevine, {len(prds)} PlayReady"
checks.append(health_check("cdm", "CDM devices", cdm_probe))
probe_service = next(iter(Services.get_tags()), "health")
for vault_config in config.key_vaults or []:
name = vault_config.get("name") or vault_config.get("type", "vault")
def vault_probe(vault_config: Any = vault_config) -> tuple[str, str]:
from unshackle.core.vaults import Vaults
cfg = dict(vault_config)
vaults = Vaults(probe_service)
vaults.load_critical(cfg.pop("type"), **cfg)
vault = vaults.vaults[0]
try:
vault.get_key(HEALTH_PROBE_KID, probe_service)
except Exception as e:
if type(e).__name__ not in ("KeyIdInvalid", "ServiceTagInvalid"):
raise
return "ok", str(vault)
checks.append(health_check(f"vault:{name}", f"vault {name}", vault_probe, config_secrets(vault_config)))
def bad_keys_probe() -> tuple[str, str]:
if not config.key_vaults:
return "ok", "no vaults configured"
local = [v.get("name") or v["type"] for v in config.key_vaults if v.get("type") == "SQLite"]
if local:
return "ok", f"flags stored in {', '.join(local)}"
return "warn", "no SQLite vault, so a content key a client proves wrong cannot be flagged"
checks.append(health_check("bad_keys", "bad content key flags", bad_keys_probe))
def proxy_probe() -> tuple[str, str]:
providers = initialize_proxy_providers(raise_errors=True, quiet=True)
if not providers:
return "ok", "none configured"
return "ok", ", ".join(type(p).__name__ for p in providers)
if config.proxy_providers:
checks.append(health_check("proxies", "proxy providers", proxy_probe, config_secrets(config.proxy_providers)))
return checks
async def dashboard_events_handler(request: web.Request) -> web.StreamResponse:
"""Server-wide SSE event stream: `log`, `session` and `job` events, plus `stats` every 5 s (also the keep-alive).
Every frame carries `id: <seq>`; a reconnecting EventSource sends Last-Event-ID, and the
server replays the missed events from the bus history. With `?since=<seq>` the same missed
events come back as one JSON burst instead of an event stream, for clients that poll.
"""
from unshackle.core.api.events import bus
if "since" in request.query:
try:
since = int(request.query["since"])
except ValueError:
since = 0
status = await dashboard_status_handler(request)
before = bus.seq
events = bus.since(since)
return web.json_response(
{"seq": _poll_cursor(before, events), "stats": json.loads(status.text or "{}"), "events": events}
)
response = web.StreamResponse(
headers={
"Content-Type": "text/event-stream",
# no-transform stops a CDN from compressing or buffering the event stream.
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
**CORS_HEADERS,
}
)
await response.prepare(request)
async def send(event: str, data: Dict[str, Any], event_id: Optional[int] = None) -> None:
payload = json.dumps(data, separators=(",", ":"), default=str)
head = f"id: {event_id}\n" if event_id is not None else ""
await response.write(f"{head}event: {event}\ndata: {payload}\n\n".encode())
async def send_stats() -> None:
status = await dashboard_status_handler(request)
await send("stats", json.loads(status.text or "{}"))
queue = bus.subscribe()
get_task: Optional[asyncio.Task] = None
loop = asyncio.get_running_loop()
try:
await send_stats()
next_stats = loop.time() + 5
try:
last_id = int(request.headers.get("Last-Event-ID", 0))
except ValueError:
last_id = 0
for item in bus.since(last_id) if last_id else []:
await send(item["event"], item["data"], item["seq"])
while True:
if get_task is None:
get_task = asyncio.create_task(queue.get())
done, _ = await asyncio.wait({get_task}, timeout=max(0.0, next_stats - loop.time()))
if not done:
await send_stats()
next_stats = loop.time() + 5
continue
item = get_task.result()
get_task = None
await send(item["event"], item["data"], item["seq"])
except (ConnectionResetError, asyncio.CancelledError):
log.debug("SSE dashboard client disconnected")
finally:
if get_task is not None:
get_task.cancel()
bus.unsubscribe(queue)
return response
async def cancel_download_job_handler(job_id: str, request: Optional[web.Request] = None) -> web.Response:
"""Answer the cancel/remove download job request."""
from unshackle.core.api.download_manager import TERMINAL_STATUSES, get_download_manager
try:
manager = get_download_manager()
job = manager.get_job(job_id)
if not job or not owns_job(job, request):
raise APIError(
APIErrorCode.JOB_NOT_FOUND,
"Job not found",
details={"job_id": job_id},
)
# Terminal jobs can't be cancelled; DELETE removes them from the manager instead.
if job.status in TERMINAL_STATUSES:
manager.remove_job(job_id)
return web.Response(status=204)
success = manager.cancel_job(job_id)
if success:
return web.json_response({"status": "success", "message": "Job cancelled"})
else:
raise APIError(
APIErrorCode.INVALID_PARAMETERS,
"Job cannot be cancelled (already completed or failed)",
details={"job_id": job_id},
)
except APIError:
raise
except (Exception, SystemExit) as e:
log.exception(f"Error cancelling download job {sanitize_log(job_id)}")
debug_mode = request.app.get("debug_api", False) if request else False
return handle_api_exception(
e,
context={"operation": "cancel_download_job", "job_id": job_id},
debug_mode=debug_mode,
)
async def clear_finished_download_jobs_handler(request: Optional[web.Request] = None) -> web.Response:
"""Answer the clear finished download jobs request."""
from unshackle.core.api.download_manager import get_download_manager
try:
manager = get_download_manager()
removed = manager.clear_finished_jobs(owner_key=caller_key(request))
return web.json_response({"removed": removed})
except APIError:
raise
except (Exception, SystemExit) as e:
log.exception("Error clearing finished download jobs")
debug_mode = request.app.get("debug_api", False) if request else False
return handle_api_exception(
e,
context={"operation": "clear_finished_download_jobs"},
debug_mode=debug_mode,
)
async def retry_download_job_handler(job_id: str, request: Optional[web.Request] = None) -> web.Response:
"""Answer the retry download job request: enqueue a new job with the original's parameters."""
from unshackle.core.api.download_manager import TERMINAL_STATUSES, get_download_manager
try:
manager = get_download_manager()
job = manager.get_job(job_id)
if not job or not owns_job(job, request):
raise APIError(
APIErrorCode.JOB_NOT_FOUND,
"Job not found",
details={"job_id": job_id},
)
if job.status not in TERMINAL_STATUSES:
raise APIError(
APIErrorCode.CONFLICT,
"Only completed, failed, or cancelled jobs can be retried",
details={"job_id": job_id, "status": job.status.value},
)
# Re-apply creation-time gates so retry cannot bypass the caller's service allowlist
# or currently-disabled cdm_overrides / allow_job_credentials config.
if not validate_service(job.service, request):
raise APIError(
APIErrorCode.INVALID_SERVICE,
f"Invalid or unavailable service: {job.service}",
details={"service": job.service},
)
await asyncio.to_thread(enforce_download_gates, {**job.parameters, "service": job.service}, request)
await manager.start_workers()
# Reuse the raw in-memory parameters; redaction only ever applies to serialized copies.
new_job = manager.create_job(
job.service,
job.title_id,
owner_key=caller_key(request),
**{**job.parameters, "server_proxy": server_proxy_allowed(request)},
)
return web.json_response(
{
"job_id": new_job.job_id,
"status": new_job.status.value,
"created_time": new_job.created_time.isoformat(),
},
status=202,
)
except APIError:
raise
except (Exception, SystemExit) as e:
log.exception(f"Error retrying download job {sanitize_log(job_id)}")
debug_mode = request.app.get("debug_api", False) if request else False
return handle_api_exception(
e,
context={"operation": "retry_download_job", "job_id": job_id},
debug_mode=debug_mode,
)
async def prioritize_download_job_handler(job_id: str, request: Optional[web.Request] = None) -> web.Response:
"""Answer the prioritise download job request: move a queued job to the front of the queue."""
from unshackle.core.api.download_manager import JobStatus, get_download_manager
try:
manager = get_download_manager()
job = manager.get_job(job_id)
if not job or not owns_job(job, request):
raise APIError(
APIErrorCode.JOB_NOT_FOUND,
"Job not found",
details={"job_id": job_id},
)
if job.status != JobStatus.QUEUED:
raise APIError(
APIErrorCode.CONFLICT,
"Only queued jobs can be prioritized",
details={"job_id": job_id, "status": job.status.value},
)
manager.prioritize_job(job_id)
return web.json_response({"job_id": job_id, "position": "front"})
except APIError:
raise
except (Exception, SystemExit) as e:
log.exception(f"Error prioritizing download job {sanitize_log(job_id)}")
debug_mode = request.app.get("debug_api", False) if request else False
return handle_api_exception(
e,
context={"operation": "prioritize_download_job", "job_id": job_id},
debug_mode=debug_mode,
)
async def download_job_input_handler(
data: Dict[str, Any], job_id: str, request: Optional[web.Request] = None
) -> web.Response:
"""Answer the prompt a running job waits on (its ``input_prompt``), to unblock the service."""
from unshackle.core.api.download_manager import get_download_manager
manager = get_download_manager()
job = manager.get_job(job_id)
if not job or not owns_job(job, request):
raise APIError(APIErrorCode.JOB_NOT_FOUND, "Job not found", details={"job_id": job_id})
response_text = data.get("response")
if response_text is None:
raise APIError(APIErrorCode.INVALID_INPUT, "Missing required field: response")
if not manager.submit_input(job, str(response_text)):
raise APIError(APIErrorCode.CONFLICT, "No prompt pending for this job", details={"job_id": job_id})
return web.json_response({"status": "accepted"})
CONFIG_SECRET_KEY_RE = re.compile(
r"secret|passw|pwd|token|api[_-]?key|credential|auth|cookie|bearer|private", re.IGNORECASE
)
def redact_config(value: Any) -> Any:
"""Recursively mask secret-looking config keys and URL userinfo. Stringify paths."""
if isinstance(value, dict):
return {
str(k): (REDACTED if v and CONFIG_SECRET_KEY_RE.search(str(k)) else redact_config(v))
for k, v in value.items()
}
if isinstance(value, (list, tuple)):
return [redact_config(v) for v in value]
if isinstance(value, Path):
return str(value)
if isinstance(value, str) and "@" in value:
return URL_USERINFO_RE.sub(f"{REDACTED}@", value)
return value
async def profiles_handler(request: Optional[web.Request] = None) -> web.Response:
"""Answer the request for the credential profiles."""
try:
allowed = get_allowed_services(request)
profiles: Dict[str, List[str]] = {}
for service, creds in (config.credentials or {}).items():
# a plain (non-dict) credential is unnamed; that service is omitted entirely
if not isinstance(creds, dict):
continue
# get_tag returns its input unchanged when no service matches; str() covers non-str YAML keys
tag = Services.get_tag(str(service))
if allowed is not None and tag not in allowed:
continue
profiles[tag] = sorted(str(name) for name in creds)
return web.json_response({"profiles": profiles})
except APIError:
raise
except (Exception, SystemExit) as e:
log.exception("Error listing profiles")
debug_mode = request.app.get("debug_api", False) if request else False
return handle_api_exception(e, context={"operation": "profiles"}, debug_mode=debug_mode)
async def server_config_handler(request: Optional[web.Request] = None) -> web.Response:
"""Answer the read-only effective server config request (secrets redacted)."""
from unshackle.core.api.download_manager import get_download_manager
try:
manager = get_download_manager()
serve_cfg = config.serve or {}
allowed = get_allowed_services(request)
service_tags = Services.get_tags()
if allowed is not None:
service_tags = [t for t in service_tags if t in allowed]
payload = {
"dl": redact_config(config.dl),
"serve": {
"max_concurrent_downloads": manager.max_concurrent_downloads,
"job_retention_hours": manager.job_retention_hours,
"history_limit": int(serve_cfg.get("history_limit", 100)),
"services": serve_cfg.get("services") or None,
"remote_only": bool(serve_cfg.get("remote_only", False)),
"cdm_overrides": redact_config(serve_cfg.get("cdm_overrides")),
"allow_job_credentials": bool(serve_cfg.get("allow_job_credentials", False)),
"server_accounts": {
tag: server_account_regions(tag)
for tag in (serve_cfg.get("server_accounts") or {})
if server_accounts_allowed(request, tag)
}
or None,
},
"services": service_tags,
}
# Server-local absolute paths are useless to a remote client and name the host layout.
if is_admin(request):
payload["directories"] = {
"downloads": str(config.directories.downloads),
"temp": str(config.directories.temp),
"cache": str(config.directories.cache),
}
return web.json_response({"config": payload})
except APIError:
raise
except (Exception, SystemExit) as e:
log.exception("Error building server config")
debug_mode = request.app.get("debug_api", False) if request else False
return handle_api_exception(e, context={"operation": "server_config"}, debug_mode=debug_mode)
async def download_history_handler(data: Dict[str, Any], request: Optional[web.Request] = None) -> web.Response:
"""Answer the persisted download history request."""
from unshackle.core.api.download_manager import owner_id, read_job_history
try:
limit = 100
limit_raw = data.get("limit")
if limit_raw is not None:
try:
limit = int(limit_raw)
except (TypeError, ValueError):
raise APIError(
APIErrorCode.INVALID_PARAMETERS, "limit must be an integer", details={"limit": limit_raw}
)
if limit < 1:
raise APIError(APIErrorCode.INVALID_PARAMETERS, "limit must be >= 1", details={"limit": limit_raw})
allowed = get_allowed_services(request)
if allowed is None:
history = read_job_history(limit=limit, service=data.get("service"), owner=owner_id(caller_key(request)))
else:
# Read unbounded, drop entries outside the caller's allowlist, then apply limit.
allowed_upper = {a.upper() for a in allowed}
entries = read_job_history(limit=0, service=data.get("service"), owner=owner_id(caller_key(request)))
history = [e for e in entries if str(e.get("service") or "").upper() in allowed_upper][:limit]
return web.json_response({"history": history, "count": len(history)})
except APIError:
raise
except (Exception, SystemExit) as e:
log.exception("Error reading download history")
debug_mode = request.app.get("debug_api", False) if request else False
return handle_api_exception(e, context={"operation": "download_history"}, debug_mode=debug_mode)
async def delete_history_handler(job_id: str, request: Optional[web.Request] = None) -> web.Response:
"""Delete one persisted history entry by job_id."""
from unshackle.core.api.download_manager import delete_job_history, owner_id
try:
allowed = get_allowed_services(request)
allowed_upper = {a.upper() for a in allowed} if allowed is not None else None
if not delete_job_history(job_id, allowed=allowed_upper, owner=owner_id(caller_key(request))):
raise APIError(APIErrorCode.NOT_FOUND, "History entry not found", details={"job_id": job_id})
return web.Response(status=204)
except APIError:
raise
except (Exception, SystemExit) as e:
log.exception("Error deleting download history")
debug_mode = request.app.get("debug_api", False) if request else False
return handle_api_exception(e, context={"operation": "delete_history"}, debug_mode=debug_mode)
def require_no_active_downloads(operation: str) -> None:
"""Raise 409 CONFLICT if any job is currently downloading."""
from unshackle.core.api.download_manager import JobStatus, get_download_manager
active = [j.job_id for j in get_download_manager().list_jobs() if j.status == JobStatus.DOWNLOADING]
if active:
raise APIError(
APIErrorCode.CONFLICT,
f"Cannot {operation} while downloads are active",
details={"active_jobs": active},
)
async def clear_cache_handler(request: Optional[web.Request] = None) -> web.Response:
"""Answer the clear cache directory request."""
from unshackle.commands.env import clear_directory
require_admin(request)
try:
require_no_active_downloads("clear cache")
_, freed_bytes = await asyncio.to_thread(clear_directory, config.directories.cache)
return web.json_response({"cleared": True, "freed_bytes": freed_bytes})
except APIError:
raise
except (Exception, SystemExit) as e:
log.exception("Error clearing cache")
debug_mode = request.app.get("debug_api", False) if request else False
return handle_api_exception(e, context={"operation": "clear_cache"}, debug_mode=debug_mode)
async def clear_temp_handler(request: Optional[web.Request] = None) -> web.Response:
"""Answer the clear temp directory request."""
from unshackle.commands.env import clear_directory
require_admin(request)
try:
require_no_active_downloads("clear temp")
_, freed_bytes = await asyncio.to_thread(clear_directory, config.directories.temp)
return web.json_response({"cleared": True, "freed_bytes": freed_bytes})
except APIError:
raise
except (Exception, SystemExit) as e:
log.exception("Error clearing temp")
debug_mode = request.app.get("debug_api", False) if request else False
return handle_api_exception(e, context={"operation": "clear_temp"}, debug_mode=debug_mode)
async def refresh_services_handler(request: Optional[web.Request] = None) -> web.Response:
"""Refresh the service repos configured in directories.services and reload the changed services."""
from unshackle.core.api.download_manager import busy_services
from unshackle.core.api.events import publish_refresh_events
from unshackle.core.services import refresh_and_reload
require_admin(request)
try:
repos = await asyncio.to_thread(refresh_and_reload, busy_services())
publish_refresh_events(repos)
return web.json_response({"refreshed": all(r["updated"] for r in repos), "repos": repos})
except APIError:
raise
except (Exception, SystemExit) as e:
log.exception("Error refreshing service repos")
debug_mode = request.app.get("debug_api", False) if request else False
return handle_api_exception(e, context={"operation": "refresh_services"}, debug_mode=debug_mode)
VERSION_RE = re.compile(r"\d+\.\d+(?:\.\d+)*")
def binary_version(path: Any) -> Optional[str]:
"""Best-effort version probe of a binary. Returns None when nothing is parseable."""
import subprocess
for flag in ("--version", "-version"):
try:
proc = subprocess.run(
[str(path), flag], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=5
)
except (OSError, subprocess.SubprocessError, ValueError):
return None
match = VERSION_RE.search(proc.stdout or "") or VERSION_RE.search(proc.stderr or "")
if match:
return match.group(0)
return None
async def env_check_handler(request: Optional[web.Request] = None) -> web.Response:
"""Answer the environment dependency check request."""
from unshackle.commands.env import get_dependencies
def run_checks() -> List[Dict[str, Any]]:
checks = []
for dep in get_dependencies():
binary = dep["binary"]
checks.append(
{
"name": dep["name"],
"installed": binary is not None,
"version": binary_version(binary) if binary else None,
"required": dep["required"],
}
)
return checks
try:
checks = await asyncio.to_thread(run_checks)
return web.json_response({"checks": checks})
except APIError:
raise
except (Exception, SystemExit) as e:
log.exception("Error running env check")
debug_mode = request.app.get("debug_api", False) if request else False
return handle_api_exception(e, context={"operation": "env_check"}, debug_mode=debug_mode)
SESSION_TRANSPORT_KEYS = {
"service",
"title_id",
"profile",
"service_params",
"season",
"episode",
"part",
"wanted",
"proxy",
"no_proxy",
"credentials",
"cookies",
"cache",
"client_region",
"proxy_region",
"server_cdm",
"cdm_type",
"cdm_security_level",
"cdm_relay",
"cdm_system_id",
"cdm_device_type",
"service_config",
"range_",
"vcodec",
"quality",
"best_available",
"dl_params",
}
def forwarded_dl_params(data: Dict[str, Any]) -> Dict[str, Any]:
"""Read the dl track selection that a remote-dl client sends under ``dl_params``.
Services read these values from ``ctx.parent.params`` to pick the manifests they
fetch. They travel in their own object so that they never collide with a service option that
has the same name. An absent or malformed value gets the dl default, so an old client that
sends no ``dl_params`` gets the same result as a ``dl`` run with no selection flags.
``acodec`` holds codec names or aliases, which become ``Audio.Codec`` members the same way
``dl -a`` converts them; an unknown name is dropped.
"""
raw = data.get("dl_params")
if not isinstance(raw, dict):
raw = {}
def str_list(key: str) -> Optional[List[str]]:
value = raw.get(key)
if isinstance(value, list) and all(isinstance(v, str) for v in value):
return list(value)
if value is not None:
log.warning(f"Ignoring dl_params.{key}: it must be an array of strings")
return None
params: Dict[str, Any] = {}
for key in ("lang", "v_lang", "a_lang"):
langs = str_list(key)
params[key] = langs if langs is not None else list(cast(List[str], DEFAULT_DOWNLOAD_PARAMS[key]))
# the dl CLI default is [], and some services test membership in it
params["acodec"] = []
for name in str_list("acodec") or []:
try:
params["acodec"].extend(AUDIO_CODEC_LIST.convert(name))
except click.BadParameter:
log.warning(f"Ignoring unknown dl_params.acodec value: {sanitize_log(name)}")
params["forced_subs"] = raw.get("forced_subs") is True
return params
# A client's service config carries a device identity (an ESN, a few keys), never a bulk payload.
MAX_CLIENT_CONFIG_BYTES = 16384
def client_config_overlay(
service_config: Dict[str, Any], allowed: Iterable[str], supplied: Any
) -> tuple[Dict[str, Any], bool]:
"""The service config for a remote session that the client's own device licenses, and whether the client sent an identity.
Only the ``allowed`` keys come from the client, and a mapping merges its entries over the server's.
When the client sends any scalar value (an identity such as an ESN), the server's scalar values for
the allowed keys are dropped, so the two identities never mix. With none, the server's stay.
"""
allowed = set(allowed)
if supplied is None:
supplied = {}
if not isinstance(supplied, dict) or len(json.dumps(supplied, default=str)) > MAX_CLIENT_CONFIG_BYTES:
raise APIError(
APIErrorCode.INVALID_INPUT, f"service_config must be an object under {MAX_CLIENT_CONFIG_BYTES} bytes"
)
def scalar(value: Any) -> bool:
return isinstance(value, str) or is_whole_number(value)
for key, value in supplied.items():
if key in allowed and not scalar(value) and not (isinstance(value, dict) and all(map(scalar, value.values()))):
raise APIError(
APIErrorCode.INVALID_INPUT,
f"service_config.{key} must be a string, a whole number, or an object of them",
)
identity = any(key in allowed and scalar(value) for key, value in supplied.items())
merged = {k: v for k, v in service_config.items() if not identity or k not in allowed or isinstance(v, dict)}
for key, value in supplied.items():
if key not in allowed:
log.warning(f"Ignoring service_config.{sanitize_log(str(key))}: the service does not take it from a client")
elif isinstance(value, dict):
existing = merged.get(key)
base = existing if isinstance(existing, dict) else {}
# JSON makes every client key a string, so a server key it spells the same way gives way
merged[key] = {**{k: v for k, v in base.items() if str(k) not in value}, **value}
else:
merged[key] = value
return merged, identity
def client_device_cdm(
data: Dict[str, Any], input_bridge: Optional[InputBridge], server_device: Optional[Callable[[], Any]] = None
) -> Any:
"""The service's view of the client's device: a relaying stand-in when the client can answer CDM calls."""
from unshackle.core.cdm.client_relay import ClientDeviceCdm
level = data.get("cdm_security_level")
level = level if is_whole_number(level) else None
if not (data.get("cdm_relay") is True and input_bridge is not None):
return cdm_type_stub(str(data.get("cdm_type")), level)
from pywidevine import DeviceTypes
system_id = data.get("cdm_system_id")
device_type = data.get("cdm_device_type")
if device_type is not None and device_type not in DeviceTypes.__members__:
raise APIError(
APIErrorCode.INVALID_INPUT, f"cdm_device_type must be one of {', '.join(DeviceTypes.__members__)}"
)
return ClientDeviceCdm(
input_bridge,
server_device=server_device,
is_playready=data.get("cdm_type") == "playready",
security_level=level,
system_id=system_id if is_whole_number(system_id) else None,
device_type=DeviceTypes[device_type] if device_type else None,
)
def create_service_instance(
normalized_service: str,
title_id: str,
data: Dict[str, Any],
proxy_param: Optional[str],
proxy_providers: list,
profile: Optional[str],
server_account: bool = False,
server_cdm: bool = False,
client_device: bool = False,
input_bridge: Optional[InputBridge] = None,
) -> Any:
"""Make a service instance and resolve its credentials and cookies.
With ``client_device`` the service sees a stand-in for the client's device, which relays CDM calls
through ``input_bridge`` when the client can answer them, and a config holding the client's own identity.
With ``server_account`` the service takes the server's own credential and cookie file for
``profile`` and drops anything the client sent. Otherwise only client-sent data counts: a
client that sends nothing authenticates with nothing, because the server never lends its
own accounts.
"""
from unshackle.commands.dl import dl
from unshackle.core.credential import Credential
from unshackle.core.tracks import Video
server_config = service_config = load_service_yaml(normalized_service)
service_module = Services.load(normalized_service)
cdm: Any
client_identity = False
if client_device:
client_config = getattr(service_module, "CLIENT_CONFIG", ())
if client_config:
service_config, client_identity = client_config_overlay(
server_config, client_config, data.get("service_config")
)
def lend_server_device() -> Any:
"""The server's device, with the server's own config back in the dict the service holds as ``self.config``."""
if service_config is not server_config:
service_config.clear()
service_config.update(server_config)
return load_full_cdm(normalized_service, profile, None)
cdm = client_device_cdm(data, input_bridge, lend_server_device)
else:
cdm = load_full_cdm(normalized_service, profile, None if server_cdm else data.get("cdm_type"))
# Reconstruct enum track-selection params from client data so service code that reads
# ctx.parent.params (Service.__init__ proxy/range/vcodec/best_available block) sees enums.
range_names = data.get("range_")
range_values: Optional[list] = None
if range_names:
range_values = []
for name in range_names:
try:
range_values.append(Video.Range[name])
except KeyError:
pass
range_values = range_values or None
vcodec_values = resolve_vcodec(data.get("vcodec"))
extra_params = {
"range_": range_values,
"vcodec": vcodec_values,
"quality": data.get("quality"),
"best_available": data.get("best_available", False),
**forwarded_dl_params(data),
}
if server_account:
cookies = server_account_cookies(normalized_service, profile)
credential = dl.get_credentials(normalized_service, profile)
else:
credential = None
cred_data = data.get("credentials")
if cred_data and isinstance(cred_data, dict):
credential = Credential(
username=cred_data["username"],
password=cred_data["password"],
extra=cred_data.get("extra"),
)
cookies = load_client_cookies(data.get("cookies"))
extra_params["cookies_supplied"] = cookies is not None
# A service with CLIENT_CONFIG reads these: with client_identity it uses only what the client sent,
# and without it the server's identity, whose keys then come only from a vault.
extra_params["client_device"] = client_device
extra_params["client_identity"] = client_identity
# Services key their token caches on this. Only sessions get it: they run on a cache
# directory of their own, where the list and search handlers share the server's cache.
extra_params["profile"] = profile
parent_ctx = build_parent_ctx(
profile,
cdm,
proxy_param,
data.get("no_proxy", False),
proxy_providers,
service_config,
extra_params=extra_params,
)
service_instance = instantiate_service(parent_ctx, service_module, title_id, data, SESSION_TRANSPORT_KEYS)
return service_instance, cookies, credential
async def session_create_handler(data: Dict[str, Any], request: Optional[web.Request] = None) -> web.Response:
"""Answer the remote session creation: authenticate + get titles + get tracks + get chapters.
This is the main entry point for remote-dl clients. It creates a persistent
remote session on the server with the authenticated service instance, fetches all
titles and tracks, and returns everything the client needs for track selection.
"""
from unshackle.core.api.session_store import get_session_store
service_tag = data.get("service")
title_id = data.get("title_id")
profile = client_profile(data)
if not service_tag:
raise APIError(APIErrorCode.INVALID_INPUT, "Missing required parameter: service")
if not title_id:
raise APIError(APIErrorCode.INVALID_INPUT, "Missing required parameter: title_id")
normalized_service = validate_service(service_tag, request)
if not normalized_service:
raise APIError(
APIErrorCode.INVALID_SERVICE,
f"Invalid or unavailable service: {service_tag}",
details={"service": service_tag},
)
try:
proxy_param, proxy_providers = await asyncio.to_thread(resolve_handler_proxy, data, normalized_service, request)
import uuid as uuid_mod
session_id = str(uuid_mod.uuid4())
api_key = request.headers.get("X-Secret-Key", "anonymous") if request else "anonymous"
session_cache_dir = f"_sessions/{api_key_namespace(request)}/{session_id}/{normalized_service}"
session_cache_tag: Optional[str] = session_cache_dir
server_account = server_account_for(request, normalized_service)
if server_account:
region = data.get("proxy_region") or data.get("client_region")
if region is not None and not (isinstance(region, str) and re.fullmatch(r"[A-Za-z]{2}", region)):
raise APIError(APIErrorCode.INVALID_INPUT, "proxy_region must be a two-letter country code.")
if region is None and data.get("no_proxy"):
region = await asyncio.to_thread(server_region)
profile = next_server_profile(normalized_service, region)
log.info(f"Using server account '{sanitize_log(profile or 'default')}' for {normalized_service}")
use_server_cdm, max_height = choose_session_cdm(request, normalized_service, data)
# an old client without cdm_relay keeps the server's device outside a capped session
client_device = (
not use_server_cdm
and bool(data.get("cdm_type"))
and (max_height is not None or data.get("cdm_relay") is True)
)
bridge = InputBridge()
log_buffer = None if server_account else SessionLogBuffer()
service_class_name = getattr(Services.load(normalized_service), "__name__", normalized_service)
def build_service() -> Any:
with capture_service_logs(service_class_name, log_buffer):
return create_service_instance(
normalized_service,
title_id,
data,
proxy_param,
proxy_providers,
profile,
server_account=server_account,
server_cdm=use_server_cdm,
client_device=client_device,
input_bridge=bridge,
)
service_instance, cookies, credential = await asyncio.to_thread(build_service)
if log_buffer:
service_instance.log = SessionLogMirror(service_instance.log, log_buffer)
if server_account:
session_cache_tag = None
service_instance.cache = Cacher(f"_accounts/{normalized_service}/{profile or 'default'}")
else:
service_instance.cache = Cacher(session_cache_dir)
cache_data = data.get("cache", {})
if cache_data and session_cache_tag:
write_client_cache(cache_data, session_cache_tag)
service_instance._input_bridge = bridge
store = get_session_store()
session = await store.create(
normalized_service,
service_instance,
session_id=session_id,
owner_key=api_key,
creator_ip=request.remote if request else None,
server_account=(profile or "default") if server_account else None,
)
session.cache_tag = session_cache_tag
session.server_cdm = use_server_cdm
session.server_cdm_max_height = max_height
session.server_vault = client_device and server_vault_allowed(request, normalized_service)
# the service asked for the server's device in __init__, so the session never licenses live
stand_in = getattr(getattr(getattr(service_instance, "ctx", None), "obj", None), "cdm", None)
session.server_device = getattr(stand_in, "lent", False) is True
session.client_auth = not server_account and (
cookies is not None or credential is not None or bool(cache_data and session_cache_tag)
)
# Echoed to every dashboard viewer, so cap what an arbitrary client can push into it.
if isinstance(data.get("client"), dict) and len(json.dumps(data["client"], default=str)) <= 4096:
session.client = data["client"]
session.input_bridge = bridge
session.log_buffer = log_buffer
session.auth_status = AuthStatus.AUTHENTICATING
async def run_auth() -> None:
try:
await asyncio.to_thread(service_instance.authenticate, cookies, credential)
if bridge.answered and not server_account:
session.client_auth = True
session.auth_status = AuthStatus.AUTHENTICATED
bridge.status = AuthStatus.AUTHENTICATED
except (Exception, SystemExit) as e:
log.exception("Auth failed for session %s", session_id)
session.auth_status = AuthStatus.FAILED
session.auth_error = redact_secrets(str(e))
bridge.status = AuthStatus.FAILED
finally:
if store.peek(session_id) is not session:
SessionStore.cleanup_cache_dir(session_cache_tag)
asyncio.create_task(run_auth())
return web.json_response(
{
"session_id": session.session_id,
"service": normalized_service,
"status": "authenticating",
"server_account": server_account,
"server_cdm": use_server_cdm,
"server_cdm_max_height": max_height,
"server_vault": session.server_vault,
"server_device": session.server_device,
}
)
except APIError:
raise
except (Exception, SystemExit) as e:
log.exception("Error creating session")
debug_mode = request.app.get("debug_api", False) if request else False
return handle_api_exception(
e,
context={"operation": "session_create", "service": service_tag, "title_id": title_id},
debug_mode=debug_mode,
)
async def session_titles_handler(session_id: str, request: Optional[web.Request] = None) -> web.Response:
"""Get titles for the authenticated remote session.
Called after `session/create`. This is separate from auth so that
interactive auth flows (OTP, captcha) can complete before the server
fetches titles.
"""
session = await get_validated_session(session_id, request)
require_authenticated(session)
try:
service_instance = session.service_instance
async with session.lock:
try:
titles = await asyncio.to_thread(service_instance.get_titles)
finally:
session.touch()
session.titles = titles
titles_list = listed_titles(titles)
serialized_titles = []
for t in titles_list:
tid = str(t.id) if hasattr(t, "id") else str(id(t))
session.title_map[tid] = t
serialized_titles.append(stamp_service_flags(serialize_title(t), service_instance))
SessionStore.publish_update(session)
return web.json_response(
{
"session_id": session_id,
"titles": serialized_titles,
}
)
except (Exception, SystemExit) as e:
log.exception("Error getting titles")
debug_mode = request.app.get("debug_api", False) if request else False
return handle_api_exception(
e,
context={"operation": "session_titles", "session_id": session_id},
debug_mode=debug_mode,
)
def client_session_auth(session: Any) -> tuple[Dict[str, str], Dict[str, str]]:
"""The service session headers and cookies a remote client may receive.
Only a client that sent its own cookies or credentials gets them back. Otherwise (a server
account, or an anonymous login through the server's proxy) the cookie jar stays on the
server and the server drops the auth-bearing headers, so the client cannot reuse a login
the server paid for. A remote session on the server's lent device keeps its cookies (the client's account)
but drops those headers too.
"""
svc_session = session.service_instance.session
headers = dict(svc_session.headers) if hasattr(svc_session, "headers") and svc_session.headers else {}
cookies: Dict[str, str] = {}
if hasattr(svc_session, "cookies"):
for cookie in svc_session.cookies:
if hasattr(cookie, "name") and hasattr(cookie, "value"):
cookies[cookie.name] = cookie.value
if not client_owns_login(session):
headers = {k: v for k, v in headers.items() if not CONFIG_SECRET_KEY_RE.search(str(k))}
if not session.client_auth:
cookies = {}
return headers, cookies
def client_owns_login(session: Any) -> bool:
"""Whether the login state of ``session`` is the client's to take back.
A remote session on the server's lent device logs in with the server's device identity, so its tokens stay there.
"""
return bool(session.client_auth) and not getattr(session, "server_device", False)
def scrub_secret_keys(value: Any) -> Any:
"""``value`` without any dict entry, at any depth, whose key looks like a secret."""
if isinstance(value, dict):
return {k: scrub_secret_keys(v) for k, v in value.items() if not CONFIG_SECRET_KEY_RE.search(str(k))}
if isinstance(value, list):
return [scrub_secret_keys(v) for v in value]
return value
def scrub_track_data(data: Optional[Dict[str, Any]], session: Any) -> Optional[Dict[str, Any]]:
"""Drop secret-looking keys, at any depth, from a serialised ``track.data`` when the login is the server's.
A service can stash a token in ``track.data`` for its license call; under the same rule as
:func:`client_session_auth` that token never reaches a client that did not log in itself.
"""
if not data or client_owns_login(session):
return data
return scrub_secret_keys(data) or None
async def session_tracks_handler(
data: Dict[str, Any], session_id: str, request: Optional[web.Request] = None
) -> web.Response:
"""Get tracks and chapters for a specific title in the remote session.
Called per-title by the client after `session/create` returns titles.
This keeps auth separate from track fetching, allowing interactive
auth flows (OTP, captcha) before the client requests any tracks.
"""
session = await get_validated_session(session_id, request)
require_authenticated(session)
title_id = data.get("title_id")
if not title_id:
raise APIError(APIErrorCode.INVALID_INPUT, "Missing required parameter: title_id")
title = session.title_map.get(str(title_id))
if not title:
raise APIError(
APIErrorCode.INVALID_INPUT,
f"Title not found in session: {title_id}",
details={"available_titles": list(session.title_map.keys())},
)
session.current_title_id = str(title_id)
SessionStore.publish_update(session)
try:
service_instance = session.service_instance
async with session.lock:
try:
tracks = await asyncio.to_thread(service_instance.get_tracks, title)
finally:
session.touch()
title_tracks: Dict[str, Any] = {}
for track in tracks.videos:
title_tracks[str(track.id)] = track
session.tracks[str(track.id)] = track
for track in tracks.audio:
title_tracks[str(track.id)] = track
session.tracks[str(track.id)] = track
for track in tracks.subtitles:
title_tracks[str(track.id)] = track
session.tracks[str(track.id)] = track
session.tracks_by_title[str(title_id)] = title_tracks
SessionStore.publish_update(session)
try:
chapters = await asyncio.to_thread(service_instance.get_chapters, title)
session.chapters_by_title[str(title_id)] = chapters if chapters else []
except (NotImplementedError, Exception):
session.chapters_by_title[str(title_id)] = []
video_tracks = sorted(tracks.videos, key=lambda t: t.bitrate or 0, reverse=True)
audio_tracks = sorted(tracks.audio, key=lambda t: t.bitrate or 0, reverse=True)
manifests = extract_manifests(tracks)
track_manifests = extract_track_manifests(tracks)
session_headers, session_cookies = client_session_auth(session)
from unshackle.core.config import config as app_config
api_key = request.headers.get("X-Secret-Key", "anonymous") if request else "anonymous"
user_cfg = serve_user_config(api_key)
has_wv = bool(user_cfg.get("devices"))
has_pr = bool(user_cfg.get("playready_devices"))
service_tag = session.service_tag
config_cdm_type = detect_cdm_type_for_service(service_tag, app_config)
track_has_wv = any(
d.__class__.__name__ == "Widevine" for t in list(tracks.videos) + list(tracks.audio) if t.drm for d in t.drm
)
track_has_pr = any(
d.__class__.__name__ == "PlayReady"
for t in list(tracks.videos) + list(tracks.audio)
if t.drm
for d in t.drm
)
if config_cdm_type:
server_cdm_type = config_cdm_type
elif track_has_pr and has_pr:
server_cdm_type = "playready"
elif track_has_wv and has_wv:
server_cdm_type = "widevine"
elif has_wv:
server_cdm_type = "widevine"
else:
server_cdm_type = "playready"
original_ids = original_audio_ids(audio_tracks, title)
video = [serialize_video_track(t, include_url=True) for t in video_tracks]
audio = [serialize_audio_track(t, include_url=True, is_original=t.id in original_ids) for t in audio_tracks]
subtitles = [serialize_subtitle_track(t, include_url=True) for t in tracks.subtitles]
for d in video + audio + subtitles:
d["data"] = scrub_track_data(d.get("data"), session)
return web.json_response(
{
"title": stamp_service_flags(serialize_title(title), service_instance),
"video": video,
"audio": audio,
"subtitles": subtitles,
"chapters": [
{"timestamp": ch.timestamp, "name": ch.name}
for ch in session.chapters_by_title.get(str(title_id), [])
],
"attachments": [a for a in map(serialize_attachment, tracks.attachments) if a],
"manifests": manifests,
"track_manifests": track_manifests,
"session_headers": session_headers,
"session_cookies": session_cookies,
"server_cdm": session.server_cdm,
"server_cdm_type": server_cdm_type,
}
)
except (Exception, SystemExit) as e:
log.exception(f"Error getting tracks for title {sanitize_log(title_id)}")
debug_mode = request.app.get("debug_api", False) if request else False
return handle_api_exception(
e,
context={"operation": "session_tracks", "session_id": session_id, "title_id": title_id},
debug_mode=debug_mode,
)
async def session_segments_handler(
data: Dict[str, Any], session_id: str, request: Optional[web.Request] = None
) -> web.Response:
"""Get segment URLs for selected tracks.
The client calls this after selecting which tracks to download.
Returns segment URLs, init data, DRM info, and any headers/cookies
needed for CDN download.
"""
session = await get_validated_session(session_id, request)
require_authenticated(session)
track_ids = data.get("track_ids", [])
if not track_ids:
raise APIError(APIErrorCode.INVALID_INPUT, "Missing required parameter: track_ids")
try:
result: Dict[str, Any] = {}
for track_id in track_ids:
track = session.tracks.get(track_id)
if not track:
raise APIError(
APIErrorCode.TRACK_NOT_FOUND,
f"Track not found in session: {track_id}",
details={"track_id": track_id, "session_id": session_id},
)
descriptor_name = track.descriptor.name if hasattr(track.descriptor, "name") else str(track.descriptor)
track_info: Dict[str, Any] = {
"descriptor": descriptor_name,
"url": str(track.url) if track.url else None,
"drm": serialize_drm(track.drm) if hasattr(track, "drm") and track.drm else None,
}
track_info["headers"], track_info["cookies"] = client_session_auth(session)
# Include manifest-specific data for segment resolution. Round-trip through
# JSON so any non-serializable value becomes its str() (default=str).
if hasattr(track, "data") and track.data:
import json
track_info["data"] = scrub_track_data(json.loads(json.dumps(track.data, default=str)), session)
else:
track_info["data"] = {}
result[track_id] = track_info
return web.json_response({"tracks": result})
except APIError:
raise
except (Exception, SystemExit) as e:
log.exception("Error resolving segments")
debug_mode = request.app.get("debug_api", False) if request else False
return handle_api_exception(
e,
context={"operation": "session_segments", "session_id": session_id},
debug_mode=debug_mode,
)
def resolve_server_cdm(service: str, profile: Optional[str], cdm_type: Optional[str]) -> Optional[Any]:
"""Get the CDM for the server context.
Checks the server's own CDM config (``config.cdm[service]``) to
determine the CDM type without loading the full CDM object. This
makes sure that when you use ``server_cdm: true``, the server's CDM
determines device selection (e.g. PlayReady vs Widevine).
Falls back to a lightweight stub from *cdm_type* only if the configuration
has no server CDM for the service.
"""
from unshackle.core.config import config as app_config
cdm_name = ci_get(app_config.cdm, service)
if cdm_name:
if isinstance(cdm_name, dict):
lower_keys = {k.lower(): v for k, v in cdm_name.items()}
if {"widevine", "playready"} & lower_keys.keys():
cdm_name = lower_keys.get("playready") or lower_keys.get("widevine")
else:
cdm_name = cdm_name.get("default") or next(iter(cdm_name.values()), None)
if cdm_name and isinstance(cdm_name, str):
detected_type = detect_cdm_type(cdm_name, app_config)
if detected_type:
return cdm_type_stub(detected_type)
if cdm_type:
return cdm_type_stub(cdm_type)
return None
def configured_cdm_name(service: str, drm_type: str, app_config: Any) -> Optional[str]:
"""The device name config.cdm maps a service to for one DRM system; None when the tier decides.
A dict names a device per system (``widevine`` / ``playready`` keys) or a ``default``.
Any other dict shape, such as the quality tiers ``dl`` reads, falls through to the
global ``cdm.default``: the server has no track context to pick a tier with.
"""
cdm_name = ci_get(app_config.cdm, service) if service else None
if isinstance(cdm_name, dict):
lower_keys = {k.lower(): v for k, v in cdm_name.items()}
drm_key = drm_type if drm_type in ("widevine", "playready") else None
cdm_name = lower_keys.get(drm_key) or lower_keys.get("default") or ci_get(app_config.cdm, "default")
if cdm_name and isinstance(cdm_name, str):
return cdm_name
return None
def detect_cdm_type_for_service(service: str, app_config: Any) -> Optional[str]:
"""The DRM system config.cdm settles for a service; None when the mapping leaves it open.
A mapping that names one system gets that system. A device name gets the type of the
device ``resolve_device_name`` returns for it, so the routes plan with the device they
load. A mapping that names a device for both systems settles nothing.
"""
cdm_name = ci_get(app_config.cdm, service)
if isinstance(cdm_name, dict):
named = [system for system in ("widevine", "playready") if system in {k.lower() for k in cdm_name}]
if named:
return named[0] if len(named) == 1 else None
device_name = configured_cdm_name(service, "widevine", app_config)
if device_name:
return detect_cdm_type(device_name, app_config)
return None
def detect_cdm_type(cdm_name: str, app_config: Any) -> Optional[str]:
"""Detect CDM type (PlayReady/widevine) from config without loading it.
Checks remote_cdm entries and local file extensions to determine the type.
"""
for entry in getattr(app_config, "remote_cdm", []) or []:
if entry.get("name") == cdm_name:
device_type = str(entry.get("device_type", entry.get("Device Type", ""))).upper()
return "playready" if device_type == "PLAYREADY" else "widevine"
prd_path = app_config.directories.prds / f"{cdm_name}.prd"
if not prd_path.is_file():
prd_path = app_config.directories.wvds / f"{cdm_name}.prd"
if prd_path.is_file():
return "playready"
wvd_path = app_config.directories.wvds / f"{cdm_name}.wvd"
if wvd_path.is_file():
return "widevine"
return None
def require_authenticated(session: Any) -> None:
"""Raise if the remote session has not finished authenticating."""
status = getattr(session, "auth_status", None)
if status == AuthStatus.FAILED:
raise APIError(
APIErrorCode.AUTH_FAILED,
f"Authentication failed: {session.auth_error or 'unknown error'}",
)
if status in (AuthStatus.AUTHENTICATING, AuthStatus.PENDING_INPUT):
raise APIError(
APIErrorCode.INVALID_INPUT,
f"Session authentication not complete (status: {status.value})",
details={"auth_status": status.value},
)
async def session_prompt_get_handler(session_id: str, request: Optional[web.Request] = None) -> web.Response:
"""Poll for pending interactive prompts during authentication, and for CDM calls at any time.
Returns the current auth status and any pending prompt that the
remote client should display to the user. A CDM call comes first, because service code can
wait on the client's CDM after authentication too, for example in get_tracks.
"""
session = await get_validated_session(session_id, request)
bridge = session.input_bridge
if bridge and (cdm_call := bridge.get_pending_cdm_call()):
return web.json_response(
{"status": "pending_input", "prompt": bridge.get_pending_prompt(), "cdm_call": cdm_call}
)
if session.auth_status == AuthStatus.AUTHENTICATED:
return web.json_response({"status": "authenticated"})
if session.auth_status == AuthStatus.FAILED:
return web.json_response({"status": "failed", "error": session.auth_error or "unknown error"})
if bridge:
prompt = bridge.get_pending_prompt()
if prompt:
return web.json_response({"status": "pending_input", "prompt": prompt})
return web.json_response({"status": "authenticating"})
async def session_prompt_post_handler(
data: Dict[str, Any], session_id: str, request: Optional[web.Request] = None
) -> web.Response:
"""Submit a response to a pending interactive prompt.
The remote client calls this after collecting user input (OTP code,
PIN, or device-code confirmation) to unblock the server auth thread.
"""
session = await get_validated_session(session_id, request)
response_text = data.get("response")
if response_text is None:
raise APIError(APIErrorCode.INVALID_INPUT, "Missing required field: response")
bridge = session.input_bridge
if bridge is None or bridge.status != AuthStatus.PENDING_INPUT:
raise APIError(APIErrorCode.INVALID_INPUT, "No prompt pending for this session")
if not bridge.submit_response(str(response_text)):
raise APIError(APIErrorCode.INVALID_INPUT, "No prompt pending for this session")
return web.json_response({"status": "accepted"})
async def session_logs_handler(session_id: str, since: int = 0, request: Optional[web.Request] = None) -> web.Response:
"""Drain the remote session's service log records newer than *since*.
Gives a remote client the service's server-side ``self.log`` output for
its own remote session - the reason an auth, title, or manifest step
failed. Works in every auth state, because a failed session is exactly
when the client needs the logs.
"""
session = await get_validated_session(session_id, request)
buffer = session.log_buffer
logs = buffer.since(since) if buffer else []
return web.json_response(
{
"session_id": session_id,
"logs": logs,
"last_seq": logs[-1]["seq"] if logs else since,
}
)
async def get_validated_session(session_id: str, request: Optional[web.Request]) -> Any:
"""Fetch a remote session and make sure that the caller owns it.
Ownership is bound to the authenticating X-Secret-Key rather than the source IP:
behind a reverse proxy every caller shares the proxy's address, so the IP check
(kept as defence in depth) cannot distinguish users on its own.
"""
import hmac
from unshackle.core.api.session_store import get_session_store
store = get_session_store()
session = await store.get(session_id)
if not session:
raise APIError(
APIErrorCode.SESSION_NOT_FOUND,
f"Session not found or expired: {session_id}",
details={"session_id": session_id},
)
if session.owner_key is not None and request is not None:
caller_key = request.headers.get("X-Secret-Key", "anonymous")
if not hmac.compare_digest(caller_key, session.owner_key):
raise APIError(
APIErrorCode.FORBIDDEN,
"Session access denied",
)
if session.creator_ip and request and request.remote != session.creator_ip:
raise APIError(
APIErrorCode.FORBIDDEN,
"Session access denied",
)
return session
def resolve_handler_proxy(
data: Dict[str, Any], normalized_service: str, request: Optional[web.Request] = None
) -> tuple[Optional[str], list]:
"""Get the proxy and the proxy providers for an API request.
``server_proxy`` on the calling API key decides whether the server spends its own proxy
subscriptions on this client. Without it the proxy-provider list stays empty, so the server
rejects a country code in ``proxy``, and a client in another region must send a full proxy
URI or ``no_proxy``. The returned list reaches ``ContextData.proxy_providers``, so the same gate
also governs the geofence auto-proxy in ``Service.__init__``.
The client region is the client-reported ``client_region`` field: an identifier, not a
security boundary, because the spend gate is ``server_proxy`` on the API key. A client that
reports no region, and an unknown region on either side, never blocks the request.
"""
proxy_param = data.get("proxy")
no_proxy = data.get("no_proxy", False)
proxy_providers: list = []
allowed = server_proxy_allowed(request)
if (
proxy_param
and re.match(r"^(https?://|socks|controld://)", str(proxy_param))
and server_account_for(request, normalized_service)
):
raise APIError(
APIErrorCode.FORBIDDEN,
"A server-account service does not accept a client proxy URI. Pass a country code or nothing.",
details={"service": normalized_service},
)
if allowed and not no_proxy:
proxy_providers = initialize_proxy_providers()
if proxy_param and not no_proxy:
try:
proxy_param = resolve_proxy(proxy_param, proxy_providers)
except ValueError as e:
if allowed:
raise APIError(
APIErrorCode.INVALID_PROXY,
f"Proxy error: {redact_text(str(e))}",
details={"proxy": redact_text(data.get("proxy")), "service": normalized_service},
)
raise APIError(
APIErrorCode.INVALID_PROXY,
"This server does not resolve country codes into proxies. "
"Pass --proxy with a full proxy URI (http://, https:// or socks5://).",
details={"service": normalized_service},
)
client_region = data.get("client_region")
if client_region is not None and not isinstance(client_region, str):
raise APIError(
APIErrorCode.INVALID_INPUT,
"client_region must be a string country code.",
details={"service": normalized_service},
)
if not proxy_param and not no_proxy and client_region and (proxy_providers or not allowed):
try:
from unshackle.core.utils.ip_info import get_ip_info
server_ip_info = get_ip_info(None, cached=True)
server_region = server_ip_info.get("country", "").lower() if server_ip_info else None
except Exception as e:
log.debug(f"Server region lookup failed: {e!r}")
server_region = None
geoblock = getattr(Services.load(normalized_service), "GEOBLOCK", ()) or ()
if client_region.lower() in {x.lower() for x in geoblock}:
raise APIError(
APIErrorCode.GEOFENCE,
f"Service is not available in your region ({client_region.upper()}). "
"Pass --proxy with a proxy outside the blocked regions.",
details={"service": normalized_service},
)
in_client_region = bool(server_region) and server_region == client_region.lower()
if not allowed:
if server_region and not in_client_region:
raise APIError(
APIErrorCode.INVALID_PROXY,
"This server is in a different region from yours. Pass --proxy with a proxy "
"in the region you want, or pass --no-proxy to use the server's own connection.",
details={"service": normalized_service},
)
elif in_client_region:
log.info(f"Server already in client region '{sanitize_log(client_region)}', no proxy needed")
else:
try:
proxy_param = resolve_proxy(client_region, proxy_providers)
log.info(f"Using server proxy for client region '{sanitize_log(client_region)}'")
except ValueError:
log.debug(f"No server proxy available for client region '{sanitize_log(client_region)}'")
return proxy_param, proxy_providers
def find_title_for_track(track_id: str, session: Any) -> Any:
"""Find the title object that owns a given track."""
for t_id, tracks_dict in session.tracks_by_title.items():
if track_id in tracks_dict:
return session.title_map.get(t_id)
if session.title_map:
return session.title_map.get(session.current_title_id) or next(iter(session.title_map.values()))
return None
def extract_pssh_from_track(track: Any, drm_type: str) -> Optional[str]:
"""The base64 PSSH of the track's DRM object for `drm_type`; None when the track has none of that system."""
for drm_obj in track.drm or []:
drm_class = drm_obj.__class__.__name__
if drm_type == "widevine" and drm_class == "Widevine":
pssh = getattr(drm_obj, "_pssh", None)
if pssh and hasattr(pssh, "dumps"):
return pssh.dumps()
elif drm_type == "playready" and drm_class == "PlayReady":
pssh_b64 = getattr(drm_obj, "data", {}).get("pssh_b64")
if pssh_b64:
return pssh_b64
return None
def fetch_init_segment(track: Any, session: Any = None) -> Optional[bytes]:
"""The init segment of a DASH track; None for a track that is not DASH, or when the fetch fails."""
from unshackle.core.manifests import DASH as DASHManifest
dash = (getattr(track, "data", None) or {}).get("dash") or {}
if not all(dash.get(k) is not None for k in ("period", "adaptation_set", "representation", "manifest")):
return None
session = getattr(track, "session", None) or session
if session is None:
return None
try:
return DASHManifest.get_period_segments(
period=dash["period"],
adaptation_set=dash["adaptation_set"],
representation=dash["representation"],
manifest=dash["manifest"],
track=track,
track_url=track.url,
session=session,
)[0]
except Exception as e:
log.warning(f"Init segment fetch failed for {track.id}: {e!r}")
return None
def drm_from_init_segment(track: Any, session: Any = None, init_data: Optional[bytes] = None) -> list:
"""Read the DRM headers out of a DASH track's init segment.
Some manifests carry no ContentProtection PSSH; the real header reaches the player only
in the init segment, so the server must read it there too.
"""
from unshackle.core.drm import PlayReady, Widevine
if init_data is None:
init_data = fetch_init_segment(track, session)
if not init_data:
return []
drms = []
for drm_cls in (Widevine, PlayReady):
try:
drms.append(drm_cls.from_init_data(init_data))
except Exception as e:
log.debug(f"No usable {drm_cls.__name__} PSSH in the init segment of {track.id}: {e!r}")
return drms
def ensure_track_drm(track: Any, session: Any = None, init_data: Optional[bytes] = None) -> None:
"""Extract DRM from manifest data if track has none.
Supports DASH (ContentProtection elements, then the init segment), HLS
(EXT-X-KEY from playlist fetch), and ISM (ProtectionHeader elements).
"""
if track.drm:
return
if track.data.get("dash"):
from unshackle.core.manifests import DASH as DASHManifest
rep = track.data["dash"].get("representation")
ada = track.data["dash"].get("adaptation_set")
if rep is not None and ada is not None:
track.drm = DASHManifest.get_drm(rep.findall("ContentProtection") + ada.findall("ContentProtection"))
if track.drm:
return
track.drm = drm_from_init_segment(track, session, init_data) or None
if track.drm:
return
if (track.data.get("hls") or descriptor_name(track) == "HLS") and track.url:
try:
import m3u8
from unshackle.core.drm import PlayReady, Widevine
from unshackle.core.manifests import HLS
http = getattr(track, "session", None) or session
playlist = m3u8.loads(http.get(track.url).text, uri=track.url) if http else m3u8.load(track.url)
keys = [k for k in (playlist.keys or []) + (playlist.session_keys or []) if k is not None]
for key in keys:
try:
drm = HLS.get_drm(key)
if isinstance(drm, (Widevine, PlayReady)):
track.drm = [drm]
return
# EXT-X-KEY data is third-party input; skip keys we cannot turn into a DRM object
except Exception as e:
log.debug(f"Skipping HLS key {key}: {e!r}")
continue
except Exception as e:
log.debug(f"HLS DRM prefetch failed for {track.id}: {e!r}")
if track.data.get("ism"):
try:
from unshackle.core.manifests import ISM as ISMManifest
manifest = track.data["ism"].get("manifest")
if manifest is not None:
track.drm = ISMManifest.get_drm(manifest.xpath(".//ProtectionHeader"))
# a swallowed error here means the track silently gets no DRM, so it must be loud
except Exception as e:
log.warning(f"ISM DRM extraction failed for {track.id}: {e!r}")
def resolve_device_name(user_config: dict, drm_type: str, service_tag: str = "") -> str:
"""Get the CDM device name, checking service-specific config.cdm first.
Resolution order:
1. config.cdm[service_tag] (service-specific CDM mapping)
2. serve.users.{key}.devices / playready_devices (user device list)
"""
from unshackle.core.config import config as app_config
cdm_name = configured_cdm_name(service_tag, drm_type, app_config)
if cdm_name:
return cdm_name
if drm_type == "playready":
device_name = (user_config.get("playready_devices") or [None])[0]
if not device_name:
raise APIError(APIErrorCode.INVALID_INPUT, "No PlayReady device configured for this API key")
else:
device_name = (user_config.get("devices") or [None])[0]
if not device_name:
raise APIError(APIErrorCode.INVALID_INPUT, "No Widevine device configured for this API key")
return device_name
def vault_candidates(client_drm_type: str) -> List[str]:
"""Both DRM systems, the client's first: a vault lookup needs only the KIDs, whatever device made the manifest."""
first = client_drm_type if client_drm_type in ("widevine", "playready") else "widevine"
return [first, "playready" if first == "widevine" else "widevine"]
def server_drm_candidates(
service_tag: str,
user_config: dict,
client_drm_type: str,
track: Any,
warn: Callable[[str], None] = log.warning,
) -> List[str]:
"""The DRM systems the server can license a track with, in the order to try them.
The server's config.cdm mapping goes first; with no mapping, the client's choice goes
first. A system stays only when the device the server would load for it is of that
system, so the route never plans a licence that fails on the wrong device type. The
track's own drm_preference moves its system to the front when the server has it.
"""
from unshackle.core.config import config as app_config
server_type = detect_cdm_type_for_service(service_tag, app_config)
first = server_type or (client_drm_type if client_drm_type in ("widevine", "playready") else "widevine")
candidates: List[str] = []
for drm_type in (first, *(system for system in ("widevine", "playready") if system != first)):
try:
device_name = resolve_device_name(user_config, drm_type, service_tag)
except APIError:
continue
if detect_cdm_type(device_name, app_config) in (None, drm_type):
candidates.append(drm_type)
preferred = drm_preference_name(track)
if preferred:
if preferred in candidates:
candidates.remove(preferred)
candidates.insert(0, preferred)
else:
warn(
f"Track {sanitize_log(str(track.id)[:12])} wants {preferred} DRM "
"but the server has no device for it, using the configured DRM instead"
)
return candidates
def pick_server_pssh(track: Any, candidates: List[str]) -> Optional[tuple[str, str]]:
"""`(drm_type, pssh)` for the first candidate the track carries a header for; None when it has none.
A track with only a PlayReady header still licenses under the Widevine device: the
Widevine DRM class converts the PlayReady PSSH on construction.
"""
for candidate in candidates:
pssh_str = extract_pssh_from_track(track, candidate)
if not pssh_str and candidate == "widevine":
pssh_str = extract_pssh_from_track(track, "playready")
if pssh_str:
log.info(
f"Track {sanitize_log(str(track.id)[:12])} has only a PlayReady PSSH, licensing it with the Widevine device"
)
if pssh_str:
return candidate, pssh_str
return None
def load_server_vaults(service_name: str) -> Any:
"""Load server vaults from config.key_vaults."""
from unshackle.core.config import config as app_config
from unshackle.core.services import Services
from unshackle.core.vaults import Vaults
vaults = Vaults(Services.get_vault_tag(service_name))
for vault_config in app_config.key_vaults:
cfg = vault_config.copy()
vault_type = cfg.pop("type", None)
if vault_type:
try:
vaults.load(vault_type, **cfg)
except (Exception, SystemExit) as e:
log.warning(f"Could not load vault '{vault_type}': {e}")
return vaults
def check_vaults(kids: list, service_name: str) -> Optional[tuple[Dict[str, str], Dict[str, str]]]:
"""Examine the server vaults for existing content keys that match all KIDs.
Returns `(KID:KEY, KID:vault name)` if ALL KIDs are found, None otherwise. The vault
name travels to the client, which is the only side that can prove the content key decrypts.
"""
from uuid import UUID
try:
vaults = load_server_vaults(service_name)
if not vaults.vaults:
return None
keys: Dict[str, str] = {}
sources: Dict[str, str] = {}
for kid in kids:
kid_uuid = kid if isinstance(kid, UUID) else UUID(hex=str(kid))
content_key, vault_used = vaults.get_key(kid_uuid)
if content_key:
keys[kid_uuid.hex] = content_key
sources[kid_uuid.hex] = vault_used.name if vault_used else "unknown"
else:
return None
if keys:
log.info(f"Vault hit: {len(keys)} key(s) from server vaults, skipping CDM")
return keys, sources
# vault lookup is a best-effort shortcut before the CDM; any failure falls through to licensing
except Exception as e:
log.debug(f"Server vault lookup failed: {e!r}")
return None
CACHED_PAIRS: set[tuple[str, str, str]] = set()
def cache_to_vaults(keys: Dict[str, str], service_name: str) -> None:
"""Cache newly obtained keys to server vaults.
A PlayReady licence always runs, and a Widevine licence runs when one PSSH KID has no content
key in a vault, so the same content keys come back on every request for a title. This process
does not send a pair again after every vault accepted it.
"""
from uuid import UUID
try:
new = {kid: key for kid, key in keys.items() if (service_name, kid, key) not in CACHED_PAIRS}
if not new:
return
vaults = load_server_vaults(service_name)
if not vaults.vaults:
return
key_map = {UUID(hex=kid): key for kid, key in new.items()}
cached = vaults.add_keys(key_map)
if cached == len([vault for vault in vaults.vaults if not vault.no_push]):
CACHED_PAIRS.update((service_name, kid, key) for kid, key in new.items())
if cached:
log.info(f"Cached {len(key_map)} key(s) to {cached}/{len(vaults)} server vault(s)")
except (Exception, SystemExit) as e:
log.warning(f"Failed to cache keys to vaults: {e}")
def track_injected_keys(track: Any, drm_class: str) -> Dict[str, str]:
"""`KID:KEY` pairs a service wrote onto the track's own DRM objects of `drm_class`.
Some services skip the CDM exchange: the licence callback fetches the keys itself,
writes them into ``track.drm[0].content_keys`` and returns a value the CDM cannot
parse. ``dl.py`` accepts that failed parse because the object it licensed holds the
keys. The server licenses a rebuilt object, so it reads the track's objects instead.
"""
keys: Dict[str, str] = {}
for drm in getattr(track, "drm", None) or []:
if drm.__class__.__name__ != drm_class:
continue
for kid, key in (getattr(drm, "content_keys", None) or {}).items():
keys[kid.hex if hasattr(kid, "hex") else str(kid).replace("-", "")] = key
return keys
def license_with_track_fallback(drm: Any, track: Any, **kwargs: Any) -> Dict[str, str]:
"""Run `drm.get_content_keys` and merge in the keys the service put on the track itself."""
try:
drm.get_content_keys(**kwargs)
except Exception:
if not track_injected_keys(track, drm.__class__.__name__):
raise
return {
**track_injected_keys(track, drm.__class__.__name__),
**{kid.hex: key for kid, key in drm.content_keys.items()},
}
def pssh_kids(pssh_b64: str, drm_type: str) -> set:
"""The KIDs a base64 PSSH names, for either DRM system."""
if drm_type == "playready":
from pyplayready.system.pssh import PSSH as PlayReadyPSSH
from unshackle.core.drm import PlayReady
return set(PlayReady(pssh=PlayReadyPSSH(base64.b64decode(pssh_b64)), pssh_b64=pssh_b64).kids)
from pywidevine.pssh import PSSH as WvPSSH
from unshackle.core.drm import Widevine
return set(Widevine(pssh=WvPSSH(pssh_b64)).kids)
def require_track_pssh(track: Any, pssh_b64: str, drm_type: str) -> None:
"""Reject a client PSSH that names a KID the track does not carry.
The server CDM path answers from the vault before any license exchange, so an
unchecked PSSH would make the key vault answer with content keys for any title. The
track's own PSSH passes as it is, so a header without KIDs still licenses.
"""
if pssh_b64 == extract_pssh_from_track(track, drm_type):
return
try:
client_kids = pssh_kids(pssh_b64, drm_type)
except Exception:
raise APIError(APIErrorCode.INVALID_INPUT, "The pssh parameter is not a valid PSSH box") from None
track_kids = {kid for drm in (getattr(track, "drm", None) or []) for kid in (getattr(drm, "kids", None) or [])}
if not client_kids or not track_kids or not client_kids <= track_kids:
raise APIError(
APIErrorCode.FORBIDDEN,
"The pssh parameter does not belong to this track.",
details={"track_id": str(getattr(track, "id", ""))},
)
def handle_single_server_cdm(
service: Any,
title: Any,
track: Any,
pssh_b64: Optional[str],
drm_type: str,
request: Optional[web.Request],
sources: Optional[Dict[str, str]] = None,
refusal: Optional[APIError] = None,
vault_only: bool = False,
) -> Dict[str, str]:
"""Do the single-track server_cdm licensing with the DRM class get_content_keys() flow.
``sources`` is filled with the vault name for every returned content key a server vault
supplied. A content key the CDM licensed gets no entry. It raises ``refusal`` before a live licence:
after a Widevine vault miss, or at once for PlayReady, whose licence can carry keys its header does not name.
With ``vault_only`` no server device is involved: the header's KIDs come from the vault, else ``refusal``.
"""
import base64
from unshackle.core.cdm import load_cdm
from unshackle.core.cdm.detect import is_playready_cdm, is_widevine_cdm
ensure_track_drm(track, getattr(service, "session", None))
if not pssh_b64:
pssh_b64 = extract_pssh_from_track(track, drm_type)
if not pssh_b64:
raise APIError(APIErrorCode.INVALID_INPUT, "No PSSH available for server_cdm licensing")
if vault_only:
from unshackle.core.drm import PlayReady, Widevine
if drm_type == "playready":
from pyplayready.system.pssh import PSSH as PlayReadyPSSH
kids = PlayReady(pssh=PlayReadyPSSH(base64.b64decode(pssh_b64)), pssh_b64=pssh_b64).kids
else:
from pywidevine.pssh import PSSH as WvPSSH
kids = Widevine(pssh=WvPSSH(pssh_b64)).kids
vault_hit = check_vaults(kids, service.__class__.__name__)
if vault_hit:
if sources is not None:
sources.update(vault_hit[1])
return vault_hit[0]
raise refusal or APIError(APIErrorCode.NO_CONTENT, "The server vault holds no key for this track")
api_key = request.headers.get("X-Secret-Key", "anonymous") if request else "anonymous"
user_config = serve_user_config(api_key)
if drm_type == "playready":
from pyplayready.system.pssh import PSSH as PlayReadyPSSH
from unshackle.core.drm import PlayReady
pr_pssh = PlayReadyPSSH(base64.b64decode(pssh_b64))
siblings = [d for d in (getattr(track, "drm", None) or []) if isinstance(d, PlayReady)]
manifest_kid = next((getattr(d, "kid", None) for d in siblings if getattr(d, "kid", None)), None)
pr_drm = PlayReady(pssh=pr_pssh, pssh_b64=pssh_b64, kid=manifest_kid)
if siblings:
pr_drm.absorb(*siblings)
# Gate on the caller's CDM device first: no device, no keys from the vault or CDM.
device_name = resolve_device_name(user_config, drm_type, service.__class__.__name__)
if refusal:
raise refusal
cdm = load_cdm(device_name, service_name=service.__class__.__name__)
if not is_playready_cdm(cdm):
raise APIError(APIErrorCode.INVALID_INPUT, f"CDM device '{device_name}' is not a PlayReady device")
keys = license_with_track_fallback(
pr_drm,
track,
cdm=cdm,
certificate=lambda challenge, **_: None,
licence=lambda **kw: service.get_playready_license(
**declared_kwargs(service.get_playready_license, {**kw, "title": title, "track": track})
),
)
elif drm_type == "widevine":
from pywidevine.pssh import PSSH as WvPSSH
from unshackle.core.drm import Widevine
wv_pssh = WvPSSH(pssh_b64)
wv_drm = Widevine(pssh=wv_pssh)
# Gate on the caller's CDM device first so a caller with no device cannot
# harvest server-side keys from the vault fallback below.
device_name = resolve_device_name(user_config, drm_type, service.__class__.__name__)
vault_hit = check_vaults(wv_drm.kids, service.__class__.__name__)
if vault_hit:
vault_keys, vault_sources = vault_hit
if sources is not None:
sources.update(vault_sources)
return vault_keys
if refusal:
raise refusal
cdm = load_cdm(device_name, service_name=service.__class__.__name__)
if not is_widevine_cdm(cdm):
raise APIError(APIErrorCode.INVALID_INPUT, f"CDM device '{device_name}' is not a Widevine device")
keys = license_with_track_fallback(
wv_drm,
track,
cdm=cdm,
certificate=lambda challenge, **_: service.get_widevine_service_certificate(
challenge=challenge, title=title, track=track
),
licence=lambda **kw: service.get_widevine_license(
**declared_kwargs(service.get_widevine_license, {**kw, "title": title, "track": track})
),
)
else:
raise APIError(
APIErrorCode.INVALID_PARAMETERS,
f"Unsupported DRM type for server_cdm: {drm_type}",
)
if not keys:
raise APIError(APIErrorCode.NO_CONTENT, "Server CDM returned no content keys")
cache_to_vaults(keys, service.__class__.__name__)
return keys
def handle_proxy_license(
service: Any,
title: Any,
track: Any,
challenge_b64: Optional[str],
drm_type: str,
certificate: bool = False,
) -> web.Response:
"""Forward a client CDM challenge to the service license endpoint, or to its certificate hook with ``certificate``."""
import base64
if not challenge_b64:
raise APIError(APIErrorCode.INVALID_INPUT, "Missing required parameter: challenge")
challenge_bytes = base64.b64decode(challenge_b64)
if drm_type not in ("widevine", "playready"):
raise APIError(
APIErrorCode.INVALID_PARAMETERS,
f"Unsupported DRM type: {drm_type}",
details={"drm_type": drm_type, "supported": ["widevine", "playready"]},
)
# A service raises when the upstream licence server rejects the challenge.
# Surface it as a structured licence error, not an uncaught 500 the edge turns into a 502.
try:
if drm_type == "widevine" and certificate:
license_blob = service.get_widevine_service_certificate(
**declared_kwargs(
service.get_widevine_service_certificate,
{"challenge": challenge_bytes, "title": title, "track": track},
)
)
elif drm_type == "widevine":
license_blob = service.get_widevine_license(
**declared_kwargs(
service.get_widevine_license, {"challenge": challenge_bytes, "title": title, "track": track}
)
)
else:
challenge_str = challenge_bytes.decode("utf-8", errors="replace")
license_blob = service.get_playready_license(
**declared_kwargs(
service.get_playready_license, {"challenge": challenge_str, "title": title, "track": track}
)
)
except APIError:
raise
except (Exception, SystemExit) as exc:
log.exception(f"{sanitize_log(drm_type)} licence request failed for the proxied challenge")
raise APIError(APIErrorCode.SERVICE_ERROR, f"Licence request failed: {exc}") from None
if license_blob is None:
return web.json_response({"license": None})
if isinstance(license_blob, str):
# a Widevine service may answer in Base64 text, and the client hands its CDM the raw bytes
license_blob = base64.b64decode(license_blob) if drm_type == "widevine" else license_blob.encode("utf-8")
return web.json_response({"license": base64.b64encode(license_blob).decode("ascii")})
async def session_segment_filter_handler(
data: Dict[str, Any], session_id: str, request: Optional[web.Request] = None
) -> web.Response:
"""Run the service's HLS ``OnSegmentFilter`` for one track and return the unwanted segment URIs.
The client fetches the same media playlist again. Only the URIs leave the server; the
filter and the data it reads stay on it.
"""
session = await get_validated_session(session_id, request)
require_authenticated(session)
track_id = data.get("track_id")
if not track_id:
raise APIError(APIErrorCode.INVALID_INPUT, "Missing required parameter: track_id")
track = session.tracks.get(track_id)
if not track:
raise APIError(
APIErrorCode.TRACK_NOT_FOUND,
f"Track not found in session: {track_id}",
details={"track_id": track_id, "session_id": session_id},
)
segment_filter = getattr(track, "OnSegmentFilter", None)
if not callable(segment_filter) or not getattr(track, "url", None):
return web.json_response({"unwanted": None})
def unwanted_segments() -> list[str]:
import m3u8
import requests
response = session.service_instance.session.get(track.url)
if not getattr(response, "ok", True):
raise ValueError(f"playlist request failed: {response.status_code}")
if isinstance(response, requests.Response):
response.encoding = response.encoding or "utf-8"
playlist = m3u8.loads(response.text, uri=str(track.url))
return [segment.absolute_uri for segment in playlist.segments if segment_filter(segment)]
try:
async with session.lock:
unwanted = await asyncio.to_thread(unwanted_segments)
except (Exception, SystemExit):
# `from None`: a debug-mode traceback would otherwise carry the service's exception chain
log.exception(f"Error running the segment filter for track {sanitize_log(str(track_id))}")
raise APIError(APIErrorCode.SERVICE_ERROR, "Could not run the segment filter for this track") from None
return web.json_response({"unwanted": unwanted})
async def session_license_handler(
data: Dict[str, Any], session_id: str, request: Optional[web.Request] = None
) -> web.Response:
"""Do the DRM licensing in proxy or server_cdm mode.
Proxy mode (default): forwards client CDM challenge to the service's
license endpoint, returns raw license bytes for client-side parsing.
Server-CDM mode (mode="server_cdm"): server uses its own CDM to make
the challenge, get the license, and extract `KID:KEY` pairs. Supports
batch (track_ids list) and single-track requests.
"""
session = await get_validated_session(session_id, request)
require_authenticated(session)
async with session.lock:
return await asyncio.to_thread(license_response, data, session, session_id, request)
def license_response(
data: Dict[str, Any], session: Any, session_id: str, request: Optional[web.Request]
) -> web.Response:
"""The licence response for :func:`session_license_handler`, made on a worker thread."""
track_id = data.get("track_id")
track_ids = data.get("track_ids")
challenge_b64 = data.get("challenge")
drm_type = data.get("drm_type", "widevine")
mode = data.get("mode", "proxy")
# a session the client's device licenses reaches the server vault only; its live licences stay client-side
vault_only = getattr(session, "server_cdm", None) is False
if mode == "server_cdm" and not (
getattr(session, "server_vault", False) if vault_only else server_cdm_allowed(request, session.service_tag)
):
raise APIError(
APIErrorCode.FORBIDDEN,
"Server CDM licensing is not enabled for this key on this service. Use a local CDM (proxy mode).",
)
if mode == "server_cdm" and track_ids:
api_key = request.headers.get("X-Secret-Key", "anonymous") if request else "anonymous"
user_config = serve_user_config(api_key)
service = session.service_instance
service_tag = session.service_tag
all_keys: Dict[str, Dict[str, str]] = {}
vault_keys: list[str] = []
clear_tracks: list[str] = []
drm_types: Dict[str, str] = {}
keys_by_pssh: Dict[tuple, Dict[str, str]] = {}
sources_by_pssh: Dict[tuple, Dict[str, str]] = {}
drm_type_by_pssh: Dict[tuple, str] = {}
actual_drm_type: Optional[str] = None
capped_tracks: Dict[str, Dict[str, Any]] = {}
def pssh_set(track: Any) -> tuple:
"""Every PSSH and KID the track carries, as the rest of the cache key.
Two tracks can share their first PSSH and still need different licences,
because the server licenses every header the track holds and asks for every
KID the manifest named on it.
"""
drms = getattr(track, "drm", None) or []
return (
tuple(sorted(str(getattr(d, "pssh_b64", "") or "") for d in drms)),
tuple(sorted(str(kid) for d in drms for kid in (getattr(d, "kids", None) or []))),
)
def warn(message: str) -> None:
"""Log on the server and copy into the remote session buffer the client drains.
The buffer takes only ``service.log``, so a licensing failure raised
here would otherwise reach the client as a bare "no content keys".
"""
log.warning(message)
if session.log_buffer:
session.log_buffer.append(logging.WARNING, message)
def license_track(
track: Any, title: Any, candidates: list
) -> tuple[Dict[str, str], Optional[str], Optional[str]]:
"""`(keys, drm_type, pssh)` for the track's current DRM, licensing once per unique PSSH.
A track the server must not license live goes into ``capped_tracks`` with no keys, and its PSSH
stays uncached, as a track under the cap can share it.
"""
picked = pick_server_pssh(track, candidates)
if not picked:
warn(f"No PSSH on track {sanitize_log(str(track.id)[:12])} for {', '.join(candidates) or 'any CDM'}")
return {}, None, None
candidate, pssh_str = picked
cache_key = (pssh_str, pssh_set(track))
if cache_key not in keys_by_pssh:
keys_by_pssh[cache_key] = {}
sources_by_pssh[cache_key] = {}
try:
keys = handle_single_server_cdm(
service,
title,
track,
pssh_str,
candidate,
request,
sources_by_pssh[cache_key],
live_licence_refusal(session, track),
vault_only=vault_only,
)
if keys:
keys_by_pssh[cache_key] = keys
drm_type_by_pssh[cache_key] = candidate
except SystemExit:
warn(f"Service exited while resolving keys for track {sanitize_log(str(track.id)[:12])}, skipping")
except (Exception, SystemExit) as e:
if isinstance(e, APIError) and e.error_code is APIErrorCode.SERVER_CDM_CAPPED:
del keys_by_pssh[cache_key], sources_by_pssh[cache_key]
capped_tracks[str(track.id)] = e.details
return {}, None, pssh_str
warn(f"Failed to resolve keys for track {sanitize_log(str(track.id)[:12])}: {redact_all(str(e))}")
return keys_by_pssh[cache_key], drm_type_by_pssh.get(cache_key), pssh_str
for tid in track_ids:
track = session.tracks.get(tid)
if not track:
continue
svc_session = getattr(service, "session", None)
init_data = fetch_init_segment(track, svc_session)
ensure_track_drm(track, svc_session, init_data)
if not track.drm:
log.info(f"Track {sanitize_log(tid[:12])} carries no DRM, so it has no keys to resolve")
clear_tracks.append(tid)
continue
title = find_title_for_track(tid, session)
# with no server device in play, the client's DRM system is the one its vault keys are for
candidates = (
vault_candidates(drm_type)
if vault_only
else server_drm_candidates(service_tag, user_config, drm_type, track, warn)
)
keys, track_drm_type, pssh_str = license_track(track, title, candidates)
# a refused track still tries its init segment PSSH, where a server vault can hold the key
refusal = capped_tracks.pop(tid, None)
track_kid = None
if init_data:
try:
track_kid = track.get_key_id(init_data)
except Exception as e:
log.debug(f"KID probe failed for {sanitize_log(tid[:12])}: {e!r}")
if track_kid and track_kid.hex not in keys:
manifest_drm = track.drm
track.drm = drm_from_init_segment(track, init_data=init_data) or manifest_drm
init_keys, init_drm_type, init_pssh = license_track(track, title, candidates)
init_refusal = capped_tracks.pop(tid, None)
if init_pssh != pssh_str:
log.info(
f"The manifest PSSH gave no content key for KID {track_kid.hex} of track "
f"{sanitize_log(tid[:12])}, tried the init segment PSSH"
)
if track_kid.hex in init_keys:
keys, track_drm_type, pssh_str = init_keys, init_drm_type, init_pssh
refusal = None
else:
track.drm = manifest_drm
refusal = refusal or init_refusal
if not refusal:
warn(f"No content key for KID {track_kid.hex} of track {sanitize_log(tid[:12])}")
if refusal:
capped_tracks[tid] = refusal
continue
if keys:
all_keys[tid] = keys
sources = sources_by_pssh.get((pssh_str, pssh_set(track)), {}) if pssh_str is not None else {}
note_served_keys(session, keys, sources)
if sources:
vault_keys.extend(sources)
if keys and track_drm_type:
drm_types[tid] = track_drm_type
actual_drm_type = track_drm_type
response: Dict[str, Any] = {"keys": all_keys}
if clear_tracks:
response["clear_tracks"] = clear_tracks
if vault_keys:
response["vault_keys"] = vault_keys
if actual_drm_type:
response["drm_type"] = actual_drm_type
if drm_types:
response["drm_types"] = drm_types
if capped_tracks:
response["capped_tracks"] = capped_tracks
return web.json_response(response)
if not track_id:
raise APIError(APIErrorCode.INVALID_INPUT, "Missing required parameter: track_id")
track = session.tracks.get(track_id)
if not track:
raise APIError(
APIErrorCode.TRACK_NOT_FOUND,
f"Track not found in session: {track_id}",
details={"track_id": track_id, "session_id": session_id},
)
try:
title = find_title_for_track(track_id, session)
service = session.service_instance
pssh_b64 = data.get("pssh")
if pssh_b64 or mode == "server_cdm":
ensure_track_drm(track, getattr(service, "session", None))
if mode == "server_cdm":
# The server's config.cdm mapping decides the DRM system, as in the batch path.
# The client only knows its own local device, which the server never uses here.
api_key = request.headers.get("X-Secret-Key", "anonymous") if request else "anonymous"
candidates = (
vault_candidates(drm_type)
if vault_only
else server_drm_candidates(session.service_tag, serve_user_config(api_key), drm_type, track)
)
picked = pick_server_pssh(track, candidates)
if not picked:
raise APIError(
APIErrorCode.INVALID_INPUT,
f"No PSSH on track {sanitize_log(track_id[:12])} for {', '.join(candidates) or 'any CDM'}",
)
server_drm_type, server_pssh = picked
if pssh_b64 and server_drm_type != drm_type:
log.info(
f"Client asked for {sanitize_log(drm_type)} on track {sanitize_log(track_id[:12])}, "
f"licensing with the server's {server_drm_type} CDM"
)
pssh_b64 = None
if pssh_b64:
require_track_pssh(track, pssh_b64, drm_type)
if drm_type == "playready":
track.pr_pssh = pssh_b64
key_sources: Dict[str, str] = {}
keys = handle_single_server_cdm(
service,
title,
track,
pssh_b64 or server_pssh,
server_drm_type,
request,
key_sources,
live_licence_refusal(session, track),
vault_only=vault_only,
)
log.info(f"Server CDM resolved {len(keys)} key(s) for track {sanitize_log(track_id[:12])}")
note_served_keys(session, keys, key_sources)
return web.json_response({"keys": keys, "vault_keys": list(key_sources), "drm_type": server_drm_type})
if pssh_b64:
require_track_pssh(track, pssh_b64, drm_type)
if drm_type == "playready":
track.pr_pssh = pssh_b64
certificate = data.get("is_certificate") is True
if getattr(session, "server_device", False) and not certificate:
denied = live_licence_refusal(session, track)
if denied:
raise denied
return handle_proxy_license(service, title, track, challenge_b64, drm_type, certificate=certificate)
except APIError:
raise
except SystemExit:
raise APIError(APIErrorCode.SERVICE_ERROR, "Service exited during license request")
except (Exception, SystemExit) as e:
log.exception(f"Error proxying license for track {sanitize_log(track_id)}")
debug_mode = request.app.get("debug_api", False) if request else False
return handle_api_exception(
e,
context={
"operation": "session_license",
"session_id": session_id,
"track_id": track_id,
"drm_type": drm_type,
},
debug_mode=debug_mode,
)
def note_served_keys(session: Any, keys: Dict[str, str], sources: Dict[str, str]) -> None:
"""Remember every KID:KEY the remote session handed out and which server vault supplied it.
The vault name stays on the server: a bad-key report names only the pair, and the
server looks the source up here to flag its own row.
"""
for kid, key in keys.items():
session.served_keys[kid] = (key, sources.get(kid, "cdm"))
async def session_bad_key_handler(
data: Dict[str, Any], session_id: str, request: Optional[web.Request] = None
) -> web.Response:
"""Flag a server-vault content key the client proved wrong, so the next licence reaches the CDM.
The server never sees a segment, so the client is the only side that can test a content key.
Only a pair this remote session served can be flagged, which keeps a client from
poisoning the bad-key table for content keys it never received.
"""
from uuid import UUID
session = await get_validated_session(session_id, request)
require_authenticated(session)
kid = str(data.get("kid") or "").replace("-", "").lower()
key = str(data.get("key") or "").lower()
served_key, source = session.served_keys.get(kid, ("", ""))
if not key or served_key.lower() != key:
log.warning(f"Session {sanitize_log(session_id[:12])} reported a bad content key it was never served: {kid}")
raise APIError(APIErrorCode.INVALID_INPUT, "This session was not served that KID:KEY pair")
vaults = load_server_vaults(session.service_instance.__class__.__name__)
def flag() -> None:
for vault in vaults.vaults:
if not vault.local and vault.name != source:
continue
try:
vault.flag_bad_key(vaults.service, UUID(hex=kid), served_key, source)
except Exception as e:
log.debug(f"Could not flag {kid} as bad in vault {sanitize_log(vault.name)}: {e!r}")
await asyncio.to_thread(flag)
session.served_keys.pop(kid, None)
log.warning(
f"Client proved {kid}:{served_key} from vault {sanitize_log(source)} wrong, flagged in the server vaults"
)
return web.json_response({"flagged": True})
async def session_info_handler(session_id: str, request: Optional[web.Request] = None) -> web.Response:
"""Make sure that the remote session is valid, and get the remote session info."""
session = await get_validated_session(session_id, request)
from unshackle.core.api.session_store import get_session_store
return web.json_response(
{
"session_id": session.session_id,
"service": session.service_tag,
"valid": True,
"expires_in": get_session_store().ttl,
"track_count": len(session.tracks),
"title_count": len(session.title_map),
}
)
async def session_delete_handler(session_id: str, request: Optional[web.Request] = None) -> web.Response:
"""Delete a remote session, return updated cache files, and clean up server-side data."""
from unshackle.core.api.session_store import get_session_store
session = await get_validated_session(session_id, request)
store = get_session_store()
if session.input_bridge:
session.input_bridge.cancel()
cache_tag = session.cache_tag
cache_data = collect_cache_files(cache_tag) if cache_tag and client_owns_login(session) else {}
await store.delete(session_id)
response: Dict[str, Any] = {"status": "ok"}
if cache_data:
response["cache"] = cache_data
return web.json_response(response)