mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-03-12 01:19:02 +00:00
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
This commit is contained in:
@@ -222,12 +222,14 @@ def _perform_download(
|
|||||||
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", []),
|
||||||
|
latest_episode=params.get("latest_episode", False),
|
||||||
lang=params.get("lang", ["orig"]),
|
lang=params.get("lang", ["orig"]),
|
||||||
v_lang=params.get("v_lang", []),
|
v_lang=params.get("v_lang", []),
|
||||||
a_lang=params.get("a_lang", []),
|
a_lang=params.get("a_lang", []),
|
||||||
s_lang=params.get("s_lang", ["all"]),
|
s_lang=params.get("s_lang", ["all"]),
|
||||||
require_subs=params.get("require_subs", []),
|
require_subs=params.get("require_subs", []),
|
||||||
forced_subs=params.get("forced_subs", False),
|
forced_subs=params.get("forced_subs", False),
|
||||||
|
exact_lang=params.get("exact_lang", False),
|
||||||
sub_format=params.get("sub_format"),
|
sub_format=params.get("sub_format"),
|
||||||
video_only=params.get("video_only", False),
|
video_only=params.get("video_only", False),
|
||||||
audio_only=params.get("audio_only", False),
|
audio_only=params.get("audio_only", False),
|
||||||
@@ -236,6 +238,7 @@ def _perform_download(
|
|||||||
no_subs=params.get("no_subs", False),
|
no_subs=params.get("no_subs", False),
|
||||||
no_audio=params.get("no_audio", False),
|
no_audio=params.get("no_audio", False),
|
||||||
no_chapters=params.get("no_chapters", False),
|
no_chapters=params.get("no_chapters", False),
|
||||||
|
audio_description=params.get("audio_description", False),
|
||||||
slow=params.get("slow", False),
|
slow=params.get("slow", False),
|
||||||
list_=False,
|
list_=False,
|
||||||
list_titles=False,
|
list_titles=False,
|
||||||
@@ -245,6 +248,7 @@ def _perform_download(
|
|||||||
no_proxy=params.get("no_proxy", False),
|
no_proxy=params.get("no_proxy", False),
|
||||||
no_folder=params.get("no_folder", False),
|
no_folder=params.get("no_folder", False),
|
||||||
no_source=params.get("no_source", False),
|
no_source=params.get("no_source", False),
|
||||||
|
no_mux=params.get("no_mux", False),
|
||||||
workers=params.get("workers"),
|
workers=params.get("workers"),
|
||||||
downloads=params.get("downloads", 1),
|
downloads=params.get("downloads", 1),
|
||||||
best_available=params.get("best_available", False),
|
best_available=params.get("best_available", False),
|
||||||
|
|||||||
@@ -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)
|
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:
|
async def download_handler(data: Dict[str, Any]) -> 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
|
||||||
@@ -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
|
{"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:
|
try:
|
||||||
# Get download manager and start workers if needed
|
# Get download manager and start workers if needed
|
||||||
manager = get_download_manager()
|
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:
|
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
|
from unshackle.core.api.download_manager import get_download_manager
|
||||||
|
|
||||||
try:
|
try:
|
||||||
manager = get_download_manager()
|
manager = get_download_manager()
|
||||||
jobs = manager.list_jobs()
|
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]
|
job_list = [job.to_dict(include_full_details=False) for job in jobs]
|
||||||
|
|
||||||
return web.json_response({"jobs": job_list})
|
return web.json_response({"jobs": job_list})
|
||||||
|
|||||||
@@ -256,9 +256,165 @@ async def download(request: web.Request) -> web.Response:
|
|||||||
title_id:
|
title_id:
|
||||||
type: string
|
type: string
|
||||||
description: Title identifier
|
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:
|
responses:
|
||||||
'200':
|
'202':
|
||||||
description: Download started
|
description: Download job created
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
job_id:
|
||||||
|
type: string
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
created_time:
|
||||||
|
type: string
|
||||||
'400':
|
'400':
|
||||||
description: Invalid request
|
description: Invalid request
|
||||||
"""
|
"""
|
||||||
@@ -272,10 +428,40 @@ async def download(request: web.Request) -> web.Response:
|
|||||||
|
|
||||||
async def download_jobs(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
|
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:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: List of download jobs
|
description: List of download jobs
|
||||||
@@ -301,10 +487,19 @@ async def download_jobs(request: web.Request) -> web.Response:
|
|||||||
type: string
|
type: string
|
||||||
progress:
|
progress:
|
||||||
type: number
|
type: number
|
||||||
|
'400':
|
||||||
|
description: Invalid query parameters
|
||||||
'500':
|
'500':
|
||||||
description: Server error
|
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:
|
async def download_job_detail(request: web.Request) -> web.Response:
|
||||||
|
|||||||
Reference in New Issue
Block a user