mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 12:05:07 +00:00
docs: add MkDocs Material documentation site
This commit is contained in:
+8
-867
@@ -4,870 +4,11 @@ The unshackle REST API allows you to control downloads, search services, drive r
|
||||
|
||||
The server is built on **aiohttp** (not FastAPI). Implementation lives in `unshackle/commands/serve.py` and `unshackle/core/api/` (`routes.py`, `handlers.py`, `session_store.py`, `input_bridge.py`, `download_manager.py`, `download_worker.py`).
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Start the server (no authentication)
|
||||
unshackle serve --no-key
|
||||
|
||||
# Start with authentication (api_secret in unshackle.yaml)
|
||||
unshackle serve
|
||||
|
||||
# Serve only the REST API (no pywidevine/pyplayready CDM)
|
||||
unshackle serve --api-only
|
||||
|
||||
# Serve only the remote-dl session endpoints (CORS/Cloudflare friendly)
|
||||
unshackle serve --remote-only
|
||||
|
||||
# Disable just one CDM
|
||||
unshackle serve --no-widevine
|
||||
unshackle serve --no-playready
|
||||
|
||||
# Verbose error responses (tracebacks/stderr in JSON)
|
||||
unshackle serve --debug-api
|
||||
```
|
||||
|
||||
`serve` flags:
|
||||
|
||||
| Flag | Description |
|
||||
| --- | --- |
|
||||
| `-h, --host` | Bind host (default `127.0.0.1`) |
|
||||
| `-p, --port` | Bind port (default `8786`) |
|
||||
| `--caddy` | Also launch Caddy using `Caddyfile` next to the unshackle config |
|
||||
| `--api-only` | REST API only; skip the bundled pywidevine/pyplayready CDM endpoints |
|
||||
| `--no-widevine` | Disable Widevine CDM endpoints |
|
||||
| `--no-playready` | Disable PlayReady CDM endpoints |
|
||||
| `--no-key` | Disable API key authentication entirely |
|
||||
| `--debug-api` | Include tracebacks/stderr in error responses |
|
||||
| `--debug` | Enable DEBUG-level logging for API operations |
|
||||
| `--remote-only` | Expose only `/api/health`, `/api/services`, `/api/search`, and `/api/session/*` (implies `--api-only`) |
|
||||
|
||||
## Authentication
|
||||
|
||||
When `api_secret` is set in `unshackle.yaml`, all API requests require the **`X-Secret-Key`** header. There is no query-parameter fallback. `/api/health` is always reachable without authentication. `--no-key` disables auth entirely (not recommended for public-facing servers).
|
||||
|
||||
```yaml
|
||||
# unshackle.yaml
|
||||
serve:
|
||||
api_secret: "your-master-secret" # falls back to global users map below
|
||||
remote_only: false # also toggleable via --remote-only
|
||||
services: ["EXAMPLE1", "EXAMPLE2"] # optional global service allowlist
|
||||
users:
|
||||
user-secret-1:
|
||||
username: alice
|
||||
devices: ["my_widevine_l3"] # Widevine WVD names this user may use
|
||||
playready_devices: ["my_pr_sl2000"] # PlayReady PRD names; defaults to [] (no access)
|
||||
services: ["EXAMPLE1"] # optional per-user allowlist (intersected with global)
|
||||
user-secret-2:
|
||||
username: bob
|
||||
devices: []
|
||||
playready_devices: []
|
||||
```
|
||||
|
||||
### Service allowlists
|
||||
|
||||
`config.serve.services` is the global allowlist; `users.<key>.services` further narrows it per key. The effective set is the intersection. Endpoints affected: `/api/services`, `/api/search`, `/api/list-titles`, `/api/list-tracks`, `/api/download`, and all `/api/session/*` routes.
|
||||
|
||||
### CDM access (server-side decryption)
|
||||
|
||||
There is no separate "tier" flag. Whether the server can return KID:KEY for a session-mode download depends solely on the device lists configured for the calling user key:
|
||||
|
||||
- Empty `devices` and `playready_devices` -> server can only proxy CDM challenges; the client must run its own CDM and parse the license.
|
||||
- Populated lists -> the client may set `mode: "server_cdm"` on `/api/session/{id}/license` and receive `{ "keys": { "<track_id>": { "<KID>": "<KEY>" } } }` instead of raw license bytes.
|
||||
|
||||
Per-service CDM type can be pinned via `config.cdm` (`widevine`/`playready`) or per-service `cdm_type`; otherwise the server picks the type the user has devices for.
|
||||
|
||||
### Server-side `dl` defaults
|
||||
|
||||
Any flag accepted by `/api/download` (see the table below) can be declared under `serve:` in `unshackle.yaml` and the API will apply it as a default. Request-body values still win. Useful for raising concurrency without changing every client call:
|
||||
|
||||
```yaml
|
||||
serve:
|
||||
api_secret: "..."
|
||||
users: { ... }
|
||||
downloads: 4 # parallel tracks per download job
|
||||
workers: 16 # threads per track segment fetch
|
||||
best_available: true
|
||||
no_proxy_download: false
|
||||
```
|
||||
|
||||
Layering order: built-in defaults < `serve.*` overrides < service-specific click defaults < request body.
|
||||
|
||||
### Per-request override gates
|
||||
|
||||
Two security gates under `serve:` guard capabilities that `/api/download` clients can request. Both default OFF; an unset or `false` value causes the server to reject the request with `403 FORBIDDEN`.
|
||||
|
||||
**`cdm_overrides`** — a `/api/download` job body may include a `cdm` field to select a specific server-side device. When this gate is off, every such request is rejected. Set to `true` to allow any device the server has, or to a list of device names to restrict to an explicit subset:
|
||||
|
||||
```yaml
|
||||
serve:
|
||||
# Allow any server-side device:
|
||||
cdm_overrides: true
|
||||
|
||||
# Or restrict to specific devices:
|
||||
cdm_overrides:
|
||||
- generic_nexus_4464_l3
|
||||
```
|
||||
|
||||
**`allow_job_credentials`** — a `/api/download` job body may include a `credential` (single) or `credentials` (map) field to authenticate the job with client-supplied secrets instead of the server's configured credentials. When this gate is off, any job containing either field is rejected. Each distinct credential gets its own isolated token cache on the server. Set to `true` to allow:
|
||||
|
||||
```yaml
|
||||
serve:
|
||||
allow_job_credentials: true
|
||||
```
|
||||
|
||||
Both gates are independent and can be combined. A typical locked-down deployment leaves both unset; a trusted single-client deployment might enable both.
|
||||
|
||||
---
|
||||
|
||||
## Endpoint Map
|
||||
|
||||
Standard endpoints (suppressed in `--remote-only` mode are marked R):
|
||||
|
||||
| Method | Path | R |
|
||||
| --- | --- | :-: |
|
||||
| GET | `/api/health` | ok |
|
||||
| GET | `/api/services` | ok |
|
||||
| POST | `/api/search` | ok |
|
||||
| POST | `/api/list-titles` | hidden |
|
||||
| POST | `/api/list-tracks` | hidden |
|
||||
| POST | `/api/download` | hidden |
|
||||
| GET | `/api/download/jobs` | hidden |
|
||||
| GET | `/api/download/jobs/{job_id}` | hidden |
|
||||
| DELETE | `/api/download/jobs/{job_id}` | hidden |
|
||||
| POST | `/api/session/create` | ok |
|
||||
| GET | `/api/session/{session_id}` | ok |
|
||||
| DELETE | `/api/session/{session_id}` | ok |
|
||||
| GET | `/api/session/{session_id}/titles` | ok |
|
||||
| POST | `/api/session/{session_id}/tracks` | ok |
|
||||
| POST | `/api/session/{session_id}/segments` | ok |
|
||||
| POST | `/api/session/{session_id}/license` | ok |
|
||||
| GET | `/api/session/{session_id}/prompt` | ok |
|
||||
| POST | `/api/session/{session_id}/prompt` | ok |
|
||||
|
||||
CDM endpoints (`/{wvd}/...`, `/playready/{prd}/...`) are exposed unless `--api-only` / `--remote-only` / `--no-widevine` / `--no-playready` is set, and use pywidevine / pyplayready's own auth scheme.
|
||||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
|
||||
### GET /api/health
|
||||
|
||||
Health check with version and update information. Always reachable without auth.
|
||||
|
||||
```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 (filtered by the effective allowlist for the caller).
|
||||
|
||||
```bash
|
||||
curl -H "X-Secret-Key: $KEY" http://localhost:8786/api/services
|
||||
```
|
||||
|
||||
Returns `{"services": [...]}`. Each entry has `tag`, `aliases`, `geofence`, `title_regex`, `url` (from `cli.short_help`), `help` (full docstring), and `cli_params` describing the service-level Click parameters.
|
||||
|
||||
---
|
||||
|
||||
### POST /api/search
|
||||
|
||||
Search for titles from a streaming service.
|
||||
|
||||
**Required parameters:**
|
||||
| Parameter | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `service` | string | Service tag |
|
||||
| `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 "X-Secret-Key: $KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"service": "EXAMPLE1", "query": "example show"}'
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"id": "abc123def456",
|
||||
"title": "Example Show",
|
||||
"description": null,
|
||||
"label": "TV Show",
|
||||
"url": "https://example.com/show/abc123def456"
|
||||
}
|
||||
],
|
||||
"count": 1
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### POST /api/list-titles
|
||||
|
||||
Get available titles (seasons/episodes/movies) for a service and title ID. Disabled in `--remote-only` mode.
|
||||
|
||||
**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 "X-Secret-Key: $KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"service": "EXAMPLE1", "title_id": "abc123def456"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### POST /api/list-tracks
|
||||
|
||||
Get video, audio, and subtitle tracks for a title. Disabled in `--remote-only` mode.
|
||||
|
||||
**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 |
|
||||
|
||||
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). Disabled in `--remote-only` mode.
|
||||
|
||||
**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`, `VP8` |
|
||||
| `acodec` | string or array | any | Audio codec(s): `AAC`, `AC3`, `EC3`, `AC4`, `OPUS`, `FLAC`, `ALAC`, `DTS`, `OGG` |
|
||||
| `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 |
|
||||
| `imdb_id` | string | `null` | Use specific IMDB ID (e.g., `tt1375666`) |
|
||||
| `animeapi_id` | string | `null` | Anime database ID via AnimeAPI (e.g., `mal:12345`) |
|
||||
| `enrich` | boolean | `false` | Override show title and year from external source |
|
||||
| `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 |
|
||||
| `no_proxy_download` | boolean | `false` | Bypass proxy for segment downloads only. Manifest, license, and auth still use proxy |
|
||||
| `workers` | int | `null` | Max threads per track download |
|
||||
| `downloads` | int | `1` | Concurrent track downloads |
|
||||
| `slow` | boolean or string | `null` | Add randomized delay between titles. `true` = 60-120s, or `"MIN-MAX"` string (e.g., `"20-40"`). Min must be >= 20 |
|
||||
| `best_available` | boolean | `false` | Continue if requested quality unavailable |
|
||||
| `worst` | boolean | `false` | Select the lowest bitrate track within the specified quality. Requires `quality` |
|
||||
| `skip_dl` | boolean | `false` | Skip download, only get decryption keys |
|
||||
| `export` | boolean | `false` | Export manifest, track URLs, keys, and subtitles to JSON in the exports directory |
|
||||
| `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 "X-Secret-Key: $KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"service": "EXAMPLE1",
|
||||
"title_id": "abc123def456",
|
||||
"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. Disabled in `--remote-only` mode.
|
||||
|
||||
**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`, `started_time`, `completed_time`, `progress`, `status`, `service` |
|
||||
| `sort_order` | string | `desc` | Sort order: `asc`, `desc` |
|
||||
|
||||
```bash
|
||||
curl -H "X-Secret-Key: $KEY" "http://localhost:8786/api/download/jobs?status=completed"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET /api/download/jobs/{job_id}
|
||||
|
||||
Get detailed information about a specific download job including progress, parameters, and error details.
|
||||
|
||||
```json
|
||||
{
|
||||
"job_id": "504db959-80b0-446c-a764-7924b761d613",
|
||||
"status": "completed",
|
||||
"created_time": "2026-02-27T18:00:00.000000",
|
||||
"service": "EXAMPLE1",
|
||||
"title_id": "abc123def456",
|
||||
"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. Returns 400 if the job has already terminated.
|
||||
|
||||
---
|
||||
|
||||
## Remote Service Sessions
|
||||
|
||||
These endpoints back the `RemoteService` adapter in `unshackle/core/remote_service.py`. They let a thin `dl` client (or any consumer) authenticate against a service on the server, fetch titles/tracks/manifests, and either proxy CDM challenges or have the server resolve KID:KEY directly. The `dl` command's `RemoteService` adapter replaces the old `remote_dl` command. These endpoints are the only `/api/*` routes available in `--remote-only` mode (in addition to `health`, `services`, and `search`).
|
||||
|
||||
### POST /api/session/create
|
||||
|
||||
Authenticate against a service and open a session. Body fields:
|
||||
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `service` | string | Service tag (required) |
|
||||
| `title_id` | string | Title ID/URL (required) |
|
||||
| `credentials` | object | Auth credentials forwarded to `Service.authenticate` |
|
||||
| `cookies` | string | Cookie blob (Netscape or JSON) |
|
||||
| `proxy` | string | Proxy URI or country code |
|
||||
| `no_proxy` | bool | Force-disable proxies |
|
||||
| `profile` | string | Profile name |
|
||||
| `cache` | object | Optional pre-warmed title cache payload |
|
||||
|
||||
If the service requires interactive input during authentication, poll `GET /api/session/{id}/prompt` and submit responses via `POST /api/session/{id}/prompt` until status is `authenticated`.
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{
|
||||
"service": "EXAMPLE1",
|
||||
"title_id": "abc123def456",
|
||||
"credentials": {"username": "alice", "password": "hunter2"},
|
||||
"cookies": "# Netscape HTTP Cookie File\n...",
|
||||
"proxy": "us",
|
||||
"no_proxy": false,
|
||||
"profile": "default",
|
||||
"cache": {}
|
||||
}
|
||||
```
|
||||
|
||||
**Response (202-style; auth runs asynchronously):**
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "f1c4a8b2-9c7e-4d2a-bf91-2d3e4f5a6b7c",
|
||||
"service": "EXAMPLE1",
|
||||
"status": "authenticating"
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/session/{session_id}
|
||||
|
||||
Returns session metadata. 404 if expired or unknown.
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "f1c4a8b2-9c7e-4d2a-bf91-2d3e4f5a6b7c",
|
||||
"service": "EXAMPLE1",
|
||||
"valid": true,
|
||||
"expires_in": 3600,
|
||||
"track_count": 0,
|
||||
"title_count": 0
|
||||
}
|
||||
```
|
||||
|
||||
### DELETE /api/session/{session_id}
|
||||
|
||||
Tears down the session, cancels any pending prompts, and returns any updated per-session cache files (base64-encoded, zlib-compressed) so the client can re-warm next time.
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"cache": {
|
||||
"tokens": "eJzLSM3JyVcozy/KSVGo5AIAGgQEvQ=="
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/session/{session_id}/titles
|
||||
|
||||
Returns the resolved titles list.
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "f1c4a8b2-9c7e-4d2a-bf91-2d3e4f5a6b7c",
|
||||
"titles": [
|
||||
{
|
||||
"type": "episode",
|
||||
"name": "Pilot",
|
||||
"series_title": "Example Show",
|
||||
"season": 1,
|
||||
"number": 1,
|
||||
"year": 2024,
|
||||
"id": "ep-0001",
|
||||
"language": "en"
|
||||
},
|
||||
{
|
||||
"type": "movie",
|
||||
"name": "Example Movie",
|
||||
"year": 2024,
|
||||
"id": "mov-0001",
|
||||
"language": "en"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/session/{session_id}/tracks
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{"title_id": "ep-0001"}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"title": {
|
||||
"type": "episode",
|
||||
"name": "Pilot",
|
||||
"series_title": "Example Show",
|
||||
"season": 1,
|
||||
"number": 1,
|
||||
"year": 2024,
|
||||
"id": "ep-0001",
|
||||
"language": "en"
|
||||
},
|
||||
"video": [
|
||||
{
|
||||
"id": "v-1080p-h264",
|
||||
"codec": "H264",
|
||||
"codec_display": "H.264",
|
||||
"bitrate": 6000,
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"resolution": "1920x1080",
|
||||
"fps": "23.976",
|
||||
"range": "SDR",
|
||||
"range_display": "SDR",
|
||||
"language": "en",
|
||||
"drm": [
|
||||
{
|
||||
"type": "widevine",
|
||||
"pssh": "AAAAW3Bzc2gAAAAA7e+...",
|
||||
"kids": ["abcdef0123456789abcdef0123456789"],
|
||||
"license_url": "https://license.example.com/widevine"
|
||||
}
|
||||
],
|
||||
"descriptor": "DASH",
|
||||
"url": "https://cdn.example.com/manifest.mpd"
|
||||
}
|
||||
],
|
||||
"audio": [
|
||||
{
|
||||
"id": "a-en-eac3",
|
||||
"codec": "EC3",
|
||||
"codec_display": "Dolby Digital Plus",
|
||||
"bitrate": 640,
|
||||
"channels": "5.1",
|
||||
"language": "en",
|
||||
"atmos": false,
|
||||
"descriptive": false,
|
||||
"drm": null,
|
||||
"descriptor": "DASH",
|
||||
"url": "https://cdn.example.com/manifest.mpd"
|
||||
}
|
||||
],
|
||||
"subtitles": [
|
||||
{
|
||||
"id": "s-en-vtt",
|
||||
"codec": "WebVTT",
|
||||
"language": "en",
|
||||
"forced": false,
|
||||
"sdh": false,
|
||||
"cc": false,
|
||||
"descriptor": "DASH",
|
||||
"url": "https://cdn.example.com/subs/en.vtt"
|
||||
}
|
||||
],
|
||||
"chapters": [
|
||||
{"timestamp": "00:00:00.000", "name": "Chapter 1"}
|
||||
],
|
||||
"attachments": [],
|
||||
"manifests": [
|
||||
{
|
||||
"type": "dash",
|
||||
"url": "https://cdn.example.com/manifest.mpd",
|
||||
"data": "eJzNVk1v2zAM/Ss..."
|
||||
}
|
||||
],
|
||||
"session_headers": {
|
||||
"User-Agent": "Mozilla/5.0 ..."
|
||||
},
|
||||
"session_cookies": {
|
||||
"session": "abc123"
|
||||
},
|
||||
"server_cdm_type": "widevine"
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/session/{session_id}/segments
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{"track_ids": ["v-1080p-h264", "a-en-eac3"]}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"tracks": {
|
||||
"v-1080p-h264": {
|
||||
"descriptor": "DASH",
|
||||
"url": "https://cdn.example.com/manifest.mpd",
|
||||
"drm": [
|
||||
{
|
||||
"type": "widevine",
|
||||
"pssh": "AAAAW3Bzc2gAAAAA7e+...",
|
||||
"kids": ["abcdef0123456789abcdef0123456789"],
|
||||
"license_url": "https://license.example.com/widevine"
|
||||
}
|
||||
],
|
||||
"headers": {"User-Agent": "Mozilla/5.0 ..."},
|
||||
"cookies": {"session": "abc123"},
|
||||
"data": {}
|
||||
},
|
||||
"a-en-eac3": {
|
||||
"descriptor": "DASH",
|
||||
"url": "https://cdn.example.com/manifest.mpd",
|
||||
"drm": null,
|
||||
"headers": {"User-Agent": "Mozilla/5.0 ..."},
|
||||
"cookies": {"session": "abc123"},
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/session/{session_id}/license
|
||||
|
||||
Two modes, selected by the `mode` field.
|
||||
|
||||
**`mode: "proxy"` (default)** -- forward a client-built CDM challenge to the service's license endpoint.
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"mode": "proxy",
|
||||
"track_id": "v-1080p-h264",
|
||||
"challenge": "CAESxQEK...",
|
||||
"drm_type": "widevine",
|
||||
"pssh": "AAAAW3Bzc2gAAAAA7e+..."
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{"license": "CAIS3wIK..."}
|
||||
```
|
||||
|
||||
**`mode: "server_cdm"`** -- the server uses its own CDM to license the track and extract keys. Single-track form takes `track_id`; batch form takes `track_ids`. Requires the calling user key to have a matching device (`devices` for Widevine, `playready_devices` for PlayReady) in `unshackle.yaml`.
|
||||
|
||||
Request (batch):
|
||||
|
||||
```json
|
||||
{
|
||||
"mode": "server_cdm",
|
||||
"track_ids": ["v-1080p-h264", "a-en-eac3"],
|
||||
"drm_type": "widevine"
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"keys": {
|
||||
"v-1080p-h264": {
|
||||
"abcdef0123456789abcdef0123456789": "00112233445566778899aabbccddeeff"
|
||||
},
|
||||
"a-en-eac3": {
|
||||
"abcdef0123456789abcdef0123456789": "00112233445566778899aabbccddeeff"
|
||||
}
|
||||
},
|
||||
"drm_type": "widevine"
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/session/{session_id}/prompt
|
||||
|
||||
Polled by the client during interactive authentication (OTP, PIN, device codes). Backed by the `InputBridge` in `unshackle/core/api/input_bridge.py`; `Service.request_input()` blocks server-side until the client posts a response.
|
||||
|
||||
Pending input:
|
||||
|
||||
```json
|
||||
{"status": "pending_input", "prompt": "Enter OTP code: "}
|
||||
```
|
||||
|
||||
Other states:
|
||||
|
||||
```json
|
||||
{"status": "authenticating"}
|
||||
```
|
||||
|
||||
```json
|
||||
{"status": "authenticated"}
|
||||
```
|
||||
|
||||
```json
|
||||
{"status": "failed", "error": "Invalid credentials"}
|
||||
```
|
||||
|
||||
### POST /api/session/{session_id}/prompt
|
||||
|
||||
Unblocks the server-side `request_input()` call.
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{"response": "123456"}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{"status": "accepted"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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 or not in the caller's allowlist
|
||||
- `SERVICE_ERROR` -- service initialization or runtime error
|
||||
- `AUTH_FAILED` -- authentication failure
|
||||
- `NOT_FOUND` / `TRACK_NOT_FOUND` / session not found -- job/session/track/title missing
|
||||
- `INTERNAL_ERROR` -- unexpected server error
|
||||
|
||||
When `--debug-api` is enabled, error responses include additional `debug_info` with tracebacks and stderr output.
|
||||
|
||||
Authentication errors from the auth middleware are returned as `{"status": 401, "message": "..."}` (not the standard error envelope).
|
||||
|
||||
---
|
||||
|
||||
## Download Job Lifecycle
|
||||
|
||||
```
|
||||
queued -> downloading -> completed
|
||||
\-> failed
|
||||
queued -> cancelled
|
||||
downloading -> cancelled
|
||||
```
|
||||
|
||||
Jobs are retained for 24 hours after completion (override via top-level `download_job_retention_hours` in `unshackle.yaml`). The server runs up to 2 concurrent download jobs by default; override via top-level `max_concurrent_downloads`. This is independent of `serve.downloads`, which controls parallel tracks **within** a single job.
|
||||
|
||||
Remote sessions are managed by `SessionStore` (`unshackle/core/api/session_store.py`); idle sessions and their `InputBridge` instances are cleaned up by a background loop started/stopped with the app lifecycle.
|
||||
|
||||
---
|
||||
|
||||
## Client Configuration: `remote_services`
|
||||
|
||||
`remote_services` is a top-level block in `unshackle.yaml` on the **client** machine. It tells `dl` how to reach a remote `unshackle serve` instance instead of running service code locally. The client handles track selection, download, decrypt, and mux; the server handles auth, manifest parsing, and (optionally) DRM licensing.
|
||||
|
||||
### CLI flags
|
||||
|
||||
| Flag | Description |
|
||||
| --- | --- |
|
||||
| `--remote` | Use a remote server instead of local service code. Reads `remote_services` from config. |
|
||||
| `--server <name>` | Select a named server when multiple are configured. If only one entry exists it is picked automatically. |
|
||||
|
||||
```bash
|
||||
unshackle dl --remote EXAMPLE1 "https://example.com/show/abc123"
|
||||
unshackle dl --remote --server us-server EXAMPLE1 "https://example.com/show/abc123"
|
||||
```
|
||||
|
||||
### Config structure
|
||||
|
||||
```yaml
|
||||
remote_services:
|
||||
my-server:
|
||||
url: "http://192.168.1.100:8786" # base URL of the remote serve instance
|
||||
api_key: "your-secret-key-here" # sent as X-Secret-Key; omit or set "" for --no-key servers
|
||||
|
||||
# server_cdm: the server runs its own CDM and returns KID:KEY pairs (mode: server_cdm).
|
||||
# false (default): client runs its own CDM; license challenges are proxied through the server.
|
||||
server_cdm: false
|
||||
|
||||
# Per-service overrides applied on the client side after the remote session is set up.
|
||||
# Supported keys: decryption, downloader, cdm (and any free-form service config keys).
|
||||
services:
|
||||
EXAMPLE1:
|
||||
decryption: mp4decrypt # override decryption tool for this service (shaka or mp4decrypt)
|
||||
```
|
||||
|
||||
**`server_cdm`** — when `false` (default) the client builds CDM challenges locally and calls `POST /api/session/{id}/license` with `mode: "proxy"`; the server forwards the challenge to the service's license endpoint and returns raw license bytes. When `true` the client skips local CDM setup and calls with `mode: "server_cdm"`; the server performs the full CDM flow and returns `{ "keys": { "<KID>": "<KEY>" } }`. The server-side user key must have a matching device configured (`devices` / `playready_devices`) for `server_cdm` to work.
|
||||
|
||||
### Multiple servers
|
||||
|
||||
When more than one server is configured, `--server <name>` is required:
|
||||
|
||||
```yaml
|
||||
remote_services:
|
||||
us-server:
|
||||
url: "https://us.example.com:8786"
|
||||
api_key: "us-key"
|
||||
server_cdm: true
|
||||
services:
|
||||
EXAMPLE1:
|
||||
decryption: mp4decrypt
|
||||
|
||||
eu-server:
|
||||
url: "https://eu.example.com:8786"
|
||||
api_key: "eu-key"
|
||||
server_cdm: false
|
||||
```
|
||||
|
||||
```bash
|
||||
unshackle dl --remote --server us-server EXAMPLE1 "https://example.com/show/abc123"
|
||||
```
|
||||
|
||||
### Cache forwarding
|
||||
|
||||
On session open the client sends any local `*.json` cache files for the service (tokens, etc.) to the server so it can skip interactive re-auth. On session close the server returns updated cache files; the client writes them back locally so the next remote session can forward them again.
|
||||
- [Quick Start](api/quick-start.md)
|
||||
- [Authentication](api/authentication.md)
|
||||
- [Endpoint Map](api/endpoint-map.md)
|
||||
- [Endpoints](api/endpoints.md)
|
||||
- [Remote Service Sessions](api/remote-service-sessions.md)
|
||||
- [Error Responses](api/error-responses.md)
|
||||
- [Download Job Lifecycle](api/download-job-lifecycle.md)
|
||||
- [Client Configuration: `remote_services`](api/client-configuration.md)
|
||||
|
||||
+34
-20
@@ -136,10 +136,12 @@ remote_cdm:
|
||||
- Support for both Widevine and PlayReady
|
||||
- Multiple security levels (L1, L2, L3, SL2000, SL3000)
|
||||
|
||||
**Note:** The `device_type` field determines whether the CDM operates in PlayReady or Widevine mode.
|
||||
Setting `device_type: PLAYREADY` (or using `device_name: SL2` / `SL3`) activates PlayReady mode.
|
||||
The `security_level` field is auto-computed from `device_name` when not specified (e.g., SL2 defaults
|
||||
to 2000, SL3 to 3000, and Widevine devices default to 3). You can override these if needed.
|
||||
!!! note
|
||||
|
||||
The `device_type` field determines whether the CDM operates in PlayReady or Widevine mode.
|
||||
Setting `device_type: PLAYREADY` (or using `device_name: SL2` / `SL3`) activates PlayReady mode.
|
||||
The `security_level` field is auto-computed from `device_name` when not specified (e.g., SL2 defaults
|
||||
to 2000, SL3 to 3000, and Widevine devices default to 3). You can override these if needed.
|
||||
|
||||
### Custom API Remote CDM
|
||||
|
||||
@@ -310,9 +312,11 @@ remote_cdm:
|
||||
secret: your-api-secret-key # X-Secret-Key sent to the serve instance
|
||||
```
|
||||
|
||||
**Note:** The `/playready` path suffix in `host` is required — `pyplayready.RemoteCdm`
|
||||
appends its own endpoint paths on top of this base, so omitting the suffix will result
|
||||
in 404 errors.
|
||||
!!! note
|
||||
|
||||
The `/playready` path suffix in `host` is required — `pyplayready.RemoteCdm`
|
||||
appends its own endpoint paths on top of this base, so omitting the suffix will result
|
||||
in 404 errors.
|
||||
|
||||
### Legacy PyWidevine Serve Format
|
||||
|
||||
@@ -329,7 +333,9 @@ remote_cdm:
|
||||
secret: secret_key
|
||||
```
|
||||
|
||||
**Note:** If the `type` field is not specified, the entry is treated as a legacy pywidevine serve CDM.
|
||||
!!! note
|
||||
|
||||
If the `type` field is not specified, the entry is treated as a legacy pywidevine serve CDM.
|
||||
|
||||
[pywidevine]: https://github.com/rlaphoenix/pywidevine
|
||||
|
||||
@@ -348,10 +354,12 @@ For example,
|
||||
decrypt_labs_api_key: "your_api_key_here"
|
||||
```
|
||||
|
||||
**Note**: This is different from the per-CDM `secret` field in `remote_cdm` entries. This provides a global
|
||||
API key that can be referenced across multiple DecryptLabs CDM configurations. If a `remote_cdm` entry with
|
||||
`type: "decrypt_labs"` does not have a `secret` field specified, the global `decrypt_labs_api_key` will be
|
||||
used as a fallback.
|
||||
!!! note
|
||||
|
||||
This is different from the per-CDM `secret` field in `remote_cdm` entries. This provides a global
|
||||
API key that can be referenced across multiple DecryptLabs CDM configurations. If a `remote_cdm` entry with
|
||||
`type: "decrypt_labs"` does not have a `secret` field specified, the global `decrypt_labs_api_key` will be
|
||||
used as a fallback.
|
||||
|
||||
---
|
||||
|
||||
@@ -402,8 +410,10 @@ locally via a WASM module.
|
||||
MonaLisa uses per-segment decryption during download (not post-download like Widevine/PlayReady),
|
||||
so segments are decrypted as they are downloaded.
|
||||
|
||||
**Note:** MonaLisa is configured per-service rather than through global config options. Services
|
||||
that use MonaLisa handle ticket/key retrieval and CDM initialization internally.
|
||||
!!! note
|
||||
|
||||
MonaLisa is configured per-service rather than through global config options. Services
|
||||
that use MonaLisa handle ticket/key retrieval and CDM initialization internally.
|
||||
|
||||
---
|
||||
|
||||
@@ -470,7 +480,7 @@ vault_timeout: 10.0 # seconds; per-vault `timeout:` overrides this
|
||||
### Using an API Vault
|
||||
|
||||
API vaults use a specific HTTP request format, therefore API or HTTP Key Vault APIs from other projects or services may
|
||||
not work in unshackle. The API format can be seen in the [API Vault Code](unshackle/vaults/API.py).
|
||||
not work in unshackle. The API format can be seen in the [API Vault Code](https://github.com/unshackle-dl/unshackle/blob/dev/unshackle/vaults/API.py).
|
||||
|
||||
```yaml
|
||||
- type: API
|
||||
@@ -523,10 +533,12 @@ case something happens to your MySQL Vault.
|
||||
# no_push: true # optional; commonly true for local backup vaults
|
||||
```
|
||||
|
||||
**Note**: You do not need to create the file at the specified path.
|
||||
SQLite will create a new SQLite database at that path if one does not exist.
|
||||
Try not to accidentally move the `db` file once created without reflecting the change in the config, or you will end
|
||||
up with multiple databases.
|
||||
!!! note
|
||||
|
||||
You do not need to create the file at the specified path.
|
||||
SQLite will create a new SQLite database at that path if one does not exist.
|
||||
Try not to accidentally move the `db` file once created without reflecting the change in the config, or you will end
|
||||
up with multiple databases.
|
||||
|
||||
If you work on a Team I recommend every team member having their own SQLite Vault even if you all use a MySQL vault
|
||||
together.
|
||||
@@ -579,6 +591,8 @@ is useful for integrating with various third-party key vault APIs.
|
||||
api_mode: "decrypt_labs"
|
||||
```
|
||||
|
||||
**Note**: The `decrypt_labs` mode is always read-only and cannot receive pushed keys.
|
||||
!!! note
|
||||
|
||||
The `decrypt_labs` mode is always read-only and cannot receive pushed keys.
|
||||
|
||||
---
|
||||
|
||||
+10
-9
@@ -65,7 +65,8 @@ Format: `gluetun:provider:region`
|
||||
3. Generate or view your service credentials
|
||||
4. Copy the username and password
|
||||
|
||||
> **Note**: Use service credentials, NOT your account email/password.
|
||||
!!! note
|
||||
Use service credentials, NOT your account email/password.
|
||||
|
||||
### WireGuard Credentials (Advanced)
|
||||
|
||||
@@ -244,15 +245,15 @@ Common issues:
|
||||
|
||||
---
|
||||
|
||||
# ExpressVPN HTTPS Proxy
|
||||
## ExpressVPN HTTPS Proxy
|
||||
|
||||
ExpressVPN is a standalone HTTPS proxy provider — it does **not** use Docker or the gluetun sub-provider system. It authenticates via a browser-extension OAuth flow (Keycloak/PKCE) and returns an authenticated `https://cat:<token>@<host>:443` proxy URL.
|
||||
|
||||
## Prerequisites
|
||||
### Prerequisites
|
||||
|
||||
An active ExpressVPN subscription is required. The provider resolves proxy-capable locations through ExpressVPN's own API, so no local VPN client or Docker container is needed.
|
||||
|
||||
## Authentication
|
||||
### Authentication
|
||||
|
||||
The provider uses a token cascade (tried in order):
|
||||
|
||||
@@ -262,7 +263,7 @@ The provider uses a token cascade (tried in order):
|
||||
|
||||
Export cookies from a browser that is logged in to `auth.expressvpn.com`. Any Netscape-format, JSON array, or `name=value` file is accepted.
|
||||
|
||||
## Configuration
|
||||
### Configuration
|
||||
|
||||
Add to `~/.config/unshackle/unshackle.yaml` under `proxy_providers`:
|
||||
|
||||
@@ -279,7 +280,7 @@ proxy_providers:
|
||||
|
||||
`region_map` maps a country code to a default city preset. An empty (null) value enables smart connection (a random location in that country). The preset is only applied when the CLI query contains no city; an explicit city in the query always takes precedence.
|
||||
|
||||
### Optional keys
|
||||
#### Optional keys
|
||||
|
||||
| Key | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
@@ -291,7 +292,7 @@ proxy_providers:
|
||||
| `refresh_token` | *(none)* | OAuth refresh token (manual override) |
|
||||
| `timeout` | `10.0` | HTTP request timeout in seconds |
|
||||
|
||||
## Usage
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
unshackle dl SERVICE CONTENT --proxy expressvpn:us # random/smart US location
|
||||
@@ -307,7 +308,7 @@ Query format: `expressvpn:<country>[-<city>[-<N>]]`
|
||||
- `city` — optional city abbreviation or slug matched against location names using first-letter abbreviation (`ny` → "New York"), exact slug, prefix, then substring strategies.
|
||||
- `N` — optional 1-based server index (e.g. `-2` selects the second server in the matched city). If `N` exceeds the available server count, a random server is selected.
|
||||
|
||||
## City matching
|
||||
### City matching
|
||||
|
||||
The city part of the query is matched against ExpressVPN location names using these strategies (in priority order):
|
||||
|
||||
@@ -316,6 +317,6 @@ The city part of the query is matched against ExpressVPN location names using th
|
||||
3. Prefix match — `mia` matches "Miami"
|
||||
4. Substring match — `york` matches "New York"
|
||||
|
||||
## Token cache
|
||||
### Token cache
|
||||
|
||||
After the first successful authentication the token cache is written to `cache/global/expressvpn_tokens.json` (mode 0600). Subsequent runs refresh expired access/SRT/connection tokens automatically without re-reading cookies.
|
||||
|
||||
@@ -43,7 +43,7 @@ Add `?` suffix to make a variable conditional (omitted when empty): `{year?}`, `
|
||||
|---|---|
|
||||
| `title` | Title of the movie, episode, or track |
|
||||
| `year` | Release year |
|
||||
| `source` | Service source tag (e.g. `AMZN`, `NF`) |
|
||||
| `source` | Service source tag (the uppercase service directory name) |
|
||||
| `tag` | Group/release tag configured via `tag:` |
|
||||
| `edition` | Edition label (e.g. `Extended`, `Theatrical`) |
|
||||
| `repack` | `REPACK` when the release is a repack, otherwise empty |
|
||||
@@ -414,14 +414,14 @@ How it works:
|
||||
local first to let your local tweaks override a repo.
|
||||
- **What you see on load.** A one-line summary is logged each run, e.g.:
|
||||
|
||||
```
|
||||
```text
|
||||
Loaded 103 services (36 duplicate(s) ignored)
|
||||
```
|
||||
|
||||
The full per-duplicate detail is logged only at debug verbosity (`unshackle -d ...`), one line per
|
||||
duplicate, naming the path that loaded and the path that was ignored:
|
||||
|
||||
```
|
||||
```text
|
||||
EXAMPLE: using <unshackle>/services/EXAMPLE/__init__.py, ignoring duplicate <unshackle>/services/_repos/your-repo/EXAMPLE/__init__.py
|
||||
```
|
||||
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
# docs/
|
||||
# unshackle Documentation
|
||||
|
||||
This folder is the **canonical configuration reference** for unshackle. `unshackle/unshackle-example.yaml` is a minimal starter config only — it intentionally omits most options. Come here for the full picture.
|
||||
This is the **canonical configuration reference** for unshackle. `unshackle/unshackle-example.yaml` is a minimal starter config only — it intentionally omits most options. Come here for the full picture.
|
||||
|
||||
## Contents
|
||||
|
||||
|
||||
+10
-6
@@ -209,7 +209,8 @@ For example,
|
||||
tmdb_api_key: cf66bf18956kca5311ada3bebb84eb9a # Not a real key
|
||||
```
|
||||
|
||||
**Note**: Keep your API key secure and do not share it publicly. This key is used by the `core/providers/tmdb.py` metadata provider to fetch metadata from TMDB for proper file tagging and ID enrichment.
|
||||
!!! note
|
||||
Keep your API key secure and do not share it publicly. This key is used by the `core/providers/tmdb.py` metadata provider to fetch metadata from TMDB for proper file tagging and ID enrichment.
|
||||
|
||||
---
|
||||
|
||||
@@ -230,7 +231,8 @@ For example,
|
||||
simkl_client_id: "your_client_id_here"
|
||||
```
|
||||
|
||||
**Note**: While optional, having a SIMKL Client ID improves metadata lookup reliability. SIMKL serves as an alternative or fallback metadata source to TMDB. This is used by the `core/providers/simkl.py` metadata provider.
|
||||
!!! note
|
||||
While optional, having a SIMKL Client ID improves metadata lookup reliability. SIMKL serves as an alternative or fallback metadata source to TMDB. This is used by the `core/providers/simkl.py` metadata provider.
|
||||
|
||||
---
|
||||
|
||||
@@ -249,7 +251,8 @@ For example,
|
||||
ipinfo_api_key: "12a3b45cd678ef" # Not a real key
|
||||
```
|
||||
|
||||
**Note**: The token is only ever sent to `api.ipinfo.io` as a per-request `Authorization` header — it is never attached to your session for service requests. Used by `core/utils/ip_info.py` for region detection and proxy verification.
|
||||
!!! note
|
||||
The token is only ever sent to `api.ipinfo.io` as a per-request `Authorization` header — it is never attached to your session for service requests. Used by `core/utils/ip_info.py` for region detection and proxy verification.
|
||||
|
||||
---
|
||||
|
||||
@@ -291,8 +294,9 @@ Log decryption keys in debug logs. Default: `false`.
|
||||
When `true`, actual content encryption keys (CEKs) are included in debug log output. Useful for
|
||||
debugging key retrieval and decryption issues.
|
||||
|
||||
**Security note:** Passwords, tokens, cookies, and session tokens are always redacted regardless
|
||||
of this setting. Only content keys (`content_key`, `key` fields) are affected. Key IDs (`kid`),
|
||||
key counts, and other metadata are always logged.
|
||||
!!! note "Security note"
|
||||
Passwords, tokens, cookies, and session tokens are always redacted regardless
|
||||
of this setting. Only content keys (`content_key`, `key` fields) are affected. Key IDs (`kid`),
|
||||
key counts, and other metadata are always logged.
|
||||
|
||||
---
|
||||
|
||||
@@ -33,7 +33,7 @@ Control subtitle conversion, SDH (hearing-impaired) stripping, formatting preser
|
||||
`--sub-format srt` for the muxed track, or `sidecar_format: srt` for sidecars. To keep raw styled
|
||||
sidecars, set `sidecar_format: original`.
|
||||
|
||||
**Segmented subtitles** (`fVTT`/WVTT and `fTTML`/STPP from DASH/HLS, e.g. HBO Max) are read directly
|
||||
**Segmented subtitles** (`fVTT`/WVTT and `fTTML`/STPP from DASH/HLS) are read directly
|
||||
from the fragmented MP4: fVTT via subby's `WVTTConverter`, fTTML via pycaption's box parsing. They
|
||||
can be converted *from* but not *to*.
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# Authentication
|
||||
|
||||
When `api_secret` is set in `unshackle.yaml`, all API requests require the **`X-Secret-Key`** header. There is no query-parameter fallback. `/api/health` is always reachable without authentication. `--no-key` disables auth entirely (not recommended for public-facing servers).
|
||||
|
||||
```yaml
|
||||
# unshackle.yaml
|
||||
serve:
|
||||
api_secret: "your-master-secret" # falls back to global users map below
|
||||
remote_only: false # also toggleable via --remote-only
|
||||
services: ["EXAMPLE1", "EXAMPLE2"] # optional global service allowlist
|
||||
users:
|
||||
user-secret-1:
|
||||
username: alice
|
||||
devices: ["my_widevine_l3"] # Widevine WVD names this user may use
|
||||
playready_devices: ["my_pr_sl2000"] # PlayReady PRD names; defaults to [] (no access)
|
||||
services: ["EXAMPLE1"] # optional per-user allowlist (intersected with global)
|
||||
user-secret-2:
|
||||
username: bob
|
||||
devices: []
|
||||
playready_devices: []
|
||||
```
|
||||
|
||||
## Service allowlists
|
||||
|
||||
`config.serve.services` is the global allowlist; `users.<key>.services` further narrows it per key. The effective set is the intersection. Endpoints affected: `/api/services`, `/api/search`, `/api/list-titles`, `/api/list-tracks`, `/api/download`, and all `/api/session/*` routes.
|
||||
|
||||
## CDM access (server-side decryption)
|
||||
|
||||
There is no separate "tier" flag. Whether the server can return KID:KEY for a session-mode download depends solely on the device lists configured for the calling user key:
|
||||
|
||||
- Empty `devices` and `playready_devices` -> server can only proxy CDM challenges; the client must run its own CDM and parse the license.
|
||||
- Populated lists -> the client may set `mode: "server_cdm"` on `/api/session/{id}/license` and receive `{ "keys": { "<track_id>": { "<KID>": "<KEY>" } } }` instead of raw license bytes.
|
||||
|
||||
Per-service CDM type can be pinned via `config.cdm` (`widevine`/`playready`) or per-service `cdm_type`; otherwise the server picks the type the user has devices for.
|
||||
|
||||
## Server-side `dl` defaults
|
||||
|
||||
Any flag accepted by `/api/download` (see the table below) can be declared under `serve:` in `unshackle.yaml` and the API will apply it as a default. Request-body values still win. Useful for raising concurrency without changing every client call:
|
||||
|
||||
```yaml
|
||||
serve:
|
||||
api_secret: "..."
|
||||
users: { ... }
|
||||
downloads: 4 # parallel tracks per download job
|
||||
workers: 16 # threads per track segment fetch
|
||||
best_available: true
|
||||
no_proxy_download: false
|
||||
```
|
||||
|
||||
Layering order: built-in defaults < `serve.*` overrides < service-specific click defaults < request body.
|
||||
|
||||
## Per-request override gates
|
||||
|
||||
Two security gates under `serve:` guard capabilities that `/api/download` clients can request. Both default OFF; an unset or `false` value causes the server to reject the request with `403 FORBIDDEN`.
|
||||
|
||||
**`cdm_overrides`** — a `/api/download` job body may include a `cdm` field to select a specific server-side device. When this gate is off, every such request is rejected. Set to `true` to allow any device the server has, or to a list of device names to restrict to an explicit subset:
|
||||
|
||||
```yaml
|
||||
serve:
|
||||
# Allow any server-side device:
|
||||
cdm_overrides: true
|
||||
|
||||
# Or restrict to specific devices:
|
||||
cdm_overrides:
|
||||
- generic_nexus_4464_l3
|
||||
```
|
||||
|
||||
**`allow_job_credentials`** — a `/api/download` job body may include a `credential` (single) or `credentials` (map) field to authenticate the job with client-supplied secrets instead of the server's configured credentials. When this gate is off, any job containing either field is rejected. Each distinct credential gets its own isolated token cache on the server. Set to `true` to allow:
|
||||
|
||||
```yaml
|
||||
serve:
|
||||
allow_job_credentials: true
|
||||
```
|
||||
|
||||
Both gates are independent and can be combined. A typical locked-down deployment leaves both unset; a trusted single-client deployment might enable both.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Client Configuration: `remote_services`
|
||||
|
||||
`remote_services` is a top-level block in `unshackle.yaml` on the **client** machine. It tells `dl` how to reach a remote `unshackle serve` instance instead of running service code locally. The client handles track selection, download, decrypt, and mux; the server handles auth, manifest parsing, and (optionally) DRM licensing.
|
||||
|
||||
## CLI flags
|
||||
|
||||
| Flag | Description |
|
||||
| --- | --- |
|
||||
| `--remote` | Use a remote server instead of local service code. Reads `remote_services` from config. |
|
||||
| `--server <name>` | Select a named server when multiple are configured. If only one entry exists it is picked automatically. |
|
||||
|
||||
```bash
|
||||
unshackle dl --remote EXAMPLE1 "https://example.com/show/abc123"
|
||||
unshackle dl --remote --server us-server EXAMPLE1 "https://example.com/show/abc123"
|
||||
```
|
||||
|
||||
## Config structure
|
||||
|
||||
```yaml
|
||||
remote_services:
|
||||
my-server:
|
||||
url: "http://192.168.1.100:8786" # base URL of the remote serve instance
|
||||
api_key: "your-secret-key-here" # sent as X-Secret-Key; omit or set "" for --no-key servers
|
||||
|
||||
# server_cdm: the server runs its own CDM and returns KID:KEY pairs (mode: server_cdm).
|
||||
# false (default): client runs its own CDM; license challenges are proxied through the server.
|
||||
server_cdm: false
|
||||
|
||||
# Per-service overrides applied on the client side after the remote session is set up.
|
||||
# Supported keys: decryption, downloader, cdm (and any free-form service config keys).
|
||||
services:
|
||||
EXAMPLE1:
|
||||
decryption: mp4decrypt # override decryption tool for this service (shaka or mp4decrypt)
|
||||
```
|
||||
|
||||
**`server_cdm`** — when `false` (default) the client builds CDM challenges locally and calls `POST /api/session/{id}/license` with `mode: "proxy"`; the server forwards the challenge to the service's license endpoint and returns raw license bytes. When `true` the client skips local CDM setup and calls with `mode: "server_cdm"`; the server performs the full CDM flow and returns `{ "keys": { "<KID>": "<KEY>" } }`. The server-side user key must have a matching device configured (`devices` / `playready_devices`) for `server_cdm` to work.
|
||||
|
||||
## Multiple servers
|
||||
|
||||
When more than one server is configured, `--server <name>` is required:
|
||||
|
||||
```yaml
|
||||
remote_services:
|
||||
us-server:
|
||||
url: "https://us.example.com:8786"
|
||||
api_key: "us-key"
|
||||
server_cdm: true
|
||||
services:
|
||||
EXAMPLE1:
|
||||
decryption: mp4decrypt
|
||||
|
||||
eu-server:
|
||||
url: "https://eu.example.com:8786"
|
||||
api_key: "eu-key"
|
||||
server_cdm: false
|
||||
```
|
||||
|
||||
```bash
|
||||
unshackle dl --remote --server us-server EXAMPLE1 "https://example.com/show/abc123"
|
||||
```
|
||||
|
||||
## Cache forwarding
|
||||
|
||||
On session open the client sends any local `*.json` cache files for the service (tokens, etc.) to the server so it can skip interactive re-auth. On session close the server returns updated cache files; the client writes them back locally so the next remote session can forward them again.
|
||||
@@ -0,0 +1,12 @@
|
||||
# Download Job Lifecycle
|
||||
|
||||
```text
|
||||
queued -> downloading -> completed
|
||||
\-> failed
|
||||
queued -> cancelled
|
||||
downloading -> cancelled
|
||||
```
|
||||
|
||||
Jobs are retained for 24 hours after completion (override via top-level `download_job_retention_hours` in `unshackle.yaml`). The server runs up to 2 concurrent download jobs by default; override via top-level `max_concurrent_downloads`. This is independent of `serve.downloads`, which controls parallel tracks **within** a single job.
|
||||
|
||||
Remote sessions are managed by `SessionStore` (`unshackle/core/api/session_store.py`); idle sessions and their `InputBridge` instances are cleaned up by a background loop started/stopped with the app lifecycle.
|
||||
@@ -0,0 +1,26 @@
|
||||
# Endpoint Map
|
||||
|
||||
Standard endpoints (suppressed in `--remote-only` mode are marked R):
|
||||
|
||||
| Method | Path | R |
|
||||
| --- | --- | :-: |
|
||||
| GET | `/api/health` | ok |
|
||||
| GET | `/api/services` | ok |
|
||||
| POST | `/api/search` | ok |
|
||||
| POST | `/api/list-titles` | hidden |
|
||||
| POST | `/api/list-tracks` | hidden |
|
||||
| POST | `/api/download` | hidden |
|
||||
| GET | `/api/download/jobs` | hidden |
|
||||
| GET | `/api/download/jobs/{job_id}` | hidden |
|
||||
| DELETE | `/api/download/jobs/{job_id}` | hidden |
|
||||
| POST | `/api/session/create` | ok |
|
||||
| GET | `/api/session/{session_id}` | ok |
|
||||
| DELETE | `/api/session/{session_id}` | ok |
|
||||
| GET | `/api/session/{session_id}/titles` | ok |
|
||||
| POST | `/api/session/{session_id}/tracks` | ok |
|
||||
| POST | `/api/session/{session_id}/segments` | ok |
|
||||
| POST | `/api/session/{session_id}/license` | ok |
|
||||
| GET | `/api/session/{session_id}/prompt` | ok |
|
||||
| POST | `/api/session/{session_id}/prompt` | ok |
|
||||
|
||||
CDM endpoints (`/{wvd}/...`, `/playready/{prd}/...`) are exposed unless `--api-only` / `--remote-only` / `--no-widevine` / `--no-playready` is set, and use pywidevine / pyplayready's own auth scheme.
|
||||
@@ -0,0 +1,277 @@
|
||||
# Endpoints
|
||||
|
||||
## GET /api/health
|
||||
|
||||
Health check with version and update information. Always reachable without auth.
|
||||
|
||||
```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 (filtered by the effective allowlist for the caller).
|
||||
|
||||
```bash
|
||||
curl -H "X-Secret-Key: $KEY" http://localhost:8786/api/services
|
||||
```
|
||||
|
||||
Returns `{"services": [...]}`. Each entry has `tag`, `aliases`, `geofence`, `title_regex`, `url` (from `cli.short_help`), `help` (full docstring), and `cli_params` describing the service-level Click parameters.
|
||||
|
||||
---
|
||||
|
||||
## POST /api/search
|
||||
|
||||
Search for titles from a streaming service.
|
||||
|
||||
**Required parameters:**
|
||||
| Parameter | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `service` | string | Service tag |
|
||||
| `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 "X-Secret-Key: $KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"service": "EXAMPLE1", "query": "example show"}'
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"id": "abc123def456",
|
||||
"title": "Example Show",
|
||||
"description": null,
|
||||
"label": "TV Show",
|
||||
"url": "https://example.com/show/abc123def456"
|
||||
}
|
||||
],
|
||||
"count": 1
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST /api/list-titles
|
||||
|
||||
Get available titles (seasons/episodes/movies) for a service and title ID. Disabled in `--remote-only` mode.
|
||||
|
||||
**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 "X-Secret-Key: $KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"service": "EXAMPLE1", "title_id": "abc123def456"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST /api/list-tracks
|
||||
|
||||
Get video, audio, and subtitle tracks for a title. Disabled in `--remote-only` mode.
|
||||
|
||||
**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 |
|
||||
|
||||
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). Disabled in `--remote-only` mode.
|
||||
|
||||
**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`, `VP8` |
|
||||
| `acodec` | string or array | any | Audio codec(s): `AAC`, `AC3`, `EC3`, `AC4`, `OPUS`, `FLAC`, `ALAC`, `DTS`, `OGG` |
|
||||
| `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 |
|
||||
| `imdb_id` | string | `null` | Use specific IMDB ID (e.g., `tt1375666`) |
|
||||
| `animeapi_id` | string | `null` | Anime database ID via AnimeAPI (e.g., `mal:12345`) |
|
||||
| `enrich` | boolean | `false` | Override show title and year from external source |
|
||||
| `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 |
|
||||
| `no_proxy_download` | boolean | `false` | Bypass proxy for segment downloads only. Manifest, license, and auth still use proxy |
|
||||
| `workers` | int | `null` | Max threads per track download |
|
||||
| `downloads` | int | `1` | Concurrent track downloads |
|
||||
| `slow` | boolean or string | `null` | Add randomized delay between titles. `true` = 60-120s, or `"MIN-MAX"` string (e.g., `"20-40"`). Min must be >= 20 |
|
||||
| `best_available` | boolean | `false` | Continue if requested quality unavailable |
|
||||
| `worst` | boolean | `false` | Select the lowest bitrate track within the specified quality. Requires `quality` |
|
||||
| `skip_dl` | boolean | `false` | Skip download, only get decryption keys |
|
||||
| `export` | boolean | `false` | Export manifest, track URLs, keys, and subtitles to JSON in the exports directory |
|
||||
| `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 "X-Secret-Key: $KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"service": "EXAMPLE1",
|
||||
"title_id": "abc123def456",
|
||||
"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. Disabled in `--remote-only` mode.
|
||||
|
||||
**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`, `started_time`, `completed_time`, `progress`, `status`, `service` |
|
||||
| `sort_order` | string | `desc` | Sort order: `asc`, `desc` |
|
||||
|
||||
```bash
|
||||
curl -H "X-Secret-Key: $KEY" "http://localhost:8786/api/download/jobs?status=completed"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GET /api/download/jobs/{job_id}
|
||||
|
||||
Get detailed information about a specific download job including progress, parameters, and error details.
|
||||
|
||||
```json
|
||||
{
|
||||
"job_id": "504db959-80b0-446c-a764-7924b761d613",
|
||||
"status": "completed",
|
||||
"created_time": "2026-02-27T18:00:00.000000",
|
||||
"service": "EXAMPLE1",
|
||||
"title_id": "abc123def456",
|
||||
"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. Returns 400 if the job has already terminated.
|
||||
@@ -0,0 +1,28 @@
|
||||
# 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 or not in the caller's allowlist
|
||||
- `SERVICE_ERROR` -- service initialization or runtime error
|
||||
- `AUTH_FAILED` -- authentication failure
|
||||
- `NOT_FOUND` / `TRACK_NOT_FOUND` / session not found -- job/session/track/title missing
|
||||
- `INTERNAL_ERROR` -- unexpected server error
|
||||
|
||||
When `--debug-api` is enabled, error responses include additional `debug_info` with tracebacks and stderr output.
|
||||
|
||||
Authentication errors from the auth middleware are returned as `{"status": 401, "message": "..."}` (not the standard error envelope).
|
||||
@@ -0,0 +1,37 @@
|
||||
# Quick Start
|
||||
|
||||
```bash
|
||||
# Start the server (no authentication)
|
||||
unshackle serve --no-key
|
||||
|
||||
# Start with authentication (api_secret in unshackle.yaml)
|
||||
unshackle serve
|
||||
|
||||
# Serve only the REST API (no pywidevine/pyplayready CDM)
|
||||
unshackle serve --api-only
|
||||
|
||||
# Serve only the remote-dl session endpoints (CORS/Cloudflare friendly)
|
||||
unshackle serve --remote-only
|
||||
|
||||
# Disable just one CDM
|
||||
unshackle serve --no-widevine
|
||||
unshackle serve --no-playready
|
||||
|
||||
# Verbose error responses (tracebacks/stderr in JSON)
|
||||
unshackle serve --debug-api
|
||||
```
|
||||
|
||||
`serve` flags:
|
||||
|
||||
| Flag | Description |
|
||||
| --- | --- |
|
||||
| `-h, --host` | Bind host (default `127.0.0.1`) |
|
||||
| `-p, --port` | Bind port (default `8786`) |
|
||||
| `--caddy` | Also launch Caddy using `Caddyfile` next to the unshackle config |
|
||||
| `--api-only` | REST API only; skip the bundled pywidevine/pyplayready CDM endpoints |
|
||||
| `--no-widevine` | Disable Widevine CDM endpoints |
|
||||
| `--no-playready` | Disable PlayReady CDM endpoints |
|
||||
| `--no-key` | Disable API key authentication entirely |
|
||||
| `--debug-api` | Include tracebacks/stderr in error responses |
|
||||
| `--debug` | Enable DEBUG-level logging for API operations |
|
||||
| `--remote-only` | Expose only `/api/health`, `/api/services`, `/api/search`, and `/api/session/*` (implies `--api-only`) |
|
||||
@@ -0,0 +1,329 @@
|
||||
# Remote Service Sessions
|
||||
|
||||
These endpoints back the `RemoteService` adapter in `unshackle/core/remote_service.py`. They let a thin `dl` client (or any consumer) authenticate against a service on the server, fetch titles/tracks/manifests, and either proxy CDM challenges or have the server resolve KID:KEY directly. The `dl` command's `RemoteService` adapter replaces the old `remote_dl` command. These endpoints are the only `/api/*` routes available in `--remote-only` mode (in addition to `health`, `services`, and `search`).
|
||||
|
||||
## POST /api/session/create
|
||||
|
||||
Authenticate against a service and open a session. Body fields:
|
||||
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `service` | string | Service tag (required) |
|
||||
| `title_id` | string | Title ID/URL (required) |
|
||||
| `credentials` | object | Auth credentials forwarded to `Service.authenticate` |
|
||||
| `cookies` | string | Cookie blob (Netscape or JSON) |
|
||||
| `proxy` | string | Proxy URI or country code |
|
||||
| `no_proxy` | bool | Force-disable proxies |
|
||||
| `profile` | string | Profile name |
|
||||
| `cache` | object | Optional pre-warmed title cache payload |
|
||||
|
||||
If the service requires interactive input during authentication, poll `GET /api/session/{id}/prompt` and submit responses via `POST /api/session/{id}/prompt` until status is `authenticated`.
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{
|
||||
"service": "EXAMPLE1",
|
||||
"title_id": "abc123def456",
|
||||
"credentials": {"username": "alice", "password": "hunter2"},
|
||||
"cookies": "# Netscape HTTP Cookie File\n...",
|
||||
"proxy": "us",
|
||||
"no_proxy": false,
|
||||
"profile": "default",
|
||||
"cache": {}
|
||||
}
|
||||
```
|
||||
|
||||
**Response (202-style; auth runs asynchronously):**
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "f1c4a8b2-9c7e-4d2a-bf91-2d3e4f5a6b7c",
|
||||
"service": "EXAMPLE1",
|
||||
"status": "authenticating"
|
||||
}
|
||||
```
|
||||
|
||||
## GET /api/session/{session_id}
|
||||
|
||||
Returns session metadata. 404 if expired or unknown.
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "f1c4a8b2-9c7e-4d2a-bf91-2d3e4f5a6b7c",
|
||||
"service": "EXAMPLE1",
|
||||
"valid": true,
|
||||
"expires_in": 3600,
|
||||
"track_count": 0,
|
||||
"title_count": 0
|
||||
}
|
||||
```
|
||||
|
||||
## DELETE /api/session/{session_id}
|
||||
|
||||
Tears down the session, cancels any pending prompts, and returns any updated per-session cache files (base64-encoded, zlib-compressed) so the client can re-warm next time.
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"cache": {
|
||||
"tokens": "eJzLSM3JyVcozy/KSVGo5AIAGgQEvQ=="
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## GET /api/session/{session_id}/titles
|
||||
|
||||
Returns the resolved titles list.
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "f1c4a8b2-9c7e-4d2a-bf91-2d3e4f5a6b7c",
|
||||
"titles": [
|
||||
{
|
||||
"type": "episode",
|
||||
"name": "Pilot",
|
||||
"series_title": "Example Show",
|
||||
"season": 1,
|
||||
"number": 1,
|
||||
"year": 2024,
|
||||
"id": "ep-0001",
|
||||
"language": "en"
|
||||
},
|
||||
{
|
||||
"type": "movie",
|
||||
"name": "Example Movie",
|
||||
"year": 2024,
|
||||
"id": "mov-0001",
|
||||
"language": "en"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## POST /api/session/{session_id}/tracks
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{"title_id": "ep-0001"}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"title": {
|
||||
"type": "episode",
|
||||
"name": "Pilot",
|
||||
"series_title": "Example Show",
|
||||
"season": 1,
|
||||
"number": 1,
|
||||
"year": 2024,
|
||||
"id": "ep-0001",
|
||||
"language": "en"
|
||||
},
|
||||
"video": [
|
||||
{
|
||||
"id": "v-1080p-h264",
|
||||
"codec": "H264",
|
||||
"codec_display": "H.264",
|
||||
"bitrate": 6000,
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"resolution": "1920x1080",
|
||||
"fps": "23.976",
|
||||
"range": "SDR",
|
||||
"range_display": "SDR",
|
||||
"language": "en",
|
||||
"drm": [
|
||||
{
|
||||
"type": "widevine",
|
||||
"pssh": "AAAAW3Bzc2gAAAAA7e+...",
|
||||
"kids": ["abcdef0123456789abcdef0123456789"],
|
||||
"license_url": "https://license.example.com/widevine"
|
||||
}
|
||||
],
|
||||
"descriptor": "DASH",
|
||||
"url": "https://cdn.example.com/manifest.mpd"
|
||||
}
|
||||
],
|
||||
"audio": [
|
||||
{
|
||||
"id": "a-en-eac3",
|
||||
"codec": "EC3",
|
||||
"codec_display": "Dolby Digital Plus",
|
||||
"bitrate": 640,
|
||||
"channels": "5.1",
|
||||
"language": "en",
|
||||
"atmos": false,
|
||||
"descriptive": false,
|
||||
"drm": null,
|
||||
"descriptor": "DASH",
|
||||
"url": "https://cdn.example.com/manifest.mpd"
|
||||
}
|
||||
],
|
||||
"subtitles": [
|
||||
{
|
||||
"id": "s-en-vtt",
|
||||
"codec": "WebVTT",
|
||||
"language": "en",
|
||||
"forced": false,
|
||||
"sdh": false,
|
||||
"cc": false,
|
||||
"descriptor": "DASH",
|
||||
"url": "https://cdn.example.com/subs/en.vtt"
|
||||
}
|
||||
],
|
||||
"chapters": [
|
||||
{"timestamp": "00:00:00.000", "name": "Chapter 1"}
|
||||
],
|
||||
"attachments": [],
|
||||
"manifests": [
|
||||
{
|
||||
"type": "dash",
|
||||
"url": "https://cdn.example.com/manifest.mpd",
|
||||
"data": "eJzNVk1v2zAM/Ss..."
|
||||
}
|
||||
],
|
||||
"session_headers": {
|
||||
"User-Agent": "Mozilla/5.0 ..."
|
||||
},
|
||||
"session_cookies": {
|
||||
"session": "abc123"
|
||||
},
|
||||
"server_cdm_type": "widevine"
|
||||
}
|
||||
```
|
||||
|
||||
## POST /api/session/{session_id}/segments
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{"track_ids": ["v-1080p-h264", "a-en-eac3"]}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"tracks": {
|
||||
"v-1080p-h264": {
|
||||
"descriptor": "DASH",
|
||||
"url": "https://cdn.example.com/manifest.mpd",
|
||||
"drm": [
|
||||
{
|
||||
"type": "widevine",
|
||||
"pssh": "AAAAW3Bzc2gAAAAA7e+...",
|
||||
"kids": ["abcdef0123456789abcdef0123456789"],
|
||||
"license_url": "https://license.example.com/widevine"
|
||||
}
|
||||
],
|
||||
"headers": {"User-Agent": "Mozilla/5.0 ..."},
|
||||
"cookies": {"session": "abc123"},
|
||||
"data": {}
|
||||
},
|
||||
"a-en-eac3": {
|
||||
"descriptor": "DASH",
|
||||
"url": "https://cdn.example.com/manifest.mpd",
|
||||
"drm": null,
|
||||
"headers": {"User-Agent": "Mozilla/5.0 ..."},
|
||||
"cookies": {"session": "abc123"},
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## POST /api/session/{session_id}/license
|
||||
|
||||
Two modes, selected by the `mode` field.
|
||||
|
||||
**`mode: "proxy"` (default)** -- forward a client-built CDM challenge to the service's license endpoint.
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"mode": "proxy",
|
||||
"track_id": "v-1080p-h264",
|
||||
"challenge": "CAESxQEK...",
|
||||
"drm_type": "widevine",
|
||||
"pssh": "AAAAW3Bzc2gAAAAA7e+..."
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{"license": "CAIS3wIK..."}
|
||||
```
|
||||
|
||||
**`mode: "server_cdm"`** -- the server uses its own CDM to license the track and extract keys. Single-track form takes `track_id`; batch form takes `track_ids`. Requires the calling user key to have a matching device (`devices` for Widevine, `playready_devices` for PlayReady) in `unshackle.yaml`.
|
||||
|
||||
Request (batch):
|
||||
|
||||
```json
|
||||
{
|
||||
"mode": "server_cdm",
|
||||
"track_ids": ["v-1080p-h264", "a-en-eac3"],
|
||||
"drm_type": "widevine"
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"keys": {
|
||||
"v-1080p-h264": {
|
||||
"abcdef0123456789abcdef0123456789": "00112233445566778899aabbccddeeff"
|
||||
},
|
||||
"a-en-eac3": {
|
||||
"abcdef0123456789abcdef0123456789": "00112233445566778899aabbccddeeff"
|
||||
}
|
||||
},
|
||||
"drm_type": "widevine"
|
||||
}
|
||||
```
|
||||
|
||||
## GET /api/session/{session_id}/prompt
|
||||
|
||||
Polled by the client during interactive authentication (OTP, PIN, device codes). Backed by the `InputBridge` in `unshackle/core/api/input_bridge.py`; `Service.request_input()` blocks server-side until the client posts a response.
|
||||
|
||||
Pending input:
|
||||
|
||||
```json
|
||||
{"status": "pending_input", "prompt": "Enter OTP code: "}
|
||||
```
|
||||
|
||||
Other states:
|
||||
|
||||
```json
|
||||
{"status": "authenticating"}
|
||||
```
|
||||
|
||||
```json
|
||||
{"status": "authenticated"}
|
||||
```
|
||||
|
||||
```json
|
||||
{"status": "failed", "error": "Invalid credentials"}
|
||||
```
|
||||
|
||||
## POST /api/session/{session_id}/prompt
|
||||
|
||||
Unblocks the server-side `request_input()` call.
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{"response": "123456"}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{"status": "accepted"}
|
||||
```
|
||||
Reference in New Issue
Block a user