docs: update API and configuration documentation with example service tags

This commit is contained in:
Andy
2026-03-19 12:55:39 -06:00
parent e9dbe3f0ac
commit 4c55f7af5b
7 changed files with 59 additions and 342 deletions

View File

@@ -72,7 +72,7 @@ Search for titles from a streaming service.
**Required parameters:**
| Parameter | Type | Description |
| --- | --- | --- |
| `service` | string | Service tag (e.g., `NF`, `AMZN`, `ATV`) |
| `service` | string | Service tag |
| `query` | string | Search query |
**Optional parameters:**
@@ -85,18 +85,18 @@ Search for titles from a streaming service.
```bash
curl -X POST http://localhost:8786/api/search \
-H "Content-Type: application/json" \
-d '{"service": "ATV", "query": "hijack"}'
-d '{"service": "EXAMPLE", "query": "example show"}'
```
```json
{
"results": [
{
"id": "umc.cmc.1dg08zn0g3zx52hs8npoj5qe3",
"title": "Hijack",
"id": "abc123def456",
"title": "Example Show",
"description": null,
"label": "TV Show",
"url": "https://tv.apple.com/us/show/hijack/umc.cmc.1dg08zn0g3zx52hs8npoj5qe3"
"url": "https://example.com/show/abc123def456"
}
],
"count": 1
@@ -118,7 +118,7 @@ Get available titles (seasons/episodes/movies) for a service and title ID.
```bash
curl -X POST http://localhost:8786/api/list-titles \
-H "Content-Type: application/json" \
-d '{"service": "ATV", "title_id": "umc.cmc.1dg08zn0g3zx52hs8npoj5qe3"}'
-d '{"service": "EXAMPLE", "title_id": "abc123def456"}'
```
```json
@@ -126,12 +126,12 @@ curl -X POST http://localhost:8786/api/list-titles \
"titles": [
{
"type": "episode",
"name": "Final Call",
"series_title": "Hijack",
"name": "Pilot",
"series_title": "Example Show",
"season": 1,
"number": 1,
"year": 2023,
"id": "umc.cmc.4levibvvz01hl4zsm0jdk5v2p"
"year": 2024,
"id": "abc123def789"
}
]
}
@@ -161,8 +161,8 @@ Get video, audio, and subtitle tracks for a title.
curl -X POST http://localhost:8786/api/list-tracks \
-H "Content-Type: application/json" \
-d '{
"service": "ATV",
"title_id": "umc.cmc.1dg08zn0g3zx52hs8npoj5qe3",
"service": "EXAMPLE",
"title_id": "abc123def456",
"wanted": ["S01E01"]
}'
```
@@ -261,8 +261,8 @@ Start a download job. Returns immediately with a job ID (HTTP 202).
curl -X POST http://localhost:8786/api/download \
-H "Content-Type: application/json" \
-d '{
"service": "ATV",
"title_id": "umc.cmc.1dg08zn0g3zx52hs8npoj5qe3",
"service": "EXAMPLE",
"title_id": "abc123def456",
"wanted": ["S01E01"],
"quality": [1080, 2160],
"vcodec": ["H265"],
@@ -303,7 +303,7 @@ curl http://localhost:8786/api/download/jobs
curl "http://localhost:8786/api/download/jobs?status=completed"
# Filter by service
curl "http://localhost:8786/api/download/jobs?service=ATV"
curl "http://localhost:8786/api/download/jobs?service=EXAMPLE"
```
---
@@ -321,8 +321,8 @@ curl http://localhost:8786/api/download/jobs/504db959-80b0-446c-a764-7924b761d61
"job_id": "504db959-80b0-446c-a764-7924b761d613",
"status": "completed",
"created_time": "2026-02-27T18:00:00.000000",
"service": "ATV",
"title_id": "umc.cmc.1dg08zn0g3zx52hs8npoj5qe3",
"service": "EXAMPLE",
"title_id": "abc123def456",
"progress": 100.0,
"parameters": { ... },
"started_time": "2026-02-27T18:00:01.000000",

View File

