diff --git a/docs/ADVANCED_CONFIG.md b/docs/ADVANCED_CONFIG.md index 865d6d7..8966ed1 100644 --- a/docs/ADVANCED_CONFIG.md +++ b/docs/ADVANCED_CONFIG.md @@ -4,11 +4,34 @@ This document covers advanced features, debugging, and system-level configuratio ## serve (dict) -Configuration data for pywidevine's serve functionality run through unshackle. -This effectively allows you to run `unshackle serve` to start serving pywidevine Serve-compliant CDMs right from your -local widevine device files. +Configuration for the integrated server that provides CDM endpoints (Widevine/PlayReady) and a REST API for remote downloading. -- `api_secret` - Secret key for REST API authentication. When set, enables the REST API server alongside the CDM serve functionality. This key is required for authenticating API requests. +Start the server with: + +```bash +unshackle serve # Default: localhost:8786 +unshackle serve -h 0.0.0.0 -p 8888 # Listen on all interfaces +unshackle serve --no-key # Disable authentication +unshackle serve --api-only # REST API only, no CDM endpoints +``` + +### CLI Options + +| Option | Default | Description | +| --- | --- | --- | +| `-h, --host` | `127.0.0.1` | Host to serve from | +| `-p, --port` | `8786` | Port to serve from | +| `--caddy` | `false` | Also serve with Caddy reverse-proxy for HTTPS | +| `--api-only` | `false` | Serve only the REST API, disable CDM endpoints | +| `--no-widevine` | `false` | Disable Widevine CDM endpoints | +| `--no-playready` | `false` | Disable PlayReady CDM endpoints | +| `--no-key` | `false` | Disable API key authentication (allows all requests) | +| `--debug-api` | `false` | Include tracebacks and stderr in API error responses | +| `--debug` | `false` | Enable debug logging for API operations | + +### Configuration + +- `api_secret` - Secret key for REST API authentication. Required unless `--no-key` is used. All API requests must include this key via the `X-API-Key` header or `api_key` query parameter. - `devices` - List of Widevine device files (.wvd). If not specified, auto-populated from the WVDs directory. - `playready_devices` - List of PlayReady device files (.prd). If not specified, auto-populated from the PRDs directory. - `users` - Dictionary mapping user secret keys to their access configuration: @@ -42,6 +65,14 @@ serve: # - 'C:\Users\john\Devices\test_devices_001.wvd' ``` +### REST API + +When the server is running, interactive API documentation is available at: + +- **Swagger UI**: `http://localhost:8786/api/docs/` + +See [API.md](API.md) for full REST API documentation with endpoints, parameters, and examples. + --- ## debug (bool) diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..3da5061 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,387 @@ +# REST API Documentation + +The unshackle REST API allows you to control downloads, search services, and manage jobs remotely. Start the server with `unshackle serve` and access the interactive Swagger UI at `http://localhost:8786/api/docs/`. + +## Quick Start + +```bash +# Start the server (no authentication) +unshackle serve --no-key + +# Start with authentication +unshackle serve # Requires api_secret in unshackle.yaml +``` + +## Authentication + +When `api_secret` is set in `unshackle.yaml`, all API requests require authentication via: + +- **Header**: `X-API-Key: your-secret-key-here` +- **Query parameter**: `?api_key=your-secret-key-here` + +Use `--no-key` to disable authentication entirely (not recommended for public-facing servers). + +```yaml +# unshackle.yaml +serve: + api_secret: "your-secret-key-here" +``` + +--- + +## Endpoints + +### GET /api/health + +Health check with version and update information. + +```bash +curl http://localhost:8786/api/health +``` + +```json +{ + "status": "ok", + "version": "4.0.0", + "update_check": { + "update_available": false, + "current_version": "4.0.0", + "latest_version": null + } +} +``` + +--- + +### GET /api/services + +List all available streaming services. + +```bash +curl http://localhost:8786/api/services +``` + +Returns an array of services with `tag`, `aliases`, `geofence`, `title_regex`, `url`, and `help` text. + +--- + +### POST /api/search + +Search for titles from a streaming service. + +**Required parameters:** +| Parameter | Type | Description | +| --- | --- | --- | +| `service` | string | Service tag (e.g., `NF`, `AMZN`, `ATV`) | +| `query` | string | Search query | + +**Optional parameters:** +| Parameter | Type | Default | Description | +| --- | --- | --- | --- | +| `profile` | string | `null` | Profile for credentials/cookies | +| `proxy` | string | `null` | Proxy URI or country code | +| `no_proxy` | boolean | `false` | Disable all proxy use | + +```bash +curl -X POST http://localhost:8786/api/search \ + -H "Content-Type: application/json" \ + -d '{"service": "ATV", "query": "hijack"}' +``` + +```json +{ + "results": [ + { + "id": "umc.cmc.1dg08zn0g3zx52hs8npoj5qe3", + "title": "Hijack", + "description": null, + "label": "TV Show", + "url": "https://tv.apple.com/us/show/hijack/umc.cmc.1dg08zn0g3zx52hs8npoj5qe3" + } + ], + "count": 1 +} +``` + +--- + +### POST /api/list-titles + +Get available titles (seasons/episodes/movies) for a service and title ID. + +**Required parameters:** +| Parameter | Type | Description | +| --- | --- | --- | +| `service` | string | Service tag | +| `title_id` | string | Title ID or URL | + +```bash +curl -X POST http://localhost:8786/api/list-titles \ + -H "Content-Type: application/json" \ + -d '{"service": "ATV", "title_id": "umc.cmc.1dg08zn0g3zx52hs8npoj5qe3"}' +``` + +```json +{ + "titles": [ + { + "type": "episode", + "name": "Final Call", + "series_title": "Hijack", + "season": 1, + "number": 1, + "year": 2023, + "id": "umc.cmc.4levibvvz01hl4zsm0jdk5v2p" + } + ] +} +``` + +--- + +### POST /api/list-tracks + +Get video, audio, and subtitle tracks for a title. + +**Required parameters:** +| Parameter | Type | Description | +| --- | --- | --- | +| `service` | string | Service tag | +| `title_id` | string | Title ID or URL | + +**Optional parameters:** +| Parameter | Type | Default | Description | +| --- | --- | --- | --- | +| `wanted` | array | all | Episode filter (e.g., `["S01E01"]`) | +| `profile` | string | `null` | Profile for credentials/cookies | +| `proxy` | string | `null` | Proxy URI or country code | +| `no_proxy` | boolean | `false` | Disable all proxy use | + +```bash +curl -X POST http://localhost:8786/api/list-tracks \ + -H "Content-Type: application/json" \ + -d '{ + "service": "ATV", + "title_id": "umc.cmc.1dg08zn0g3zx52hs8npoj5qe3", + "wanted": ["S01E01"] + }' +``` + +Returns video, audio, and subtitle tracks with codec, bitrate, resolution, language, and DRM information. + +--- + +### POST /api/download + +Start a download job. Returns immediately with a job ID (HTTP 202). + +**Required parameters:** +| Parameter | Type | Description | +| --- | --- | --- | +| `service` | string | Service tag | +| `title_id` | string | Title ID or URL | + +**Quality and codec parameters:** +| Parameter | Type | Default | Description | +| --- | --- | --- | --- | +| `quality` | array[int] | best | Resolution(s) (e.g., `[1080, 2160]`) | +| `vcodec` | string or array | any | Video codec(s): `H264`, `H265`/`HEVC`, `VP9`, `AV1`, `VC1` | +| `acodec` | string or array | any | Audio codec(s): `AAC`, `AC3`, `EC3`, `AC4`, `OPUS`, `FLAC`, `ALAC`, `DTS` | +| `vbitrate` | int | highest | Video bitrate in kbps | +| `abitrate` | int | highest | Audio bitrate in kbps | +| `range` | array[string] | `["SDR"]` | Color range(s): `SDR`, `HDR10`, `HDR10+`, `HLG`, `DV`, `HYBRID` | +| `channels` | float | any | Audio channels (e.g., `5.1`, `7.1`) | +| `no_atmos` | boolean | `false` | Exclude Dolby Atmos tracks | +| `split_audio` | boolean | `null` | Create separate output per audio codec | +| `sub_format` | string | `null` | Output subtitle format: `SRT`, `VTT`, `ASS`, `SSA`, `TTML` | + +**Episode selection:** +| Parameter | Type | Default | Description | +| --- | --- | --- | --- | +| `wanted` | array[string] | all | Episodes (e.g., `["S01E01", "S01E02-S01E05"]`) | +| `latest_episode` | boolean | `false` | Download only the most recent episode | + +**Language parameters:** +| Parameter | Type | Default | Description | +| --- | --- | --- | --- | +| `lang` | array[string] | `["orig"]` | Language for video and audio (`orig` = original) | +| `v_lang` | array[string] | `[]` | Language override for video tracks only | +| `a_lang` | array[string] | `[]` | Language override for audio tracks only | +| `s_lang` | array[string] | `["all"]` | Language for subtitles | +| `require_subs` | array[string] | `[]` | Required subtitle languages (skip if missing) | +| `forced_subs` | boolean | `false` | Include forced subtitle tracks | +| `exact_lang` | boolean | `false` | Exact language matching (no variants) | + +**Track selection:** +| Parameter | Type | Default | Description | +| --- | --- | --- | --- | +| `video_only` | boolean | `false` | Only download video tracks | +| `audio_only` | boolean | `false` | Only download audio tracks | +| `subs_only` | boolean | `false` | Only download subtitle tracks | +| `chapters_only` | boolean | `false` | Only download chapters | +| `no_video` | boolean | `false` | Skip video tracks | +| `no_audio` | boolean | `false` | Skip audio tracks | +| `no_subs` | boolean | `false` | Skip subtitle tracks | +| `no_chapters` | boolean | `false` | Skip chapters | +| `audio_description` | boolean | `false` | Include audio description tracks | + +**Output and tagging:** +| Parameter | Type | Default | Description | +| --- | --- | --- | --- | +| `tag` | string | `null` | Override group tag | +| `repack` | boolean | `false` | Add REPACK tag to filename | +| `tmdb_id` | int | `null` | Use specific TMDB ID for tagging | +| `tmdb_name` | boolean | `false` | Rename titles using TMDB name | +| `tmdb_year` | boolean | `false` | Use TMDB release year | +| `imdb_id` | string | `null` | Use specific IMDB ID (e.g., `tt1375666`) | +| `no_folder` | boolean | `false` | Disable folder creation for TV shows | +| `no_source` | boolean | `false` | Remove source tag from filename | +| `no_mux` | boolean | `false` | Do not mux tracks into container | +| `output_dir` | string | `null` | Override output directory | + +**Download behavior:** +| Parameter | Type | Default | Description | +| --- | --- | --- | --- | +| `profile` | string | `null` | Profile for credentials/cookies | +| `proxy` | string | `null` | Proxy URI or country code | +| `no_proxy` | boolean | `false` | Disable all proxy use | +| `workers` | int | `null` | Max threads per track download | +| `downloads` | int | `1` | Concurrent track downloads | +| `slow` | boolean | `false` | Add 60-120s delay between titles | +| `best_available` | boolean | `false` | Continue if requested quality unavailable | +| `skip_dl` | boolean | `false` | Skip download, only get decryption keys | +| `export` | string | `null` | Export keys to JSON file path | +| `cdm_only` | boolean | `null` | Only use CDM (`true`) or only vaults (`false`) | +| `no_cache` | boolean | `false` | Bypass title cache | +| `reset_cache` | boolean | `false` | Clear title cache before fetching | + +**Example:** + +```bash +curl -X POST http://localhost:8786/api/download \ + -H "Content-Type: application/json" \ + -d '{ + "service": "ATV", + "title_id": "umc.cmc.1dg08zn0g3zx52hs8npoj5qe3", + "wanted": ["S01E01"], + "quality": [1080, 2160], + "vcodec": ["H265"], + "acodec": ["AAC", "EC3"], + "range": ["HDR10", "SDR"], + "split_audio": true, + "lang": ["en"] + }' +``` + +```json +{ + "job_id": "504db959-80b0-446c-a764-7924b761d613", + "status": "queued", + "created_time": "2026-02-27T18:00:00.000000" +} +``` + +--- + +### GET /api/download/jobs + +List all download jobs with optional filtering and sorting. + +**Query parameters:** +| Parameter | Type | Default | Description | +| --- | --- | --- | --- | +| `status` | string | all | Filter by status: `queued`, `downloading`, `completed`, `failed`, `cancelled` | +| `service` | string | all | Filter by service tag | +| `sort_by` | string | `created_time` | Sort field: `created_time`, `status`, `service` | +| `sort_order` | string | `desc` | Sort order: `asc`, `desc` | + +```bash +# List all jobs +curl http://localhost:8786/api/download/jobs + +# Filter by status +curl "http://localhost:8786/api/download/jobs?status=completed" + +# Filter by service +curl "http://localhost:8786/api/download/jobs?service=ATV" +``` + +--- + +### GET /api/download/jobs/{job_id} + +Get detailed information about a specific download job including progress, parameters, and error details. + +```bash +curl http://localhost:8786/api/download/jobs/504db959-80b0-446c-a764-7924b761d613 +``` + +```json +{ + "job_id": "504db959-80b0-446c-a764-7924b761d613", + "status": "completed", + "created_time": "2026-02-27T18:00:00.000000", + "service": "ATV", + "title_id": "umc.cmc.1dg08zn0g3zx52hs8npoj5qe3", + "progress": 100.0, + "parameters": { ... }, + "started_time": "2026-02-27T18:00:01.000000", + "completed_time": "2026-02-27T18:00:15.000000", + "output_files": [], + "error_message": null, + "error_details": null +} +``` + +--- + +### DELETE /api/download/jobs/{job_id} + +Cancel a queued or running download job. + +```bash +curl -X DELETE http://localhost:8786/api/download/jobs/504db959-80b0-446c-a764-7924b761d613 +``` + +Returns confirmation on success, or an error if the job has already completed or been cancelled. + +--- + +## Error Responses + +All endpoints return consistent error responses: + +```json +{ + "status": "error", + "error_code": "INVALID_PARAMETERS", + "message": "Invalid vcodec: XYZ. Must be one of: H264, H265, VP9, AV1, VC1, VP8", + "timestamp": "2026-02-27T18:00:00.000000+00:00", + "details": { ... } +} +``` + +Common error codes: +- `INVALID_INPUT` - Malformed request body +- `INVALID_PARAMETERS` - Invalid parameter values +- `MISSING_SERVICE` - Service tag not provided +- `INVALID_SERVICE` - Service not found +- `SERVICE_ERROR` - Service initialization or runtime error +- `AUTH_FAILED` - Authentication failure +- `NOT_FOUND` - Job or resource not found +- `INTERNAL_ERROR` - Unexpected server error + +When `--debug-api` is enabled, error responses include additional `debug_info` with tracebacks and stderr output. + +--- + +## Download Job Lifecycle + +``` +queued -> downloading -> completed + \-> failed +queued -> cancelled +downloading -> cancelled +``` + +Jobs are retained for 24 hours after completion. The server supports up to 2 concurrent downloads by default. diff --git a/unshackle/core/api/download_manager.py b/unshackle/core/api/download_manager.py index 0297181..a733ec5 100644 --- a/unshackle/core/api/download_manager.py +++ b/unshackle/core/api/download_manager.py @@ -10,6 +10,7 @@ from contextlib import suppress from dataclasses import dataclass, field from datetime import datetime, timedelta from enum import Enum +from pathlib import Path from typing import Any, Callable, Dict, List, Optional log = logging.getLogger("download_manager") @@ -105,11 +106,44 @@ def _perform_download( from unshackle.commands.dl import dl from unshackle.core.config import config from unshackle.core.services import Services + from unshackle.core.tracks import Subtitle, Video from unshackle.core.utils.click_types import ContextData from unshackle.core.utils.collections import merge_dict log.info(f"Starting sync download for job {job_id}") + # Convert string parameters to enums (API receives strings, dl.result() expects enums) + vcodec_raw = params.get("vcodec") + if vcodec_raw: + if isinstance(vcodec_raw, str): + vcodec_raw = [vcodec_raw] + if isinstance(vcodec_raw, list) and vcodec_raw and not isinstance(vcodec_raw[0], Video.Codec): + codec_map = {c.name.upper(): c for c in Video.Codec} + codec_map.update({c.value.upper(): c for c in Video.Codec}) + params["vcodec"] = [codec_map[v.upper()] for v in vcodec_raw if v.upper() in codec_map] + else: + params["vcodec"] = [] + + range_raw = params.get("range") + if range_raw: + if isinstance(range_raw, str): + range_raw = [range_raw] + if isinstance(range_raw, list) and range_raw and not isinstance(range_raw[0], Video.Range): + range_map = {r.name.upper(): r for r in Video.Range} + range_map.update({r.value.upper(): r for r in Video.Range}) + params["range"] = [range_map[r.upper()] for r in range_raw if r.upper() in range_map] + else: + params["range"] = [Video.Range.SDR] + + sub_format_raw = params.get("sub_format") + if sub_format_raw and isinstance(sub_format_raw, str): + sub_map = {c.name.upper(): c for c in Subtitle.Codec} + sub_map.update({c.value.upper(): c for c in Subtitle.Codec}) + params["sub_format"] = sub_map.get(sub_format_raw.upper()) + + if params.get("export") and isinstance(params["export"], str): + params["export"] = Path(params["export"]) + # Load service configuration service_config_path = Services.get_path(service) / config.filenames.config if service_config_path.exists(): @@ -127,10 +161,15 @@ def _perform_download( "proxy": params.get("proxy"), "no_proxy": params.get("no_proxy", False), "profile": params.get("profile"), + "repack": params.get("repack", False), "tag": params.get("tag"), "tmdb_id": params.get("tmdb_id"), "tmdb_name": params.get("tmdb_name", False), "tmdb_year": params.get("tmdb_year", False), + "imdb_id": params.get("imdb_id"), + "output_dir": Path(params["output_dir"]) if params.get("output_dir") else None, + "no_cache": params.get("no_cache", False), + "reset_cache": params.get("reset_cache", False), } dl_instance = dl( @@ -138,10 +177,13 @@ def _perform_download( no_proxy=params.get("no_proxy", False), profile=params.get("profile"), proxy=params.get("proxy"), + repack=params.get("repack", False), tag=params.get("tag"), tmdb_id=params.get("tmdb_id"), tmdb_name=params.get("tmdb_name", False), tmdb_year=params.get("tmdb_year", False), + imdb_id=params.get("imdb_id"), + output_dir=Path(params["output_dir"]) if params.get("output_dir") else None, ) service_module = Services.load(service) @@ -220,14 +262,14 @@ def _perform_download( dl_instance.result( service=service_instance, quality=params.get("quality", []), - vcodec=params.get("vcodec"), + vcodec=params.get("vcodec", []), acodec=params.get("acodec"), vbitrate=params.get("vbitrate"), abitrate=params.get("abitrate"), range_=params.get("range", ["SDR"]), channels=params.get("channels"), no_atmos=params.get("no_atmos", False), - split_audio=params.get("split_audio"), + select_titles=False, wanted=params.get("wanted", []), latest_episode=params.get("latest_episode", False), lang=params.get("lang", ["orig"]), @@ -245,6 +287,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), + no_video=params.get("no_video", False), audio_description=params.get("audio_description", False), slow=params.get("slow", False), list_=False, @@ -259,6 +302,7 @@ def _perform_download( workers=params.get("workers"), downloads=params.get("downloads", 1), best_available=params.get("best_available", False), + split_audio=params.get("split_audio"), ) except SystemExit as exc: diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index ca69476..1076b3c 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -1,3 +1,4 @@ +import enum import logging from typing import Any, Dict, List, Optional @@ -42,8 +43,10 @@ DEFAULT_DOWNLOAD_PARAMS = { "no_subs": False, "no_audio": False, "no_chapters": False, + "no_video": False, "audio_description": False, "slow": False, + "split_audio": None, "skip_dl": False, "export": None, "cdm_only": None, @@ -54,6 +57,11 @@ DEFAULT_DOWNLOAD_PARAMS = { "workers": None, "downloads": 1, "best_available": False, + "repack": False, + "imdb_id": None, + "output_dir": None, + "no_cache": False, + "reset_cache": False, } @@ -341,6 +349,137 @@ def serialize_subtitle_track(track: Subtitle, include_url: bool = False) -> Dict return result +async def search_handler(data: Dict[str, Any], request: Optional[web.Request] = None) -> web.Response: + """Handle search request.""" + import inspect + + import click + import yaml + + from unshackle.commands.dl import dl + from unshackle.core.config import config + from unshackle.core.services import Services + from unshackle.core.utils.click_types import ContextData + from unshackle.core.utils.collections import merge_dict + + service_tag = data.get("service") + query = data.get("query") + + if not service_tag: + raise APIError(APIErrorCode.MISSING_SERVICE, "Missing required 'service' field") + if not query: + raise APIError(APIErrorCode.INVALID_PARAMETERS, "Missing required 'query' field") + + normalized_service = Services.get_tag(service_tag) + if not normalized_service: + raise APIError( + APIErrorCode.INVALID_SERVICE, + f"Service '{service_tag}' not found", + details={"service": service_tag}, + ) + + profile = data.get("profile") + proxy_param = data.get("proxy") + no_proxy = data.get("no_proxy", False) + + service_config_path = Services.get_path(normalized_service) / config.filenames.config + if service_config_path.exists(): + service_config = yaml.safe_load(service_config_path.read_text(encoding="utf8")) + else: + service_config = {} + merge_dict(config.services.get(normalized_service), service_config) + + proxy_providers = [] + if not no_proxy: + proxy_providers = initialize_proxy_providers() + + if proxy_param and not no_proxy: + try: + resolved_proxy = resolve_proxy(proxy_param, proxy_providers) + proxy_param = resolved_proxy + except ValueError as e: + raise APIError( + APIErrorCode.INVALID_PROXY, + f"Proxy error: {e}", + details={"proxy": proxy_param, "service": normalized_service}, + ) + + @click.command() + @click.pass_context + def dummy_service(ctx: click.Context) -> None: + pass + + ctx = click.Context(dummy_service) + ctx.obj = ContextData(config=service_config, cdm=None, proxy_providers=proxy_providers, profile=profile) + ctx.params = {"proxy": proxy_param, "no_proxy": no_proxy} + + service_module = Services.load(normalized_service) + + dummy_service.name = normalized_service + ctx.invoked_subcommand = normalized_service + + service_ctx = click.Context(dummy_service, parent=ctx) + service_ctx.obj = ctx.obj + + service_init_params = inspect.signature(service_module.__init__).parameters + service_kwargs = {"title": query} + + # Extract default values from the 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 param.name not in service_kwargs: + if hasattr(param, "default") and param.default is not None and not isinstance(param.default, enum.Enum): + service_kwargs[param.name] = param.default + + for param_name, param_info in service_init_params.items(): + if param_name not in service_kwargs and param_name not in ["self", "ctx"]: + if param_info.default is inspect.Parameter.empty: + if param_name == "meta_lang": + service_kwargs[param_name] = None + elif param_name == "movie": + service_kwargs[param_name] = False + else: + service_kwargs[param_name] = None + + # Filter to only accepted params + accepted_params = set(service_init_params.keys()) - {"self", "ctx"} + service_kwargs = {k: v for k, v in service_kwargs.items() if k in accepted_params} + + try: + service_instance = service_module(service_ctx, **service_kwargs) + except Exception as exc: + raise APIError( + APIErrorCode.SERVICE_ERROR, + f"Failed to initialize service: {exc}", + details={"service": normalized_service}, + ) + + # Authenticate + cookies = dl.get_cookie_jar(normalized_service, profile) + credential = dl.get_credentials(normalized_service, profile) + service_instance.authenticate(cookies, credential) + + # Search + results = [] + try: + for result in service_instance.search(): + results.append({ + "id": result.id, + "title": result.title, + "description": result.description, + "label": result.label, + "url": result.url, + }) + except NotImplementedError: + raise APIError( + APIErrorCode.SERVICE_ERROR, + f"Search is not supported by {normalized_service}", + details={"service": normalized_service}, + ) + + return web.json_response({"results": results, "count": len(results)}) + + async def list_titles_handler(data: Dict[str, Any], request: Optional[web.Request] = None) -> web.Response: """Handle list-titles request.""" service_tag = data.get("service") @@ -439,7 +578,7 @@ async def list_titles_handler(data: Dict[str, Any], request: Optional[web.Reques for param in service_module.cli.params: if hasattr(param, "name") and param.name not in service_kwargs: # Add default value if parameter is not already provided - if hasattr(param, "default") and param.default is not None: + if hasattr(param, "default") and param.default is not None and not isinstance(param.default, enum.Enum): service_kwargs[param.name] = param.default # Handle required parameters that don't have click defaults @@ -587,7 +726,7 @@ async def list_tracks_handler(data: Dict[str, Any], request: Optional[web.Reques for param in service_module.cli.params: if hasattr(param, "name") and param.name not in service_kwargs: # Add default value if parameter is not already provided - if hasattr(param, "default") and param.default is not None: + if hasattr(param, "default") and param.default is not None and not isinstance(param.default, enum.Enum): service_kwargs[param.name] = param.default # Handle required parameters that don't have click defaults @@ -631,7 +770,10 @@ async def list_tracks_handler(data: Dict[str, Any], request: Optional[web.Reques try: season_range = SeasonRange() - wanted = season_range.parse_tokens(wanted_param) + if isinstance(wanted_param, list): + wanted = season_range.parse_tokens(*wanted_param) + else: + wanted = season_range.parse_tokens(wanted_param) log.debug(f"Parsed wanted '{wanted_param}' into {len(wanted)} episodes: {wanted[:10]}...") except Exception as e: raise APIError( @@ -760,9 +902,17 @@ def validate_download_parameters(data: Dict[str, Any]) -> Optional[str]: 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)}" + valid_vcodecs = ["H264", "H265", "H.264", "H.265", "AVC", "HEVC", "VC1", "VC-1", "VP8", "VP9", "AV1"] + if isinstance(data["vcodec"], str): + vcodec_values = [v.strip() for v in data["vcodec"].split(",") if v.strip()] + elif isinstance(data["vcodec"], list): + vcodec_values = [str(v).strip() for v in data["vcodec"] if str(v).strip()] + else: + return "vcodec must be a string or list" + + invalid = [value for value in vcodec_values if value.upper() not in valid_vcodecs] + if invalid: + return f"Invalid vcodec: {', '.join(invalid)}. Must be one of: {', '.join(valid_vcodecs)}" if "acodec" in data and data["acodec"]: valid_acodecs = ["AAC", "AC3", "EC3", "EAC3", "DD", "DD+", "AC4", "OPUS", "FLAC", "ALAC", "VORBIS", "OGG", "DTS"] @@ -778,7 +928,7 @@ def validate_download_parameters(data: Dict[str, Any]) -> Optional[str]: return f"Invalid acodec: {', '.join(invalid)}. Must be one of: {', '.join(valid_acodecs)}" if "sub_format" in data and data["sub_format"]: - valid_sub_formats = ["SRT", "VTT", "ASS", "SSA"] + valid_sub_formats = ["SRT", "VTT", "ASS", "SSA", "TTML", "STPP", "WVTT", "SMI", "SUB", "MPL2", "TMP"] 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)}" @@ -880,7 +1030,7 @@ async def download_handler(data: Dict[str, Any], request: Optional[web.Request] # 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: + if hasattr(param, "name") and hasattr(param, "default") and param.default is not None and not isinstance(param.default, enum.Enum): # Store service-specific defaults (e.g., drm_system, hydrate_track, profile for NF) service_specific_defaults[param.name] = param.default diff --git a/unshackle/core/api/routes.py b/unshackle/core/api/routes.py index 9adf8be..b907135 100644 --- a/unshackle/core/api/routes.py +++ b/unshackle/core/api/routes.py @@ -7,7 +7,8 @@ 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) + list_download_jobs_handler, list_titles_handler, list_tracks_handler, + search_handler) from unshackle.core.services import Services from unshackle.core.update_checker import UpdateChecker @@ -199,6 +200,93 @@ async def services(request: web.Request) -> web.Response: return handle_api_exception(e, context={"operation": "list_services"}, debug_mode=debug_mode) +async def search(request: web.Request) -> web.Response: + """ + Search for titles from a service. + --- + summary: Search for titles + description: Search for titles by query string from a service + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - service + - query + properties: + service: + type: string + description: Service tag (e.g., NF, AMZN, ATV) + query: + type: string + description: Search query string + profile: + type: string + description: Profile to use for credentials and cookies (default - None) + proxy: + type: string + description: Proxy URI or country code (default - None) + no_proxy: + type: boolean + description: Force disable all proxy use (default - false) + responses: + '200': + description: Search results + content: + application/json: + schema: + type: object + properties: + results: + type: array + items: + type: object + properties: + id: + type: string + description: Title ID for use with other endpoints + title: + type: string + description: Title name + description: + type: string + description: Title description + label: + type: string + description: Informative label (e.g., availability, region) + url: + type: string + description: URL to the title page + count: + type: integer + description: Number of results returned + '400': + description: Invalid request + """ + try: + data = await request.json() + 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), + ) + + try: + return await search_handler(data, request) + except APIError as e: + return build_error_response(e, request.app.get("debug_api", False)) + except Exception as e: + log.exception("Error in search") + debug_mode = request.app.get("debug_api", False) + return handle_api_exception(e, context={"operation": "search"}, debug_mode=debug_mode) + + async def list_titles(request: web.Request) -> web.Response: """ List titles for a service and title ID. @@ -409,11 +497,19 @@ async def download(request: web.Request) -> web.Response: type: integer description: Download resolution(s) (default - best available) vcodec: - type: string - description: Video codec to download (e.g., H264, H265, VP9, AV1) (default - None) + oneOf: + - type: string + - type: array + items: + type: string + description: Video codec(s) to download (e.g., "H265" or ["H264", "H265"]) - accepts H264, H265, AVC, HEVC, VP8, VP9, AV1, VC1 (default - None) acodec: - type: string - description: Audio codec(s) to download (e.g., AAC or AAC,EC3) (default - None) + oneOf: + - type: string + - type: array + items: + type: string + description: Audio codec(s) to download (e.g., "AAC" or ["AAC", "EC3"]) - accepts AAC, AC3, EC3, AC4, OPUS, FLAC, ALAC, DTS, OGG (default - None) vbitrate: type: integer description: Video bitrate in kbps (default - None) @@ -424,7 +520,7 @@ async def download(request: web.Request) -> web.Response: type: array items: type: string - description: Video color range (SDR, HDR10, DV) (default - ["SDR"]) + description: Video color range (SDR, HDR10, HDR10+, HLG, DV, HYBRID) (default - ["SDR"]) channels: type: number description: Audio channels (e.g., 2.0, 5.1, 7.1) (default - None) @@ -494,12 +590,18 @@ async def download(request: web.Request) -> web.Response: no_chapters: type: boolean description: Do not download chapters (default - false) + no_video: + type: boolean + description: Do not download video tracks (default - false) audio_description: type: boolean description: Download audio description tracks (default - false) slow: type: boolean description: Add 60-120s delay between downloads (default - false) + split_audio: + type: boolean + description: Create separate output files per audio codec instead of merging all audio (default - null) skip_dl: type: boolean description: Skip downloading, only retrieve decryption keys (default - false) @@ -545,6 +647,21 @@ async def download(request: web.Request) -> web.Response: best_available: type: boolean description: Continue with best available if requested quality unavailable (default - false) + repack: + type: boolean + description: Add REPACK tag to the output filename (default - false) + imdb_id: + type: string + description: Use this IMDB ID (e.g. tt1375666) for tagging (default - None) + output_dir: + type: string + description: Override the output directory for this download (default - None) + no_cache: + type: boolean + description: Bypass title cache for this download (default - false) + reset_cache: + type: boolean + description: Clear title cache before fetching (default - false) responses: '202': description: Download job created @@ -723,6 +840,7 @@ def setup_routes(app: web.Application) -> None: """Setup all API routes.""" app.router.add_get("/api/health", health) app.router.add_get("/api/services", services) + app.router.add_post("/api/search", search) app.router.add_post("/api/list-titles", list_titles) app.router.add_post("/api/list-tracks", list_tracks) app.router.add_post("/api/download", download) @@ -748,6 +866,7 @@ def setup_swagger(app: web.Application) -> None: [ web.get("/api/health", health), web.get("/api/services", services), + web.post("/api/search", search), web.post("/api/list-titles", list_titles), web.post("/api/list-tracks", list_tracks), web.post("/api/download", download),