mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-06-13 12:42:03 +00:00
Replace the class-level Track.download monkeypatch with a per-job progress sink threaded through dl.result(). The API now reports a single aggregate signal instead of each track's bouncing 0-100%:
- bitrate-weighted completion so video/audio dominate subtitles
- completed_tracks/total_tracks counts and active_tracks labels (e.g. "video 2160p DV", "audio en-US 5.1")
- downloads fill 0-90%; repackaging (when needed) and a "muxing" stage carry it to 100% so post-download work is no longer frozen at 100%
- monotonic throughout (handles the download->decrypt callable reuse)
Also:
- accept "HDR10P" as the canonical API range value ("HDR10+" still works)
- declare AUTH_METHODS opt-in on the Service base
- raise typed APIError (WORKER_ERROR/DOWNLOAD_ERROR) from the worker path
- move the progress helpers to unshackle/core/api/progress.py
2424 lines
91 KiB
Python
2424 lines
91 KiB
Python
import asyncio
|
|
import enum
|
|
import logging
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from aiohttp import web
|
|
|
|
from unshackle.core.api.errors import APIError, APIErrorCode, handle_api_exception
|
|
from unshackle.core.api.input_bridge import AuthStatus, InputBridge
|
|
from unshackle.core.config import config
|
|
from unshackle.core.constants import AUDIO_CODEC_MAP, DYNAMIC_RANGE_MAP, VIDEO_CODEC_MAP
|
|
from unshackle.core.proxies.resolve import initialize_proxy_providers, resolve_proxy
|
|
from unshackle.core.services import Services
|
|
from unshackle.core.titles import Episode, Movie, Title_T
|
|
from unshackle.core.tracks import Audio, Subtitle, Video
|
|
|
|
log = logging.getLogger("api")
|
|
|
|
|
|
def sanitize_log(value: object) -> str:
|
|
"""Sanitize a value for safe logging by removing newlines and control characters."""
|
|
return str(value).replace("\n", "").replace("\r", "").replace("\x00", "")
|
|
|
|
|
|
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,
|
|
"lang": ["orig"],
|
|
"v_lang": [],
|
|
"a_lang": [],
|
|
"s_lang": ["all"],
|
|
"require_subs": [],
|
|
"forced_subs": False,
|
|
"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,
|
|
"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,
|
|
"no_folder": False,
|
|
"no_source": False,
|
|
"no_mux": False,
|
|
"workers": None,
|
|
"downloads": 1,
|
|
"worst": False,
|
|
"best_available": False,
|
|
"repack": False,
|
|
"tag": None,
|
|
"tmdb_id": None,
|
|
"imdb_id": None,
|
|
"animeapi_id": None,
|
|
"enrich": 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",
|
|
"wanted",
|
|
"proxy",
|
|
"no_proxy",
|
|
"query",
|
|
}
|
|
|
|
|
|
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 no device is configured or loading fails.
|
|
"""
|
|
from unshackle.core.cdm import load_cdm
|
|
from unshackle.core.config import config as app_config
|
|
|
|
cdm_name = app_config.cdm.get(service) or app_config.cdm.get("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 app_config.cdm.get("default")
|
|
|
|
if not cdm_name or not isinstance(cdm_name, str):
|
|
return _resolve_server_cdm(service, profile, cdm_type)
|
|
|
|
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({cdm_name!r}) failed for {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:
|
|
"""Build a parent click Context for invoking a service.cli via ctx.invoke().
|
|
|
|
The service's CLI callback uses ``ctx.parent.params`` (proxy, range_, vcodec, etc.)
|
|
and ``ctx.obj`` (ContextData). Both flow through Click's parent chain.
|
|
"""
|
|
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 = {"proxy": proxy_param, "no_proxy": no_proxy}
|
|
if extra_params:
|
|
params.update(extra_params)
|
|
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 via ``param.get_default()`` and runs type coercion,
|
|
so we no longer have to inspect ``__init__`` or stitch defaults by hand. Extra
|
|
kwargs are pulled from ``data`` when the key matches a cli option name and is
|
|
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
|
|
return parent_ctx.invoke(service_module.cli, title=title, **extras)
|
|
|
|
|
|
def get_allowed_services(request: Optional[web.Request] = None) -> Optional[List[str]]:
|
|
"""Get effective service allowlist considering global + per-key config.
|
|
|
|
Returns None if all services are allowed.
|
|
"""
|
|
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 request:
|
|
secret_key = request.headers.get("X-Secret-Key")
|
|
if secret_key:
|
|
users = config.serve.get("users", {})
|
|
user_config = users.get(secret_key, {})
|
|
user_services = user_config.get("services")
|
|
if user_services:
|
|
key_set = {Services.get_tag(s) for s in user_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 validate_service(service_tag: str, request: Optional[web.Request] = None) -> Optional[str]:
|
|
"""Validate, normalize, and check allowlist for service tag."""
|
|
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 Exception:
|
|
return None
|
|
|
|
|
|
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
|
|
|
|
if isinstance(title, Episode):
|
|
episode_name = title.name if title.name else f"Episode {title.number:02d}"
|
|
result = {
|
|
"type": "episode",
|
|
"name": episode_name,
|
|
"series_title": str(title.title),
|
|
"season": title.season,
|
|
"number": title.number,
|
|
"year": title.year,
|
|
"id": str(title.id) if hasattr(title, "id") else None,
|
|
"language": title_language,
|
|
}
|
|
elif isinstance(title, Movie):
|
|
result = {
|
|
"type": "movie",
|
|
"name": str(title.name) if hasattr(title, "name") else str(title),
|
|
"year": title.year,
|
|
"id": str(title.id) if hasattr(title, "id") else None,
|
|
"language": title_language,
|
|
}
|
|
else:
|
|
result = {
|
|
"type": "other",
|
|
"name": str(title.name) if hasattr(title, "name") else str(title),
|
|
"id": str(title.id) if hasattr(title, "id") else None,
|
|
"language": title_language,
|
|
}
|
|
|
|
return result
|
|
|
|
|
|
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 no manifest serialization is needed.
|
|
"""
|
|
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"):
|
|
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"):
|
|
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 serialize_drm(drm_list) -> Optional[List[Dict[str, Any]]]:
|
|
"""Serialize 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()
|
|
|
|
# Get PSSH - handle both Widevine and PlayReady
|
|
if hasattr(drm, "_pssh") and drm._pssh:
|
|
pssh_obj = None
|
|
try:
|
|
pssh_obj = drm._pssh
|
|
# Try to get base64 representation
|
|
if hasattr(pssh_obj, "dumps"):
|
|
# pywidevine PSSH has dumps() method
|
|
drm_info["pssh"] = pssh_obj.dumps()
|
|
elif hasattr(pssh_obj, "__bytes__"):
|
|
# Convert to base64
|
|
import base64
|
|
|
|
drm_info["pssh"] = base64.b64encode(bytes(pssh_obj)).decode()
|
|
elif hasattr(pssh_obj, "to_base64"):
|
|
drm_info["pssh"] = pssh_obj.to_base64()
|
|
else:
|
|
# Fallback - str() works for pywidevine PSSH
|
|
pssh_str = str(pssh_obj)
|
|
# Check if it's already base64-like or an object repr
|
|
if not pssh_str.startswith("<"):
|
|
drm_info["pssh"] = pssh_str
|
|
except (ValueError, TypeError, KeyError):
|
|
# Some PSSH implementations can fail to parse/serialize; log and continue.
|
|
pssh_type = type(pssh_obj).__name__ if pssh_obj is not None else None
|
|
log.warning(
|
|
"Failed to extract/serialize PSSH for DRM type=%s pssh_type=%s",
|
|
drm_class,
|
|
pssh_type,
|
|
exc_info=True,
|
|
)
|
|
except Exception:
|
|
# Don't silently swallow unexpected failures; make them visible and propagate.
|
|
pssh_type = type(pssh_obj).__name__ if pssh_obj is not None else None
|
|
log.exception(
|
|
"Unexpected error while extracting/serializing PSSH for DRM type=%s pssh_type=%s",
|
|
drm_class,
|
|
pssh_type,
|
|
)
|
|
raise
|
|
|
|
# Get KIDs
|
|
if hasattr(drm, "kids") and drm.kids:
|
|
drm_info["kids"] = [str(kid) for kid in drm.kids]
|
|
|
|
# Get content keys if available
|
|
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 serialize_video_track(track: Video, include_url: bool = False) -> Dict[str, Any]:
|
|
"""Convert video track to JSON-serializable dict."""
|
|
codec_name = track.codec.name if hasattr(track.codec, "name") else str(track.codec)
|
|
range_name = track.range.name if hasattr(track.range, "name") else str(track.range)
|
|
|
|
# Serialize the manifest descriptor (HLS, DASH, URL, etc.)
|
|
descriptor_name = None
|
|
if hasattr(track, "descriptor") and track.descriptor:
|
|
descriptor_name = track.descriptor.name if hasattr(track.descriptor, "name") else str(track.descriptor)
|
|
|
|
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,
|
|
"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),
|
|
"language": str(track.language) if track.language else None,
|
|
"drm": serialize_drm(track.drm) if hasattr(track, "drm") and track.drm else None,
|
|
"descriptor": descriptor_name,
|
|
}
|
|
if include_url and hasattr(track, "url") and track.url:
|
|
result["url"] = str(track.url)
|
|
return result
|
|
|
|
|
|
def serialize_audio_track(track: Audio, include_url: bool = False) -> Dict[str, Any]:
|
|
"""Convert audio track to JSON-serializable dict."""
|
|
codec_name = track.codec.name if hasattr(track.codec, "name") else str(track.codec)
|
|
|
|
# Serialize the manifest descriptor (HLS, DASH, URL, etc.)
|
|
descriptor_name = None
|
|
if hasattr(track, "descriptor") and track.descriptor:
|
|
descriptor_name = track.descriptor.name if hasattr(track.descriptor, "name") else str(track.descriptor)
|
|
|
|
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,
|
|
"channels": track.channels if track.channels else None,
|
|
"language": str(track.language) if track.language else None,
|
|
"atmos": track.atmos if hasattr(track, "atmos") else False,
|
|
"descriptive": track.descriptive if hasattr(track, "descriptive") else False,
|
|
"drm": serialize_drm(track.drm) if hasattr(track, "drm") and track.drm else None,
|
|
"descriptor": descriptor_name,
|
|
}
|
|
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."""
|
|
# Get descriptor for compatibility
|
|
descriptor_name = None
|
|
if hasattr(track, "descriptor") and track.descriptor:
|
|
descriptor_name = track.descriptor.name if hasattr(track.descriptor, "name") else str(track.descriptor)
|
|
|
|
result = {
|
|
"id": str(track.id),
|
|
"codec": track.codec.name if hasattr(track.codec, "name") else str(track.codec),
|
|
"language": str(track.language) if track.language else None,
|
|
"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,
|
|
"descriptor": descriptor_name,
|
|
}
|
|
if include_url and hasattr(track, "url") and track.url:
|
|
result["url"] = str(track.url)
|
|
return result
|
|
|
|
|
|
async def search_handler(data: Dict[str, Any], request: Optional[web.Request] = None) -> web.Response:
|
|
"""Handle search request."""
|
|
from unshackle.commands.dl import dl
|
|
|
|
service_tag = data.get("service")
|
|
query = data.get("query")
|
|
|
|
if not service_tag:
|
|
raise APIError(APIErrorCode.MISSING_SERVICE, "Missing required 'service' field")
|
|
if not query:
|
|
raise APIError(APIErrorCode.INVALID_PARAMETERS, "Missing required 'query' field")
|
|
|
|
normalized_service = Services.get_tag(service_tag)
|
|
if not normalized_service:
|
|
raise APIError(
|
|
APIErrorCode.INVALID_SERVICE,
|
|
f"Service '{service_tag}' not found",
|
|
details={"service": service_tag},
|
|
)
|
|
|
|
allowed = get_allowed_services(request)
|
|
if allowed is not None and normalized_service not in allowed:
|
|
raise APIError(
|
|
APIErrorCode.INVALID_SERVICE,
|
|
f"Service '{service_tag}' not found",
|
|
details={"service": service_tag},
|
|
)
|
|
|
|
profile = data.get("profile")
|
|
proxy_param = data.get("proxy")
|
|
no_proxy = data.get("no_proxy", False)
|
|
|
|
service_config = load_service_yaml(normalized_service)
|
|
|
|
proxy_providers = []
|
|
if not no_proxy:
|
|
proxy_providers = initialize_proxy_providers()
|
|
|
|
if proxy_param and not no_proxy:
|
|
try:
|
|
resolved_proxy = resolve_proxy(proxy_param, proxy_providers)
|
|
proxy_param = resolved_proxy
|
|
except ValueError as e:
|
|
raise APIError(
|
|
APIErrorCode.INVALID_PROXY,
|
|
f"Proxy error: {e}",
|
|
details={"proxy": proxy_param, "service": normalized_service},
|
|
)
|
|
|
|
cdm = load_full_cdm(normalized_service, profile, data.get("cdm_type"))
|
|
parent_ctx = build_parent_ctx(profile, cdm, proxy_param, no_proxy, proxy_providers, service_config)
|
|
service_module = Services.load(normalized_service)
|
|
|
|
try:
|
|
service_instance = instantiate_service(parent_ctx, service_module, query)
|
|
except Exception as exc:
|
|
raise APIError(
|
|
APIErrorCode.SERVICE_ERROR,
|
|
f"Failed to initialize service: {exc}",
|
|
details={"service": normalized_service},
|
|
)
|
|
|
|
# Authenticate
|
|
cookies = dl.get_cookie_jar(normalized_service, profile)
|
|
credential = dl.get_credentials(normalized_service, profile)
|
|
service_instance.authenticate(cookies, credential)
|
|
|
|
# Search
|
|
results = []
|
|
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 web.json_response({"results": results, "count": len(results)})
|
|
|
|
|
|
async def list_titles_handler(data: Dict[str, Any], request: Optional[web.Request] = None) -> web.Response:
|
|
"""Handle list-titles request."""
|
|
service_tag = data.get("service")
|
|
title_id = data.get("title_id")
|
|
profile = data.get("profile")
|
|
|
|
if not service_tag:
|
|
raise APIError(
|
|
APIErrorCode.INVALID_INPUT,
|
|
"Missing required parameter: service",
|
|
details={"missing_parameter": "service"},
|
|
)
|
|
|
|
if not title_id:
|
|
raise APIError(
|
|
APIErrorCode.INVALID_INPUT,
|
|
"Missing required parameter: title_id",
|
|
details={"missing_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:
|
|
from unshackle.commands.dl import dl
|
|
|
|
service_config = load_service_yaml(normalized_service)
|
|
|
|
proxy_param = data.get("proxy")
|
|
no_proxy = data.get("no_proxy", False)
|
|
proxy_providers = []
|
|
|
|
if not no_proxy:
|
|
proxy_providers = initialize_proxy_providers()
|
|
|
|
if proxy_param and not no_proxy:
|
|
try:
|
|
resolved_proxy = resolve_proxy(proxy_param, proxy_providers)
|
|
proxy_param = resolved_proxy
|
|
except ValueError as e:
|
|
raise APIError(
|
|
APIErrorCode.INVALID_PROXY,
|
|
f"Proxy error: {e}",
|
|
details={"proxy": proxy_param, "service": normalized_service},
|
|
)
|
|
|
|
cdm = load_full_cdm(normalized_service, profile, data.get("cdm_type"))
|
|
parent_ctx = build_parent_ctx(profile, cdm, proxy_param, no_proxy, proxy_providers, service_config)
|
|
service_module = Services.load(normalized_service)
|
|
service_instance = instantiate_service(parent_ctx, service_module, title_id, data, LIST_HANDLER_TRANSPORT_KEYS)
|
|
|
|
cookies = dl.get_cookie_jar(normalized_service, profile)
|
|
credential = dl.get_credentials(normalized_service, profile)
|
|
service_instance.authenticate(cookies, credential)
|
|
|
|
titles = service_instance.get_titles()
|
|
|
|
if hasattr(titles, "__iter__") and not isinstance(titles, str):
|
|
title_list = [serialize_title(t) for t in titles]
|
|
else:
|
|
title_list = [serialize_title(titles)]
|
|
|
|
return web.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,
|
|
)
|
|
|
|
|
|
async def list_tracks_handler(data: Dict[str, Any], request: Optional[web.Request] = None) -> web.Response:
|
|
"""Handle list-tracks request."""
|
|
service_tag = data.get("service")
|
|
title_id = data.get("title_id")
|
|
profile = data.get("profile")
|
|
|
|
if not service_tag:
|
|
raise APIError(
|
|
APIErrorCode.INVALID_INPUT,
|
|
"Missing required parameter: service",
|
|
details={"missing_parameter": "service"},
|
|
)
|
|
|
|
if not title_id:
|
|
raise APIError(
|
|
APIErrorCode.INVALID_INPUT,
|
|
"Missing required parameter: title_id",
|
|
details={"missing_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:
|
|
from unshackle.commands.dl import dl
|
|
|
|
service_config = load_service_yaml(normalized_service)
|
|
|
|
proxy_param = data.get("proxy")
|
|
no_proxy = data.get("no_proxy", False)
|
|
proxy_providers = []
|
|
|
|
if not no_proxy:
|
|
proxy_providers = initialize_proxy_providers()
|
|
|
|
if proxy_param and not no_proxy:
|
|
try:
|
|
resolved_proxy = resolve_proxy(proxy_param, proxy_providers)
|
|
proxy_param = resolved_proxy
|
|
except ValueError as e:
|
|
raise APIError(
|
|
APIErrorCode.INVALID_PROXY,
|
|
f"Proxy error: {e}",
|
|
details={"proxy": proxy_param, "service": normalized_service},
|
|
)
|
|
|
|
cdm = load_full_cdm(normalized_service, profile, data.get("cdm_type"))
|
|
parent_ctx = build_parent_ctx(profile, cdm, proxy_param, no_proxy, proxy_providers, service_config)
|
|
service_module = Services.load(normalized_service)
|
|
service_instance = instantiate_service(parent_ctx, service_module, title_id, data, LIST_HANDLER_TRANSPORT_KEYS)
|
|
|
|
cookies = dl.get_cookie_jar(normalized_service, profile)
|
|
credential = dl.get_credentials(normalized_service, profile)
|
|
service_instance.authenticate(cookies, credential)
|
|
|
|
titles = service_instance.get_titles()
|
|
|
|
wanted_param = data.get("wanted")
|
|
season = data.get("season")
|
|
episode = data.get("episode")
|
|
|
|
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:
|
|
wanted = season_range.parse_tokens(wanted_param)
|
|
log.debug(f"Parsed wanted '{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}"]
|
|
|
|
if wanted:
|
|
# Filter titles based on wanted episodes, similar to how dl.py does it
|
|
matching_titles = []
|
|
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}"
|
|
if episode_key in 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")
|
|
else:
|
|
matching_titles.append(title)
|
|
|
|
log.debug(f"Found {len(matching_titles)} matching titles")
|
|
|
|
if not matching_titles:
|
|
raise APIError(
|
|
APIErrorCode.NO_CONTENT,
|
|
"No episodes found matching wanted criteria",
|
|
details={
|
|
"service": normalized_service,
|
|
"title_id": title_id,
|
|
"wanted": wanted_param or f"{season}x{episode}",
|
|
},
|
|
)
|
|
|
|
# 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))
|
|
|
|
for title in sorted_titles:
|
|
try:
|
|
tracks = 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)
|
|
|
|
episode_data = {
|
|
"title": serialize_title(title),
|
|
"video": [serialize_video_track(t) for t in video_tracks],
|
|
"audio": [serialize_audio_track(t) 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}")
|
|
log.debug(f"Episode {title.season}x{title.number} not available, skipping")
|
|
continue
|
|
except (Exception, SystemExit) as e:
|
|
# Handle other errors gracefully
|
|
failed_episodes.append(f"S{title.season}E{title.number:02d}")
|
|
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 web.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:
|
|
# Single episode or movie
|
|
first_title = matching_titles[0]
|
|
else:
|
|
first_title = titles_list[0]
|
|
else:
|
|
first_title = titles
|
|
|
|
tracks = 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)
|
|
|
|
response = {
|
|
"title": serialize_title(first_title),
|
|
"video": [serialize_video_track(t) for t in video_tracks],
|
|
"audio": [serialize_audio_track(t) for t in audio_tracks],
|
|
"subtitles": [serialize_subtitle_track(t) for t in tracks.subtitles],
|
|
}
|
|
|
|
return web.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,
|
|
)
|
|
|
|
|
|
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
|
|
"""
|
|
if "vcodec" in data and data["vcodec"]:
|
|
valid_vcodecs = ["H264", "H265", "H.264", "H.265", "AVC", "HEVC", "VC1", "VC-1", "VP8", "VP9", "AV1"]
|
|
if isinstance(data["vcodec"], str):
|
|
vcodec_values = [v.strip() for v in data["vcodec"].split(",") if v.strip()]
|
|
elif isinstance(data["vcodec"], list):
|
|
vcodec_values = [str(v).strip() for v in data["vcodec"] if str(v).strip()]
|
|
else:
|
|
return "vcodec must be a string or list"
|
|
|
|
invalid = [value for value in vcodec_values if value.upper() not in valid_vcodecs]
|
|
if invalid:
|
|
return f"Invalid vcodec: {', '.join(invalid)}. Must be one of: {', '.join(valid_vcodecs)}"
|
|
|
|
if "acodec" in data and data["acodec"]:
|
|
valid_acodecs = [
|
|
"AAC",
|
|
"AC3",
|
|
"EC3",
|
|
"EAC3",
|
|
"DD",
|
|
"DD+",
|
|
"AC4",
|
|
"OPUS",
|
|
"FLAC",
|
|
"ALAC",
|
|
"VORBIS",
|
|
"OGG",
|
|
"DTS",
|
|
]
|
|
if isinstance(data["acodec"], str):
|
|
acodec_values = [v.strip() for v in data["acodec"].split(",") if v.strip()]
|
|
elif isinstance(data["acodec"], list):
|
|
acodec_values = [str(v).strip() for v in data["acodec"] if str(v).strip()]
|
|
else:
|
|
return "acodec must be a string or list"
|
|
|
|
invalid = [value for value in acodec_values if value.upper() not in valid_acodecs]
|
|
if invalid:
|
|
return f"Invalid acodec: {', '.join(invalid)}. Must be one of: {', '.join(valid_acodecs)}"
|
|
|
|
if "sub_format" in data and data["sub_format"]:
|
|
valid_sub_formats = ["SRT", "VTT", "ASS", "SSA", "TTML", "STPP", "WVTT", "SMI", "SUB", "MPL2", "TMP"]
|
|
if 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 "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"
|
|
|
|
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"
|
|
|
|
if data.get("s_lang") and data.get("require_subs"):
|
|
return "Cannot use both s_lang and require_subs"
|
|
|
|
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
|
|
|
|
|
|
async def download_handler(data: Dict[str, Any], request: Optional[web.Request] = None) -> web.Response:
|
|
"""Handle download request - create and queue a download job."""
|
|
from unshackle.core.api.download_manager import get_download_manager
|
|
|
|
service_tag = data.get("service")
|
|
title_id = data.get("title_id")
|
|
|
|
if not service_tag:
|
|
raise APIError(
|
|
APIErrorCode.INVALID_INPUT,
|
|
"Missing required parameter: service",
|
|
details={"missing_parameter": "service"},
|
|
)
|
|
|
|
if not title_id:
|
|
raise APIError(
|
|
APIErrorCode.INVALID_INPUT,
|
|
"Missing required parameter: title_id",
|
|
details={"missing_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},
|
|
)
|
|
|
|
validation_error = validate_download_parameters(data)
|
|
if validation_error:
|
|
raise APIError(
|
|
APIErrorCode.INVALID_PARAMETERS,
|
|
validation_error,
|
|
details={"service": normalized_service, "title_id": title_id},
|
|
)
|
|
|
|
# A per-request `cdm` selects a server-side device, so it is gated here rather than honoured
|
|
# 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.
|
|
requested_cdm = data.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},
|
|
)
|
|
|
|
# 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; mirrors the CDM gate.
|
|
if data.get("credential") or data.get("credentials"):
|
|
if not (config.serve or {}).get("allow_job_credentials"):
|
|
raise APIError(
|
|
APIErrorCode.FORBIDDEN,
|
|
"Per-request credentials are not permitted for API downloads.",
|
|
)
|
|
|
|
try:
|
|
# Load service module to extract service-specific parameter defaults
|
|
service_module = Services.load(normalized_service)
|
|
service_specific_defaults = {}
|
|
|
|
# Extract default values from the service's click command.
|
|
# 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):
|
|
# Store service-specific defaults (e.g., drm_system, hydrate_track, profile for NF)
|
|
service_specific_defaults[param.name] = param.default
|
|
|
|
# Get download manager and start workers if needed
|
|
manager = get_download_manager()
|
|
await manager.start_workers()
|
|
|
|
# Create download job with filtered parameters (exclude service and title_id as they're already passed)
|
|
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,
|
|
}
|
|
job = manager.create_job(normalized_service, title_id, **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:
|
|
"""Handle list download jobs request with optional filtering and sorting."""
|
|
from unshackle.core.api.download_manager import get_download_manager
|
|
|
|
try:
|
|
manager = get_download_manager()
|
|
jobs = manager.list_jobs()
|
|
|
|
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 sorting key value, handling None values."""
|
|
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)
|
|
|
|
job_list = [job.to_dict(include_full_details=False) 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,
|
|
)
|
|
|
|
|
|
async def get_download_job_handler(job_id: str, request: Optional[web.Request] = None) -> web.Response:
|
|
"""Handle get specific download job request."""
|
|
from unshackle.core.api.download_manager import get_download_manager
|
|
|
|
try:
|
|
manager = get_download_manager()
|
|
job = manager.get_job(job_id)
|
|
|
|
if not job:
|
|
raise APIError(
|
|
APIErrorCode.JOB_NOT_FOUND,
|
|
"Job not found",
|
|
details={"job_id": job_id},
|
|
)
|
|
|
|
return web.json_response(job.to_dict(include_full_details=True))
|
|
|
|
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 cancel_download_job_handler(job_id: str, request: Optional[web.Request] = None) -> web.Response:
|
|
"""Handle cancel download job request."""
|
|
from unshackle.core.api.download_manager import get_download_manager
|
|
|
|
try:
|
|
manager = get_download_manager()
|
|
|
|
if not manager.get_job(job_id):
|
|
raise APIError(
|
|
APIErrorCode.JOB_NOT_FOUND,
|
|
"Job not found",
|
|
details={"job_id": job_id},
|
|
)
|
|
|
|
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,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Remote-DL Session Handlers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
SESSION_TRANSPORT_KEYS = {
|
|
"service",
|
|
"title_id",
|
|
"season",
|
|
"episode",
|
|
"wanted",
|
|
"proxy",
|
|
"no_proxy",
|
|
"credentials",
|
|
"cookies",
|
|
"cache",
|
|
"client_region",
|
|
"cdm_type",
|
|
"range_",
|
|
"vcodec",
|
|
"quality",
|
|
"best_available",
|
|
}
|
|
|
|
|
|
def _create_service_instance(
|
|
normalized_service: str,
|
|
title_id: str,
|
|
data: Dict[str, Any],
|
|
proxy_param: Optional[str],
|
|
proxy_providers: list,
|
|
profile: Optional[str],
|
|
) -> Any:
|
|
"""Create and authenticate a service instance.
|
|
|
|
Supports client-sent credentials/cookies (for remote-dl) with fallback
|
|
to server-local config (for backward compatibility).
|
|
"""
|
|
from unshackle.commands.dl import dl
|
|
from unshackle.core.credential import Credential
|
|
from unshackle.core.tracks import Video
|
|
|
|
service_config = load_service_yaml(normalized_service)
|
|
cdm = load_full_cdm(normalized_service, profile, 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_names = data.get("vcodec")
|
|
vcodec_values: Optional[list] = None
|
|
if vcodec_names:
|
|
vcodec_values = []
|
|
for name in vcodec_names:
|
|
try:
|
|
vcodec_values.append(Video.Codec[name])
|
|
except KeyError:
|
|
pass
|
|
vcodec_values = vcodec_values or None
|
|
|
|
extra_params = {
|
|
"range_": range_values,
|
|
"vcodec": vcodec_values,
|
|
"quality": data.get("quality"),
|
|
"best_available": data.get("best_available", False),
|
|
}
|
|
|
|
parent_ctx = build_parent_ctx(
|
|
profile,
|
|
cdm,
|
|
proxy_param,
|
|
data.get("no_proxy", False),
|
|
proxy_providers,
|
|
service_config,
|
|
extra_params=extra_params,
|
|
)
|
|
|
|
service_module = Services.load(normalized_service)
|
|
service_instance = instantiate_service(parent_ctx, service_module, title_id, data, SESSION_TRANSPORT_KEYS)
|
|
|
|
# Resolve credentials: client-sent > server-local
|
|
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"),
|
|
)
|
|
else:
|
|
credential = dl.get_credentials(normalized_service, profile)
|
|
|
|
# Resolve cookies: client-sent > server-local
|
|
cookie_text = data.get("cookies")
|
|
if cookie_text and isinstance(cookie_text, str):
|
|
import base64
|
|
import tempfile
|
|
import zlib
|
|
from http.cookiejar import MozillaCookieJar
|
|
|
|
cookie_str = zlib.decompress(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:
|
|
cookies = MozillaCookieJar(tmp_path)
|
|
cookies.load(ignore_discard=True, ignore_expires=True)
|
|
finally:
|
|
import os
|
|
|
|
os.unlink(tmp_path)
|
|
else:
|
|
cookies = dl.get_cookie_jar(normalized_service, profile)
|
|
|
|
return service_instance, cookies, credential
|
|
|
|
|
|
async def session_create_handler(data: Dict[str, Any], request: Optional[web.Request] = None) -> web.Response:
|
|
"""Handle session creation: authenticate + get titles + get tracks + get chapters.
|
|
|
|
This is the main entry point for remote-dl clients. It creates a persistent
|
|
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 = data.get("profile")
|
|
|
|
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 = _resolve_handler_proxy(data, normalized_service)
|
|
|
|
import hashlib
|
|
import uuid as uuid_mod
|
|
|
|
from unshackle.core.cacher import Cacher
|
|
from unshackle.core.config import config as app_config
|
|
|
|
session_id = str(uuid_mod.uuid4())
|
|
api_key = request.headers.get("X-Secret-Key", "anonymous") if request else "anonymous"
|
|
api_key_hash = hashlib.sha256(api_key.encode()).hexdigest()[:12]
|
|
session_cache_tag = f"_sessions/{api_key_hash}/{session_id}/{normalized_service}"
|
|
|
|
service_instance, cookies, credential = _create_service_instance(
|
|
normalized_service,
|
|
title_id,
|
|
data,
|
|
proxy_param,
|
|
proxy_providers,
|
|
profile,
|
|
)
|
|
|
|
service_instance.cache = Cacher(session_cache_tag)
|
|
|
|
cache_data = data.get("cache", {})
|
|
if cache_data:
|
|
import base64
|
|
import zlib
|
|
|
|
cache_dir = app_config.directories.cache / session_cache_tag
|
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
for key, content in cache_data.items():
|
|
decompressed = zlib.decompress(base64.b64decode(content)).decode("utf-8")
|
|
(cache_dir / key).with_suffix(".json").write_text(decompressed, encoding="utf-8")
|
|
|
|
bridge = InputBridge()
|
|
service_instance._input_bridge = bridge
|
|
|
|
store = get_session_store()
|
|
session = await store.create(
|
|
normalized_service,
|
|
service_instance,
|
|
session_id=session_id,
|
|
)
|
|
session.creator_ip = request.remote if request else None
|
|
session.cache_tag = session_cache_tag
|
|
session.input_bridge = bridge
|
|
session.auth_status = AuthStatus.AUTHENTICATING
|
|
|
|
async def _run_auth() -> None:
|
|
try:
|
|
await asyncio.to_thread(service_instance.authenticate, cookies, credential)
|
|
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 = str(e)
|
|
bridge.status = AuthStatus.FAILED
|
|
bridge.error = str(e)
|
|
|
|
asyncio.create_task(_run_auth())
|
|
|
|
return web.json_response(
|
|
{
|
|
"session_id": session.session_id,
|
|
"service": normalized_service,
|
|
"status": "authenticating",
|
|
}
|
|
)
|
|
|
|
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 session.
|
|
|
|
Called after session/create. This is separate from auth so that
|
|
interactive auth flows (OTP, captcha) can complete before titles
|
|
are fetched.
|
|
"""
|
|
session = await _get_validated_session(session_id, request)
|
|
_require_authenticated(session)
|
|
|
|
try:
|
|
service_instance = session.service_instance
|
|
titles = service_instance.get_titles()
|
|
session.titles = titles
|
|
|
|
# Serialize titles and build title map
|
|
if hasattr(titles, "__iter__") and not isinstance(titles, str):
|
|
titles_list = list(titles)
|
|
else:
|
|
titles_list = [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(serialize_title(t))
|
|
|
|
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,
|
|
)
|
|
|
|
|
|
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 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 any tracks are requested.
|
|
"""
|
|
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())},
|
|
)
|
|
|
|
try:
|
|
service_instance = session.service_instance
|
|
tracks = service_instance.get_tracks(title)
|
|
|
|
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
|
|
|
|
try:
|
|
chapters = 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)
|
|
|
|
svc_session = session.service_instance.session
|
|
session_headers = dict(svc_session.headers) if hasattr(svc_session, "headers") else {}
|
|
session_cookies = {}
|
|
if hasattr(svc_session, "cookies"):
|
|
for cookie in svc_session.cookies:
|
|
if hasattr(cookie, "name") and hasattr(cookie, "value"):
|
|
session_cookies[cookie.name] = cookie.value
|
|
|
|
from unshackle.core.config import config as app_config
|
|
|
|
api_key = request.headers.get("X-Secret-Key", "anonymous") if request else "anonymous"
|
|
user_cfg = app_config.serve.get("users", {}).get(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"
|
|
|
|
return web.json_response(
|
|
{
|
|
"title": serialize_title(title),
|
|
"video": [serialize_video_track(t, include_url=True) for t in video_tracks],
|
|
"audio": [serialize_audio_track(t, include_url=True) for t in audio_tracks],
|
|
"subtitles": [serialize_subtitle_track(t, include_url=True) for t in tracks.subtitles],
|
|
"chapters": [
|
|
{"timestamp": ch.timestamp, "name": ch.name}
|
|
for ch in session.chapters_by_title.get(str(title_id), [])
|
|
],
|
|
"attachments": [
|
|
{"url": a.url, "name": a.name, "mime_type": a.mime_type, "description": a.description}
|
|
for a in tracks.attachments
|
|
if hasattr(a, "url") and a.url
|
|
],
|
|
"manifests": manifests,
|
|
"session_headers": session_headers,
|
|
"session_cookies": session_cookies,
|
|
"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:
|
|
"""Resolve 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)
|
|
|
|
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,
|
|
}
|
|
|
|
# Extract session headers/cookies for CDN access
|
|
service_session = session.service_instance.session
|
|
if hasattr(service_session, "headers"):
|
|
# Only include relevant headers, not all session headers
|
|
headers = dict(service_session.headers) if service_session.headers else {}
|
|
track_info["headers"] = headers
|
|
else:
|
|
track_info["headers"] = {}
|
|
|
|
if hasattr(service_session, "cookies"):
|
|
cookie_dict = {}
|
|
for cookie in service_session.cookies:
|
|
if hasattr(cookie, "name") and hasattr(cookie, "value"):
|
|
cookie_dict[cookie.name] = cookie.value
|
|
elif isinstance(cookie, str):
|
|
pass # Skip non-standard cookie objects
|
|
track_info["cookies"] = cookie_dict
|
|
else:
|
|
track_info["cookies"] = {}
|
|
|
|
# Include manifest-specific data for segment resolution
|
|
if hasattr(track, "data") and track.data:
|
|
track_data = {}
|
|
for key, val in track.data.items():
|
|
if isinstance(val, dict):
|
|
# Convert non-serializable values
|
|
serializable = {}
|
|
for k, v in val.items():
|
|
try:
|
|
import json
|
|
|
|
json.dumps(v)
|
|
serializable[k] = v
|
|
except (TypeError, ValueError):
|
|
serializable[k] = str(v)
|
|
track_data[key] = serializable
|
|
else:
|
|
try:
|
|
import json
|
|
|
|
json.dumps(val)
|
|
track_data[key] = val
|
|
except (TypeError, ValueError):
|
|
track_data[key] = str(val)
|
|
track_info["data"] = track_data
|
|
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,
|
|
)
|
|
|
|
|
|
class _CdmTypeStub:
|
|
"""Lightweight CDM stub so ``is_playready_cdm()`` can detect CDM type.
|
|
|
|
Used on the server when the client sends ``cdm_type`` but the server
|
|
does not need a full CDM (e.g. for cache key / device selection only).
|
|
"""
|
|
|
|
def __init__(self, cdm_type: str) -> None:
|
|
self.is_playready = cdm_type == "playready"
|
|
|
|
|
|
def _resolve_server_cdm(service: str, profile: Optional[str], cdm_type: Optional[str]) -> Optional[Any]:
|
|
"""Resolve 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
|
|
ensures that when ``server_cdm: true`` is used, the server's CDM
|
|
determines device selection (e.g. PlayReady vs Widevine for AMZN).
|
|
|
|
Falls back to a lightweight stub from *cdm_type* only if no server
|
|
CDM is configured for the service.
|
|
"""
|
|
from unshackle.core.config import config as app_config
|
|
|
|
cdm_name = app_config.cdm.get(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 _CdmTypeStub(detected_type)
|
|
|
|
if cdm_type:
|
|
return _CdmTypeStub(cdm_type)
|
|
return None
|
|
|
|
|
|
def _detect_cdm_type_for_service(service: str, app_config: Any) -> Optional[str]:
|
|
"""Detect the CDM type configured for a service in config.cdm."""
|
|
cdm_name = app_config.cdm.get(service)
|
|
if not cdm_name:
|
|
return None
|
|
if isinstance(cdm_name, dict):
|
|
lower_keys = {k.lower(): v for k, v in cdm_name.items()}
|
|
if {"widevine", "playready"} & lower_keys.keys():
|
|
return "playready" if "playready" in lower_keys else "widevine"
|
|
cdm_name = cdm_name.get("default") or next(iter(cdm_name.values()), None)
|
|
if cdm_name and isinstance(cdm_name, str):
|
|
return _detect_cdm_type(cdm_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 session has not finished authenticating."""
|
|
if session.auth_status == AuthStatus.FAILED:
|
|
raise APIError(
|
|
APIErrorCode.AUTH_FAILED,
|
|
f"Authentication failed: {session.auth_error or 'unknown error'}",
|
|
)
|
|
if session.auth_status in (AuthStatus.AUTHENTICATING, AuthStatus.PENDING_INPUT):
|
|
raise APIError(
|
|
APIErrorCode.INVALID_INPUT,
|
|
f"Session authentication not complete (status: {session.auth_status.value})",
|
|
details={"auth_status": session.auth_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.
|
|
|
|
Returns the current auth status and any pending prompt that the
|
|
remote client should display to the user.
|
|
"""
|
|
session = await _get_validated_session(session_id, request)
|
|
|
|
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"})
|
|
|
|
bridge = session.input_bridge
|
|
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")
|
|
|
|
bridge.submit_response(str(response_text))
|
|
return web.json_response({"status": "accepted"})
|
|
|
|
|
|
async def _get_validated_session(session_id: str, request: Optional[web.Request]) -> Any:
|
|
"""Fetch a session and verify the requesting IP matches the creator."""
|
|
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.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) -> tuple[Optional[str], list]:
|
|
"""Resolve proxy and initialize providers from API request data.
|
|
|
|
Handles explicit proxy param, provider:country format, and
|
|
client_region-based auto-proxy when server region differs.
|
|
"""
|
|
proxy_param = data.get("proxy")
|
|
no_proxy = data.get("no_proxy", False)
|
|
proxy_providers: list = []
|
|
|
|
if 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:
|
|
raise APIError(
|
|
APIErrorCode.INVALID_PROXY,
|
|
f"Proxy error: {e}",
|
|
details={"proxy": data.get("proxy"), "service": normalized_service},
|
|
)
|
|
|
|
client_region = data.get("client_region")
|
|
if not proxy_param and not no_proxy and client_region and proxy_providers:
|
|
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:
|
|
server_region = None
|
|
|
|
if server_region and server_region == client_region.lower():
|
|
log.info(f"Server already in client region '{client_region}', no proxy needed")
|
|
else:
|
|
try:
|
|
proxy_param = resolve_proxy(client_region, proxy_providers)
|
|
log.info(f"Using server proxy for client region '{client_region}'")
|
|
except ValueError:
|
|
log.debug(f"No server proxy available for client region '{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 next(iter(session.title_map.values()))
|
|
return None
|
|
|
|
|
|
def _extract_pssh_from_track(track: Any, drm_type: str) -> Optional[str]:
|
|
"""Extract PSSH base64 string from a track's DRM objects."""
|
|
if not track.drm:
|
|
return None
|
|
pssh_b64 = None
|
|
for drm_obj in track.drm:
|
|
drm_class = drm_obj.__class__.__name__
|
|
if drm_class == "Widevine" and hasattr(drm_obj, "_pssh") and drm_obj._pssh:
|
|
if hasattr(drm_obj._pssh, "dumps"):
|
|
pssh_b64 = drm_obj._pssh.dumps()
|
|
if drm_type == "widevine":
|
|
break
|
|
elif drm_class == "PlayReady":
|
|
if hasattr(drm_obj, "data") and drm_obj.data.get("pssh_b64"):
|
|
pssh_b64 = drm_obj.data["pssh_b64"]
|
|
if drm_type == "playready":
|
|
break
|
|
return pssh_b64
|
|
|
|
|
|
def _ensure_track_drm(track: Any) -> None:
|
|
"""Extract DRM from manifest data if track has none.
|
|
|
|
Supports DASH (ContentProtection elements), HLS (EXT-X-KEY from
|
|
playlist fetch), and ISM (ProtectionHeader elements).
|
|
"""
|
|
if track.drm:
|
|
return
|
|
|
|
# DASH: extract from ContentProtection elements
|
|
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
|
|
|
|
# HLS: fetch playlist and extract DRM from EXT-X-KEY
|
|
if track.data.get("hls") and track.url:
|
|
try:
|
|
import m3u8
|
|
|
|
from unshackle.core.drm import PlayReady, Widevine
|
|
from unshackle.core.manifests import HLS
|
|
|
|
playlist = 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
|
|
except Exception:
|
|
continue
|
|
except Exception:
|
|
pass
|
|
|
|
# ISM: extract from ProtectionHeader elements
|
|
if track.data.get("ism"):
|
|
try:
|
|
from unshackle.core.manifests import ISM as ISMManifest
|
|
|
|
stream_index = track.data["ism"].get("stream_index")
|
|
if stream_index is not None:
|
|
track.drm = ISMManifest.get_drm(stream_index)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
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 = app_config.cdm.get(service_tag) if service_tag else None
|
|
if isinstance(cdm_name, dict):
|
|
drm_key = {"widevine": "widevine", "playready": "playready"}.get(drm_type)
|
|
lower_keys = {k.lower(): v for k, v in cdm_name.items()}
|
|
cdm_name = lower_keys.get(drm_key) or lower_keys.get("default") or app_config.cdm.get("default")
|
|
if cdm_name and isinstance(cdm_name, str):
|
|
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 _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[Dict[str, str]]:
|
|
"""Check server vaults for existing keys matching all KIDs.
|
|
|
|
Returns a KID:KEY dict if ALL KIDs are found, None otherwise.
|
|
"""
|
|
from uuid import UUID
|
|
|
|
try:
|
|
vaults = _load_server_vaults(service_name)
|
|
if not vaults.vaults:
|
|
return None
|
|
keys: 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
|
|
else:
|
|
return None
|
|
if keys:
|
|
log.info(f"Vault hit: {len(keys)} key(s) from server vaults, skipping CDM")
|
|
return keys
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def _cache_to_vaults(keys: Dict[str, str], service_name: str) -> None:
|
|
"""Cache newly obtained keys to server vaults."""
|
|
from uuid import UUID
|
|
|
|
try:
|
|
vaults = _load_server_vaults(service_name)
|
|
if not vaults.vaults:
|
|
return
|
|
|
|
key_map = {UUID(hex=kid): key for kid, key in keys.items()}
|
|
cached = vaults.add_keys(key_map)
|
|
if cached:
|
|
log.info(f"Cached {cached} key(s) to {cached} server vault(s)")
|
|
except (Exception, SystemExit) as e:
|
|
log.warning(f"Failed to cache keys to vaults: {e}")
|
|
|
|
|
|
def _handle_single_server_cdm(
|
|
service: Any,
|
|
title: Any,
|
|
track: Any,
|
|
pssh_b64: Optional[str],
|
|
drm_type: str,
|
|
request: Optional[web.Request],
|
|
) -> Dict[str, str]:
|
|
"""Handle single-track server_cdm licensing using the DRM class get_content_keys() flow."""
|
|
import base64
|
|
|
|
from unshackle.core.cdm import load_cdm
|
|
from unshackle.core.config import config as app_config
|
|
|
|
_ensure_track_drm(track)
|
|
|
|
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")
|
|
|
|
api_key = request.headers.get("X-Secret-Key", "anonymous") if request else "anonymous"
|
|
user_config = app_config.serve.get("users", {}).get(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))
|
|
pr_drm = PlayReady(pssh=pr_pssh, pssh_b64=pssh_b64)
|
|
|
|
vault_keys = _check_vaults(pr_drm.kids, service.__class__.__name__)
|
|
if vault_keys:
|
|
return vault_keys
|
|
|
|
device_name = _resolve_device_name(user_config, drm_type, service.__class__.__name__)
|
|
cdm = load_cdm(device_name, service_name=service.__class__.__name__)
|
|
pr_drm.get_content_keys(
|
|
cdm=cdm,
|
|
certificate=lambda challenge, **_: None,
|
|
licence=lambda challenge, **_: service.get_playready_license(challenge=challenge, title=title, track=track),
|
|
)
|
|
keys = {kid.hex: key for kid, key in pr_drm.content_keys.items()}
|
|
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)
|
|
|
|
vault_keys = _check_vaults(wv_drm.kids, service.__class__.__name__)
|
|
if vault_keys:
|
|
return vault_keys
|
|
|
|
device_name = _resolve_device_name(user_config, drm_type, service.__class__.__name__)
|
|
cdm = load_cdm(device_name, service_name=service.__class__.__name__)
|
|
wv_drm.get_content_keys(
|
|
cdm=cdm,
|
|
certificate=lambda challenge, **_: service.get_widevine_service_certificate(
|
|
challenge=challenge, title=title, track=track
|
|
),
|
|
licence=lambda challenge, **_: service.get_widevine_license(challenge=challenge, title=title, track=track),
|
|
)
|
|
keys = {kid.hex: key for kid, key in wv_drm.content_keys.items()}
|
|
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,
|
|
) -> web.Response:
|
|
"""Forward a client CDM challenge to the service license endpoint."""
|
|
import base64
|
|
|
|
if not challenge_b64:
|
|
raise APIError(APIErrorCode.INVALID_INPUT, "Missing required parameter: challenge")
|
|
challenge_bytes = base64.b64decode(challenge_b64)
|
|
|
|
if drm_type == "widevine":
|
|
license_response = service.get_widevine_license(challenge=challenge_bytes, title=title, track=track)
|
|
elif drm_type == "playready":
|
|
license_response = service.get_playready_license(challenge=challenge_bytes, title=title, track=track)
|
|
else:
|
|
raise APIError(
|
|
APIErrorCode.INVALID_PARAMETERS,
|
|
f"Unsupported DRM type: {drm_type}",
|
|
details={"drm_type": drm_type, "supported": ["widevine", "playready"]},
|
|
)
|
|
|
|
if isinstance(license_response, str):
|
|
license_response = license_response.encode("utf-8")
|
|
|
|
return web.json_response({"license": base64.b64encode(license_response).decode("ascii")})
|
|
|
|
|
|
async def session_license_handler(
|
|
data: Dict[str, Any], session_id: str, request: Optional[web.Request] = None
|
|
) -> web.Response:
|
|
"""Handle 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 generate
|
|
the challenge, obtain the license, and extract KID:KEY pairs. Supports
|
|
batch (track_ids list) and single-track requests.
|
|
"""
|
|
import base64
|
|
|
|
session = await _get_validated_session(session_id, request)
|
|
|
|
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")
|
|
|
|
if mode == "server_cdm" and track_ids:
|
|
from unshackle.core.config import config as app_config
|
|
|
|
api_key = request.headers.get("X-Secret-Key", "anonymous") if request else "anonymous"
|
|
user_config = app_config.serve.get("users", {}).get(api_key, {})
|
|
service = session.service_instance
|
|
has_wv_device = bool(user_config.get("devices"))
|
|
has_pr_device = bool(user_config.get("playready_devices"))
|
|
|
|
service_tag = session.service_tag
|
|
config_cdm_type = _detect_cdm_type_for_service(service_tag, app_config)
|
|
|
|
all_keys: Dict[str, Dict[str, str]] = {}
|
|
seen_pssh: set[str] = set()
|
|
actual_drm_type: Optional[str] = None
|
|
|
|
for tid in track_ids:
|
|
track = session.tracks.get(tid)
|
|
if not track:
|
|
continue
|
|
|
|
_ensure_track_drm(track)
|
|
if not track.drm:
|
|
continue
|
|
|
|
title = _find_title_for_track(tid, session)
|
|
|
|
track_drm_type = None
|
|
pssh_str = None
|
|
if config_cdm_type == "playready":
|
|
pssh_str = _extract_pssh_from_track(track, "playready")
|
|
if pssh_str:
|
|
track_drm_type = "playready"
|
|
if not pssh_str:
|
|
pssh_str = _extract_pssh_from_track(track, "widevine")
|
|
if pssh_str:
|
|
track_drm_type = "widevine"
|
|
elif config_cdm_type == "widevine":
|
|
pssh_str = _extract_pssh_from_track(track, "widevine")
|
|
if pssh_str:
|
|
track_drm_type = "widevine"
|
|
if not pssh_str:
|
|
pssh_str = _extract_pssh_from_track(track, "playready")
|
|
if pssh_str:
|
|
track_drm_type = "playready"
|
|
else:
|
|
if has_wv_device:
|
|
pssh_str = _extract_pssh_from_track(track, "widevine")
|
|
if pssh_str:
|
|
track_drm_type = "widevine"
|
|
if not pssh_str and has_pr_device:
|
|
pssh_str = _extract_pssh_from_track(track, "playready")
|
|
if pssh_str:
|
|
track_drm_type = "playready"
|
|
|
|
if not pssh_str or not track_drm_type:
|
|
continue
|
|
|
|
if pssh_str in seen_pssh:
|
|
for prev_keys in all_keys.values():
|
|
if prev_keys:
|
|
all_keys[tid] = prev_keys
|
|
break
|
|
continue
|
|
seen_pssh.add(pssh_str)
|
|
|
|
try:
|
|
keys = _handle_single_server_cdm(service, title, track, pssh_str, track_drm_type, request)
|
|
if keys:
|
|
all_keys[tid] = keys
|
|
if track_drm_type:
|
|
actual_drm_type = track_drm_type
|
|
except SystemExit:
|
|
log.warning(f"Service exited while resolving keys for track {tid[:12]}, skipping")
|
|
except (Exception, SystemExit) as e:
|
|
log.warning(f"Failed to resolve keys for track {tid[:12]}: {e}")
|
|
|
|
response: Dict[str, Any] = {"keys": all_keys}
|
|
if actual_drm_type:
|
|
response["drm_type"] = actual_drm_type
|
|
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:
|
|
if not track.drm:
|
|
track.drm = []
|
|
if drm_type == "playready":
|
|
track.pr_pssh = pssh_b64
|
|
from pyplayready.system.pssh import PSSH as PlayReadyPSSH
|
|
|
|
from unshackle.core.drm import PlayReady
|
|
|
|
pr_pssh = PlayReadyPSSH(base64.b64decode(pssh_b64))
|
|
pr_drm = PlayReady(pssh=pr_pssh, pssh_b64=pssh_b64)
|
|
track.drm.append(pr_drm)
|
|
elif drm_type == "widevine":
|
|
from pywidevine.pssh import PSSH as WidevinePSSH
|
|
|
|
from unshackle.core.drm import Widevine
|
|
|
|
wv_pssh = WidevinePSSH(pssh_b64)
|
|
wv_drm = Widevine(pssh=wv_pssh)
|
|
track.drm.append(wv_drm)
|
|
|
|
if mode == "server_cdm":
|
|
keys = _handle_single_server_cdm(service, title, track, pssh_b64, drm_type, request)
|
|
log.info(f"Server CDM resolved {len(keys)} key(s) for track {track_id[:12]}")
|
|
return web.json_response({"keys": keys})
|
|
|
|
return _handle_proxy_license(service, title, track, challenge_b64, drm_type)
|
|
|
|
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 {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,
|
|
)
|
|
|
|
|
|
async def session_info_handler(session_id: str, request: Optional[web.Request] = None) -> web.Response:
|
|
"""Check session validity and get 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 session, return updated cache files, and clean up server-side data."""
|
|
import base64
|
|
import zlib
|
|
|
|
from unshackle.core.api.session_store import get_session_store
|
|
from unshackle.core.config import config as app_config
|
|
|
|
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: Dict[str, str] = {}
|
|
if cache_tag:
|
|
cache_dir = app_config.directories.cache / cache_tag
|
|
if cache_dir.is_dir():
|
|
for f in cache_dir.glob("*.json"):
|
|
if not f.stem.startswith("titles_"):
|
|
try:
|
|
cache_data[f.stem] = base64.b64encode(zlib.compress(f.read_bytes())).decode("ascii")
|
|
except Exception:
|
|
pass
|
|
|
|
await store.delete(session_id)
|
|
|
|
response: Dict[str, Any] = {"status": "ok"}
|
|
if cache_data:
|
|
response["cache"] = cache_data
|
|
return web.json_response(response)
|