@@ -62,9 +62,9 @@ Example mapping:
```yaml
downloader:
NF: requests
AMZN: n_m3u8dl_re
DSNP: n_m3u8dl_re
EXAMPLE: requests
EXAMPLE2: n_m3u8dl_re
EXAMPLE3: n_m3u8dl_re
default: requests
```
@@ -131,11 +131,11 @@ downloads: 4
workers: 16
```
to set `--bitrate=CVBR` for the AMZN service,
to set `--bitrate=CVBR` for a specific service,
```yaml
lang: de
AMZN:
EXAMPLE:
bitrate: CVBR
```
@@ -229,9 +229,9 @@ dl:
lang: en
downloads: 4
workers: 16
AMZN:
EXAMPLE:
bitrate: CVBR
NF:
EXAMPLE2:
worst: true
quality: 1080
```
@@ -307,8 +307,8 @@ Example mapping:
```yaml
decryption:
ATVP: mp4decrypt
AMZN: shaka
EXAMPLE: mp4decrypt
EXAMPLE2: shaka
default: shaka
```

View File

@@ -12,8 +12,8 @@ for a matching file.
For example,
```yaml
AMZN: chromecdm_903_l3
NF: nexus_6_l1
EXAMPLE: chromecdm_903_l3
EXAMPLE2: nexus_6_l1
```
You may also specify this device based on the profile used.
@@ -21,9 +21,9 @@ You may also specify this device based on the profile used.
For example,
```yaml
AMZN: chromecdm_903_l3
NF: nexus_6_l1
DSNP:
EXAMPLE: chromecdm_903_l3
EXAMPLE2: nexus_6_l1
EXAMPLE3:
john_sd: chromecdm_903_l3
jane_uhd: nexus_5_l1
```
@@ -35,8 +35,8 @@ For example, the following has the same result as the previous example, as well
services and profiles being pre-defined to use `chromecdm_903_l3`.
```yaml
NF: nexus_6_l1
DSNP:
EXAMPLE2: nexus_6_l1
EXAMPLE3:
jane_uhd: nexus_5_l1
default: chromecdm_903_l3
```
@@ -56,12 +56,12 @@ EXAMPLE:
You can mix profiles and quality thresholds in the same service:
```yaml
NETFLIX:
john: netflix_l3_profile # Profile-based selection
"<=720": netflix_mobile_l3 # Quality-based selection
"1080": netflix_standard_l3 # Exact match for 1080p
">=1440": netflix_premium_l1 # Quality-based selection
default: netflix_standard_l3 # Fallback
EXAMPLE:
john: example_l3_profile # Profile-based selection
"<=720": example_mobile_l3 # Quality-based selection
"1080": example_standard_l3 # Exact match for 1080p
">=1440": example_premium_l1 # Quality-based selection
default: example_standard_l3 # Fallback
```
---
@@ -311,8 +311,8 @@ Or configure per-service with a `DEFAULT` fallback:
```yaml
decryption:
DEFAULT: shaka
AMZN: mp4decrypt
NF: shaka
EXAMPLE: mp4decrypt
EXAMPLE2: shaka
```
Service keys are case-insensitive (normalized to uppercase internally).

View File

