Files
unshackle/docs/API.md
2026-05-08 17:54:45 -06:00

23 KiB

REST API Documentation

The unshackle REST API allows you to control downloads, search services, drive remote downloads from a thin client, and (optionally) co-host the pywidevine/pyplayready CDM. Start the server with unshackle serve and access the interactive Swagger UI at http://localhost:8786/api/docs/.

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

# 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).

# 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.


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.

curl http://localhost:8786/api/health
{
  "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).

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
curl -X POST http://localhost:8786/api/search \
  -H "X-Secret-Key: $KEY" \
  -H "Content-Type: application/json" \
  -d '{"service": "EXAMPLE1", "query": "example show"}'
{
  "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
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
workers int null Max threads per track download
downloads int 1 Concurrent track downloads
slow boolean false Add 60-120s delay between titles
best_available boolean false Continue if requested quality unavailable
skip_dl boolean false Skip download, only get decryption keys
export 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:

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"]
  }'
{
  "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
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.

{
  "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:

{
  "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):

{
  "session_id": "f1c4a8b2-9c7e-4d2a-bf91-2d3e4f5a6b7c",
  "service": "EXAMPLE1",
  "status": "authenticating"
}

GET /api/session/{session_id}

Returns session metadata. 404 if expired or unknown.

{
  "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.

{
  "status": "ok",
  "cache": {
    "tokens": "eJzLSM3JyVcozy/KSVGo5AIAGgQEvQ=="
  }
}

GET /api/session/{session_id}/titles

Returns the resolved titles list.

{
  "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:

{"title_id": "ep-0001"}

Response:

{
  "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:

{"track_ids": ["v-1080p-h264", "a-en-eac3"]}

Response:

{
  "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:

{
  "mode": "proxy",
  "track_id": "v-1080p-h264",
  "challenge": "CAESxQEK...",
  "drm_type": "widevine",
  "pssh": "AAAAW3Bzc2gAAAAA7e+..."
}

Response:

{"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):

{
  "mode": "server_cdm",
  "track_ids": ["v-1080p-h264", "a-en-eac3"],
  "drm_type": "widevine"
}

Response:

{
  "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:

{"status": "pending_input", "prompt": "Enter OTP code: "}

Other states:

{"status": "authenticating"}
{"status": "authenticated"}
{"status": "failed", "error": "Invalid credentials"}

POST /api/session/{session_id}/prompt

Unblocks the server-side request_input() call.

Request:

{"response": "123456"}

Response:

{"status": "accepted"}

Error Responses

All endpoints return consistent error responses:

{
  "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. The server supports up to 2 concurrent downloads by default.

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.