28 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
devicesandplayready_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}/licenseand 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:
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:
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:
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.
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 |
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:
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 bodyINVALID_PARAMETERS-- invalid parameter valuesMISSING_SERVICE-- service tag not providedINVALID_SERVICE-- service not found or not in the caller's allowlistSERVICE_ERROR-- service initialization or runtime errorAUTH_FAILED-- authentication failureNOT_FOUND/TRACK_NOT_FOUND/ session not found -- job/session/track/title missingINTERNAL_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. |
unshackle dl --remote EXAMPLE1 "https://example.com/show/abc123"
unshackle dl --remote --server us-server EXAMPLE1 "https://example.com/show/abc123"
Config structure
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:
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
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.