@@ -56,10 +56,10 @@ output_template:
```
Example outputs:
- Scene movies: `The.Matrix.1999.1080p.NF.WEB-DL.DDP5.1.H.264-EXAMPLE`
- Scene movies (REPACK): `Dune.2021.REPACK.2160p.HBO.WEB-DL.DDP5.1.H.265-EXAMPLE`
- Scene series: `Breaking.Bad.2008.S01E01.Pilot.1080p.NF.WEB-DL.DDP5.1.H.264-EXAMPLE`
- Plex movies: `The Matrix (1999) 1080p`
- Scene movies: `Example.Movie.2024.1080p.EXAMPLE.WEB-DL.DDP5.1.H.264-TAG`
- Scene movies (REPACK): `Example.Movie.2024.REPACK.2160p.EXAMPLE.WEB-DL.DDP5.1.H.265-TAG`
- Scene series: `Example.Show.2024.S01E01.Pilot.1080p.EXAMPLE.WEB-DL.DDP5.1.H.264-TAG`
- Plex movies: `Example Movie (2024) 1080p`
---
@@ -106,10 +106,10 @@ output_template:
```
Example outputs:
- Danish audio: `Show.S01E01.DANiSH.1080p.NF.WEB-DL.DDP5.1.H.264-TAG`
- English audio + multiple Nordic subs: `Show.S01E01.NORDiC.1080p.NF.WEB-DL.DDP5.1.H.264-TAG`
- English audio + Danish subs only: `Show.S01E01.DKsubs.1080p.NF.WEB-DL.DDP5.1.H.264-TAG`
- No matching languages: `Show.S01E01.1080p.NF.WEB-DL.DDP5.1.H.264-TAG`
- Danish audio: `Example.Show.S01E01.DANiSH.1080p.EXAMPLE.WEB-DL.DDP5.1.H.264-TAG`
- English audio + multiple Nordic subs: `Example.Show.S01E01.NORDiC.1080p.EXAMPLE.WEB-DL.DDP5.1.H.264-TAG`
- English audio + Danish subs only: `Example.Show.S01E01.DKsubs.1080p.EXAMPLE.WEB-DL.DDP5.1.H.264-TAG`
- No matching languages: `Example.Show.S01E01.1080p.EXAMPLE.WEB-DL.DDP5.1.H.264-TAG`
### Example: Other regional tags

View File

@@ -1,283 +0,0 @@
# 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,<br/>cookies, client_region, profile}
Note over Server: Check if server region matches client<br/>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<br/>Extract service session cookies/headers<br/>Detect server CDM type from track DRM + config
Server-->>Client: Tracks + manifests + chapters +<br/>session cookies + server_cdm_type
Note over Client: Re-parse manifest locally (DASH/ISM)<br/>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<br/>(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),<br/>drm_type, pssh}
Server->>Service: get_widevine_license(challenge, title, track)<br/>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()<br/>before downloads start
Client->>Server: POST /api/session/{id}/license
Note right of Client: {track_ids: [...], mode: "server_cdm",<br/>drm_type from server_cdm_type}
loop Per track with DRM
Note over Server: Extract PSSH from track manifest<br/>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<br/>(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,<br/>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 |

View File

@@ -17,7 +17,7 @@ a dictionary.
For example,
```yaml
NOW:
EXAMPLE:
client:
auth_scheme: MESSO
# ... more sensitive data
@@ -55,20 +55,20 @@ Specify login credentials to use for each Service, and optionally per-profile.
For example,
```yaml
ALL4: jane@gmail.com:LoremIpsum100 # directly
AMZN: # or per-profile, optionally with a default
EXAMPLE: jane@example.tld:LoremIpsum100 # directly
EXAMPLE2: # or per-profile, optionally with a default
default: jane@example.tld:LoremIpsum99 # <-- used by default if -p/--profile is not used
james: james@gmail.com:TheFriend97
james: james@example.tld:TheFriend97
john: john@example.tld:LoremIpsum98
NF: # the `default` key is not necessary, but no credential will be used by default
john: john@gmail.com:TheGuyWhoPaysForTheNetflix69420
EXAMPLE3: # the `default` key is not necessary, but no credential will be used by default
john: john@example.tld:SecretPassword123
```
The value should be in string form, i.e. `john@gmail.com:password123` or `john:password123`.
The value should be in string form, i.e. `john@example.tld:password123` or `john:password123`.
Any arbitrary values can be used on the left (username/password/phone) and right (password/secret).
You can also specify these in list form, i.e., `["john@gmail.com", ":PasswordWithAColon"]`.
You can also specify these in list form, i.e., `["john@example.tld", ":PasswordWithAColon"]`.
If you specify multiple credentials with keys like the `AMZN` and `NF` example above, then you should
If you specify multiple credentials with keys like the `EXAMPLE2` and `EXAMPLE3` example above, then you should
use a `default` key or no credential will be loaded automatically unless you use `-p/--profile`. You
do not have to use a `default` key at all.

View File

@@ -247,7 +247,7 @@ async def search(request: web.Request) -> web.Response:
properties:
service:
type: string
description: Service tag (e.g., NF, AMZN, ATV)
description: Service tag
query:
type: string
description: Search query string