# Remote Services — Client ↔ Server Architecture
The `--remote` flag on the `dl` command connects to a remote unshackle server
(`unshackle serve`) that holds service plugins. The client never has service
code — it sends credentials/cookies, the server authenticates and fetches
titles/tracks, and the client handles downloading, decryption, and muxing locally.
## How It Works
The `RemoteService` adapter in `remote_service.py` implements the same interface
as a local `Service`. From dl.py's perspective, it's just another service — the
entire download pipeline runs unchanged.
```
unshackle dl --remote [-s server_name] SERVICE_TAG TITLE_ID [options]
```
## Session Lifecycle
```mermaid
sequenceDiagram
participant Client as Client (dl --remote)
participant Server as Server (serve API)
participant Service as Service Plugin
Note over Client: Load credentials/cookies locally
Note over Client: Detect client region via IP check
Client->>Server: POST /api/session/create
Note right of Client: {service, title_id, credentials,
cookies, client_region, profile}
Note over Server: Check if server region matches client
Skip proxy if same region
Server->>Service: authenticate(cookies, credential)
Service-->>Server: Authenticated session
Server-->>Client: {session_id, service}
Client->>Server: GET /api/session/{id}/titles
Server->>Service: get_titles(title_id)
Server-->>Client: Serialized titles (episodes/movies)
Note over Client: dl.py filters by --wanted
Client->>Server: POST /api/session/{id}/tracks
Server->>Service: get_tracks(title)
Server->>Service: get_chapters(title)
Note over Server: Extract manifest XML as base64
Extract service session cookies/headers
Detect server CDM type from track DRM + config
Server-->>Client: Tracks + manifests + chapters +
session cookies + server_cdm_type
Note over Client: Re-parse manifest locally (DASH/ISM)
HLS tracks download from URL directly
Note over Client: Match tracks by ID to populate track.data
Note over Client: dl.py runs full track selection pipeline
rect rgb(40, 40, 60)
Note over Client: Licensing (depends on mode)
end
rect rgb(40, 60, 40)
Note over Client: Download + Post-Processing (all local)
Note over Client: Concurrent downloads via ThreadPoolExecutor
Note over Client: Decrypt (mp4decrypt / Shaka Packager)
Note over Client: FFMPEG repack
Note over Client: Subtitle conversion
Note over Client: Hybrid HDR10+DV injection (dovi_tool)
Note over Client: Per-resolution muxing (mkvmerge)
Note over Client: Output naming from template
Note over Client: Tagging (TMDB/IMDB/TVDB)
end
Client->>Server: DELETE /api/session/{id}
Server-->>Client: Session cleaned up
```
---
## Licensing Modes
### Proxy Mode (`server_cdm: false` — default)
The client has its own CDM (WVD/PRD file). The server only proxies the license
request through the authenticated service session.
```mermaid
sequenceDiagram
participant Client as Client
participant Server as Server
participant Service as Service Plugin
participant CDM as Client CDM (local WVD/PRD)
Note over Client: track.download() discovers DRM
(PSSH from manifest/init data)
Client->>CDM: Generate challenge from PSSH
CDM-->>Client: Challenge bytes
Client->>Server: POST /api/session/{id}/license
Note right of Client: {track_id, challenge (base64),
drm_type, pssh}
Server->>Service: get_widevine_license(challenge, title, track)
or get_playready_license(challenge, title, track)
Service-->>Server: Raw license response bytes
Server-->>Client: {license: base64_encoded_bytes}
Client->>CDM: Parse license response
CDM-->>Client: Content keys (KID:KEY)
Note over Client: Keys stored in local vaults
Note over Client: Decrypt track with keys
```
**Key points:**
- Client must have a local CDM device file (`.wvd` or `.prd`)
- Server never sees decryption keys — only forwards encrypted license blob
- Client parses the license locally to extract keys
- Keys are cached in local vaults for future use
### Server-CDM Mode (`server_cdm: true`)
The server handles all CDM operations using its own devices. The client does
not need a local CDM. There are two paths depending on when DRM is discovered:
#### Path A: Pre-fetch (DASH services with manifest DRM)
For services where DRM info is in the manifest (ContentProtection elements),
the server resolves keys before downloads start.
```mermaid
sequenceDiagram
participant Client as Client
participant Server as Server
participant Service as Service Plugin
participant CDM as Server CDM (WVD/PRD)
Note over Client: dl.py calls service.resolve_server_keys()
before downloads start
Client->>Server: POST /api/session/{id}/license
Note right of Client: {track_ids: [...], mode: "server_cdm",
drm_type from server_cdm_type}
loop Per track with DRM
Note over Server: Extract PSSH from track manifest
ContentProtection elements
Server->>CDM: Load device from serve.users config
Server->>CDM: Generate challenge from PSSH
Server->>Service: get_license(challenge, title, track)
Service-->>Server: License response
Server->>CDM: Parse license → extract keys
end
Server-->>Client: {keys: {track_id: {kid: key, ...}}}
Note over Client: Inject keys into track.drm.content_keys
Note over Client: prepare_drm finds keys → skips CDM call
Note over Client: Keys stored in local vaults
```
#### Path B: On-demand (HLS services / late DRM discovery)
For services like ATV where DRM is only discovered during download (from init
segments or EXT-X-KEY tags), keys are fetched per-track during download.
```mermaid
sequenceDiagram
participant Client as Client
participant Server as Server
participant Service as Service Plugin
participant CDM as Server CDM (WVD/PRD)
Note over Client: track.download() discovers DRM
(PSSH from init data / EXT-X-KEY)
Note over Client: prepare_drm calls licence callback
Client->>Server: POST /api/session/{id}/license
Note right of Client: {track_id, pssh, drm_type,
mode: "server_cdm"}
Server->>CDM: Load device (Widevine or PlayReady)
Server->>CDM: Generate challenge from PSSH
Server->>Service: get_license(challenge, title, track)
Service-->>Server: License response
Server->>CDM: Parse license → extract keys
CDM-->>Server: Content keys
Server-->>Client: {keys: {kid: key, ...}}
Note over Client: Inject keys into track.drm.content_keys
Note over Client: prepare_drm sees keys → skips re-raise
Note over Client: Keys stored in local vaults
```
**Key points:**
- Client does NOT need a local CDM device file
- Server uses devices from `serve.users.{api_key}.devices` (Widevine) and
`serve.users.{api_key}.playready_devices` (PlayReady)
- Server detects DRM type from actual track DRM objects and available devices
- Keys are returned as `{kid_hex: key_hex}` pairs
- Keys are still cached in client's local vaults (unless `--cdm-only` is used)
- `prepare_drm` skips local CDM if all KIDs already have keys
---
## Configuration
### Client (`unshackle.yaml`)
```yaml
remote_services:
my_server:
url: "https://server:8786"
api_key: "your-secret-key"
server_cdm: true # server handles licensing (optional, default: false)
services: # per-service overrides (optional)
EXAMPLE:
downloader: n_m3u8dl_re
decryption: mp4decrypt
EXAMPLE2:
downloader: n_m3u8dl_re
```
### Server (`unshackle.yaml`)
```yaml
serve:
api_secret: "your-secret-key"
users:
"your-secret-key":
username: api_user
devices: # Widevine CDMs
- xiaomi_mi_a1_15.0.0_l3
playready_devices: # PlayReady CDMs
- qingdao_haier_tv_sl3000
```
---
## Manifest Data Transfer
Track manifests (DASH XML, ISM XML) cannot be JSON-serialized directly (they
contain lxml Element objects). The server serializes them as base64 strings in
the `/tracks` response. The client decodes and re-parses them locally.
| Manifest | Serialization | Client Re-parse | Notes |
| -------- | --------------------------- | ---------------------------------------------- | --------------------------------- |
| **DASH** | `etree.tostring()` → base64 | `DASH(etree.fromstring(xml), url).to_tracks()` | Match by track ID (crc32 hash) |
| **HLS** | Not needed | Downloads playlist from `track.url` directly | `HLS.download_track()` re-fetches |
| **ISM** | `etree.tostring()` → base64 | `ISM(etree.fromstring(xml), url).to_tracks()` | Match by track ID |
The `/tracks` response also includes:
- `session_headers` / `session_cookies` — for CDN authentication
- `server_cdm_type` — "widevine" or "playready" (detected from track DRM + config)
---
## Region & Proxy Handling
1. Client detects its own country via `get_cached_ip_info()`
2. Sends `client_region` in session create (no IP sent, just country code)
3. Server checks its own region — if it matches, no proxy needed
4. If regions differ, server resolves a proxy from its own providers
5. Client can also send explicit `--proxy` which takes precedence
---
## Comparison: Proxy vs Server-CDM
| | Proxy Mode (default) | Server-CDM Mode |
| ----------------------- | ----------------------------- | -------------------------------- |
| **Client needs CDM** | Yes (WVD/PRD file) | No |
| **License request** | Client sends challenge bytes | Client sends PSSH (or track IDs) |
| **License response** | Raw license bytes | KID:KEY pairs |
| **Key extraction** | Client CDM parses license | Server CDM parses license |
| **What leaves server** | Encrypted license blob | Decryption keys |
| **Vault caching** | Client caches keys | Client caches keys |
| **`--cdm-only` effect** | Skip vaults, CDM only | Skip vaults, server CDM only |
| **Config** | `server_cdm: false` (default) | `server_cdm: true` |
## API Endpoints
| Endpoint | Method | Purpose |
| --------------------------- | ------ | --------------------------------- |
| `/api/session/create` | POST | Authenticate, create session |
| `/api/session/{id}/titles` | GET | Get titles for session |
| `/api/session/{id}/tracks` | POST | Get tracks + manifests + chapters |
| `/api/session/{id}/license` | POST | License proxy or server-CDM keys |
| `/api/session/{id}` | GET | Check session validity |
| `/api/session/{id}` | DELETE | Cleanup session |