From 351a60625883201e24003db65c54c571bafc3caf Mon Sep 17 00:00:00 2001 From: Andy Date: Thu, 30 Oct 2025 05:16:14 +0000 Subject: [PATCH] feat(api): add default parameter handling and improved error responses Add default parameter system to API server that matches CLI behavior, eliminating errors from missing optional parameters. --- unshackle/commands/serve.py | 13 +- unshackle/core/api/download_manager.py | 27 ++- unshackle/core/api/download_worker.py | 20 +- unshackle/core/api/errors.py | 322 +++++++++++++++++++++++++ unshackle/core/api/handlers.py | 257 ++++++++++++++++---- unshackle/core/api/routes.py | 285 +++++++++++++++++----- 6 files changed, 814 insertions(+), 110 deletions(-) create mode 100644 unshackle/core/api/errors.py diff --git a/unshackle/commands/serve.py b/unshackle/commands/serve.py index 515cd45..a28d633 100644 --- a/unshackle/commands/serve.py +++ b/unshackle/commands/serve.py @@ -18,7 +18,13 @@ from unshackle.core.constants import context_settings @click.option("--caddy", is_flag=True, default=False, help="Also serve with Caddy.") @click.option("--api-only", is_flag=True, default=False, help="Serve only the REST API, not pywidevine CDM.") @click.option("--no-key", is_flag=True, default=False, help="Disable API key authentication (allows all requests).") -def serve(host: str, port: int, caddy: bool, api_only: bool, no_key: bool) -> None: +@click.option( + "--debug-api", + is_flag=True, + default=False, + help="Include technical debug information (tracebacks, stderr) in API error responses.", +) +def serve(host: str, port: int, caddy: bool, api_only: bool, no_key: bool, debug_api: bool) -> None: """ Serve your Local Widevine Devices and REST API for Remote Access. @@ -50,6 +56,9 @@ def serve(host: str, port: int, caddy: bool, api_only: bool, no_key: bool) -> No api_secret = None log.warning("Running with --no-key: Authentication is DISABLED for all API endpoints!") + if debug_api: + log.warning("Running with --debug-api: Error responses will include technical debug information!") + if caddy: if not binaries.Caddy: raise click.ClickException('Caddy executable "caddy" not found but is required for --caddy.') @@ -73,6 +82,7 @@ def serve(host: str, port: int, caddy: bool, api_only: bool, no_key: bool) -> No else: app = web.Application(middlewares=[cors_middleware, pywidevine_serve.authentication]) app["config"] = {"users": [api_secret]} + app["debug_api"] = debug_api setup_routes(app) setup_swagger(app) log.info(f"REST API endpoints available at http://{host}:{port}/api/") @@ -101,6 +111,7 @@ def serve(host: str, port: int, caddy: bool, api_only: bool, no_key: bool) -> No app.on_startup.append(pywidevine_serve._startup) app.on_cleanup.append(pywidevine_serve._cleanup) app.add_routes(pywidevine_serve.routes) + app["debug_api"] = debug_api setup_routes(app) setup_swagger(app) diff --git a/unshackle/core/api/download_manager.py b/unshackle/core/api/download_manager.py index e59e083..2f45d44 100644 --- a/unshackle/core/api/download_manager.py +++ b/unshackle/core/api/download_manager.py @@ -43,6 +43,9 @@ class DownloadJob: output_files: List[str] = field(default_factory=list) error_message: Optional[str] = None error_details: Optional[str] = None + error_code: Optional[str] = None + error_traceback: Optional[str] = None + worker_stderr: Optional[str] = None # Cancellation support cancel_event: threading.Event = field(default_factory=threading.Event) @@ -67,6 +70,9 @@ class DownloadJob: "output_files": self.output_files, "error_message": self.error_message, "error_details": self.error_details, + "error_code": self.error_code, + "error_traceback": self.error_traceback, + "worker_stderr": self.worker_stderr, } ) @@ -218,7 +224,7 @@ def _perform_download( acodec=params.get("acodec"), vbitrate=params.get("vbitrate"), abitrate=params.get("abitrate"), - range_=params.get("range", []), + range_=params.get("range", ["SDR"]), channels=params.get("channels"), no_atmos=params.get("no_atmos", False), wanted=params.get("wanted", []), @@ -483,9 +489,21 @@ class DownloadQueueManager: job.progress = 100.0 log.info(f"Download completed for job {job.job_id}: {len(output_files)} files") except Exception as e: + import traceback + + from unshackle.core.api.errors import categorize_exception + job.status = JobStatus.FAILED job.error_message = str(e) job.error_details = str(e) + + api_error = categorize_exception( + e, context={"service": job.service, "title_id": job.title_id, "job_id": job.job_id} + ) + job.error_code = api_error.error_code.value + + job.error_traceback = traceback.format_exc() + log.error(f"Download failed for job {job.job_id}: {e}") raise @@ -567,6 +585,7 @@ class DownloadQueueManager: log.debug(f"Worker stdout for job {job.job_id}: {stdout.strip()}") if stderr.strip(): log.warning(f"Worker stderr for job {job.job_id}: {stderr.strip()}") + job.worker_stderr = stderr.strip() result_data: Optional[Dict[str, Any]] = None try: @@ -579,10 +598,16 @@ class DownloadQueueManager: if returncode != 0: message = result_data.get("message") if result_data else "unknown error" + if result_data: + job.error_details = result_data.get("error_details", message) + job.error_code = result_data.get("error_code") raise Exception(f"Worker exited with code {returncode}: {message}") if not result_data or result_data.get("status") != "success": message = result_data.get("message") if result_data else "worker did not report success" + if result_data: + job.error_details = result_data.get("error_details", message) + job.error_code = result_data.get("error_code") raise Exception(f"Worker failure: {message}") return result_data.get("output_files", []) diff --git a/unshackle/core/api/download_worker.py b/unshackle/core/api/download_worker.py index 08810d4..7afca32 100644 --- a/unshackle/core/api/download_worker.py +++ b/unshackle/core/api/download_worker.py @@ -66,10 +66,28 @@ def main(argv: list[str]) -> int: result = {"status": "success", "output_files": output_files} except Exception as exc: # noqa: BLE001 - capture for parent process + from unshackle.core.api.errors import categorize_exception + exit_code = 1 tb = traceback.format_exc() log.error(f"Worker failed with error: {exc}") - result = {"status": "error", "message": str(exc), "traceback": tb} + + api_error = categorize_exception( + exc, + context={ + "service": payload.get("service") if "payload" in locals() else None, + "title_id": payload.get("title_id") if "payload" in locals() else None, + "job_id": payload.get("job_id") if "payload" in locals() else None, + }, + ) + + result = { + "status": "error", + "message": str(exc), + "error_details": api_error.message, + "error_code": api_error.error_code.value, + "traceback": tb, + } finally: try: diff --git a/unshackle/core/api/errors.py b/unshackle/core/api/errors.py new file mode 100644 index 0000000..312ee12 --- /dev/null +++ b/unshackle/core/api/errors.py @@ -0,0 +1,322 @@ +""" +API Error Handling System + +Provides structured error responses with error codes, categorization, +and optional debug information for the unshackle REST API. +""" + +from __future__ import annotations + +import traceback +from datetime import datetime, timezone +from enum import Enum +from typing import Any + +from aiohttp import web + + +class APIErrorCode(str, Enum): + """Standard API error codes for programmatic error handling.""" + + # Client errors (4xx) + INVALID_INPUT = "INVALID_INPUT" # Missing or malformed request data + INVALID_SERVICE = "INVALID_SERVICE" # Unknown service name + INVALID_TITLE_ID = "INVALID_TITLE_ID" # Invalid or malformed title ID + INVALID_PROFILE = "INVALID_PROFILE" # Profile doesn't exist + INVALID_PROXY = "INVALID_PROXY" # Invalid proxy specification + INVALID_LANGUAGE = "INVALID_LANGUAGE" # Invalid language code + INVALID_PARAMETERS = "INVALID_PARAMETERS" # Invalid download parameters + + AUTH_FAILED = "AUTH_FAILED" # Authentication failure (invalid credentials/cookies) + AUTH_REQUIRED = "AUTH_REQUIRED" # Missing authentication + FORBIDDEN = "FORBIDDEN" # Action not allowed + GEOFENCE = "GEOFENCE" # Content not available in region + + NOT_FOUND = "NOT_FOUND" # Resource not found (title, job, etc.) + NO_CONTENT = "NO_CONTENT" # No titles/tracks/episodes found + JOB_NOT_FOUND = "JOB_NOT_FOUND" # Download job doesn't exist + + RATE_LIMITED = "RATE_LIMITED" # Service rate limiting + + # Server errors (5xx) + INTERNAL_ERROR = "INTERNAL_ERROR" # Unexpected server error + SERVICE_ERROR = "SERVICE_ERROR" # Streaming service API error + NETWORK_ERROR = "NETWORK_ERROR" # Network connectivity issue + DRM_ERROR = "DRM_ERROR" # DRM/license acquisition failure + DOWNLOAD_ERROR = "DOWNLOAD_ERROR" # Download process failure + SERVICE_UNAVAILABLE = "SERVICE_UNAVAILABLE" # Service temporarily unavailable + WORKER_ERROR = "WORKER_ERROR" # Download worker process error + + +class APIError(Exception): + """ + Structured API error with error code, message, and details. + + Attributes: + error_code: Standardized error code from APIErrorCode enum + message: User-friendly error message + details: Additional structured error information + retryable: Whether the operation can be retried + http_status: HTTP status code to return (default based on error_code) + """ + + def __init__( + self, + error_code: APIErrorCode, + message: str, + details: dict[str, Any] | None = None, + retryable: bool = False, + http_status: int | None = None, + ): + super().__init__(message) + self.error_code = error_code + self.message = message + self.details = details or {} + self.retryable = retryable + self.http_status = http_status or self._default_http_status(error_code) + + @staticmethod + def _default_http_status(error_code: APIErrorCode) -> int: + """Map error codes to default HTTP status codes.""" + status_map = { + # 400 Bad Request + APIErrorCode.INVALID_INPUT: 400, + APIErrorCode.INVALID_SERVICE: 400, + APIErrorCode.INVALID_TITLE_ID: 400, + APIErrorCode.INVALID_PROFILE: 400, + APIErrorCode.INVALID_PROXY: 400, + APIErrorCode.INVALID_LANGUAGE: 400, + APIErrorCode.INVALID_PARAMETERS: 400, + # 401 Unauthorized + APIErrorCode.AUTH_REQUIRED: 401, + APIErrorCode.AUTH_FAILED: 401, + # 403 Forbidden + APIErrorCode.FORBIDDEN: 403, + APIErrorCode.GEOFENCE: 403, + # 404 Not Found + APIErrorCode.NOT_FOUND: 404, + APIErrorCode.NO_CONTENT: 404, + APIErrorCode.JOB_NOT_FOUND: 404, + # 429 Too Many Requests + APIErrorCode.RATE_LIMITED: 429, + # 500 Internal Server Error + APIErrorCode.INTERNAL_ERROR: 500, + # 502 Bad Gateway + APIErrorCode.SERVICE_ERROR: 502, + APIErrorCode.DRM_ERROR: 502, + # 503 Service Unavailable + APIErrorCode.NETWORK_ERROR: 503, + APIErrorCode.SERVICE_UNAVAILABLE: 503, + APIErrorCode.DOWNLOAD_ERROR: 500, + APIErrorCode.WORKER_ERROR: 500, + } + return status_map.get(error_code, 500) + + +def build_error_response( + error: APIError | Exception, + debug_mode: bool = False, + extra_debug_info: dict[str, Any] | None = None, +) -> web.Response: + """ + Build a structured JSON error response. + + Args: + error: APIError or generic Exception to convert to response + debug_mode: Whether to include technical debug information + extra_debug_info: Additional debug info (stderr, stdout, etc.) + + Returns: + aiohttp JSON response with structured error data + """ + if isinstance(error, APIError): + error_code = error.error_code.value + message = error.message + details = error.details + http_status = error.http_status + retryable = error.retryable + else: + # Generic exception - convert to INTERNAL_ERROR + error_code = APIErrorCode.INTERNAL_ERROR.value + message = str(error) or "An unexpected error occurred" + details = {} + http_status = 500 + retryable = False + + response_data: dict[str, Any] = { + "status": "error", + "error_code": error_code, + "message": message, + "timestamp": datetime.now(timezone.utc).isoformat(), + } + + # Add details if present + if details: + response_data["details"] = details + + # Add retryable hint if specified + if retryable: + response_data["retryable"] = True + + # Add debug information if in debug mode + if debug_mode: + debug_info: dict[str, Any] = { + "exception_type": type(error).__name__, + } + + # Add traceback for debugging + if isinstance(error, Exception): + debug_info["traceback"] = traceback.format_exc() + + # Add any extra debug info provided + if extra_debug_info: + debug_info.update(extra_debug_info) + + response_data["debug_info"] = debug_info + + return web.json_response(response_data, status=http_status) + + +def categorize_exception( + exc: Exception, + context: dict[str, Any] | None = None, +) -> APIError: + """ + Categorize a generic exception into a structured APIError. + + This function attempts to identify the type of error based on the exception + type, message patterns, and optional context information. + + Args: + exc: The exception to categorize + context: Optional context (service name, operation type, etc.) + + Returns: + APIError with appropriate error code and details + """ + context = context or {} + exc_str = str(exc).lower() + exc_type = type(exc).__name__ + + # Authentication errors + if any(keyword in exc_str for keyword in ["auth", "login", "credential", "unauthorized", "forbidden", "token"]): + return APIError( + error_code=APIErrorCode.AUTH_FAILED, + message=f"Authentication failed: {exc}", + details={**context, "reason": "authentication_error"}, + retryable=False, + ) + + # Network errors + if any( + keyword in exc_str + for keyword in [ + "connection", + "timeout", + "network", + "unreachable", + "socket", + "dns", + "resolve", + ] + ) or exc_type in ["ConnectionError", "TimeoutError", "URLError", "SSLError"]: + return APIError( + error_code=APIErrorCode.NETWORK_ERROR, + message=f"Network error occurred: {exc}", + details={**context, "reason": "network_connectivity"}, + retryable=True, + http_status=503, + ) + + # Geofence/region errors + if any(keyword in exc_str for keyword in ["geofence", "region", "not available in", "territory"]): + return APIError( + error_code=APIErrorCode.GEOFENCE, + message=f"Content not available in your region: {exc}", + details={**context, "reason": "geofence_restriction"}, + retryable=False, + ) + + # Not found errors + if any(keyword in exc_str for keyword in ["not found", "404", "does not exist", "invalid id"]): + return APIError( + error_code=APIErrorCode.NOT_FOUND, + message=f"Resource not found: {exc}", + details={**context, "reason": "not_found"}, + retryable=False, + ) + + # Rate limiting + if any(keyword in exc_str for keyword in ["rate limit", "too many requests", "429", "throttle"]): + return APIError( + error_code=APIErrorCode.RATE_LIMITED, + message=f"Rate limit exceeded: {exc}", + details={**context, "reason": "rate_limited"}, + retryable=True, + http_status=429, + ) + + # DRM errors + if any(keyword in exc_str for keyword in ["drm", "license", "widevine", "playready", "decrypt"]): + return APIError( + error_code=APIErrorCode.DRM_ERROR, + message=f"DRM error: {exc}", + details={**context, "reason": "drm_failure"}, + retryable=False, + ) + + # Service unavailable + if any(keyword in exc_str for keyword in ["service unavailable", "503", "maintenance", "temporarily unavailable"]): + return APIError( + error_code=APIErrorCode.SERVICE_UNAVAILABLE, + message=f"Service temporarily unavailable: {exc}", + details={**context, "reason": "service_unavailable"}, + retryable=True, + http_status=503, + ) + + # Validation errors + if any(keyword in exc_str for keyword in ["invalid", "malformed", "validation"]) or exc_type in [ + "ValueError", + "ValidationError", + ]: + return APIError( + error_code=APIErrorCode.INVALID_INPUT, + message=f"Invalid input: {exc}", + details={**context, "reason": "validation_failed"}, + retryable=False, + ) + + # Default to internal error for unknown exceptions + return APIError( + error_code=APIErrorCode.INTERNAL_ERROR, + message=f"An unexpected error occurred: {exc}", + details={**context, "exception_type": exc_type}, + retryable=False, + ) + + +def handle_api_exception( + exc: Exception, + context: dict[str, Any] | None = None, + debug_mode: bool = False, + extra_debug_info: dict[str, Any] | None = None, +) -> web.Response: + """ + Convenience function to categorize an exception and build an error response. + + Args: + exc: The exception to handle + context: Optional context information + debug_mode: Whether to include debug information + extra_debug_info: Additional debug info + + Returns: + Structured JSON error response + """ + if isinstance(exc, APIError): + api_error = exc + else: + api_error = categorize_exception(exc, context) + + return build_error_response(api_error, debug_mode, extra_debug_info) diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index 61cee5d..ba94adb 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -3,6 +3,7 @@ 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.constants import AUDIO_CODEC_MAP, DYNAMIC_RANGE_MAP, VIDEO_CODEC_MAP from unshackle.core.proxies.basic import Basic from unshackle.core.proxies.hola import Hola @@ -14,6 +15,47 @@ from unshackle.core.tracks import Audio, Subtitle, Video log = logging.getLogger("api") +DEFAULT_DOWNLOAD_PARAMS = { + "profile": None, + "quality": [], + "vcodec": None, + "acodec": None, + "vbitrate": None, + "abitrate": 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, + "audio_description": False, + "slow": False, + "skip_dl": False, + "export": None, + "cdm_only": None, + "no_proxy": False, + "no_folder": False, + "no_source": False, + "no_mux": False, + "workers": None, + "downloads": 1, + "best_available": False, +} + def initialize_proxy_providers() -> List[Any]: """Initialize and return available proxy providers.""" @@ -199,22 +241,32 @@ def serialize_subtitle_track(track: Subtitle) -> Dict[str, Any]: } -async def list_titles_handler(data: Dict[str, Any]) -> web.Response: +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: - return web.json_response({"status": "error", "message": "Missing required parameter: service"}, status=400) + raise APIError( + APIErrorCode.INVALID_INPUT, + "Missing required parameter: service", + details={"missing_parameter": "service"}, + ) if not title_id: - return web.json_response({"status": "error", "message": "Missing required parameter: title_id"}, status=400) + raise APIError( + APIErrorCode.INVALID_INPUT, + "Missing required parameter: title_id", + details={"missing_parameter": "title_id"}, + ) normalized_service = validate_service(service_tag) if not normalized_service: - return web.json_response( - {"status": "error", "message": f"Invalid or unavailable service: {service_tag}"}, status=400 + raise APIError( + APIErrorCode.INVALID_SERVICE, + f"Invalid or unavailable service: {service_tag}", + details={"service": service_tag}, ) try: @@ -253,7 +305,11 @@ async def list_titles_handler(data: Dict[str, Any]) -> web.Response: resolved_proxy = resolve_proxy(proxy_param, proxy_providers) proxy_param = resolved_proxy except ValueError as e: - return web.json_response({"status": "error", "message": f"Proxy error: {e}"}, status=400) + raise APIError( + APIErrorCode.INVALID_PROXY, + f"Proxy error: {e}", + details={"proxy": proxy_param, "service": normalized_service}, + ) ctx = click.Context(dummy_service) ctx.obj = ContextData(config=service_config, cdm=None, proxy_providers=proxy_providers, profile=profile) @@ -321,27 +377,44 @@ async def list_titles_handler(data: Dict[str, Any]) -> web.Response: return web.json_response({"titles": title_list}) + except APIError: + raise except Exception as e: log.exception("Error listing titles") - return web.json_response({"status": "error", "message": str(e)}, status=500) + 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]) -> web.Response: +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: - return web.json_response({"status": "error", "message": "Missing required parameter: service"}, status=400) + raise APIError( + APIErrorCode.INVALID_INPUT, + "Missing required parameter: service", + details={"missing_parameter": "service"}, + ) if not title_id: - return web.json_response({"status": "error", "message": "Missing required parameter: title_id"}, status=400) + raise APIError( + APIErrorCode.INVALID_INPUT, + "Missing required parameter: title_id", + details={"missing_parameter": "title_id"}, + ) normalized_service = validate_service(service_tag) if not normalized_service: - return web.json_response( - {"status": "error", "message": f"Invalid or unavailable service: {service_tag}"}, status=400 + raise APIError( + APIErrorCode.INVALID_SERVICE, + f"Invalid or unavailable service: {service_tag}", + details={"service": service_tag}, ) try: @@ -380,7 +453,11 @@ async def list_tracks_handler(data: Dict[str, Any]) -> web.Response: resolved_proxy = resolve_proxy(proxy_param, proxy_providers) proxy_param = resolved_proxy except ValueError as e: - return web.json_response({"status": "error", "message": f"Proxy error: {e}"}, status=400) + raise APIError( + APIErrorCode.INVALID_PROXY, + f"Proxy error: {e}", + details={"proxy": proxy_param, "service": normalized_service}, + ) ctx = click.Context(dummy_service) ctx.obj = ContextData(config=service_config, cdm=None, proxy_providers=proxy_providers, profile=profile) @@ -457,8 +534,10 @@ async def list_tracks_handler(data: Dict[str, Any]) -> web.Response: wanted = season_range.parse_tokens(wanted_param) log.debug(f"Parsed wanted '{wanted_param}' into {len(wanted)} episodes: {wanted[:10]}...") except Exception as e: - return web.json_response( - {"status": "error", "message": f"Invalid wanted parameter: {e}"}, status=400 + 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}"] @@ -481,8 +560,14 @@ async def list_tracks_handler(data: Dict[str, Any]) -> web.Response: log.debug(f"Found {len(matching_titles)} matching titles") if not matching_titles: - return web.json_response( - {"status": "error", "message": "No episodes found matching wanted criteria"}, status=404 + 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 @@ -524,12 +609,14 @@ async def list_tracks_handler(data: Dict[str, Any]) -> web.Response: response["unavailable_episodes"] = failed_episodes return web.json_response(response) else: - return web.json_response( - { - "status": "error", - "message": f"No available episodes found. Unavailable: {', '.join(failed_episodes)}", + 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, }, - status=404, ) else: # Single episode or movie @@ -553,9 +640,16 @@ async def list_tracks_handler(data: Dict[str, Any]) -> web.Response: return web.json_response(response) + except APIError: + raise except Exception as e: log.exception("Error listing tracks") - return web.json_response({"status": "error", "message": str(e)}, status=500) + 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]: @@ -633,7 +727,7 @@ def validate_download_parameters(data: Dict[str, Any]) -> Optional[str]: return None -async def download_handler(data: Dict[str, Any]) -> web.Response: +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 @@ -641,40 +735,74 @@ async def download_handler(data: Dict[str, Any]) -> web.Response: title_id = data.get("title_id") if not service_tag: - return web.json_response({"status": "error", "message": "Missing required parameter: service"}, status=400) + raise APIError( + APIErrorCode.INVALID_INPUT, + "Missing required parameter: service", + details={"missing_parameter": "service"}, + ) if not title_id: - return web.json_response({"status": "error", "message": "Missing required parameter: title_id"}, status=400) + raise APIError( + APIErrorCode.INVALID_INPUT, + "Missing required parameter: title_id", + details={"missing_parameter": "title_id"}, + ) normalized_service = validate_service(service_tag) if not normalized_service: - return web.json_response( - {"status": "error", "message": f"Invalid or unavailable service: {service_tag}"}, status=400 + 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: - return web.json_response({"status": "error", "message": validation_error}, status=400) + raise APIError( + APIErrorCode.INVALID_PARAMETERS, + validation_error, + details={"service": normalized_service, "title_id": title_id}, + ) 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 + if hasattr(service_module, "cli") and hasattr(service_module.cli, "params"): + for param in service_module.cli.params: + if hasattr(param, "name") and hasattr(param, "default") and param.default is not None: + # 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"]} - job = manager.create_job(normalized_service, title_id, **filtered_params) + # Merge defaults with provided parameters (user params override service defaults, which override global defaults) + params_with_defaults = {**DEFAULT_DOWNLOAD_PARAMS, **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 as e: log.exception("Error creating download job") - return web.json_response({"status": "error", "message": str(e)}, status=500) + 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]) -> web.Response: +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 @@ -695,17 +823,17 @@ async def list_download_jobs_handler(data: Dict[str, Any]) -> web.Response: valid_sort_fields = ["created_time", "started_time", "completed_time", "progress", "status", "service"] if sort_by not in valid_sort_fields: - return web.json_response( - { - "status": "error", - "message": f"Invalid sort_by: {sort_by}. Must be one of: {', '.join(valid_sort_fields)}", - }, - status=400, + 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"]: - return web.json_response( - {"status": "error", "message": "Invalid sort_order: must be 'asc' or 'desc'"}, status=400 + 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" @@ -730,12 +858,19 @@ async def list_download_jobs_handler(data: Dict[str, Any]) -> web.Response: return web.json_response({"jobs": job_list}) + except APIError: + raise except Exception as e: log.exception("Error listing download jobs") - return web.json_response({"status": "error", "message": str(e)}, status=500) + 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) -> web.Response: +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 @@ -744,16 +879,27 @@ async def get_download_job_handler(job_id: str) -> web.Response: job = manager.get_job(job_id) if not job: - return web.json_response({"status": "error", "message": "Job not found"}, status=404) + 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 as e: log.exception(f"Error getting download job {job_id}") - return web.json_response({"status": "error", "message": str(e)}, status=500) + 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) -> web.Response: +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 @@ -761,15 +907,30 @@ async def cancel_download_job_handler(job_id: str) -> web.Response: manager = get_download_manager() if not manager.get_job(job_id): - return web.json_response({"status": "error", "message": "Job not found"}, status=404) + 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: - return web.json_response({"status": "error", "message": "Job cannot be cancelled"}, status=400) + raise APIError( + APIErrorCode.INVALID_PARAMETERS, + "Job cannot be cancelled (already completed or failed)", + details={"job_id": job_id}, + ) + except APIError: + raise except Exception as e: log.exception(f"Error cancelling download job {job_id}") - return web.json_response({"status": "error", "message": str(e)}, status=500) + 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, + ) diff --git a/unshackle/core/api/routes.py b/unshackle/core/api/routes.py index 7b2907b..a5202c5 100644 --- a/unshackle/core/api/routes.py +++ b/unshackle/core/api/routes.py @@ -1,9 +1,11 @@ import logging +import re from aiohttp import web from aiohttp_swagger3 import SwaggerDocs, SwaggerInfo, SwaggerUiSettings from unshackle.core import __version__ +from unshackle.core.api.errors import APIError, APIErrorCode, build_error_response, handle_api_exception from unshackle.core.api.handlers import (cancel_download_job_handler, download_handler, get_download_job_handler, list_download_jobs_handler, list_titles_handler, list_tracks_handler) from unshackle.core.services import Services @@ -107,7 +109,11 @@ async def services(request: web.Request) -> web.Response: items: type: string title_regex: - type: string + oneOf: + - type: string + - type: array + items: + type: string nullable: true url: type: string @@ -119,6 +125,28 @@ async def services(request: web.Request) -> web.Response: description: Full service documentation '500': description: Server error + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: error + error_code: + type: string + example: INTERNAL_ERROR + message: + type: string + example: An unexpected error occurred + details: + type: object + timestamp: + type: string + format: date-time + debug_info: + type: object + description: Only present when --debug-api flag is enabled """ try: service_tags = Services.get_tags() @@ -137,7 +165,21 @@ async def services(request: web.Request) -> web.Response: service_data["geofence"] = list(service_module.GEOFENCE) if hasattr(service_module, "TITLE_RE"): - service_data["title_regex"] = service_module.TITLE_RE + title_re = service_module.TITLE_RE + # Handle different types of TITLE_RE + if isinstance(title_re, re.Pattern): + service_data["title_regex"] = title_re.pattern + elif isinstance(title_re, str): + service_data["title_regex"] = title_re + elif isinstance(title_re, (list, tuple)): + # Convert list/tuple of patterns to list of strings + patterns = [] + for item in title_re: + if isinstance(item, re.Pattern): + patterns.append(item.pattern) + elif isinstance(item, str): + patterns.append(item) + service_data["title_regex"] = patterns if patterns else None if hasattr(service_module, "cli") and hasattr(service_module.cli, "short_help"): service_data["url"] = service_module.cli.short_help @@ -153,7 +195,8 @@ async def services(request: web.Request) -> web.Response: return web.json_response({"services": services_info}) except Exception as e: log.exception("Error listing services") - return web.json_response({"status": "error", "message": str(e)}, status=500) + debug_mode = request.app.get("debug_api", False) + return handle_api_exception(e, context={"operation": "list_services"}, debug_mode=debug_mode) async def list_titles(request: web.Request) -> web.Response: @@ -182,14 +225,104 @@ async def list_titles(request: web.Request) -> web.Response: '200': description: List of titles '400': - description: Invalid request + description: Invalid request (missing parameters, invalid service) + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: error + error_code: + type: string + example: INVALID_INPUT + message: + type: string + example: Missing required parameter + details: + type: object + timestamp: + type: string + format: date-time + '401': + description: Authentication failed + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: error + error_code: + type: string + example: AUTH_FAILED + message: + type: string + details: + type: object + timestamp: + type: string + format: date-time + '404': + description: Title not found + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: error + error_code: + type: string + example: NOT_FOUND + message: + type: string + details: + type: object + timestamp: + type: string + format: date-time + '500': + description: Server error + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: error + error_code: + type: string + example: INTERNAL_ERROR + message: + type: string + details: + type: object + timestamp: + type: string + format: date-time """ try: data = await request.json() - except Exception: - return web.json_response({"status": "error", "message": "Invalid JSON request body"}, status=400) + except Exception as e: + return build_error_response( + APIError( + APIErrorCode.INVALID_INPUT, + "Invalid JSON request body", + details={"error": str(e)}, + ), + request.app.get("debug_api", False), + ) - return await list_titles_handler(data) + try: + return await list_titles_handler(data, request) + except APIError as e: + debug_mode = request.app.get("debug_api", False) + return build_error_response(e, debug_mode) async def list_tracks(request: web.Request) -> web.Response: @@ -228,10 +361,21 @@ async def list_tracks(request: web.Request) -> web.Response: """ try: data = await request.json() - except Exception: - return web.json_response({"status": "error", "message": "Invalid JSON request body"}, status=400) + except Exception as e: + return build_error_response( + APIError( + APIErrorCode.INVALID_INPUT, + "Invalid JSON request body", + details={"error": str(e)}, + ), + request.app.get("debug_api", False), + ) - return await list_tracks_handler(data) + try: + return await list_tracks_handler(data, request) + except APIError as e: + debug_mode = request.app.get("debug_api", False) + return build_error_response(e, debug_mode) async def download(request: web.Request) -> web.Response: @@ -258,149 +402,149 @@ async def download(request: web.Request) -> web.Response: description: Title identifier profile: type: string - description: Profile to use for credentials and cookies + description: Profile to use for credentials and cookies (default - None) quality: type: array items: type: integer - description: Download resolution(s), defaults to best available + description: Download resolution(s) (default - best available) vcodec: type: string - description: Video codec to download (e.g., H264, H265, VP9, AV1) + description: Video codec to download (e.g., H264, H265, VP9, AV1) (default - None) acodec: type: string - description: Audio codec to download (e.g., AAC, AC3, EAC3) + description: Audio codec to download (e.g., AAC, AC3, EAC3) (default - None) vbitrate: type: integer - description: Video bitrate in kbps + description: Video bitrate in kbps (default - None) abitrate: type: integer - description: Audio bitrate in kbps + description: Audio bitrate in kbps (default - None) range: type: array items: type: string - description: Video color range (SDR, HDR10, DV) + description: Video color range (SDR, HDR10, DV) (default - ["SDR"]) channels: type: number - description: Audio channels (e.g., 2.0, 5.1, 7.1) + description: Audio channels (e.g., 2.0, 5.1, 7.1) (default - None) no_atmos: type: boolean - description: Exclude Dolby Atmos audio tracks + description: Exclude Dolby Atmos audio tracks (default - false) wanted: type: array items: type: string - description: Wanted episodes (e.g., ["S01E01", "S01E02"]) + description: Wanted episodes (e.g., ["S01E01", "S01E02"]) (default - all) latest_episode: type: boolean - description: Download only the single most recent episode + description: Download only the single most recent episode (default - false) lang: type: array items: type: string - description: Language for video and audio (use 'orig' for original) + description: Language for video and audio (use 'orig' for original) (default - ["orig"]) v_lang: type: array items: type: string - description: Language for video tracks only + description: Language for video tracks only (default - []) a_lang: type: array items: type: string - description: Language for audio tracks only + description: Language for audio tracks only (default - []) s_lang: type: array items: type: string - description: Language for subtitle tracks (default is 'all') + description: Language for subtitle tracks (default - ["all"]) require_subs: type: array items: type: string - description: Required subtitle languages + description: Required subtitle languages (default - []) forced_subs: type: boolean - description: Include forced subtitle tracks + description: Include forced subtitle tracks (default - false) exact_lang: type: boolean - description: Use exact language matching (no variants) + description: Use exact language matching (no variants) (default - false) sub_format: type: string - description: Output subtitle format (SRT, VTT, etc.) + description: Output subtitle format (SRT, VTT, etc.) (default - None) video_only: type: boolean - description: Only download video tracks + description: Only download video tracks (default - false) audio_only: type: boolean - description: Only download audio tracks + description: Only download audio tracks (default - false) subs_only: type: boolean - description: Only download subtitle tracks + description: Only download subtitle tracks (default - false) chapters_only: type: boolean - description: Only download chapters + description: Only download chapters (default - false) no_subs: type: boolean - description: Do not download subtitle tracks + description: Do not download subtitle tracks (default - false) no_audio: type: boolean - description: Do not download audio tracks + description: Do not download audio tracks (default - false) no_chapters: type: boolean - description: Do not download chapters + description: Do not download chapters (default - false) audio_description: type: boolean - description: Download audio description tracks + description: Download audio description tracks (default - false) slow: type: boolean - description: Add 60-120s delay between downloads + description: Add 60-120s delay between downloads (default - false) skip_dl: type: boolean - description: Skip downloading, only retrieve decryption keys + description: Skip downloading, only retrieve decryption keys (default - false) export: type: string - description: Path to export decryption keys as JSON + description: Path to export decryption keys as JSON (default - None) cdm_only: type: boolean - description: Only use CDM for key retrieval (true) or only vaults (false) + description: Only use CDM for key retrieval (true) or only vaults (false) (default - None) proxy: type: string - description: Proxy URI or country code + description: Proxy URI or country code (default - None) no_proxy: type: boolean - description: Force disable all proxy use + description: Force disable all proxy use (default - false) tag: type: string - description: Set the group tag to be used + description: Set the group tag to be used (default - None) tmdb_id: type: integer - description: Use this TMDB ID for tagging + description: Use this TMDB ID for tagging (default - None) tmdb_name: type: boolean - description: Rename titles using TMDB name + description: Rename titles using TMDB name (default - false) tmdb_year: type: boolean - description: Use release year from TMDB + description: Use release year from TMDB (default - false) no_folder: type: boolean - description: Disable folder creation for TV shows + description: Disable folder creation for TV shows (default - false) no_source: type: boolean - description: Disable source tag from output file name + description: Disable source tag from output file name (default - false) no_mux: type: boolean - description: Do not mux tracks into a container file + description: Do not mux tracks into a container file (default - false) workers: type: integer - description: Max workers/threads per track download + description: Max workers/threads per track download (default - None) downloads: type: integer - description: Amount of tracks to download concurrently + description: Amount of tracks to download concurrently (default - 1) best_available: type: boolean - description: Continue with best available if requested quality unavailable + description: Continue with best available if requested quality unavailable (default - false) responses: '202': description: Download job created @@ -420,10 +564,21 @@ async def download(request: web.Request) -> web.Response: """ try: data = await request.json() - except Exception: - return web.json_response({"status": "error", "message": "Invalid JSON request body"}, status=400) + except Exception as e: + return build_error_response( + APIError( + APIErrorCode.INVALID_INPUT, + "Invalid JSON request body", + details={"error": str(e)}, + ), + request.app.get("debug_api", False), + ) - return await download_handler(data) + try: + return await download_handler(data, request) + except APIError as e: + debug_mode = request.app.get("debug_api", False) + return build_error_response(e, debug_mode) async def download_jobs(request: web.Request) -> web.Response: @@ -499,7 +654,11 @@ async def download_jobs(request: web.Request) -> web.Response: "sort_by": request.query.get("sort_by", "created_time"), "sort_order": request.query.get("sort_order", "desc"), } - return await list_download_jobs_handler(query_params) + try: + return await list_download_jobs_handler(query_params, request) + except APIError as e: + debug_mode = request.app.get("debug_api", False) + return build_error_response(e, debug_mode) async def download_job_detail(request: web.Request) -> web.Response: @@ -523,7 +682,11 @@ async def download_job_detail(request: web.Request) -> web.Response: description: Server error """ job_id = request.match_info["job_id"] - return await get_download_job_handler(job_id) + try: + return await get_download_job_handler(job_id, request) + except APIError as e: + debug_mode = request.app.get("debug_api", False) + return build_error_response(e, debug_mode) async def cancel_download_job(request: web.Request) -> web.Response: @@ -549,7 +712,11 @@ async def cancel_download_job(request: web.Request) -> web.Response: description: Server error """ job_id = request.match_info["job_id"] - return await cancel_download_job_handler(job_id) + try: + return await cancel_download_job_handler(job_id, request) + except APIError as e: + debug_mode = request.app.get("debug_api", False) + return build_error_response(e, debug_mode) def setup_routes(app: web.Application) -> None: