mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-10-23 16:22:18 +02:00
446 lines
17 KiB
Markdown
446 lines
17 KiB
Markdown
# REST API Quick Start
|
|
|
|
Unshackle ships with a built-in REST API that exposes the same download engine you drive from the command line. The `unshackle serve` command serves it and lets you queue downloads, poll their progress, browse services, and manage history over HTTP instead of the terminal.
|
|
|
|
The full loop runs end to end: start the server, authenticate, submit a download job, and poll it to completion. The `curl` commands here come directly from the API routes.
|
|
|
|
!!! note "Who this page is for"
|
|
This is a **developer** guide. It assumes you are comfortable with HTTP, JSON, and a tool like `curl`. If you only want to download something, the [command line](../../index.md) is simpler. The REST API is meant for building tools, UIs, and automation on top of unshackle.
|
|
|
|
---
|
|
|
|
## Before you begin
|
|
|
|
You need a working unshackle install with services and CDM devices already configured, exactly as you would for a normal command-line download. If `unshackle dl` works for you, the API will too, since it runs the identical download pipeline.
|
|
|
|
=== "Installed as a tool"
|
|
|
|
```shell
|
|
uv tool install git+https://github.com/unshackle-dl/unshackle.git
|
|
unshackle --help
|
|
```
|
|
|
|
=== "From a clone"
|
|
|
|
```shell
|
|
uv run unshackle --help
|
|
```
|
|
|
|
---
|
|
|
|
## Step 1: Configure an API secret
|
|
|
|
The server authenticates every request (except the health check) with an API key that you send in the `X-Secret-Key` header. The server reads this API key from the `serve` section of your `unshackle.yaml` config.
|
|
|
|
At minimum you must set `api_secret`:
|
|
|
|
```yaml title="unshackle.yaml"
|
|
serve:
|
|
api_secret: "choose-a-long-random-secret"
|
|
```
|
|
|
|
If you want the API to license and download DRM-protected titles, you must also grant CDM devices to the API key. Configure that under `serve.users`, keyed by the secret:
|
|
|
|
```yaml title="unshackle.yaml"
|
|
serve:
|
|
api_secret: "choose-a-long-random-secret"
|
|
users:
|
|
choose-a-long-random-secret:
|
|
devices: ["your_widevine_device"] # Widevine .wvd stems
|
|
playready_devices: ["your_playready_device"] # PlayReady .prd stems
|
|
username: api_user
|
|
```
|
|
|
|
!!! warning "The secret must exist"
|
|
If `api_secret` is not set and you do **not** pass `--no-key`, `unshackle serve` refuses to start and raises an error. Treat the secret like a password. Anyone who has it can queue downloads on your machine.
|
|
|
|
!!! tip "Disabling auth for local testing"
|
|
`--no-key` turns authentication off entirely: the server allows every request and ignores the `X-Secret-Key` header. This is convenient for a throwaway local test, but never put a `--no-key` server on a network you do not fully trust.
|
|
|
|
---
|
|
|
|
## Step 2: Start the server
|
|
|
|
Start the REST API with the `serve` command:
|
|
|
|
```shell
|
|
unshackle serve --api-only
|
|
```
|
|
|
|
By default the server binds to `127.0.0.1:8786`. The `--api-only` flag serves only the REST API and omits the Widevine/PlayReady CDM HTTP endpoints. Drop it when you also want those endpoints.
|
|
|
|
Common options:
|
|
|
|
| Option | Default | Effect |
|
|
| --- | --- | --- |
|
|
| `-h, --host` | `127.0.0.1` | Address to bind to |
|
|
| `-p, --port` | `8786` | Port to bind to |
|
|
| `--api-only` | off | Serve only the REST API, no CDM endpoints |
|
|
| `--no-key` | off | Disable API-key authentication (allow all requests) |
|
|
| `--debug-api` | off | Include tracebacks and stderr in error responses |
|
|
| `--debug` | off | Enable DEBUG-level logging |
|
|
| `--remote-only` | off | Expose only the remote-session subset of routes |
|
|
| `--caddy` | off | Also launch Caddy as a reverse proxy (for HTTPS) |
|
|
|
|
Once it is up you will see the API mounted under `/api/` and, unless you started in remote-only mode, an interactive Swagger UI:
|
|
|
|
```text
|
|
REST API endpoints available at http://127.0.0.1:8786/api/
|
|
Swagger UI available at http://127.0.0.1:8786/api/docs/
|
|
```
|
|
|
|
!!! note "Swagger UI is also behind auth"
|
|
In `--api-only` mode (without `--no-key`), the Swagger UI at `/api/docs/` requires the `X-Secret-Key` header, the same as every other route. Only `/api/health` is exempt from authentication.
|
|
|
|
For the rest of this page we will assume the server is reachable at `http://127.0.0.1:8786` and store the secret in an environment variable:
|
|
|
|
```shell
|
|
export SECRET="choose-a-long-random-secret"
|
|
export BASE="http://127.0.0.1:8786"
|
|
```
|
|
|
|
---
|
|
|
|
## Step 3: Make sure that the server is alive
|
|
|
|
The health endpoint is the one route that never requires an API key. Use it to make sure that the server operates, and to read its version:
|
|
|
|
```shell
|
|
curl "$BASE/api/health"
|
|
```
|
|
|
|
```json title="200 OK"
|
|
{
|
|
"status": "ok",
|
|
"version": "5.3.0",
|
|
"update_check": {
|
|
"update_available": false,
|
|
"current_version": "5.3.0",
|
|
"latest_version": "5.3.0"
|
|
}
|
|
}
|
|
```
|
|
|
|
The `update_check` block is best effort. If unshackle cannot reach the update source, `update_available` and `latest_version` come back as `null` rather than failing the request.
|
|
|
|
---
|
|
|
|
## Step 4: Authenticate a real request
|
|
|
|
Every other endpoint needs the `X-Secret-Key` header. A quick way to prove your API key works is to show the services the server can see:
|
|
|
|
```shell
|
|
curl "$BASE/api/services" \
|
|
-H "X-Secret-Key: $SECRET"
|
|
```
|
|
|
|
```json title="200 OK (truncated)"
|
|
{
|
|
"services": [
|
|
{
|
|
"tag": "EXAMPLE",
|
|
"aliases": ["EX"],
|
|
"geofence": [],
|
|
"title_regex": null,
|
|
"url": "https://example.com",
|
|
"help": "EXAMPLE service documentation...",
|
|
"needs_auth": true,
|
|
"has_search": true,
|
|
"has_drm": true,
|
|
"auth_methods": ["cookies", "credentials"],
|
|
"cli_params": []
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
If the API key is missing or wrong, you get a `401` with a plain body:
|
|
|
|
```json title="401 Unauthorized"
|
|
{ "status": 401, "message": "Secret Key is Invalid." }
|
|
```
|
|
|
|
!!! warning "Two error shapes exist"
|
|
The authentication middleware returns the short `{"status": 401, "message": "..."}` shape shown above. Every **other** error the API produces uses the richer structured shape (`error_code`, `timestamp`, `details`, ...) described in [Error handling](#error-handling). Parse both shapes in your client.
|
|
|
|
The `tag` value for the service you want is the identifier you pass as `service` in the calls below.
|
|
|
|
---
|
|
|
|
## Step 5: Submit a download job
|
|
|
|
Downloads are asynchronous. You `POST` a job to `/api/download`, the server immediately hands back a `job_id`, and the actual download runs in the background so your request never blocks.
|
|
|
|
The only required fields are `service` and `title_id`. Everything else mirrors the flags of the `dl` command and has a sensible default, so a minimal job looks like this:
|
|
|
|
```shell
|
|
curl -X POST "$BASE/api/download" \
|
|
-H "X-Secret-Key: $SECRET" \
|
|
-H "Content-Type: application/json" \
|
|
-d '{
|
|
"service": "EXAMPLE",
|
|
"title_id": "12345"
|
|
}'
|
|
```
|
|
|
|
A more realistic job pins the quality and codecs and asks for specific episodes:
|
|
|
|
```shell
|
|
curl -X POST "$BASE/api/download" \
|
|
-H "X-Secret-Key: $SECRET" \
|
|
-H "Content-Type: application/json" \
|
|
-d '{
|
|
"service": "EXAMPLE",
|
|
"title_id": "12345",
|
|
"quality": [1080],
|
|
"vcodec": "H265",
|
|
"range": ["SDR"],
|
|
"wanted": ["S01E01", "S01E02"]
|
|
}'
|
|
```
|
|
|
|
On success the server responds with **`202 Accepted`** and the identifier you will poll:
|
|
|
|
```json title="202 Accepted"
|
|
{
|
|
"job_id": "3f9c1e0a-8d2b-4e6f-9a1c-77b0f2a1c4d5",
|
|
"status": "queued",
|
|
"created_time": "2026-07-03T12:00:00.000000+00:00"
|
|
}
|
|
```
|
|
|
|
Capture the `job_id` for the next step:
|
|
|
|
```shell
|
|
JOB=$(curl -s -X POST "$BASE/api/download" \
|
|
-H "X-Secret-Key: $SECRET" \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"service":"EXAMPLE","title_id":"12345","quality":[1080]}' \
|
|
| python -c "import sys, json; print(json.load(sys.stdin)['job_id'])")
|
|
```
|
|
|
|
### Frequently used download fields
|
|
|
|
The request body accepts the full download parameter set. A few of the most useful:
|
|
|
|
| Field | Default | Meaning |
|
|
| --- | --- | --- |
|
|
| `service` | - | Service tag (required) |
|
|
| `title_id` | - | Title identifier (required) |
|
|
| `profile` | `null` | Named credential/cookie profile to use |
|
|
| `quality` | `[]` | Resolution(s), e.g. `[1080]`; empty means best available |
|
|
| `vcodec` | `null` | Video codec, e.g. `"H265"` |
|
|
| `acodec` | `null` | Audio codec, e.g. `"EC3"` |
|
|
| `range` | `["SDR"]` | Dynamic range: `SDR`, `HDR10`, `HDR10+`, `DV`, `HLG`, `HYBRID` |
|
|
| `wanted` | `[]` | Episodes to fetch, e.g. `["S01E01"]`, or music tracks, e.g. `["1-5"]`. Empty means all |
|
|
| `lang` | `["orig"]` | Video/audio language(s) |
|
|
| `s_lang` | `["all"]` | Subtitle language(s) |
|
|
| `downloads` | `1` | Number of tracks to download concurrently |
|
|
| `proxy` | `null` | Proxy URI or country code |
|
|
|
|
Validation is strict: invalid codecs, subtitle formats, dynamic-range names, non-positive counts, or mutually exclusive flags (for example both `video_only` and `audio_only`) come back as `400` with error code `INVALID_PARAMETERS`. See the full reference on the [download reference page](endpoints.md) for every accepted field.
|
|
|
|
!!! note "Gated fields"
|
|
The server refuses a per-request CDM (`cdm`) or inline credentials (`credential` / `credentials`) with `403 FORBIDDEN`, unless the server operator has explicitly opted in with `serve.cdm_overrides` and `serve.allow_job_credentials`. Both are off by default.
|
|
|
|
---
|
|
|
|
## Step 6: Poll the job to completion
|
|
|
|
A job moves through these states:
|
|
|
|
```text
|
|
queued → downloading → completed | failed | cancelled
|
|
```
|
|
|
|
The last three are **terminal**. Poll the job detail endpoint until you reach one of them. This endpoint always returns the full job record:
|
|
|
|
```shell
|
|
curl "$BASE/api/download/jobs/$JOB" \
|
|
-H "X-Secret-Key: $SECRET"
|
|
```
|
|
|
|
While the download runs you will see `status: "downloading"` and a `progress` value climbing from `0` toward `100`:
|
|
|
|
```json title="200 OK (in progress)"
|
|
{
|
|
"job_id": "3f9c1e0a-8d2b-4e6f-9a1c-77b0f2a1c4d5",
|
|
"status": "downloading",
|
|
"progress": 42.7,
|
|
"phase": "downloading",
|
|
"service": "EXAMPLE",
|
|
"title_id": "12345",
|
|
"title": "Example Show S01E01",
|
|
"current_title": "Example Show S01E01",
|
|
"completed_tracks": 1,
|
|
"total_tracks": 3,
|
|
"segments_done": 512,
|
|
"segments_total": 1200,
|
|
"speed": "8.4 MiB/s",
|
|
"created_time": "2026-07-03T12:00:00.000000+00:00",
|
|
"started_time": "2026-07-03T12:00:03.000000+00:00"
|
|
}
|
|
```
|
|
|
|
When it finishes you will see `status: "completed"` and the produced files under `output_files`:
|
|
|
|
```json title="200 OK (completed)"
|
|
{
|
|
"job_id": "3f9c1e0a-8d2b-4e6f-9a1c-77b0f2a1c4d5",
|
|
"status": "completed",
|
|
"progress": 100.0,
|
|
"output_files": ["/path/to/downloads/Example Show S01E01.mkv"],
|
|
"completed_time": "2026-07-03T12:04:31.000000+00:00"
|
|
}
|
|
```
|
|
|
|
A simple polling loop in shell:
|
|
|
|
```shell title="poll.sh"
|
|
while true; do
|
|
STATUS=$(curl -s "$BASE/api/download/jobs/$JOB" \
|
|
-H "X-Secret-Key: $SECRET" \
|
|
| python -c "import sys, json; print(json.load(sys.stdin)['status'])")
|
|
echo "job status: $STATUS"
|
|
case "$STATUS" in
|
|
completed|failed|cancelled) break ;;
|
|
esac
|
|
sleep 3
|
|
done
|
|
```
|
|
|
|
!!! tip "Polling cadence"
|
|
A few seconds between polls is plenty. The server updates a job's progress roughly twice a second internally, and each poll reads the current snapshot.
|
|
|
|
!!! tip "Or let the server push the updates"
|
|
`GET /api/download/jobs/{job_id}/events` streams the same job record as Server-Sent Events, and closes the event stream when the job ends. It removes the polling loop above. See [Endpoints](endpoints.md#get-apidownloadjobsjob_idevents).
|
|
|
|
```bash
|
|
curl -N "$BASE/api/download/jobs/$JOB/events" \
|
|
-H "X-Secret-Key: $SECRET"
|
|
```
|
|
|
|
If a job ends in `failed`, the full record includes `error_message`, `error_code`, and (when you start the server with `--debug-api`) `error_traceback` and `worker_stderr` to help you find the cause. The server scrubs sensitive values in these fields before it returns them.
|
|
|
|
---
|
|
|
|
## Step 7: Clean up
|
|
|
|
Once you have collected the output you can remove the finished job. `DELETE` on a job has two behaviors depending on its state:
|
|
|
|
```shell
|
|
curl -X DELETE "$BASE/api/download/jobs/$JOB" \
|
|
-H "X-Secret-Key: $SECRET"
|
|
```
|
|
|
|
- If the job is **terminal** (completed/failed/cancelled), the manager removes it. You get **`204 No Content`** with an empty body.
|
|
- If the job is still **queued or downloading**, the manager cancels it instead. You get `200` with `{"status": "success", "message": "Job cancelled"}`.
|
|
|
|
!!! warning "Do not parse JSON on 204"
|
|
A `204` response has no body. This applies to terminal-job removal here and to deleting history entries. Examine the status code before you decode JSON.
|
|
|
|
To clear every finished job at once instead of one at a time:
|
|
|
|
```shell
|
|
curl -X POST "$BASE/api/download/jobs/clear-finished" \
|
|
-H "X-Secret-Key: $SECRET"
|
|
```
|
|
|
|
```json title="200 OK"
|
|
{ "removed": 3 }
|
|
```
|
|
|
|
---
|
|
|
|
## Managing the queue
|
|
|
|
A handful of endpoints round out job control. All require the `X-Secret-Key` header.
|
|
|
|
| Method | Path | Purpose |
|
|
| --- | --- | --- |
|
|
| `GET` | `/api/download/jobs` | List all jobs, with optional filtering and sorting |
|
|
| `GET` | `/api/download/jobs/{job_id}` | Full detail for one job |
|
|
| `DELETE` | `/api/download/jobs/{job_id}` | Cancel a running job, or remove a terminal one |
|
|
| `POST` | `/api/download/jobs/{job_id}/retry` | Re-queue a new job from a terminal one |
|
|
| `POST` | `/api/download/jobs/{job_id}/priority` | Move a queued job to the front of the queue |
|
|
| `POST` | `/api/download/jobs/clear-finished` | Remove all terminal jobs |
|
|
|
|
Listing supports query parameters for filtering by `status` or `service`, sorting with `sort_by` (`created_time`, `started_time`, `completed_time`, `progress`, `status`, `service`) and `sort_order` (`asc` / `desc`), and requesting full per-job detail with `full=true`:
|
|
|
|
```shell
|
|
curl "$BASE/api/download/jobs?status=downloading&sort_by=progress&sort_order=desc" \
|
|
-H "X-Secret-Key: $SECRET"
|
|
```
|
|
|
|
```json title="200 OK"
|
|
{
|
|
"jobs": [
|
|
{
|
|
"job_id": "3f9c1e0a-8d2b-4e6f-9a1c-77b0f2a1c4d5",
|
|
"status": "downloading",
|
|
"service": "EXAMPLE",
|
|
"title_id": "12345",
|
|
"title": "Example Show S01E01",
|
|
"progress": 42.7,
|
|
"phase": "downloading",
|
|
"completed_tracks": 1,
|
|
"total_tracks": 3
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
A retry reuses the original job's service, title, and parameters to enqueue a brand new job, and returns `202` with a fresh `job_id`. A retry works only on terminal jobs, and returns `409 CONFLICT` otherwise. A priority change works only on jobs still in the `queued` state.
|
|
|
|
---
|
|
|
|
## Beyond a single download
|
|
|
|
The download loop above is the core of the API, but the same server exposes more:
|
|
|
|
- **Discovery**: `POST /api/search` finds titles by query, and `POST /api/list-titles` / `POST /api/list-tracks` inspect what a title offers before you commit to downloading it. See [titles and tracks](endpoints.md).
|
|
- **History**: `GET /api/history` reads a persisted log of jobs that reached a terminal state (newest first), and `DELETE /api/history/{job_id}` removes a single entry. See [history](endpoints.md).
|
|
- **Introspection**: `GET /api/profiles` lists configured credential profiles per service, `GET /api/config` returns a redacted view of the effective server config, and `GET /api/env/check` reports which external binaries the machine has.
|
|
- **Maintenance**: `POST /api/maintenance/clear-cache`, `.../clear-temp`, and `.../refresh-services` do housekeeping (the server blocks the cache and temp clears with `409` while a download is active).
|
|
- **Remote sessions**: the `/api/session/*` endpoints drive the thin-client "remote download" flow, where the server authenticates and licenses on behalf of a local client. See [remote sessions](remote-sessions.md).
|
|
|
|
The interactive **Swagger UI at `/api/docs/`** documents every route and lets you try them in the browser (send your `X-Secret-Key` there too).
|
|
|
|
---
|
|
|
|
## Error handling
|
|
|
|
Apart from the auth middleware's short form, every error the API returns uses a single structured envelope:
|
|
|
|
```json
|
|
{
|
|
"status": "error",
|
|
"error_code": "INVALID_PARAMETERS",
|
|
"message": "Human-readable description",
|
|
"timestamp": "2026-07-03T12:00:00Z",
|
|
"details": { }
|
|
}
|
|
```
|
|
|
|
`details` appears only when the server has more to add. `retryable: true` appears only for transient failures. The server includes a `debug_info` block (exception type and traceback) only when it runs with `--debug-api`.
|
|
|
|
The `error_code` maps to an HTTP status. The ones you will meet most often:
|
|
|
|
| Error code | HTTP | Typical cause |
|
|
| --- | --- | --- |
|
|
| `INVALID_INPUT` | 400 | Malformed JSON body or missing required field |
|
|
| `INVALID_SERVICE` | 400 | Unknown or disallowed service tag |
|
|
| `INVALID_PARAMETERS` | 400 | A download field failed validation |
|
|
| `AUTH_FAILED` | 401 | Service login/credential failure |
|
|
| `FORBIDDEN` | 403 | A gated field was used without server opt-in |
|
|
| `NOT_FOUND` | 404 | Title or resource does not exist |
|
|
| `JOB_NOT_FOUND` | 404 | No job with that `job_id` |
|
|
| `CONFLICT` | 409 | Illegal state transition (e.g. retry a running job) |
|
|
| `RATE_LIMITED` | 429 | Upstream throttling (retryable) |
|
|
| `INTERNAL_ERROR` | 500 | Unexpected server-side failure |
|
|
| `SERVICE_ERROR` | 502 | The service raised an error |
|
|
| `NETWORK_ERROR` | 503 | Connection/timeout reaching the service (retryable) |
|
|
|
|
!!! tip "Retry politely"
|
|
Codes marked retryable (`NETWORK_ERROR`, `RATE_LIMITED`, `SERVICE_UNAVAILABLE`) reflect transient conditions. Back off and retry rather than hammering the server.
|