From 5c8eb2107a1b908efe90d84775bcbb6f78b97b6d Mon Sep 17 00:00:00 2001 From: Andy Date: Sun, 26 Oct 2025 04:40:55 +0000 Subject: [PATCH] feat(api): complete API enhancements for v2.0.0 - Add missing download parameters (latest_episode, exact_lang, audio_description, no_mux) - Expand OpenAPI schema with comprehensive documentation for all 40+ download parameters - Add robust parameter validation with clear error messages - Implement job filtering by status/service and sorting capabilities --- unshackle/core/api/download_manager.py | 4 + unshackle/core/api/handlers.py | 125 ++++++++++++++- unshackle/core/api/routes.py | 205 ++++++++++++++++++++++++- 3 files changed, 328 insertions(+), 6 deletions(-) diff --git a/unshackle/core/api/download_manager.py b/unshackle/core/api/download_manager.py index 4b87c17..e59e083 100644 --- a/unshackle/core/api/download_manager.py +++ b/unshackle/core/api/download_manager.py @@ -222,12 +222,14 @@ def _perform_download( channels=params.get("channels"), no_atmos=params.get("no_atmos", False), wanted=params.get("wanted", []), + latest_episode=params.get("latest_episode", False), lang=params.get("lang", ["orig"]), v_lang=params.get("v_lang", []), a_lang=params.get("a_lang", []), s_lang=params.get("s_lang", ["all"]), require_subs=params.get("require_subs", []), forced_subs=params.get("forced_subs", False), + exact_lang=params.get("exact_lang", False), sub_format=params.get("sub_format"), video_only=params.get("video_only", False), audio_only=params.get("audio_only", False), @@ -236,6 +238,7 @@ def _perform_download( no_subs=params.get("no_subs", False), no_audio=params.get("no_audio", False), no_chapters=params.get("no_chapters", False), + audio_description=params.get("audio_description", False), slow=params.get("slow", False), list_=False, list_titles=False, @@ -245,6 +248,7 @@ def _perform_download( no_proxy=params.get("no_proxy", False), no_folder=params.get("no_folder", False), no_source=params.get("no_source", False), + no_mux=params.get("no_mux", False), workers=params.get("workers"), downloads=params.get("downloads", 1), best_available=params.get("best_available", False), diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index 3b8dd1f..61cee5d 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -558,6 +558,81 @@ async def list_tracks_handler(data: Dict[str, Any]) -> web.Response: return web.json_response({"status": "error", "message": str(e)}, status=500) +def validate_download_parameters(data: Dict[str, Any]) -> Optional[str]: + """ + Validate download parameters and return error message if invalid. + + Returns: + None if valid, error message string if invalid + """ + if "vcodec" in data and data["vcodec"]: + valid_vcodecs = ["H264", "H265", "VP9", "AV1"] + if data["vcodec"].upper() not in valid_vcodecs: + return f"Invalid vcodec: {data['vcodec']}. Must be one of: {', '.join(valid_vcodecs)}" + + if "acodec" in data and data["acodec"]: + valid_acodecs = ["AAC", "AC3", "EAC3", "OPUS", "FLAC", "ALAC", "VORBIS", "DTS"] + if data["acodec"].upper() not in valid_acodecs: + return f"Invalid acodec: {data['acodec']}. Must be one of: {', '.join(valid_acodecs)}" + + if "sub_format" in data and data["sub_format"]: + valid_sub_formats = ["SRT", "VTT", "ASS", "SSA"] + if data["sub_format"].upper() not in valid_sub_formats: + return f"Invalid sub_format: {data['sub_format']}. Must be one of: {', '.join(valid_sub_formats)}" + + if "vbitrate" in data and data["vbitrate"] is not None: + if not isinstance(data["vbitrate"], int) or data["vbitrate"] <= 0: + return "vbitrate must be a positive integer" + + if "abitrate" in data and data["abitrate"] is not None: + if not isinstance(data["abitrate"], int) or data["abitrate"] <= 0: + return "abitrate must be a positive integer" + + if "channels" in data and data["channels"] is not None: + if not isinstance(data["channels"], (int, float)) or data["channels"] <= 0: + return "channels must be a positive number" + + if "workers" in data and data["workers"] is not None: + if not isinstance(data["workers"], int) or data["workers"] <= 0: + return "workers must be a positive integer" + + if "downloads" in data and data["downloads"] is not None: + if not isinstance(data["downloads"], int) or data["downloads"] <= 0: + return "downloads must be a positive integer" + + exclusive_flags = [] + if data.get("video_only"): + exclusive_flags.append("video_only") + if data.get("audio_only"): + exclusive_flags.append("audio_only") + if data.get("subs_only"): + exclusive_flags.append("subs_only") + if data.get("chapters_only"): + exclusive_flags.append("chapters_only") + + if len(exclusive_flags) > 1: + return f"Cannot use multiple exclusive flags: {', '.join(exclusive_flags)}" + + if data.get("no_subs") and data.get("subs_only"): + return "Cannot use both no_subs and subs_only" + if data.get("no_audio") and data.get("audio_only"): + return "Cannot use both no_audio and audio_only" + + if data.get("s_lang") and data.get("require_subs"): + return "Cannot use both s_lang and require_subs" + + if "range" in data and data["range"]: + valid_ranges = ["SDR", "HDR10", "HDR10+", "DV", "HLG"] + if isinstance(data["range"], list): + for r in data["range"]: + if r.upper() not in valid_ranges: + return f"Invalid range value: {r}. Must be one of: {', '.join(valid_ranges)}" + elif data["range"].upper() not in valid_ranges: + return f"Invalid range value: {data['range']}. Must be one of: {', '.join(valid_ranges)}" + + return None + + async def download_handler(data: Dict[str, Any]) -> web.Response: """Handle download request - create and queue a download job.""" from unshackle.core.api.download_manager import get_download_manager @@ -577,6 +652,10 @@ async def download_handler(data: Dict[str, Any]) -> web.Response: {"status": "error", "message": f"Invalid or unavailable service: {service_tag}"}, status=400 ) + validation_error = validate_download_parameters(data) + if validation_error: + return web.json_response({"status": "error", "message": validation_error}, status=400) + try: # Get download manager and start workers if needed manager = get_download_manager() @@ -596,13 +675,57 @@ async def download_handler(data: Dict[str, Any]) -> web.Response: async def list_download_jobs_handler(data: Dict[str, Any]) -> web.Response: - """Handle list download jobs request.""" + """Handle list download jobs request with optional filtering and sorting.""" from unshackle.core.api.download_manager import get_download_manager try: manager = get_download_manager() jobs = manager.list_jobs() + status_filter = data.get("status") + if status_filter: + jobs = [job for job in jobs if job.status.value == status_filter] + + service_filter = data.get("service") + if service_filter: + jobs = [job for job in jobs if job.service == service_filter] + + sort_by = data.get("sort_by", "created_time") + sort_order = data.get("sort_order", "desc") + + valid_sort_fields = ["created_time", "started_time", "completed_time", "progress", "status", "service"] + if sort_by not in valid_sort_fields: + return web.json_response( + { + "status": "error", + "message": f"Invalid sort_by: {sort_by}. Must be one of: {', '.join(valid_sort_fields)}", + }, + status=400, + ) + + if sort_order not in ["asc", "desc"]: + return web.json_response( + {"status": "error", "message": "Invalid sort_order: must be 'asc' or 'desc'"}, status=400 + ) + + reverse = sort_order == "desc" + + def get_sort_key(job): + """Get the sorting key value, handling None values.""" + value = getattr(job, sort_by, None) + if value is None: + if sort_by in ["created_time", "started_time", "completed_time"]: + from datetime import datetime + + return datetime.min if not reverse else datetime.max + elif sort_by == "progress": + return 0 + elif sort_by in ["status", "service"]: + return "" + return value + + jobs = sorted(jobs, key=get_sort_key, reverse=reverse) + job_list = [job.to_dict(include_full_details=False) for job in jobs] return web.json_response({"jobs": job_list}) diff --git a/unshackle/core/api/routes.py b/unshackle/core/api/routes.py index 36b458c..7b2907b 100644 --- a/unshackle/core/api/routes.py +++ b/unshackle/core/api/routes.py @@ -256,9 +256,165 @@ async def download(request: web.Request) -> web.Response: title_id: type: string description: Title identifier + profile: + type: string + description: Profile to use for credentials and cookies + quality: + type: array + items: + type: integer + description: Download resolution(s), defaults to best available + vcodec: + type: string + description: Video codec to download (e.g., H264, H265, VP9, AV1) + acodec: + type: string + description: Audio codec to download (e.g., AAC, AC3, EAC3) + vbitrate: + type: integer + description: Video bitrate in kbps + abitrate: + type: integer + description: Audio bitrate in kbps + range: + type: array + items: + type: string + description: Video color range (SDR, HDR10, DV) + channels: + type: number + description: Audio channels (e.g., 2.0, 5.1, 7.1) + no_atmos: + type: boolean + description: Exclude Dolby Atmos audio tracks + wanted: + type: array + items: + type: string + description: Wanted episodes (e.g., ["S01E01", "S01E02"]) + latest_episode: + type: boolean + description: Download only the single most recent episode + lang: + type: array + items: + type: string + description: Language for video and audio (use 'orig' for original) + v_lang: + type: array + items: + type: string + description: Language for video tracks only + a_lang: + type: array + items: + type: string + description: Language for audio tracks only + s_lang: + type: array + items: + type: string + description: Language for subtitle tracks (default is 'all') + require_subs: + type: array + items: + type: string + description: Required subtitle languages + forced_subs: + type: boolean + description: Include forced subtitle tracks + exact_lang: + type: boolean + description: Use exact language matching (no variants) + sub_format: + type: string + description: Output subtitle format (SRT, VTT, etc.) + video_only: + type: boolean + description: Only download video tracks + audio_only: + type: boolean + description: Only download audio tracks + subs_only: + type: boolean + description: Only download subtitle tracks + chapters_only: + type: boolean + description: Only download chapters + no_subs: + type: boolean + description: Do not download subtitle tracks + no_audio: + type: boolean + description: Do not download audio tracks + no_chapters: + type: boolean + description: Do not download chapters + audio_description: + type: boolean + description: Download audio description tracks + slow: + type: boolean + description: Add 60-120s delay between downloads + skip_dl: + type: boolean + description: Skip downloading, only retrieve decryption keys + export: + type: string + description: Path to export decryption keys as JSON + cdm_only: + type: boolean + description: Only use CDM for key retrieval (true) or only vaults (false) + proxy: + type: string + description: Proxy URI or country code + no_proxy: + type: boolean + description: Force disable all proxy use + tag: + type: string + description: Set the group tag to be used + tmdb_id: + type: integer + description: Use this TMDB ID for tagging + tmdb_name: + type: boolean + description: Rename titles using TMDB name + tmdb_year: + type: boolean + description: Use release year from TMDB + no_folder: + type: boolean + description: Disable folder creation for TV shows + no_source: + type: boolean + description: Disable source tag from output file name + no_mux: + type: boolean + description: Do not mux tracks into a container file + workers: + type: integer + description: Max workers/threads per track download + downloads: + type: integer + description: Amount of tracks to download concurrently + best_available: + type: boolean + description: Continue with best available if requested quality unavailable responses: - '200': - description: Download started + '202': + description: Download job created + content: + application/json: + schema: + type: object + properties: + job_id: + type: string + status: + type: string + created_time: + type: string '400': description: Invalid request """ @@ -272,10 +428,40 @@ async def download(request: web.Request) -> web.Response: async def download_jobs(request: web.Request) -> web.Response: """ - List all download jobs. + List all download jobs with optional filtering and sorting. --- summary: List download jobs - description: Get list of all download jobs with their status + description: Get list of all download jobs with their status, with optional filtering by status/service and sorting + parameters: + - name: status + in: query + required: false + schema: + type: string + enum: [queued, downloading, completed, failed, cancelled] + description: Filter jobs by status + - name: service + in: query + required: false + schema: + type: string + description: Filter jobs by service tag + - name: sort_by + in: query + required: false + schema: + type: string + enum: [created_time, started_time, completed_time, progress, status, service] + default: created_time + description: Field to sort by + - name: sort_order + in: query + required: false + schema: + type: string + enum: [asc, desc] + default: desc + description: Sort order (ascending or descending) responses: '200': description: List of download jobs @@ -301,10 +487,19 @@ async def download_jobs(request: web.Request) -> web.Response: type: string progress: type: number + '400': + description: Invalid query parameters '500': description: Server error """ - return await list_download_jobs_handler({}) + # Extract query parameters + query_params = { + "status": request.query.get("status"), + "service": request.query.get("service"), + "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) async def download_job_detail(request: web.Request) -> web.Response: