mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-03-10 08:29:00 +00:00
fix(api): resolve Sentinel serialization, missing params, and add search endpoint (#80)
Fix multiple issues with the REST API that caused downloads to fail: - Filter Click Sentinel.UNSET enum values from service parameter defaults that caused "Object of type Sentinel is not JSON serializable" errors - Add missing select_titles and no_video args to dl.result() call - Fix wanted param unpacking for list-tracks SeasonRange.parse_tokens() - Add enum conversion for vcodec, range, sub_format, and export params that were passed as strings but expected as enums by dl.result() - Add missing dl command params: split_audio, repack, imdb_id, output_dir, no_cache, reset_cache to DEFAULT_DOWNLOAD_PARAMS and download worker - Expand vcodec/acodec/sub_format validation to cover all supported values - Add POST /api/search endpoint for searching services by query - Update Swagger docs with all new params and correct type definitions - Add comprehensive REST API documentation (docs/API.md) - Update ADVANCED_CONFIG.md with serve CLI options and API reference
This commit is contained in:
@@ -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)
|
||||
|
||||
387
docs/API.md
Normal file
387
docs/API.md
Normal file
@@ -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.
|
||||
@@ -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:
|
||||
|
||||
@@ -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,6 +770,9 @@ async def list_tracks_handler(data: Dict[str, Any], request: Optional[web.Reques
|
||||
|
||||
try:
|
||||
season_range = SeasonRange()
|
||||
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:
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
oneOf:
|
||||
- type: string
|
||||
- type: array
|
||||
items:
|
||||
type: string
|
||||
description: Video codec to download (e.g., H264, H265, VP9, AV1) (default - None)
|
||||
description: Video codec(s) to download (e.g., "H265" or ["H264", "H265"]) - accepts H264, H265, AVC, HEVC, VP8, VP9, AV1, VC1 (default - None)
|
||||
acodec:
|
||||
oneOf:
|
||||
- type: string
|
||||
- type: array
|
||||
items:
|
||||
type: string
|
||||
description: Audio codec(s) to download (e.g., AAC or AAC,EC3) (default - None)
|
||||
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),
|
||||
|
||||
Reference in New Issue
Block a user