refactor(api): dedup handler serializers, validation, and service setup

Collapse repeated serializer/validation/CDM/list-setup logic in handlers.py behind small helpers; output behaviour unchanged.
This commit is contained in:
imSp4rky
2026-06-21 13:23:22 -06:00
parent 8a9a1e1bdc
commit fb90cd87b0
2 changed files with 152 additions and 265 deletions
+136 -265
View File
@@ -1,6 +1,7 @@
import asyncio
import enum
import logging
from types import SimpleNamespace
from typing import Any, Dict, List, Optional
from aiohttp import web
@@ -200,6 +201,44 @@ def instantiate_service(
return parent_ctx.invoke(service_module.cli, title=title, **extras)
def setup_list_service(data: Dict[str, Any], normalized_service: str, profile: Optional[str], title_id: str) -> Any:
"""Build and authenticate a service instance for list_titles / list_tracks.
Runs the shared preamble: load yaml → resolve proxy → load CDM → build ctx →
instantiate → authenticate. Raises APIError on proxy failure.
"""
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:
proxy_param = resolve_proxy(proxy_param, proxy_providers)
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)
return service_instance
def get_allowed_services(request: Optional[web.Request] = None) -> Optional[List[str]]:
"""Get effective service allowlist considering global + per-key config.
@@ -247,6 +286,17 @@ def validate_service(service_tag: str, request: Optional[web.Request] = None) ->
return None
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 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
@@ -258,42 +308,28 @@ def serialize_title(title: Title_T) -> Dict[str, Any]:
date = _data.get("date") if isinstance(_data, dict) else None
cover_url = _data.get("cover_url") if isinstance(_data, dict) 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,
"description": description,
"date": date,
"cover_url": cover_url,
}
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,
"description": description,
"date": date,
"cover_url": cover_url,
}
is_episode = isinstance(title, Episode)
if is_episode:
name = title.name if title.name else f"Episode {title.number:02d}"
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,
"description": description,
"date": date,
"cover_url": cover_url,
}
name = str(title.name) if hasattr(title, "name") else str(title)
result = {
"type": "episode" if is_episode else "movie" if isinstance(title, Movie) 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 do.
if isinstance(title, (Episode, Movie)):
result["year"] = title.year
if is_episode:
result["series_title"] = str(title.title)
result["season"] = title.season
result["number"] = title.number
return result
@@ -362,46 +398,19 @@ def serialize_drm(drm_list) -> Optional[List[Dict[str, Any]]]:
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
# PSSH: pywidevine exposes dumps(); pyplayready's PSSH has no base64 method
# here, so PlayReady omits the field (unchanged from prior behaviour).
pssh_obj = getattr(drm, "_pssh", None)
if pssh_obj is not None and hasattr(pssh_obj, "dumps"):
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
drm_info["pssh"] = pssh_obj.dumps()
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",
"Failed to serialize PSSH for DRM type=%s pssh_type=%s",
drm_class,
pssh_type,
type(pssh_obj).__name__,
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:
@@ -422,15 +431,21 @@ def serialize_drm(drm_list) -> Optional[List[Dict[str, Any]]]:
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 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)
codec_name = enum_name(track.codec)
range_name = enum_name(track.range)
result = {
"id": str(track.id),
@@ -445,7 +460,7 @@ def serialize_video_track(track: Video, include_url: bool = False) -> Dict[str,
"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,
"descriptor": descriptor_name(track),
}
if include_url and hasattr(track, "url") and track.url:
result["url"] = str(track.url)
@@ -454,12 +469,7 @@ def serialize_video_track(track: Video, include_url: bool = False) -> Dict[str,
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)
codec_name = enum_name(track.codec)
result = {
"id": str(track.id),
@@ -471,7 +481,7 @@ def serialize_audio_track(track: Audio, include_url: bool = False) -> Dict[str,
"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,
"descriptor": descriptor_name(track),
}
if include_url and hasattr(track, "url") and track.url:
result["url"] = str(track.url)
@@ -480,19 +490,14 @@ def serialize_audio_track(track: Audio, include_url: bool = False) -> Dict[str,
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),
"codec": enum_name(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,
"descriptor": descriptor_name(track),
}
if include_url and hasattr(track, "url") and track.url:
result["url"] = str(track.url)
@@ -591,24 +596,11 @@ async def search_handler(data: Dict[str, Any], request: Optional[web.Request] =
async def list_titles_handler(data: Dict[str, Any], request: Optional[web.Request] = None) -> web.Response:
"""Handle list-titles request."""
require_fields(data, "service", "title_id")
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(
@@ -618,37 +610,7 @@ async def list_titles_handler(data: Dict[str, Any], request: Optional[web.Reques
)
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)
service_instance = setup_list_service(data, normalized_service, profile, title_id)
titles = service_instance.get_titles()
if hasattr(titles, "__iter__") and not isinstance(titles, str):
@@ -672,24 +634,11 @@ async def list_titles_handler(data: Dict[str, Any], request: Optional[web.Reques
async def list_tracks_handler(data: Dict[str, Any], request: Optional[web.Request] = None) -> web.Response:
"""Handle list-tracks request."""
require_fields(data, "service", "title_id")
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(
@@ -699,37 +648,7 @@ async def list_tracks_handler(data: Dict[str, Any], request: Optional[web.Reques
)
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)
service_instance = setup_list_service(data, normalized_service, profile, title_id)
titles = service_instance.get_titles()
wanted_param = data.get("wanted")
@@ -871,6 +790,25 @@ async def list_tracks_handler(data: Dict[str, Any], request: Optional[web.Reques
)
VALID_VCODECS = ["H264", "H265", "H.264", "H.265", "AVC", "HEVC", "VC1", "VC-1", "VP8", "VP9", "AV1"]
VALID_ACODECS = ["AAC", "AC3", "EC3", "EAC3", "DD", "DD+", "AC4", "OPUS", "FLAC", "ALAC", "VORBIS", "OGG", "DTS"]
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.
@@ -879,44 +817,14 @@ def validate_download_parameters(data: Dict[str, Any]) -> Optional[str]:
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)}"
err = check_codec(data["vcodec"], VALID_VCODECS, "vcodec")
if err:
return err
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)}"
err = check_codec(data["acodec"], VALID_ACODECS, "acodec")
if err:
return err
if "sub_format" in data and data["sub_format"]:
valid_sub_formats = ["SRT", "VTT", "ASS", "SSA", "TTML", "STPP", "WVTT", "SMI", "SUB", "MPL2", "TMP"]
@@ -988,23 +896,10 @@ async def download_handler(data: Dict[str, Any], request: Optional[web.Request]
"""Handle download request - create and queue a download job."""
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")
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(
@@ -1686,31 +1581,12 @@ async def session_segments_handler(
else:
track_info["cookies"] = {}
# Include manifest-specific data for segment resolution
# 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:
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
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
track_info["data"] = json.loads(json.dumps(track.data, default=str))
else:
track_info["data"] = {}
@@ -1730,15 +1606,10 @@ async def session_segments_handler(
)
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 _cdm_type_stub(cdm_type: str) -> SimpleNamespace:
"""Lightweight CDM stand-in so ``is_playready_cdm()`` (reads ``.is_playready``)
can detect the type without loading a full CDM."""
return SimpleNamespace(is_playready=cdm_type == "playready")
def _resolve_server_cdm(service: str, profile: Optional[str], cdm_type: Optional[str]) -> Optional[Any]:
@@ -1766,10 +1637,10 @@ def _resolve_server_cdm(service: str, profile: Optional[str], cdm_type: Optional
if cdm_name and isinstance(cdm_name, str):
detected_type = _detect_cdm_type(cdm_name, app_config)
if detected_type:
return _CdmTypeStub(detected_type)
return _cdm_type_stub(detected_type)
if cdm_type:
return _CdmTypeStub(cdm_type)
return _cdm_type_stub(cdm_type)
return None