diff --git a/tests/remote/unit/test_handlers_serialize.py b/tests/remote/unit/test_handlers_serialize.py index 73efc990..473b0fe9 100644 --- a/tests/remote/unit/test_handlers_serialize.py +++ b/tests/remote/unit/test_handlers_serialize.py @@ -199,6 +199,22 @@ def test_serialize_drm_widevine_minimal() -> None: assert info["license_url"] == "https://lic.example.com/wv" +def test_serialize_drm_playready_pssh_without_dumps_is_omitted() -> None: + # pyplayready's PSSH exposes no dumps()/to_base64()/__bytes__; serialization + # must omit the pssh field rather than emit an object repr. + class _PSSH: + pass + + class _PlayReady: + def __init__(self) -> None: + self._pssh = _PSSH() + self.kids = ["00112233445566778899aabbccddeeff"] + + info = serialize_drm(_PlayReady())[0] + assert "pssh" not in info + assert info["kids"] == ["00112233445566778899aabbccddeeff"] + + # ---------- validate_service ---------- diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index a529d4e8..9108c084 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -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