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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user