forked from kenzuya/unshackle
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.
This commit is contained in:
@@ -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("--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("--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).")
|
@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.
|
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
|
api_secret = None
|
||||||
log.warning("Running with --no-key: Authentication is DISABLED for all API endpoints!")
|
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 caddy:
|
||||||
if not binaries.Caddy:
|
if not binaries.Caddy:
|
||||||
raise click.ClickException('Caddy executable "caddy" not found but is required for --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:
|
else:
|
||||||
app = web.Application(middlewares=[cors_middleware, pywidevine_serve.authentication])
|
app = web.Application(middlewares=[cors_middleware, pywidevine_serve.authentication])
|
||||||
app["config"] = {"users": [api_secret]}
|
app["config"] = {"users": [api_secret]}
|
||||||
|
app["debug_api"] = debug_api
|
||||||
setup_routes(app)
|
setup_routes(app)
|
||||||
setup_swagger(app)
|
setup_swagger(app)
|
||||||
log.info(f"REST API endpoints available at http://{host}:{port}/api/")
|
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_startup.append(pywidevine_serve._startup)
|
||||||
app.on_cleanup.append(pywidevine_serve._cleanup)
|
app.on_cleanup.append(pywidevine_serve._cleanup)
|
||||||
app.add_routes(pywidevine_serve.routes)
|
app.add_routes(pywidevine_serve.routes)
|
||||||
|
app["debug_api"] = debug_api
|
||||||
setup_routes(app)
|
setup_routes(app)
|
||||||
setup_swagger(app)
|
setup_swagger(app)
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ class DownloadJob:
|
|||||||
output_files: List[str] = field(default_factory=list)
|
output_files: List[str] = field(default_factory=list)
|
||||||
error_message: Optional[str] = None
|
error_message: Optional[str] = None
|
||||||
error_details: 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
|
# Cancellation support
|
||||||
cancel_event: threading.Event = field(default_factory=threading.Event)
|
cancel_event: threading.Event = field(default_factory=threading.Event)
|
||||||
@@ -67,6 +70,9 @@ class DownloadJob:
|
|||||||
"output_files": self.output_files,
|
"output_files": self.output_files,
|
||||||
"error_message": self.error_message,
|
"error_message": self.error_message,
|
||||||
"error_details": self.error_details,
|
"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"),
|
acodec=params.get("acodec"),
|
||||||
vbitrate=params.get("vbitrate"),
|
vbitrate=params.get("vbitrate"),
|
||||||
abitrate=params.get("abitrate"),
|
abitrate=params.get("abitrate"),
|
||||||
range_=params.get("range", []),
|
range_=params.get("range", ["SDR"]),
|
||||||
channels=params.get("channels"),
|
channels=params.get("channels"),
|
||||||
no_atmos=params.get("no_atmos", False),
|
no_atmos=params.get("no_atmos", False),
|
||||||
wanted=params.get("wanted", []),
|
wanted=params.get("wanted", []),
|
||||||
@@ -483,9 +489,21 @@ class DownloadQueueManager:
|
|||||||
job.progress = 100.0
|
job.progress = 100.0
|
||||||
log.info(f"Download completed for job {job.job_id}: {len(output_files)} files")
|
log.info(f"Download completed for job {job.job_id}: {len(output_files)} files")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
from unshackle.core.api.errors import categorize_exception
|
||||||
|
|
||||||
job.status = JobStatus.FAILED
|
job.status = JobStatus.FAILED
|
||||||
job.error_message = str(e)
|
job.error_message = str(e)
|
||||||
job.error_details = 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}")
|
log.error(f"Download failed for job {job.job_id}: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
@@ -567,6 +585,7 @@ class DownloadQueueManager:
|
|||||||
log.debug(f"Worker stdout for job {job.job_id}: {stdout.strip()}")
|
log.debug(f"Worker stdout for job {job.job_id}: {stdout.strip()}")
|
||||||
if stderr.strip():
|
if stderr.strip():
|
||||||
log.warning(f"Worker stderr for job {job.job_id}: {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
|
result_data: Optional[Dict[str, Any]] = None
|
||||||
try:
|
try:
|
||||||
@@ -579,10 +598,16 @@ class DownloadQueueManager:
|
|||||||
|
|
||||||
if returncode != 0:
|
if returncode != 0:
|
||||||
message = result_data.get("message") if result_data else "unknown error"
|
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}")
|
raise Exception(f"Worker exited with code {returncode}: {message}")
|
||||||
|
|
||||||
if not result_data or result_data.get("status") != "success":
|
if not result_data or result_data.get("status") != "success":
|
||||||
message = result_data.get("message") if result_data else "worker did not report 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}")
|
raise Exception(f"Worker failure: {message}")
|
||||||
|
|
||||||
return result_data.get("output_files", [])
|
return result_data.get("output_files", [])
|
||||||
|
|||||||
@@ -66,10 +66,28 @@ def main(argv: list[str]) -> int:
|
|||||||
result = {"status": "success", "output_files": output_files}
|
result = {"status": "success", "output_files": output_files}
|
||||||
|
|
||||||
except Exception as exc: # noqa: BLE001 - capture for parent process
|
except Exception as exc: # noqa: BLE001 - capture for parent process
|
||||||
|
from unshackle.core.api.errors import categorize_exception
|
||||||
|
|
||||||
exit_code = 1
|
exit_code = 1
|
||||||
tb = traceback.format_exc()
|
tb = traceback.format_exc()
|
||||||
log.error(f"Worker failed with error: {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:
|
finally:
|
||||||
try:
|
try:
|
||||||
|
|||||||
322
unshackle/core/api/errors.py
Normal file
322
unshackle/core/api/errors.py
Normal file
@@ -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)
|
||||||
@@ -3,6 +3,7 @@ from typing import Any, Dict, List, Optional
|
|||||||
|
|
||||||
from aiohttp import web
|
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.constants import AUDIO_CODEC_MAP, DYNAMIC_RANGE_MAP, VIDEO_CODEC_MAP
|
||||||
from unshackle.core.proxies.basic import Basic
|
from unshackle.core.proxies.basic import Basic
|
||||||
from unshackle.core.proxies.hola import Hola
|
from unshackle.core.proxies.hola import Hola
|
||||||
@@ -14,6 +15,47 @@ from unshackle.core.tracks import Audio, Subtitle, Video
|
|||||||
|
|
||||||
log = logging.getLogger("api")
|
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]:
|
def initialize_proxy_providers() -> List[Any]:
|
||||||
"""Initialize and return available proxy providers."""
|
"""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."""
|
"""Handle list-titles request."""
|
||||||
service_tag = data.get("service")
|
service_tag = data.get("service")
|
||||||
title_id = data.get("title_id")
|
title_id = data.get("title_id")
|
||||||
profile = data.get("profile")
|
profile = data.get("profile")
|
||||||
|
|
||||||
if not service_tag:
|
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:
|
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)
|
normalized_service = validate_service(service_tag)
|
||||||
if not normalized_service:
|
if not normalized_service:
|
||||||
return web.json_response(
|
raise APIError(
|
||||||
{"status": "error", "message": f"Invalid or unavailable service: {service_tag}"}, status=400
|
APIErrorCode.INVALID_SERVICE,
|
||||||
|
f"Invalid or unavailable service: {service_tag}",
|
||||||
|
details={"service": service_tag},
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -253,7 +305,11 @@ async def list_titles_handler(data: Dict[str, Any]) -> web.Response:
|
|||||||
resolved_proxy = resolve_proxy(proxy_param, proxy_providers)
|
resolved_proxy = resolve_proxy(proxy_param, proxy_providers)
|
||||||
proxy_param = resolved_proxy
|
proxy_param = resolved_proxy
|
||||||
except ValueError as e:
|
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 = click.Context(dummy_service)
|
||||||
ctx.obj = ContextData(config=service_config, cdm=None, proxy_providers=proxy_providers, profile=profile)
|
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})
|
return web.json_response({"titles": title_list})
|
||||||
|
|
||||||
|
except APIError:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.exception("Error listing titles")
|
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."""
|
"""Handle list-tracks request."""
|
||||||
service_tag = data.get("service")
|
service_tag = data.get("service")
|
||||||
title_id = data.get("title_id")
|
title_id = data.get("title_id")
|
||||||
profile = data.get("profile")
|
profile = data.get("profile")
|
||||||
|
|
||||||
if not service_tag:
|
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:
|
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)
|
normalized_service = validate_service(service_tag)
|
||||||
if not normalized_service:
|
if not normalized_service:
|
||||||
return web.json_response(
|
raise APIError(
|
||||||
{"status": "error", "message": f"Invalid or unavailable service: {service_tag}"}, status=400
|
APIErrorCode.INVALID_SERVICE,
|
||||||
|
f"Invalid or unavailable service: {service_tag}",
|
||||||
|
details={"service": service_tag},
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -380,7 +453,11 @@ async def list_tracks_handler(data: Dict[str, Any]) -> web.Response:
|
|||||||
resolved_proxy = resolve_proxy(proxy_param, proxy_providers)
|
resolved_proxy = resolve_proxy(proxy_param, proxy_providers)
|
||||||
proxy_param = resolved_proxy
|
proxy_param = resolved_proxy
|
||||||
except ValueError as e:
|
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 = click.Context(dummy_service)
|
||||||
ctx.obj = ContextData(config=service_config, cdm=None, proxy_providers=proxy_providers, profile=profile)
|
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)
|
wanted = season_range.parse_tokens(wanted_param)
|
||||||
log.debug(f"Parsed wanted '{wanted_param}' into {len(wanted)} episodes: {wanted[:10]}...")
|
log.debug(f"Parsed wanted '{wanted_param}' into {len(wanted)} episodes: {wanted[:10]}...")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return web.json_response(
|
raise APIError(
|
||||||
{"status": "error", "message": f"Invalid wanted parameter: {e}"}, status=400
|
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:
|
elif season is not None and episode is not None:
|
||||||
wanted = [f"{season}x{episode}"]
|
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")
|
log.debug(f"Found {len(matching_titles)} matching titles")
|
||||||
|
|
||||||
if not matching_titles:
|
if not matching_titles:
|
||||||
return web.json_response(
|
raise APIError(
|
||||||
{"status": "error", "message": "No episodes found matching wanted criteria"}, status=404
|
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 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
|
response["unavailable_episodes"] = failed_episodes
|
||||||
return web.json_response(response)
|
return web.json_response(response)
|
||||||
else:
|
else:
|
||||||
return web.json_response(
|
raise APIError(
|
||||||
{
|
APIErrorCode.NO_CONTENT,
|
||||||
"status": "error",
|
f"No available episodes found. Unavailable: {', '.join(failed_episodes)}",
|
||||||
"message": f"No available episodes found. Unavailable: {', '.join(failed_episodes)}",
|
details={
|
||||||
|
"service": normalized_service,
|
||||||
|
"title_id": title_id,
|
||||||
|
"unavailable_episodes": failed_episodes,
|
||||||
},
|
},
|
||||||
status=404,
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Single episode or movie
|
# Single episode or movie
|
||||||
@@ -553,9 +640,16 @@ async def list_tracks_handler(data: Dict[str, Any]) -> web.Response:
|
|||||||
|
|
||||||
return web.json_response(response)
|
return web.json_response(response)
|
||||||
|
|
||||||
|
except APIError:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.exception("Error listing tracks")
|
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]:
|
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
|
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."""
|
"""Handle download request - create and queue a download job."""
|
||||||
from unshackle.core.api.download_manager import get_download_manager
|
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")
|
title_id = data.get("title_id")
|
||||||
|
|
||||||
if not service_tag:
|
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:
|
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)
|
normalized_service = validate_service(service_tag)
|
||||||
if not normalized_service:
|
if not normalized_service:
|
||||||
return web.json_response(
|
raise APIError(
|
||||||
{"status": "error", "message": f"Invalid or unavailable service: {service_tag}"}, status=400
|
APIErrorCode.INVALID_SERVICE,
|
||||||
|
f"Invalid or unavailable service: {service_tag}",
|
||||||
|
details={"service": service_tag},
|
||||||
)
|
)
|
||||||
|
|
||||||
validation_error = validate_download_parameters(data)
|
validation_error = validate_download_parameters(data)
|
||||||
if validation_error:
|
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:
|
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
|
# Get download manager and start workers if needed
|
||||||
manager = get_download_manager()
|
manager = get_download_manager()
|
||||||
await manager.start_workers()
|
await manager.start_workers()
|
||||||
|
|
||||||
# Create download job with filtered parameters (exclude service and title_id as they're already passed)
|
# 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"]}
|
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(
|
return web.json_response(
|
||||||
{"job_id": job.job_id, "status": job.status.value, "created_time": job.created_time.isoformat()}, status=202
|
{"job_id": job.job_id, "status": job.status.value, "created_time": job.created_time.isoformat()}, status=202
|
||||||
)
|
)
|
||||||
|
|
||||||
|
except APIError:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.exception("Error creating download job")
|
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."""
|
"""Handle list download jobs request with optional filtering and sorting."""
|
||||||
from unshackle.core.api.download_manager import get_download_manager
|
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"]
|
valid_sort_fields = ["created_time", "started_time", "completed_time", "progress", "status", "service"]
|
||||||
if sort_by not in valid_sort_fields:
|
if sort_by not in valid_sort_fields:
|
||||||
return web.json_response(
|
raise APIError(
|
||||||
{
|
APIErrorCode.INVALID_PARAMETERS,
|
||||||
"status": "error",
|
f"Invalid sort_by: {sort_by}. Must be one of: {', '.join(valid_sort_fields)}",
|
||||||
"message": f"Invalid sort_by: {sort_by}. Must be one of: {', '.join(valid_sort_fields)}",
|
details={"sort_by": sort_by, "valid_values": valid_sort_fields},
|
||||||
},
|
|
||||||
status=400,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if sort_order not in ["asc", "desc"]:
|
if sort_order not in ["asc", "desc"]:
|
||||||
return web.json_response(
|
raise APIError(
|
||||||
{"status": "error", "message": "Invalid sort_order: must be 'asc' or 'desc'"}, status=400
|
APIErrorCode.INVALID_PARAMETERS,
|
||||||
|
"Invalid sort_order: must be 'asc' or 'desc'",
|
||||||
|
details={"sort_order": sort_order, "valid_values": ["asc", "desc"]},
|
||||||
)
|
)
|
||||||
|
|
||||||
reverse = sort_order == "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})
|
return web.json_response({"jobs": job_list})
|
||||||
|
|
||||||
|
except APIError:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.exception("Error listing download jobs")
|
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."""
|
"""Handle get specific download job request."""
|
||||||
from unshackle.core.api.download_manager import get_download_manager
|
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)
|
job = manager.get_job(job_id)
|
||||||
|
|
||||||
if not job:
|
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))
|
return web.json_response(job.to_dict(include_full_details=True))
|
||||||
|
|
||||||
|
except APIError:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.exception(f"Error getting download job {job_id}")
|
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."""
|
"""Handle cancel download job request."""
|
||||||
from unshackle.core.api.download_manager import get_download_manager
|
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()
|
manager = get_download_manager()
|
||||||
|
|
||||||
if not manager.get_job(job_id):
|
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)
|
success = manager.cancel_job(job_id)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
return web.json_response({"status": "success", "message": "Job cancelled"})
|
return web.json_response({"status": "success", "message": "Job cancelled"})
|
||||||
else:
|
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:
|
except Exception as e:
|
||||||
log.exception(f"Error cancelling download job {job_id}")
|
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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from aiohttp_swagger3 import SwaggerDocs, SwaggerInfo, SwaggerUiSettings
|
from aiohttp_swagger3 import SwaggerDocs, SwaggerInfo, SwaggerUiSettings
|
||||||
|
|
||||||
from unshackle.core import __version__
|
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,
|
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)
|
list_download_jobs_handler, list_titles_handler, list_tracks_handler)
|
||||||
from unshackle.core.services import Services
|
from unshackle.core.services import Services
|
||||||
@@ -107,7 +109,11 @@ async def services(request: web.Request) -> web.Response:
|
|||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
title_regex:
|
title_regex:
|
||||||
type: string
|
oneOf:
|
||||||
|
- type: string
|
||||||
|
- type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
nullable: true
|
nullable: true
|
||||||
url:
|
url:
|
||||||
type: string
|
type: string
|
||||||
@@ -119,6 +125,28 @@ async def services(request: web.Request) -> web.Response:
|
|||||||
description: Full service documentation
|
description: Full service documentation
|
||||||
'500':
|
'500':
|
||||||
description: Server error
|
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:
|
try:
|
||||||
service_tags = Services.get_tags()
|
service_tags = Services.get_tags()
|
||||||
@@ -137,7 +165,21 @@ async def services(request: web.Request) -> web.Response:
|
|||||||
service_data["geofence"] = list(service_module.GEOFENCE)
|
service_data["geofence"] = list(service_module.GEOFENCE)
|
||||||
|
|
||||||
if hasattr(service_module, "TITLE_RE"):
|
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"):
|
if hasattr(service_module, "cli") and hasattr(service_module.cli, "short_help"):
|
||||||
service_data["url"] = 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})
|
return web.json_response({"services": services_info})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.exception("Error listing services")
|
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:
|
async def list_titles(request: web.Request) -> web.Response:
|
||||||
@@ -182,14 +225,104 @@ async def list_titles(request: web.Request) -> web.Response:
|
|||||||
'200':
|
'200':
|
||||||
description: List of titles
|
description: List of titles
|
||||||
'400':
|
'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:
|
try:
|
||||||
data = await request.json()
|
data = await request.json()
|
||||||
except Exception:
|
except Exception as e:
|
||||||
return web.json_response({"status": "error", "message": "Invalid JSON request body"}, status=400)
|
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:
|
async def list_tracks(request: web.Request) -> web.Response:
|
||||||
@@ -228,10 +361,21 @@ async def list_tracks(request: web.Request) -> web.Response:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
data = await request.json()
|
data = await request.json()
|
||||||
except Exception:
|
except Exception as e:
|
||||||
return web.json_response({"status": "error", "message": "Invalid JSON request body"}, status=400)
|
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:
|
async def download(request: web.Request) -> web.Response:
|
||||||
@@ -258,149 +402,149 @@ async def download(request: web.Request) -> web.Response:
|
|||||||
description: Title identifier
|
description: Title identifier
|
||||||
profile:
|
profile:
|
||||||
type: string
|
type: string
|
||||||
description: Profile to use for credentials and cookies
|
description: Profile to use for credentials and cookies (default - None)
|
||||||
quality:
|
quality:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
type: integer
|
type: integer
|
||||||
description: Download resolution(s), defaults to best available
|
description: Download resolution(s) (default - best available)
|
||||||
vcodec:
|
vcodec:
|
||||||
type: string
|
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:
|
acodec:
|
||||||
type: string
|
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:
|
vbitrate:
|
||||||
type: integer
|
type: integer
|
||||||
description: Video bitrate in kbps
|
description: Video bitrate in kbps (default - None)
|
||||||
abitrate:
|
abitrate:
|
||||||
type: integer
|
type: integer
|
||||||
description: Audio bitrate in kbps
|
description: Audio bitrate in kbps (default - None)
|
||||||
range:
|
range:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
description: Video color range (SDR, HDR10, DV)
|
description: Video color range (SDR, HDR10, DV) (default - ["SDR"])
|
||||||
channels:
|
channels:
|
||||||
type: number
|
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:
|
no_atmos:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Exclude Dolby Atmos audio tracks
|
description: Exclude Dolby Atmos audio tracks (default - false)
|
||||||
wanted:
|
wanted:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
description: Wanted episodes (e.g., ["S01E01", "S01E02"])
|
description: Wanted episodes (e.g., ["S01E01", "S01E02"]) (default - all)
|
||||||
latest_episode:
|
latest_episode:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Download only the single most recent episode
|
description: Download only the single most recent episode (default - false)
|
||||||
lang:
|
lang:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
type: string
|
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:
|
v_lang:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
description: Language for video tracks only
|
description: Language for video tracks only (default - [])
|
||||||
a_lang:
|
a_lang:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
description: Language for audio tracks only
|
description: Language for audio tracks only (default - [])
|
||||||
s_lang:
|
s_lang:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
description: Language for subtitle tracks (default is 'all')
|
description: Language for subtitle tracks (default - ["all"])
|
||||||
require_subs:
|
require_subs:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
description: Required subtitle languages
|
description: Required subtitle languages (default - [])
|
||||||
forced_subs:
|
forced_subs:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Include forced subtitle tracks
|
description: Include forced subtitle tracks (default - false)
|
||||||
exact_lang:
|
exact_lang:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Use exact language matching (no variants)
|
description: Use exact language matching (no variants) (default - false)
|
||||||
sub_format:
|
sub_format:
|
||||||
type: string
|
type: string
|
||||||
description: Output subtitle format (SRT, VTT, etc.)
|
description: Output subtitle format (SRT, VTT, etc.) (default - None)
|
||||||
video_only:
|
video_only:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Only download video tracks
|
description: Only download video tracks (default - false)
|
||||||
audio_only:
|
audio_only:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Only download audio tracks
|
description: Only download audio tracks (default - false)
|
||||||
subs_only:
|
subs_only:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Only download subtitle tracks
|
description: Only download subtitle tracks (default - false)
|
||||||
chapters_only:
|
chapters_only:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Only download chapters
|
description: Only download chapters (default - false)
|
||||||
no_subs:
|
no_subs:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Do not download subtitle tracks
|
description: Do not download subtitle tracks (default - false)
|
||||||
no_audio:
|
no_audio:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Do not download audio tracks
|
description: Do not download audio tracks (default - false)
|
||||||
no_chapters:
|
no_chapters:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Do not download chapters
|
description: Do not download chapters (default - false)
|
||||||
audio_description:
|
audio_description:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Download audio description tracks
|
description: Download audio description tracks (default - false)
|
||||||
slow:
|
slow:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Add 60-120s delay between downloads
|
description: Add 60-120s delay between downloads (default - false)
|
||||||
skip_dl:
|
skip_dl:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Skip downloading, only retrieve decryption keys
|
description: Skip downloading, only retrieve decryption keys (default - false)
|
||||||
export:
|
export:
|
||||||
type: string
|
type: string
|
||||||
description: Path to export decryption keys as JSON
|
description: Path to export decryption keys as JSON (default - None)
|
||||||
cdm_only:
|
cdm_only:
|
||||||
type: boolean
|
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:
|
proxy:
|
||||||
type: string
|
type: string
|
||||||
description: Proxy URI or country code
|
description: Proxy URI or country code (default - None)
|
||||||
no_proxy:
|
no_proxy:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Force disable all proxy use
|
description: Force disable all proxy use (default - false)
|
||||||
tag:
|
tag:
|
||||||
type: string
|
type: string
|
||||||
description: Set the group tag to be used
|
description: Set the group tag to be used (default - None)
|
||||||
tmdb_id:
|
tmdb_id:
|
||||||
type: integer
|
type: integer
|
||||||
description: Use this TMDB ID for tagging
|
description: Use this TMDB ID for tagging (default - None)
|
||||||
tmdb_name:
|
tmdb_name:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Rename titles using TMDB name
|
description: Rename titles using TMDB name (default - false)
|
||||||
tmdb_year:
|
tmdb_year:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Use release year from TMDB
|
description: Use release year from TMDB (default - false)
|
||||||
no_folder:
|
no_folder:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Disable folder creation for TV shows
|
description: Disable folder creation for TV shows (default - false)
|
||||||
no_source:
|
no_source:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Disable source tag from output file name
|
description: Disable source tag from output file name (default - false)
|
||||||
no_mux:
|
no_mux:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Do not mux tracks into a container file
|
description: Do not mux tracks into a container file (default - false)
|
||||||
workers:
|
workers:
|
||||||
type: integer
|
type: integer
|
||||||
description: Max workers/threads per track download
|
description: Max workers/threads per track download (default - None)
|
||||||
downloads:
|
downloads:
|
||||||
type: integer
|
type: integer
|
||||||
description: Amount of tracks to download concurrently
|
description: Amount of tracks to download concurrently (default - 1)
|
||||||
best_available:
|
best_available:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Continue with best available if requested quality unavailable
|
description: Continue with best available if requested quality unavailable (default - false)
|
||||||
responses:
|
responses:
|
||||||
'202':
|
'202':
|
||||||
description: Download job created
|
description: Download job created
|
||||||
@@ -420,10 +564,21 @@ async def download(request: web.Request) -> web.Response:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
data = await request.json()
|
data = await request.json()
|
||||||
except Exception:
|
except Exception as e:
|
||||||
return web.json_response({"status": "error", "message": "Invalid JSON request body"}, status=400)
|
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:
|
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_by": request.query.get("sort_by", "created_time"),
|
||||||
"sort_order": request.query.get("sort_order", "desc"),
|
"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:
|
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
|
description: Server error
|
||||||
"""
|
"""
|
||||||
job_id = request.match_info["job_id"]
|
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:
|
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
|
description: Server error
|
||||||
"""
|
"""
|
||||||
job_id = request.match_info["job_id"]
|
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:
|
def setup_routes(app: web.Application) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user