Merge branch 'dev' into feat/downloader-speed

# Conflicts:
#	unshackle/core/downloaders/requests.py
#	unshackle/core/manifests/dash.py
This commit is contained in:
imSp4rky
2026-07-16 12:47:59 -06:00
103 changed files with 3270 additions and 1706 deletions
+20 -20
View File
@@ -45,16 +45,16 @@ unshackle dl [OPTIONS] SERVICE [SERVICE ARGS...]
```
- `OPTIONS`: every flag below, parsed at the `dl` level.
- `SERVICE`: a service tag (e.g. `NF`, `DSNP`). Tags are case-insensitive and honour each service's aliases (`disney+`, `DSNP`, etc. all resolve to the same tag).
- `SERVICE`: a service tag (e.g. `EXAMPLE1`, `EXAMPLE2`). Tags are case-insensitive and honour each service's aliases (`example+`, `EXAMPLE2`, etc. all resolve to the same tag).
- `SERVICE ARGS`: the title, URL, or ID, plus any service-specific options. These belong to the service, not to `dl`.
!!! example "Typical downloads"
```shell
# Best available 1080p SDR of a Netflix title
unshackle dl -q 1080 NF 81234567
# Best available 1080p SDR of a title
unshackle dl -q 1080 EXAMPLE 81234567
# 4K HDR10, English audio + subs, from a URL
unshackle dl -q 2160 -r HDR10 -l en DSNP "https://www.disneyplus.com/…"
unshackle dl -q 2160 -r HDR10 -l en EXAMPLE "https://www.example.com/..."
# A season range, HEVC video, EC3 audio
unshackle dl -w S01-S03 -v H.265 -a EC3 SERVICE TITLE-ID
@@ -67,16 +67,16 @@ unshackle dl [OPTIONS] SERVICE [SERVICE ARGS...]
| Flag | Default | Description |
|---|---|---|
| `-p`, `--profile` | — | Profile for credentials and cookies. |
| `-p`, `--profile` | - | Profile for credentials and cookies. |
| `-q`, `--quality` | best | Resolution(s), comma-separated, e.g. `1080,720`. `-q 1080` also matches anamorphic tracks by 16:9 canvas. |
| `-v`, `--vcodec` | any | Video codec(s). Accepts names or values: `AVC`/`H.264`, `HEVC`/`H.265`, `VC1`, `VP8`, `VP9`, `AV1`. |
| `-a`, `--acodec` | any | Audio codec(s), comma-separated. Accepts `AAC`, `AC3`/`DD`, `EC3`/`DD+`/`eac3`/`ddp`, `AC4`, `OPUS`, `OGG`/`vorbis`, `DTS`, `ALAC`, `FLAC`. |
| `-vb`, `--vbitrate` | highest | Exact video bitrate in kbps. |
| `-ab`, `--abitrate` | highest | Exact audio bitrate in kbps. |
| `-vb-range`, `--vbitrate-range` | — | Video bitrate range in kbps, e.g. `6000-7000`; picks highest within. Mutually exclusive with `--vbitrate`. |
| `-ab-range`, `--abitrate-range` | — | Audio bitrate range in kbps, e.g. `128-256`. Mutually exclusive with `--abitrate`. |
| `-vb-range`, `--vbitrate-range` | - | Video bitrate range in kbps, e.g. `6000-7000`; picks highest within. Mutually exclusive with `--vbitrate`. |
| `-ab-range`, `--abitrate-range` | - | Audio bitrate range in kbps, e.g. `128-256`. Mutually exclusive with `--abitrate`. |
| `-r`, `--range` | `SDR` | Colour range(s): `SDR`, `HLG`, `HDR10`, `HDR10P` (HDR10+), `DV`, `HYBRID`. |
| `-c`, `--channels` | — | Audio channels; matches sub-layouts (5.1 ≈ 6.0). |
| `-c`, `--channels` | - | Audio channels; matches sub-layouts (5.1 ≈ 6.0). |
| `-naa`, `--noatmos` | off | Exclude Dolby Atmos audio tracks. |
| `--worst` | off | Pick the lowest bitrate within the requested quality. **Requires `-q`.** |
| `--best-available` | off | Continue with the best available if a requested resolution/language is absent, instead of failing. |
@@ -91,13 +91,13 @@ unshackle dl [OPTIONS] SERVICE [SERVICE ARGS...]
| Flag | Default | Description |
|---|---|---|
| `-l`, `--lang` | `orig` | Language(s) for **both** video and audio. `orig` = the title's original language; e.g. `orig,en`. |
| `-vl`, `--v-lang` | — | Video-only language (overrides `-l` for video). |
| `-al`, `--a-lang` | — | Audio-only language (overrides `-l` for audio). |
| `-vl`, `--v-lang` | - | Video-only language (overrides `-l` for video). |
| `-al`, `--a-lang` | - | Audio-only language (overrides `-l` for audio). |
| `-sl`, `--s-lang` | `all` | Subtitle language(s). |
| `--require-subs` | — | Required subtitle langs; keeps **all** subs only if these exist. **Cannot combine with `--s-lang`.** |
| `--require-subs` | - | Required subtitle langs; keeps **all** subs only if these exist. **Cannot combine with `--s-lang`.** |
| `-fs`, `--forced-subs` | off | Include forced subtitle tracks. |
| `--exact-lang` | off | Exact matching only: `-l es-419` matches `es-419`, not `es-ES`. |
| `--sub-format` | — | Output subtitle format (`SRT`/`srt`, `VTT`/`webvtt`, `ASS`/`ssa`, `TTML`, `SMI`, …), or `original` to keep the source format. |
| `--sub-format` | - | Output subtitle format (`SRT`/`srt`, `VTT`/`webvtt`, `ASS`/`ssa`, `TTML`, `SMI`, ...), or `original` to keep the source format. |
The special language tokens `orig`, `all`, and `best` are honoured everywhere a language is expected.
@@ -177,7 +177,7 @@ Keep only certain track types, or skip certain track types. Attachments are alwa
| `--no-cache` | off | Bypass the title cache. |
| `--reset-cache` | off | Clear the title cache. |
| `--list` | off | List available/would-be-downloaded tracks; do not download. |
| `--slow` | — | Inter-title delay. Bare `--slow` = 60-120s; `--slow 20-40` = custom range (minimum 20s). |
| `--slow` | - | Inter-title delay. Bare `--slow` = 60-120s; `--slow 20-40` = custom range (minimum 20s). |
!!! warning "Some flags cannot be combined"
`--require-subs` and `--s-lang`; `--select-titles` and `--wanted`; `--worst` requires `--quality`; `--vbitrate` and `--vbitrate-range` (and the audio equivalents) are mutually exclusive.
@@ -202,8 +202,8 @@ The query syntax is defined per service. Results are printed as a tree of titles
!!! example
```shell
unshackle search NF "The Office"
unshackle search -p myprofile NF "breaking bad"
unshackle search EXAMPLE "My Show"
unshackle search -p myprofile EXAMPLE "Another Show"
unshackle search --proxy nordvpn:ca SERVICE "query"
```
@@ -308,7 +308,7 @@ Clear an environment directory. The directory is emptied and recreated, and the
unshackle env check
unshackle env info
unshackle env clear cache
unshackle env clear cache NF # one service only
unshackle env clear cache EXAMPLE # one service only
unshackle env clear temp
```
@@ -358,10 +358,10 @@ Create service tables on vaults that use tables, for every installed service, wh
!!! example
```shell
unshackle kv copy main backup1 backup2
unshackle kv copy main backup -s NF
unshackle kv copy main backup -s EXAMPLE
unshackle kv sync main mysql_vault
unshackle kv add keys.txt NF main backup
unshackle kv search 0123456789abcdef0123456789abcdef -s NF -v main
unshackle kv add keys.txt EXAMPLE main backup
unshackle kv search 0123456789abcdef0123456789abcdef -s EXAMPLE -v main
unshackle kv prepare main mysql_vault
```
@@ -523,7 +523,7 @@ Losslessly set the video range flag to full or limited at the bitstream level.
| Option | Default | Description |
|---|---|---|
| `--full` / `--limited` | — | Full (0-255) or limited (16-235) range. |
| `--full` / `--limited` | - | Full (0-255) or limited (16-235) range. |
| `-p`, `--preview` | off | Preview instead of writing a file. |
### `util test PATH`
+42 -42
View File
@@ -26,23 +26,23 @@ unshackle dl [OPTIONS] SERVICE [SERVICE ARGUMENTS]
- `unshackle`: the root command.
- `dl`: the download command. **All of the options on this page are parsed here**, so
they go *before* the service tag.
- `SERVICE`: a service tag such as `NF`, `DSNP`, `AMZN`. Service tags are
case-insensitive and honour each service's aliases (for example `disney+` resolves to
- `SERVICE`: a service tag such as `EXAMPLE1`, `EXAMPLE2`, `EXAMPLE3`. Service tags are
case-insensitive and honour each service's aliases (for example `example+` resolves to
its real tag).
- `SERVICE ARGUMENTS`: usually a title ID or URL. This positional argument belongs to
the **service**, not to `dl`, so it comes *after* the tag.
```shell title="A minimal download"
unshackle dl NF 81234567
unshackle dl EXAMPLE 81234567
```
```shell title="Flags belong to dl, the title belongs to the service"
unshackle dl -q 1080 -v H.265 -r HDR10 --lang en DSNP 'https://www.disneyplus.com/…'
unshackle dl -q 1080 -v H.265 -r HDR10 --lang en EXAMPLE 'https://www.example.com/...'
```
!!! tip "See what a service accepts"
Because the positional argument is defined by each service, run
`unshackle dl SERVICE --help` (e.g. `unshackle dl NF --help`) to see that service's
`unshackle dl SERVICE --help` (e.g. `unshackle dl EXAMPLE --help`) to see that service's
own argument and any service-specific options.
## The download flow
@@ -75,11 +75,11 @@ downloaded. `--skip-dl` runs the license step but skips the actual segment downl
list. Without it, unshackle picks the **best available** resolution.
```shell title="Single resolution"
unshackle dl -q 1080 NF 81234567
unshackle dl -q 1080 EXAMPLE 81234567
```
```shell title="Multiple resolutions in one run"
unshackle dl -q 2160,1080,720 NF 81234567
unshackle dl -q 2160,1080,720 EXAMPLE 81234567
```
!!! note "16:9 canvas matching"
@@ -93,11 +93,11 @@ By default a missing requested resolution is an error. Two flags change that:
| Flag | Behaviour |
| --- | --- |
| `--best-available` | If the requested resolution(s) aren't present, continue with the best that *is* available instead of failing. Also softens missing audio/subtitle languages and hybrid fallbacks. |
| `--best-available` | If the requested resolution(s) aren't present, continue with the best that *is* available instead of failing. Also softens missing video/audio/subtitle languages and hybrid fallbacks. |
| `--worst` | Within the specified quality, pick the **lowest** bitrate rendition. **Requires `-q/--quality`.** |
```shell title="Never fail on a missing resolution"
unshackle dl -q 2160 --best-available AMZN 0ABC123
unshackle dl -q 2160 --best-available EXAMPLE 0ABC123
```
## Video codec and color range
@@ -117,9 +117,9 @@ either enum **names** or their **values**, comma-separated.
| `AV1` | `AV1` |
```shell title="Either spelling works"
unshackle dl -v HEVC NF 81234567
unshackle dl -v H.265 NF 81234567
unshackle dl -v hevc,avc NF 81234567
unshackle dl -v HEVC EXAMPLE 81234567
unshackle dl -v H.265 EXAMPLE 81234567
unshackle dl -v hevc,avc EXAMPLE 81234567
```
### Color range
@@ -136,7 +136,7 @@ unshackle dl -v hevc,avc NF 81234567
| `HYBRID` | Fetch both an HDR10/HDR10+ base and a DV track, then merge them |
```shell title="Grab HDR10 and Dolby Vision in one run"
unshackle dl -q 2160 -r HDR10,DV DSNP '…'
unshackle dl -q 2160 -r HDR10,DV EXAMPLE '...'
```
!!! warning "HYBRID requires dovi_tool"
@@ -215,7 +215,7 @@ Names, values, and a few aliases are accepted: `eac3` and `ddp` both resolve to
and `vorbis` resolves to `OGG`.
```shell title="Prefer Dolby Digital Plus, fall back to AAC"
unshackle dl -a EC3,AAC NF 81234567
unshackle dl -a EC3,AAC EXAMPLE 81234567
```
### Channels and Atmos
@@ -225,7 +225,7 @@ unshackle dl -a EC3,AAC NF 81234567
- `-naa` / `--noatmos`: exclude Dolby Atmos audio tracks from selection.
```shell title="5.1 audio, no Atmos"
unshackle dl -c 5.1 --noatmos AMZN 0ABC123
unshackle dl -c 5.1 --noatmos EXAMPLE 0ABC123
```
## Languages
@@ -236,7 +236,7 @@ unshackle dl -c 5.1 --noatmos AMZN 0ABC123
`orig`, the title's original language.
```shell title="Original language plus English"
unshackle dl -l orig,en NF 81234567
unshackle dl -l orig,en EXAMPLE 81234567
```
The special token `orig` is resolved to the title's actual original language everywhere
@@ -247,7 +247,7 @@ it is used. You can override each stream type independently:
- `-al` / `--a-lang`: language for **audio only** (overrides `-l` for audio).
```shell title="English audio over the original video"
unshackle dl -al en NF 81234567
unshackle dl -al en EXAMPLE 81234567
```
### Exact vs fuzzy matching
@@ -269,7 +269,7 @@ matches only `es-419`, not `es-ES`.
every available subtitle language is downloaded.
```shell title="Only English and Spanish subtitles"
unshackle dl -sl en,es NF 81234567
unshackle dl -sl en,es EXAMPLE 81234567
```
### Requiring subtitles
@@ -299,7 +299,7 @@ download on the presence of a specific subtitle track.
| `original` | Keep the source format, no conversion |
```shell title="Convert subtitles to SRT"
unshackle dl --sub-format srt NF 81234567
unshackle dl --sub-format srt EXAMPLE 81234567
```
!!! note "SDH stripping happens by default"
@@ -321,24 +321,24 @@ with `-` to exclude it.
=== "Whole seasons"
```shell
unshackle dl -w S01 NF 81234567
unshackle dl -w S01-S03 NF 81234567
unshackle dl -w S01,S03,S05 NF 81234567
unshackle dl -w S01 EXAMPLE 81234567
unshackle dl -w S01-S03 EXAMPLE 81234567
unshackle dl -w S01,S03,S05 EXAMPLE 81234567
```
=== "Specific episodes"
```shell
unshackle dl -w S01E01 NF 81234567
unshackle dl -w S01E01-S01E05 NF 81234567
unshackle dl -w S01E01-S02E03 NF 81234567
unshackle dl -w S01E01 EXAMPLE 81234567
unshackle dl -w S01E01-S01E05 EXAMPLE 81234567
unshackle dl -w S01E01-S02E03 EXAMPLE 81234567
```
=== "Exclusions"
```shell
# Seasons 1 through 5, but not season 3
unshackle dl -w S01-S05,-S03 NF 81234567
unshackle dl -w S01-S05,-S03 EXAMPLE 81234567
```
### Other selection flags
@@ -350,7 +350,7 @@ with `-` to exclude it.
| `--list-titles` | List the titles that would be downloaded, then stop. |
```shell title="Grab just the newest episode"
unshackle dl --latest-episode NF 81234567
unshackle dl --latest-episode EXAMPLE 81234567
```
## Including and excluding track types
@@ -380,11 +380,11 @@ Additional track-type flags:
than aborting the whole title. Video and audio failures remain fatal.
```shell title="Subtitles only"
unshackle dl -S -sl en NF 81234567
unshackle dl -S -sl en EXAMPLE 81234567
```
```shell title="Everything except chapters"
unshackle dl -nc NF 81234567
unshackle dl -nc EXAMPLE 81234567
```
!!! note
@@ -403,7 +403,7 @@ Before committing to a long download, inspect what unshackle *would* do:
| `--skip-dl` | Skip downloading but still acquire the decryption keys. |
```shell title="See the track selection without downloading"
unshackle dl -q 1080 -v H.265 -r HDR10 --list NF 81234567
unshackle dl -q 1080 -v H.265 -r HDR10 --list EXAMPLE 81234567
```
## Output and muxing
@@ -416,7 +416,7 @@ unshackle dl -q 1080 -v H.265 -r HDR10 --list NF 81234567
- `--no-source`: remove the service source tag from the filename and path.
```shell title="Send this download somewhere specific"
unshackle dl -o ~/Videos/incoming NF 81234567
unshackle dl -o ~/Videos/incoming EXAMPLE 81234567
```
### Muxing behaviour
@@ -426,7 +426,7 @@ flags change how the output is assembled:
| Flag | Behaviour | Default source |
| --- | --- | --- |
| `--no-mux` | Do not mux; keep the individual track files. | — |
| `--no-mux` | Do not mux; keep the individual track files. | - |
| `--split-audio` | Write a separate output file per audio codec instead of merging all audio. | config `muxing.merge_audio` (on) |
| `--merge-video` | Mux all selected video tracks into one file. | config `muxing.merge_video` (off) |
@@ -436,7 +436,7 @@ flags change how the output is assembled:
- `--repack`: add a `REPACK` tag to the output filename.
```shell title="Custom group tag and a REPACK label"
unshackle dl --tag MYGRP --repack NF 81234567
unshackle dl --tag MYGRP --repack EXAMPLE 81234567
```
## Proxies
@@ -445,9 +445,9 @@ unshackle dl --tag MYGRP --repack NF 81234567
configured proxy providers), or a `provider:region` form.
```shell title="Proxy forms"
unshackle dl --proxy us NF 81234567
unshackle dl --proxy nordvpn:ca NF 81234567
unshackle dl --proxy 'http://user:pass@host:8080' NF 81234567
unshackle dl --proxy us EXAMPLE 81234567
unshackle dl --proxy nordvpn:ca EXAMPLE 81234567
unshackle dl --proxy 'http://user:pass@host:8080' EXAMPLE 81234567
```
Two related flags:
@@ -470,11 +470,11 @@ Two related flags:
| `--reset-cache` | Clear the title cache before fetching. |
```shell title="Two tracks at a time, eight threads each"
unshackle dl --downloads 2 --workers 8 NF 81234567
unshackle dl --downloads 2 --workers 8 EXAMPLE 81234567
```
```shell title="Space out a season download"
unshackle dl -w S01 --slow 30-60 NF 81234567
unshackle dl -w S01 --slow 30-60 EXAMPLE 81234567
```
## Keys, vaults, and export
@@ -486,7 +486,7 @@ a key when the vault misses. You can force one side or the other:
- `--vaults-only`: only use key vaults (never license via the CDM); a missing key fails.
```shell title="Fetch keys only, no download"
unshackle dl --skip-dl NF 81234567
unshackle dl --skip-dl EXAMPLE 81234567
```
`--export` writes a JSON file, into the configured exports directory, containing track
@@ -494,7 +494,7 @@ info and the acquired content keys for each title. This is the format consumed b
`unshackle import` to reconstruct a download later.
```shell title="Export track info and keys"
unshackle dl --skip-dl --export NF 81234567
unshackle dl --skip-dl --export EXAMPLE 81234567
```
!!! note "Region is recorded only with a proxy"
@@ -511,10 +511,10 @@ tagging and naming:
| `--tmdb` | `--tmdb 27205` | Use this TMDB ID instead of automatic lookup. |
| `--imdb` | `--imdb tt1375666` | Use this IMDb ID. |
| `--animeapi` | `--animeapi mal:12345` | Resolve via AnimeAPI (`mal:`/`anilist:` prefix; defaults to MAL). |
| `--enrich` | — | Override the show title and year from an external source. **Requires** one of `--tmdb`, `--imdb`, or `--animeapi`. |
| `--enrich` | - | Override the show title and year from an external source. **Requires** one of `--tmdb`, `--imdb`, or `--animeapi`. |
```shell title="Force the right IMDb match and enrich the title"
unshackle dl --imdb tt1375666 --enrich NF 81234567
unshackle dl --imdb tt1375666 --enrich EXAMPLE 81234567
```
## Configuration defaults
+12 -16
View File
@@ -48,7 +48,7 @@ license-server handshake; the rest are special cases.
|--------|-------------|--------------|------------|
| **Widevine** (Google) | `.wvd` | CDM challenge → license server → CDM parses → keys | shaka-packager / mp4decrypt |
| **PlayReady** (Microsoft) | `.prd` | CDM challenge (WRM header) → license server → CDM parses → keys | shaka-packager / mp4decrypt |
| **ClearKey (CENC)**, W3C EME `org.w3.clearkey` | none | JSON `{"kids":[…],"type":"temporary"}` → license server returns a JWK Set. No CDM. | shaka-packager / mp4decrypt |
| **ClearKey (CENC)**, W3C EME `org.w3.clearkey` | none | JSON `{"kids":[...],"type":"temporary"}` → license server returns a JWK Set. No CDM. | shaka-packager / mp4decrypt |
| **ClearKey (HLS)**, AES-128 `EXT-X-KEY` | none | Key fetched directly from a URI or `data:` URL. No CDM, no challenge. | in-process AES-CBC |
| **MonaLisa** (proprietary, WASM) | `.mld` | Ticket from the service API; keys extracted locally via WASM. No license server. | ML-Worker binary + AES-ECB, per segment |
@@ -71,8 +71,8 @@ The differences that matter to you are:
[Widevine and PlayReady on the same title](#widevine-and-playready-on-the-same-title)).
To download a Widevine-protected title you need a `.wvd` device; for PlayReady you
need a `.prd` device. You can hold both and let unshackle choose, or force one with
the `--drm` flag.
need a `.prd` device. You can hold both, and unshackle automatically selects the
appropriate one for each track.
!!! info "Obtaining device files"
A `.wvd` or `.prd` encodes the private keys of a real client device. unshackle
@@ -201,16 +201,11 @@ fallback:
```yaml title="unshackle.yaml"
cdm:
default: nexus_6p_26830_l3
AMZN: chromecdm_l3
NF: sm_g935f_l1
EXAMPLE1: chromecdm_l3
EXAMPLE2: sm_g935f_l1
```
Lookups are case-insensitive. On the command line you can override the configured
device for a single run:
```console
$ unshackle dl -w chromecdm_l3 AMZN B0ABCDEF
```
Lookups are case-insensitive.
!!! tip "The device name is the file name"
A device named `chromecdm_l3` in the config resolves to `chromecdm_l3.wvd` in the
@@ -238,8 +233,9 @@ profile.
=== "By DRM system"
Keys `widevine` and `playready` select a device per system. This pairs with the
`--drm` flag, which accepts `wv`/`widevine` or `pr`/`playready`.
Keys `widevine` and `playready` select a device per system. unshackle applies
these automatically, using the `widevine` device for Widevine tracks and the
`playready` device for PlayReady tracks.
```yaml
cdm:
@@ -451,7 +447,7 @@ returns the keys as a JWK Set rather than a DRM-wrapped blob. unshackle builds a
EME request of the form:
```json
{"kids": ["<base64url-kid>", …], "type": "temporary"}
{"kids": ["<base64url-kid>", ...], "type": "temporary"}
```
It sends this to the manifest's license URL (or to whatever a service's ClearKey
@@ -471,8 +467,8 @@ Title needs a Widevine CDM but SOMESERVICE is configured with PlayReady.
```
Use the `cdm` mapping's [DRM-based selection](#advanced-selection-by-quality-drm-or-profile)
to configure a device for each system, or force one system with `--drm` on the
command line.
to configure a device for each system; unshackle selects the right one per track
automatically.
## Keys, vaults, and caching
+15 -15
View File
@@ -13,15 +13,15 @@ When a download finishes muxing, unshackle builds the final path from three piec
```
- **Output directory**: `directories.downloads` from your config, or whatever you pass to `-o/--output` on the command line for a single run.
- **Folder template**: an optional per-title subfolder. TV episodes and music tracks are **always** placed in a folder; movies only get one if you define a `movies` folder template (see [Folder templates](#folder-templates)).
- **Folder template**: an optional per-title subfolder. TV episodes and music tracks are **always** placed in a folder; movies only get one if you define a `movies` folder template, or a single-string `folder` template that applies to all title kinds (see [Folder templates](#folder-templates)).
- **Filename template**: the `output_template` for the title kind (movie, series, or song).
- **Extension**: chosen by the muxer: `.mkv` for video, `.mka` for audio-only, `.mks` for subtitle-only.
!!! example "A typical episode path"
```text
/home/user/Downloads/
└─ Show Name S01 1080p AMZN WEB-DL DDP5.1 H.264/
└─ Show.Name.S01E01.Pilot.1080p.AMZN.WEB-DL.DDP5.1.H.264-TAG.mkv
└─ Show Name S01 1080p EXAMPLE WEB-DL DDP5.1 H.264/
└─ Show.Name.S01E01.Pilot.1080p.EXAMPLE.WEB-DL.DDP5.1.H.264-TAG.mkv
```
The folder name comes from the `series` folder template and the file from the `series` output template.
@@ -51,7 +51,7 @@ The recognized kinds are:
| `movies` | Movies | *(required for movie downloads to be named, no built-in default)* |
| `series` | TV episodes | *(required for episode downloads to be named)* |
| `songs` | Music tracks | `"{track_number}. {title}"` |
| `albums` | Album **folders** only (see below) | — |
| `albums` | Album **folders** only (see below) | - |
!!! note
`movies` and `series` have no hardcoded filename fallback. If you download those title types, define their templates. `songs` falls back to `"{track_number}. {title}"` when omitted.
@@ -80,7 +80,7 @@ unshackle looks at the characters you place *between* variables to decide the fi
movies: "{title}.{year}.{quality}.{source}.WEB-DL.{audio_full}.{video}-{tag}"
```
```text
The.Movie.2024.1080p.NF.WEB-DL.DDP5.1.H.264-TAG.mkv
The.Movie.2024.1080p.EXAMPLE.WEB-DL.DDP5.1.H.264-TAG.mkv
```
=== "Readable style (spaces)"
@@ -90,7 +90,7 @@ unshackle looks at the characters you place *between* variables to decide the fi
movies: "{title} ({year}) {quality} {source} WEB-DL {audio_full} {video}"
```
```text
The Movie (2024) 1080p NF WEB-DL DDP5.1 H.264.mkv
The Movie (2024) 1080p EXAMPLE WEB-DL DDP5.1 H.264.mkv
```
## Template variables
@@ -103,7 +103,7 @@ Every variable below is valid in both output and folder templates. Values are de
|---|---|---|
| `title` | Title name (movie/show/song name; `$` is rendered as `S`) | `The Show` |
| `year` | Release year | `2024` |
| `source` | Service tag / class name (empty with `--no-source`) | `NF` |
| `source` | Service tag / class name (empty with `--no-source`) | `EXAMPLE` |
| `quality` | Resolution with scan suffix | `1080p`, `2160p`, `576i` |
| `resolution` | Resolution number only | `1080` |
| `video` | Video codec | `H.264`, `H.265` |
@@ -188,10 +188,10 @@ The per-kind folder keys are `movies`, `series`, `songs`, and `albums`. Any othe
**Fallback behavior:**
- A per-kind folder template wins if present; otherwise the single `folder` string is used; otherwise unshackle falls back to a built-in default.
- Built-in defaults when no folder template is set: movies get no folder at all unless a `movies` folder template exists; series fall back to a folder *derived* from the `series` output template (stripping `{episode}`, `{episode_name}`, and collapsing `{season_episode}` down to `{season}`); music albums fall back to `{artist} - {album} ({year})`.
- Built-in defaults when no folder template is set: movies get no folder at all unless a `movies` folder template (or a single-string `folder` template) exists; series fall back to a folder *derived* from the `series` output template (stripping `{episode}`, `{episode_name}`, and collapsing `{season_episode}` down to `{season}`); music albums fall back to `{artist} - {album} ({year})`.
!!! note "Movies are flat by default"
Episodes and songs are always foldered. Movies are written directly into the output directory *unless* you define a `movies` folder template. Set one if you want each movie in its own directory.
Episodes and songs are always foldered. Movies are written directly into the output directory *unless* you define a `movies` folder template (or a single-string `folder` template, which folders every kind). Set one if you want each movie in its own directory.
## Muxing options
@@ -200,7 +200,7 @@ Muxing (combining video, audio, subtitle, chapter and attachment tracks into a s
```yaml title="unshackle.yaml"
muxing:
set_title: true
merge_video: true
merge_video: false
merge_audio: true
default_language:
audio: en
@@ -210,7 +210,7 @@ muxing:
| Key | Type | Default | Effect |
|---|---|---|---|
| `set_title` | bool | `true` | Write the title name into the MKV container title with `--title`. Set to `false` to omit it. |
| `merge_video` | bool | `true` | Group video tracks that share the same resolution, range, and codec into one file so only language varies inside it. |
| `merge_video` | bool | `false` | Group video tracks that share the same resolution, range, and codec into one file so only language varies inside it. |
| `merge_audio` | bool | `true` | Merge audio tracks of the same kind so multiple languages sit in one file. |
| `default_language` | map | *(unset)* | Preferred language per track type (`video` / `audio` / `subtitle`). A track in the preferred language is flagged as the default track. |
@@ -249,13 +249,13 @@ tag_imdb_tmdb: true
You can override the tag per run without editing config:
```bash
unshackle dl --tag OTHERGROUP AMZN "..."
unshackle dl --tag OTHERGROUP EXAMPLE "..."
```
The `--repack` flag adds the `REPACK` marker (surfaced through the `{repack}` variable) to the filename for that run:
```bash
unshackle dl --repack NF "..."
unshackle dl --repack EXAMPLE "..."
```
!!! note "Empty tags collapse cleanly"
@@ -349,7 +349,7 @@ The fallback supports two placeholders: `{i}` (chapter number, starting at 1) an
```
```text
The Movie (2024)/
└─ The.Movie.2024.1080p.NF.WEB-DL.DDP5.1.Atmos.H.265.HDR-TAG.mkv
└─ The.Movie.2024.1080p.EXAMPLE.WEB-DL.DDP5.1.Atmos.H.265.HDR-TAG.mkv
```
=== "TV series"
@@ -362,7 +362,7 @@ The fallback supports two placeholders: `{i}` (chapter number, starting at 1) an
```
```text
The Show (2023)/Season S02/
└─ The.Show.S02E04.The.Reckoning.2160p.AMZN.WEB-DL.DDP5.1.H.265-TAG.mkv
└─ The.Show.S02E04.The.Reckoning.2160p.EXAMPLE.WEB-DL.DDP5.1.H.265-TAG.mkv
```
=== "Music"
+38 -30
View File
@@ -31,15 +31,15 @@ Proxying is controlled at download time by three options on the `dl` command:
| `--no-proxy-download` | Use the proxy for the manifest, licence, and authentication, but bypass it for the **segment downloads** themselves. |
```shell title="Explicit proxy URI"
unshackle dl --proxy http://user:pass@1.2.3.4:8080 NF 81234567
unshackle dl --proxy http://user:pass@1.2.3.4:8080 EXAMPLE 81234567
```
```shell title="Resolve a US server from your configured providers"
unshackle dl --proxy us NF 81234567
unshackle dl --proxy us EXAMPLE 81234567
```
```shell title="Target a specific provider"
unshackle dl --proxy nordvpn:us NF 81234567
unshackle dl --proxy nordvpn:us EXAMPLE 81234567
```
!!! tip "Set a default proxy in config"
@@ -55,7 +55,7 @@ proxy where it matters (passing the geo-check for the manifest and the licence)
pulling the actual audio and video segments over your normal connection for full speed.
```shell title="Proxy the manifest and licence, download segments direct"
unshackle dl --proxy gb --no-proxy-download ITV 10a1234
unshackle dl --proxy gb --no-proxy-download EXAMPLE 10a1234
```
!!! warning "When this is unsafe"
@@ -78,8 +78,8 @@ flowchart TD
D -- no --> F["Ask every provider in order,<br/>first match wins"]
```
1. **An explicit URI.** Anything shaped like `http://…`, `https://…`, or a `socks…`
URI is used exactly as given. unshackle logs `Using explicit Proxy: …` and does no
1. **An explicit URI.** Anything shaped like `http://...`, `https://...`, or a `socks...`
URI is used exactly as given. unshackle logs `Using explicit Proxy: ...` and does no
lookup.
2. **A provider-prefixed query**: `provider:query`, for example `nordvpn:us` or
`gluetun:windscribe:us`. unshackle finds the provider whose name matches the prefix
@@ -107,8 +107,8 @@ provider, prefix the query (`--proxy nordvpn:us`).
!!! note "Auto-loading providers"
Most providers only load when you configure them under `proxy_providers:`. Three are
special: **ExpressVPN** and **Proton VPN** also load automatically if their cookie
file already exists on disk (even with no YAML), and **Hola** loads automatically
special: **ExpressVPN** and **Proton VPN** also load automatically once their cached
session file exists on disk (even with no YAML), and **Hola** loads automatically
whenever the `hola-proxy` binary is found on your `PATH`.
### Connection feedback
@@ -154,7 +154,7 @@ proxy_providers:
malformed URI is rejected up front rather than failing mid-download.
```shell
unshackle dl --proxy de2 NF 81234567
unshackle dl --proxy de2 EXAMPLE 81234567
```
## Gluetun
@@ -187,11 +187,11 @@ gluetun:<vpn-provider>:<region>
```
```shell title="A Windscribe US exit via Gluetun"
unshackle dl --proxy gluetun:windscribe:us NF 81234567
unshackle dl --proxy gluetun:windscribe:us EXAMPLE 81234567
```
```shell title="A NordVPN Germany exit via Gluetun"
unshackle dl --proxy gluetun:nordvpn:de NF 81234567
unshackle dl --proxy gluetun:nordvpn:de EXAMPLE 81234567
```
After unshackle strips the leading `gluetun:`, the provider receives exactly
@@ -347,24 +347,24 @@ Understanding what Gluetun does behind the scenes helps when something goes wron
## ExpressVPN
ExpressVPN emulates the flow used by the ExpressVPN browser extension: it authenticates
with cookies (or a cached token), obtains proxy-capable locations from ExpressVPN's API,
and returns an authenticated HTTPS proxy. Because it relies on browser cookies rather than
service credentials, setup is slightly more involved than the credential-based providers.
ExpressVPN mirrors the ExpressVPN Android TV app: it signs in through Express's OAuth 2.0
device-login flow, obtains proxy-capable locations from Express's API, and returns an
authenticated HTTPS proxy. The device login runs once; after that the cached refresh token
keeps the session alive headlessly.
ExpressVPN **auto-loads** if its cookie file exists, so once the cookie is in place you do
not strictly need a YAML block, but you can add one to pin regions or servers.
Enable it and run a download interactively once:
**Default file locations:**
```yaml title="unshackle.yaml"
proxy_providers:
expressvpn:
enable: true
```
| File | Default path |
|---|---|
| Browser cookies | `{cookies}/vpn/expressvpn.txt` |
| Token cache | `{cache}/global/expressvpn_tokens.json` |
Where `{cookies}` and `{cache}` are your configured cookie and cache directories. The
cookie file may be a Netscape `cookies.txt`, a JSON cookie array, or a JSON dict. Export
your ExpressVPN session cookies from a logged-in browser into one of those forms.
With `enable: true` and an interactive terminal, unshackle prints a code and a URL to
enter it at. Once approved, the tokens are cached and reused automatically, so later runs
need no terminal. ExpressVPN also **auto-loads** once its token cache exists, so you can
drop `enable` after the first login, but you can keep a YAML block to pin regions or
servers:
```yaml title="unshackle.yaml (optional)"
proxy_providers:
@@ -376,6 +376,14 @@ proxy_providers:
myserver: usny-newyork-2
```
**Default file location:**
| File | Default path |
|---|---|
| Token cache | `{cache}/vpn/expressvpn_tokens.json` |
Where `{cache}` is your configured cache directory.
**Query forms:**
| Query | Meaning |
@@ -410,7 +418,7 @@ proxy_providers:
```
**Required keys:** `username`, `password`. unshackle validates them up front: the
combined username and password must be exactly 48 lowercase alphanumeric characters, and
combined username and password must be exactly 48 alphanumeric characters (case-insensitive), and
the username must not contain an `@`. If validation fails, you have almost certainly used
your login credentials instead of your service credentials.
@@ -437,7 +445,7 @@ The returned proxy is HTTPS on **port 89** (NordVPN disabled its plain-HTTP prox
port 80 in 2021).
```shell
unshackle dl --proxy nordvpn:us:seattle NF 81234567
unshackle dl --proxy nordvpn:us:seattle EXAMPLE 81234567
```
## Proton VPN
@@ -521,7 +529,7 @@ that binary is found on your `PATH`. If you query Hola but the binary is missing
provider raises an error telling you to install it.
```shell title="Install hola-proxy, then just query a country"
unshackle dl --proxy hola:us NF 81234567
unshackle dl --proxy hola:us EXAMPLE 81234567
```
**Query forms:** a two-letter country code (e.g. `us`, `gb`). unshackle asks `hola-proxy`
@@ -547,7 +555,7 @@ proxy_providers:
```
**Required keys:** `username`, `password`, validated with the same 48-character rule as
NordVPN (combined username+password must be 48 lowercase alphanumeric characters, no `@`).
NordVPN (combined username+password must be 48 alphanumeric characters, case-insensitive, no `@`).
`server_map` optionally pins server IDs per region.
**Query forms:**
+50 -9
View File
@@ -82,7 +82,7 @@ By default (`--s-lang all`) unshackle keeps every subtitle language the service
To narrow it down, pass a comma-separated list of language tags:
```shell title="English and Spanish subtitles only"
unshackle dl --s-lang en,es NF 81234567
unshackle dl --s-lang en,es EXAMPLE 81234567
```
Language matching is fuzzy by default: `en` will match `en-US`, `en-GB`, and similar
@@ -93,7 +93,7 @@ For strict matching, where `es-419` matches only `es-419` and never `es-ES`, add
`--exact-lang`:
```shell title="Only Latin-American Spanish, no other Spanish variants"
unshackle dl --s-lang es-419 --exact-lang DSNP 'https://www.disneyplus.com/…'
unshackle dl --s-lang es-419 --exact-lang EXAMPLE 'https://www.example.com/...'
```
### Requiring languages before downloading
@@ -104,7 +104,7 @@ errors out. This is useful for batch jobs where a title without your must-have l
should fail loudly rather than silently produce a partial result.
```shell title="Fail unless both English and French subs exist, then grab everything"
unshackle dl --require-subs en,fr AMZN B0ABCDEFGH
unshackle dl --require-subs en,fr EXAMPLE B0ABCDEFGH
```
!!! warning "`--require-subs` and `--s-lang` are mutually exclusive"
@@ -118,7 +118,7 @@ to display even when you are watching in your own language. They are **excluded
default**. Add `-fs` / `--forced-subs` to include them:
```shell title="Include forced tracks alongside full subtitles"
unshackle dl --s-lang en --forced-subs NF 81234567
unshackle dl --s-lang en --forced-subs EXAMPLE 81234567
```
## Track types: forced, SDH, and CC
@@ -181,7 +181,7 @@ TTML to WebVTT for muxing, as noted above). To force a specific output format, u
`--sub-format`:
```shell title="Convert every subtitle to SRT"
unshackle dl --s-lang en,es --sub-format srt NF 81234567
unshackle dl --s-lang en,es --sub-format srt EXAMPLE 81234567
```
`--sub-format` accepts the common format names and aliases, including `srt`, `vtt`,
@@ -189,7 +189,7 @@ unshackle dl --s-lang en,es --sub-format srt NF 81234567
all conversion:
```shell title="Keep whatever the service delivered"
unshackle dl --sub-format original DSNP 'https://www.disneyplus.com/…'
unshackle dl --sub-format original EXAMPLE 'https://www.example.com/...'
```
Conversion only runs when the source format differs from the target, so
@@ -218,14 +218,55 @@ formats involved:
| Backend | Best at | Requires |
| --- | --- | --- |
| `subtitleedit` | Highest fidelity; preserves positioning and italics | SubtitleEdit installed |
| `subby` | Native SRT output with clean-up | — |
| `pysubs2` | Best fidelity for styled SSA/ASS | — (pure Python, always available) |
| `pycaption` | Last-resort fallback; flattens styling | — |
| `subby` | Native SRT output with clean-up | - |
| `pysubs2` | Best fidelity for styled SSA/ASS | - (pure Python, always available) |
| `pycaption` | Last-resort fallback; flattens styling | - |
With `conversion_method: auto`, unshackle ranks these automatically per conversion.
Setting it to a specific value pins that backend as the first choice, falling back to
others only if the pin cannot handle the pair.
#### What each backend actually preserves
The table below comes from round-tripping a subtitle that carries italics, bold,
underline, positioning, and colour through every backend. Each cell lists the
styling that survives; `—` means the backend cannot handle that pair.
| Conversion | `subtitleedit` | `pysubs2` | `subby` | `pycaption` |
| --- | --- | --- | --- | --- |
| WebVTT → SRT | italic, bold, underline, position | italic, underline | italic, position | *(strips all)* |
| WebVTT → ASS | italic, bold, underline, position | italic, bold, underline | — | — |
| WebVTT → TTML | italic, bold, underline, position | italic, bold, underline | position | *(strips all)* |
| ASS/SSA → SRT | all (+ colour) | italic, underline | — | — |
| ASS/SSA → TTML | position only | italic, bold, underline | — | — |
| ASS/SSA → VTT | position only | italic, underline | — | — |
Reading the table:
- **Keep the original**: leaving `--sub-format` unset (or set to `original`) never
round-trips the file, so every style survives. Only convert when your player cannot
read the source format.
- **`subtitleedit`** (SubtitleEdit / `seconv`): the best choice for anything → SRT. It
is the only backend that carries colour, and it embeds `{\an8}` tags to preserve
positioning. When writing TTML or WebVTT from ASS it flattens inline styling to plain
text and keeps only positioning, so avoid it there.
- **`pysubs2`**: keeps inline italic/underline on every pair, and bold except when
writing SRT or WebVTT. It never carries positioning or colour. SSA/ASS is its native
model, which makes it the best pick for SSA↔ASS.
- **`subby`**: reads only WebVTT/fVTT/SAMI (never ASS) and is tuned for → SRT, where it
uniquely converts WebVTT cue settings into `{\an8}` positioning. Its
`CommonIssuesFixer` may also drop near-duplicate cues.
- **`pycaption`**: strips all styling; last-resort fallback only.
!!! tip "What `auto` picks"
`auto` prefers `subtitleedit` first when it is installed, then falls back to `subby`
for → SRT and `pysubs2` otherwise. The one exception comes from the table above:
for ASS/SSA → TTML or WebVTT it picks `pysubs2` even when SubtitleEdit is
installed, since SubtitleEdit flattens inline styling on those pairs. Most installs
do not ship SubtitleEdit, so in practice `auto` means `subby` (→ SRT) or `pysubs2`.
Install [SubtitleEdit / `seconv`](#subtitle-configuration) if you need colour or the
highest → SRT fidelity.
!!! note "Config wins over a service's preference"
A service may set a `preferred_conversion_method` on its own tracks (for example when
it ships subtitles that a particular backend handles best). An explicit
+8 -8
View File
@@ -64,7 +64,7 @@ The debug log is created during a `dl` run (and by [`import`](cli-reference.md#i
<logs directory>/unshackle_debug_<service>_<timestamp>.jsonl
```
For example, `unshackle_debug_NETFLIX_20260703-142530.jsonl`. The `<service>` tag and the timestamp mean each download gets its own file, so runs never overwrite each other.
For example, `unshackle_debug_EXAMPLE_20260703-142530.jsonl`. The `<service>` tag and the timestamp mean each download gets its own file, so runs never overwrite each other.
!!! tip "Find your logs directory"
The logs directory defaults to a `logs` folder inside the package data directory, but it can be relocated with the `directories.logs` config key. To see the exact resolved path on your machine, run [`unshackle env info`](#env-info) and read the **Directories** table.
@@ -99,16 +99,16 @@ Because each line is independent JSON, `jq` is the natural tool:
```console
# Show only error-level entries
jq 'select(.level == "ERROR")' unshackle_debug_NETFLIX_20260703-142530.jsonl
jq 'select(.level == "ERROR")' unshackle_debug_EXAMPLE_20260703-142530.jsonl
# List every operation and whether it succeeded
jq -c '{operation, success}' unshackle_debug_NETFLIX_20260703-142530.jsonl
jq -c '{operation, success}' unshackle_debug_EXAMPLE_20260703-142530.jsonl
# Pull the traceback out of the first error
jq -r 'select(.error) | .error.traceback[]' unshackle_debug_NETFLIX_20260703-142530.jsonl
jq -r 'select(.error) | .error.traceback[]' unshackle_debug_EXAMPLE_20260703-142530.jsonl
# Show only DRM / licensing operations
jq 'select(.operation | startswith("drm_"))' unshackle_debug_NETFLIX_20260703-142530.jsonl
jq 'select(.operation | startswith("drm_"))' unshackle_debug_EXAMPLE_20260703-142530.jsonl
```
The session's binary-tool versions (Shaka Packager, mp4decrypt, mkvmerge, FFmpeg, FFprobe) are logged near the start of a `dl` run, which is often the fastest way to confirm a muxing or decryption failure is really a wrong-version problem.
@@ -168,7 +168,7 @@ unshackle redacts logged strings so that debug output is safe to share. Three pa
Path redaction is controlled by the `redact_paths` config key, which is **on by default**:
```yaml
redact_paths: true # default — mask local base directories in logged paths
redact_paths: true # default - mask local base directories in logged paths
```
Set `redact_paths: false` if you are debugging a path problem locally and want to see full, unmasked paths in the console and logs. Secret and URL redaction in the JSON debug log are always applied and are not affected by this toggle.
@@ -223,7 +223,7 @@ Stale cache or leftover temp files can cause confusing behaviour (an old cached
```console
unshackle env clear cache # clear the whole cache directory
unshackle env clear cache NETFLIX # clear just one service's cache subdirectory
unshackle env clear cache EXAMPLE # clear just one service's cache subdirectory
unshackle env clear temp # clear the temp working directory
```
@@ -287,7 +287,7 @@ The debug log is tuned for **developers troubleshooting pipeline flow** (maximum
```console
# Read the pipeline's high-level flow, one line per stage
jq 'select(.level == "INFO")' unshackle_debug_NETFLIX_20260703-142530.jsonl
jq 'select(.level == "INFO")' unshackle_debug_EXAMPLE_20260703-142530.jsonl
```
!!! warning "No raw dumps"
+3 -3
View File
@@ -13,7 +13,7 @@ Every key is stored as a **KID → KEY** pair, grouped by **service**:
- **KID** (Key ID): a 16-byte identifier for a specific encrypted stream, stored as 32 lowercase hex characters with no dashes.
- **KEY**: the 16-byte content key that decrypts that stream, also 32 hex characters.
- **Service**: the canonical service tag (for example `DSNP`, not `DisneyPlus`) that namespaces the key. Each service gets its own logical table.
- **Service**: the canonical service tag (for example `EXAMPLE`, not `ExampleService`) that namespaces the key. Each service gets its own logical table.
!!! note "Why keys are matched by KID"
Vaults match keys by KID, never by PSSH/CENC header. A KID does not change unless the underlying video file itself changes, whereas the PSSH box can vary between requests for the very same content. That makes the KID a far more reliable match key.
@@ -242,7 +242,7 @@ $ unshackle kv --help
Add `KID:KEY` pairs to one or more vaults for a given service. The file must contain one pair per line as `HEX:HEX` (32 hex characters each); lines that do not match are ignored.
```console
$ unshackle kv add keys.txt DSNP local shared
$ unshackle kv add keys.txt EXAMPLE local shared
```
```text title="keys.txt"
@@ -250,7 +250,7 @@ $ unshackle kv add keys.txt DSNP local shared
0e7cc1a4e2f9411db0f24a3fbb8f8b21:00112233445566778899aabbccddeeff
```
The service argument (`DSNP` above) is normalized to its canonical tag automatically. Keys that already exist in a vault are reported as skipped.
The service argument (`EXAMPLE` above) is normalized to its canonical tag automatically. Keys that already exist in a vault are reported as skipped.
### `kv search`: find a key by KID