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:
Andy
2025-10-30 05:16:14 +00:00
parent 504de2197a
commit 351a606258
6 changed files with 814 additions and 110 deletions

View File

@@ -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: