diff --git a/guide/cli-reference/index.html b/guide/cli-reference/index.html index 7cacc75d..ca05ada7 100644 --- a/guide/cli-reference/index.html +++ b/guide/cli-reference/index.html @@ -968,6 +968,17 @@ + + +
Shows where the config was loaded from (or lists the candidate config locations if none was found) and prints a table of every configured directory (downloads, temp, cache, cookies, logs, exports, WVDs, PRDs, services, and more).
+env theme¶Prints a sample of every available CLI theme: colour swatches, help text with option rows, a track listing with video/audio/subtitle entries, log lines, a progress bar, and the gradient pulse bar. The active theme is marked and each theme's aliases are listed. Set your choice with the theme config key.
env clear¶Clear an environment directory. The directory is emptied and recreated, and the number of files and bytes freed is reported.
Example
-unshackle env check
-unshackle env info
-unshackle env clear cache
-unshackle env clear cache EXAMPLE # one service only
-unshackle env clear temp
+
kv¶Manage Key Vaults. Vaults are configured under key_vaults in unshackle.yaml, each with a name, a type (sqlite, mysql, http, api), and type-specific options. Service tags are normalised automatically.
unshackle kv COMMAND [ARGS]...
+
kv copy TO_VAULT FROM_VAULT...¶
Copy keys from one or more source vaults into a destination vault. Rows with matching KIDs are skipped unless the existing row has no key; existing data is never altered or deleted.
@@ -3543,18 +3570,18 @@ and keeps the service's numbering unchanged. Pick an order that covers the whole
Create service tables on vaults that use tables, for every installed service, where they don't already exist.
Example
-unshackle kv copy main backup1 backup2
-unshackle kv copy main backup -s EXAMPLE
-unshackle kv sync main mysql_vault
-unshackle kv add keys.txt EXAMPLE main backup
-unshackle kv search 0123456789abcdef0123456789abcdef -s EXAMPLE -v main
-unshackle kv prepare main mysql_vault
+
wvd¶
Manage Widevine Device (.wvd) files. Devices live in the configured WVDs directory.
-unshackle wvd COMMAND [ARGS]...
+
@@ -3615,16 +3642,16 @@ and keeps the service's numbering unchanged. Pick an order that covers the whole
Example
-unshackle wvd add device.wvd
-unshackle wvd parse nexus_6p_1234_l1
-unshackle wvd dump device.wvd ./out_dir
-unshackle wvd new "Nexus 6P" private_key.pem client_id.bin vmp.bin -t ANDROID -l 3
+
prd¶
Manage PlayReady Device (.prd) files. Devices live in the configured PRDs directory. Built on pyplayready.
-unshackle prd COMMAND [ARGS]...
+
prd new PATHS...¶
Create a new .prd. Provide either a single folder containing zgpriv.dat (group key) and bgroupcert.dat (group certificate), or two file paths (group key, then group certificate).
@@ -3678,16 +3705,16 @@ and keeps the service's numbering unchanged. Pick an order that covers the whole
Example
-unshackle prd new ./device_folder/
-unshackle prd new zgpriv.dat bgroupcert.dat -o /out/dir
-unshackle prd reprovision mydevice.prd
-unshackle prd test mydevice.prd -sl 3000 -c aescbc
+
serve¶
Serve your local Widevine/PlayReady devices and the REST API for remote access. Built on aiohttp.
-unshackle serve [OPTIONS]
+
@@ -3754,31 +3781,31 @@ and keeps the service's numbering unchanged. Pick an order that covers the whole
Configure api_secret first
Unless you pass --no-key, serve.api_secret must be set in your config. Requests authenticate with the X-Secret-Key header. Running with --no-key disables authentication entirely and should only be used on a trusted, private network.
-unshackle.yamlserve:
- api_secret: "your-api-secret"
- users:
- your-secret-key:
- devices: ["device_name"] # Widevine devices
- playready_devices: ["device_name"] # PlayReady devices
- username: user
+
Example
-unshackle serve
-unshackle serve -h 0.0.0.0 -p 8080
-unshackle serve --api-only
-unshackle serve --remote-only
-unshackle serve --caddy
+
util¶
Various helper media utilities. When a command is given a directory, it processes every .mkv/.mp4 inside it in natural (S01E01 before S01E10) order.
-unshackle util COMMAND [ARGS]...
+
util refresh-services¶
-unshackle util refresh-services
+
Force a refresh (git pull / hard reset) of all service repos configured under directories.services.
util crop PATH ASPECT¶
@@ -3852,11 +3879,11 @@ and keeps the service's numbering unchanged. Pick an order that covers the whole
Example
-unshackle util refresh-services
-unshackle util crop video.mkv 2.39:1
-unshackle util crop ./season/ 16:9 --pillar -o 10
-unshackle util range video.mkv --full
-unshackle util test video.mkv -m 0:v:0
+
diff --git a/reference/configuration/index.html b/reference/configuration/index.html
index 2c210c19..6c780ede 100644
--- a/reference/configuration/index.html
+++ b/reference/configuration/index.html
@@ -1958,6 +1958,10 @@ default.
title_cache_enabled, title_cache_time, title_cache_max_retention, update_checks, update_check_interval
+Appearance
+theme
+
+
Logging, privacy & debug
redact_paths, debug, debug_keys, debug_requests, set_terminal_bg
diff --git a/reference/configuration/misc/index.html b/reference/configuration/misc/index.html
index 60d70839..968f594c 100644
--- a/reference/configuration/misc/index.html
+++ b/reference/configuration/misc/index.html
@@ -1291,6 +1291,17 @@
+
+
+
+
+
+
+ Appearance
+
+
+
+
@@ -2022,6 +2033,17 @@
+
+
+
+
+
+
+ Appearance
+
+
+
+
@@ -2292,6 +2314,26 @@ calls.
result in directories.cache/update_check.json.
+Appearance¶
+
+
+
+Key
+Type
+Default
+Description
+
+
+
+
+theme
+str
+catppuccin-mocha
+Named colour theme for all CLI output: banner, logs, progress bars, and help screens. One of catppuccin-mocha (aliases default, catppuccin, mocha), dracula, nord, gruvbox, one-dark (alias onedark), tokyo-night (alias tokyonight), monokai, flexoki, solarized-dark (alias solarized). Case-insensitive; underscores work in place of hyphens. Unknown names fall back to the default with a warning. Preview them all with unshackle env theme.
+
+
+
+
Logging, privacy & debug¶
diff --git a/reference/configuration/services/index.html b/reference/configuration/services/index.html
index d6e4d3da..ceb90411 100644
--- a/reference/configuration/services/index.html
+++ b/reference/configuration/services/index.html
@@ -2121,7 +2121,9 @@ not repeated in the fallbacks, so auth_headers: ["Authorization", "x-secre
Authorization, x-secret-key, X-Api-Key.
In --remote mode unshackle turns the server's service list into synthetic CLI
commands that run against it, falling back to the tags in that services sub-dict when the
-list cannot be fetched. See remote sessions for the
+list cannot be fetched. Each synthetic command carries the server-side service's options and
+documentation, so unshackle dl --remote <TAG> -h shows the same help text as it does on the
+server. See remote sessions for the
full setup.
serve¶
diff --git a/search/search_index.json b/search/search_index.json
index 305f33ac..5bef82fc 100644
--- a/search/search_index.json
+++ b/search/search_index.json
@@ -1 +1 @@
-{"config":{"lang":["en"],"separator":"[\\s\\-]+","pipeline":["stopWordFilter"],"fields":{"title":{"boost":1000.0},"text":{"boost":1.0},"tags":{"boost":1000000.0}}},"docs":[{"location":"","title":"unshackle","text":"unshackle is a modular archival tool for movies, TV, and music. It fetches media from streaming manifests, handles the DRM, and muxes everything into clean, well-tagged files, all driven from a single command-line tool, or served over a REST API for automation.
It is a fork of Devine with DASH, HLS, and Smooth Streaming (ISM) parsing, both Widevine and PlayReady DRM support, and a built-in HTTP server.
Use responsibly
unshackle is licensed under GPL-3.0. Do not use it for content you do not have the rights to. Keep the core free and open, keep service code private, and be kind.
"},{"location":"#what-it-does","title":"What it does","text":"unshackle does everything except talk to the streaming platform. You write that part as a service module (Creating a Service). Point unshackle at a title and it will:
- Fetch the title metadata (movies, full series, seasons, episodes, albums, tracks).
- Parse the streaming manifest: DASH, HLS, or Smooth Streaming (ISM).
- Select the tracks you asked for by resolution, codec, bitrate, range, language, and channel layout.
- Acquire decryption keys via a Widevine or PlayReady CDM, with optional key vaults so keys are reused instead of re-licensed.
- Decrypt, extract closed captions, repack, and mux the result into a Matroska (
.mkv) file with correct language, forced, and default flags. - Tag and name the output using configurable filename templates.
"},{"location":"#feature-highlights","title":"Feature highlights","text":"Area What you get Media types Movies, TV series/seasons/episodes, and music albums/tracks Manifests DASH, HLS, and Smooth Streaming (ISM) DRM Widevine and PlayReady, with ClearKey support Video AVC, HEVC, VC-1, VP8, VP9, AV1; SDR, HLG, HDR10, HDR10+, Dolby Vision, and Dolby Vision hybrid merging Audio AAC, AC-3, E-AC-3, AC-4, Opus, Vorbis, DTS, ALAC, FLAC; Dolby Atmos handling; per-channel-layout selection Subtitles SRT, WebVTT, ASS/SSA, TTML and more, with optional SDH stripping and format conversion Key vaults SQLite, MySQL, and HTTP/API vaults to store and share content keys Proxies Basic proxies plus NordVPN, ProtonVPN, Surfshark, Windscribe, ExpressVPN, Gluetun, and Hola providers Services A plugin API for the service modules you write. unshackle includes none Automation A REST API with a job queue, live progress, history, and remote-download sessions"},{"location":"#installation-at-a-glance","title":"Installation at a glance","text":"Install as a toolRun from a clone
uv tool install git+https://github.com/unshackle-dl/unshackle.git\nunshackle --help\n
git clone https://github.com/unshackle-dl/unshackle.git\ncd unshackle\nuv run unshackle --help\n
External tools
unshackle shells out to FFmpeg, MKVToolNix, shaka-packager, Bento4, and dovi_tool for decryption, repacking, muxing, and Dolby Vision work. See Installation for the full list and recommended versions.
"},{"location":"#a-first-download","title":"A first download","text":"Every download is three parts: the dl command, a service tag, and the service's title argument (a URL, ID, or slug).
Download a 1080p title with English audiounshackle dl -q 1080 -l en EXAMPLE 81234567\n
The dl command carries all the quality, language, and output flags; the service tag (EXAMPLE above) selects which streaming service to talk to, and the value after it is passed to that service. See the Quickstart to go from a fresh install to your first file.
"},{"location":"#where-to-go-next","title":"Where to go next","text":" - Installation. Install unshackle and the external tools it depends on.
- Quickstart. Configure
unshackle.yaml and run your first download. - Downloading. The full
dl command guide: quality, codecs, languages, track selection, and output. - Configuration. Where
unshackle.yaml lives, the directory layout, and how to edit values. Full key list in the Configuration Reference. - REST API. Run the
serve HTTP server and drive downloads programmatically.
Getting help
Run unshackle --help or unshackle dl --help at any time. The community Discord is the fastest place to ask questions.
"},{"location":"dev/creating-a-service/","title":"Creating a Service","text":"A service is a plugin that teaches unshackle how to talk to one streaming provider: how to log in, how to look up a title, what video/audio/subtitle tracks exist, and how to license any DRM. Everything else (track selection, downloading, decryption, muxing, naming) is handled by the unshackle core.
Who this page is for
This is a developer guide. If you only want to use services, see downloading and configuration. Nothing here is required to run the CLI. Service code is deliberately kept separate from the core so it can live in its own private repository.
Unshackle ships one reference service, EXAMPLE, at unshackle/services/EXAMPLE/. It is a deliberately exhaustive, non-runnable showcase of every framework feature in a single file. Read it alongside this page; most snippets below are drawn from it.
"},{"location":"dev/creating-a-service/#how-a-service-is-discovered","title":"How a service is discovered","text":"At startup unshackle scans every path in the directories.services config key (a list). Each entry is either a local directory of services or a remote repo spec (a git URL or owner/repo shorthand, which is cloned automatically). The default is the bundled unshackle/services directory.
Within a services directory, every subfolder that contains an __init__.py is a service, and the folder name is the service tag:
services/\n\u2514\u2500\u2500 EXAMPLE/\n \u251c\u2500\u2500 __init__.py # required - defines the service class\n \u2514\u2500\u2500 config.yaml # optional - per-service configuration\n
Two rules the loader enforces:
- The class name must exactly match the folder/tag name. Folder
EXAMPLE must define class EXAMPLE. A mismatch raises a RuntimeError. - List order is priority. The first services path to define a given tag wins; later duplicates are shadowed. Put your own local overrides last to use them as fallbacks, or first to override a repo.
Once installed, a service is invoked as a subcommand of dl:
unshackle dl EXAMPLE 20914\nunshackle dl EX 20914 # via an ALIAS\n
Config lives next to the code
A service's own settings go in config.yaml inside the service folder (directories.services/<TAG>/config.yaml). This is separate from the global unshackle.yaml. Access it in code as self.config[...]. Never hardcode URLs, user agents, or certificates; put them here.
config.yaml is shared: keep secrets out of it
Treat config.yaml as a checked-in file of defaults: it belongs in the service repo and is shared by everyone who runs the service. Per-user secrets (API keys, device IDs, account tokens) must not live here. Those go in the user's own unshackle.yaml under services.<TAG>, which is merged into self.config at runtime. That checked-in-defaults / per-user-overrides split is exactly why every URL lives in config.yaml with {}/{name} placeholders while the secrets that fill them stay out of it. Both halves read back through the same self.config[...].
Split across files? Use relative imports
If your service grows beyond a single __init__.py, import your own modules relatively:
from .helpers import parse_manifest # correct\nfrom unshackle.services.TAG.helpers import parse_manifest # breaks from a repo\n
A service loaded from a remote repo lives outside the unshackle package, so the absolute unshackle.services.<TAG>.* path does not resolve and the import fails with no known parent package. Relative imports work both locally and from a cloned repo.
"},{"location":"dev/creating-a-service/#the-service-base-class","title":"The Service base class","text":"Every service subclasses unshackle.core.service.Service. The minimum is a Click cli entry point plus three abstract methods.
"},{"location":"dev/creating-a-service/#class-variables","title":"Class variables","text":"Declared at class level to configure framework behaviour:
Variable Type Purpose ALIASES tuple[str, ...] Extra tags that resolve to this service (e.g. (\"EX\", \"DOMAIN\")). Default (). GEOFENCE tuple[str, ...] IP region codes the service requires. Empty = no geofence. The first entry is treated as the main region for auto-proxy. VAULT_TAG Optional[str] Overrides the key-vault namespace so sibling services can share one vault. Default None (use the service's own tag). AUTH_METHODS Optional[tuple[str, ...]] Auth methods accepted (\"cookies\" / \"credentials\"). When None, the REST /services endpoint infers them from authenticate(). NO_SUBTITLES bool Set True on a service with no subtitle tracks to skip subtitle handling entirely. ANIME bool Set True when the catalog is anime, so metadata lookups prefer AniList. A title's own anime flag overrides it. DAILY bool Set True when the catalog is daily/date-based (talk shows, news, sports), so episodes are named by air date. A title's own daily flag overrides it. NO_SUBTITLES is a convention, not a base-class attribute
NO_SUBTITLES is checked by dl.py via hasattr and is deliberately not declared on the Service base class, so it only exists if your service defines it. Setting NO_SUBTITLES = True is what makes the pipeline skip subtitle work for services that never carry subtitles; leave it off otherwise.
GEOFENCE drives an automatic proxy: on startup, if you did not pass an explicit --proxy, the base class does a live IP check and, if your region is blocked, fetches a proxy to GEOFENCE[0] from your configured proxy providers.
class EXAMPLE(Service):\n ALIASES = (\"EX\", \"DOMAIN\")\n GEOFENCE = (\"US\", \"UK\")\n VAULT_TAG = \"DIFFERENT_NAME\"\n
"},{"location":"dev/creating-a-service/#what-service__init__-wires-up","title":"What Service.__init__ wires up","text":"When you call super().__init__(ctx), the base class sets up the attributes you will use throughout the service:
Attribute What it is self.config This service's config.yaml contents (a dict), or None if absent. self.log A logging.Logger named after your class. self.session A prepared requests.Session with config headers and a retry adapter (5 retries, backoff 0.2, retries on 429/500/502/503/504). self.cache A Cacher for arbitrary key/value data (tokens, etc.). self.title_cache A region/account-aware TitleCacher used by get_titles_cached. self.cache_dir config.directories.cache / <ClassName>. self.track_request A TrackRequest built from the CLI --vcodec / --range / --best-available flags. self.credential None until authenticate() runs. self.current_region The two-letter region resolved from proxy/IP. self.track_request is a small dataclass you can read and rewrite before fetching tracks:
@dataclass\nclass TrackRequest:\n codecs: list[Video.Codec] = [] # empty == accept any codec\n ranges: list[Video.Range] = [Video.Range.SDR]\n best_available: bool = False\n
Read the request, don't second-guess the user
Services may narrow track_request for hard technical constraints (e.g. \"HDR on this service is only delivered as HEVC\"), but must not filter the tracks they return by resolution, bitrate, or language. All selection is done by the core after get_tracks returns.
Override get_session() to defeat TLS-fingerprint bot detection
The base get_session() returns a plain requests.Session (config headers + retry adapter) and deliberately does not do TLS impersonation. When a provider blocks you on TLS fingerprint alone, override it to return session(\"Chrome131\") (any rnet.Impersonate preset). RnetSession is a drop-in requests.Session replacement (same .get/.post/cookie-jar interface), so nothing else in the service changes. If instead you need specific SSL cipher behaviour, mount unshackle.core.utils.sslciphers.SSLCiphers onto a plain requests.Session.
"},{"location":"dev/creating-a-service/#the-methods-you-implement","title":"The methods you implement","text":"Methods run in the order shown. Only the three abstract ones are mandatory.
graph LR\n A[cli] --> B[__init__] --> C[authenticate] --> D[get_titles] --> E[get_tracks] --> F[get_chapters]
"},{"location":"dev/creating-a-service/#cli-the-click-entry-point","title":"cli: the Click entry point","text":"A static method decorated as a Click command. It defines the CLI arguments and returns an instance of your service. Keep the command name= matching the tag.
name= is cosmetic: the directory name is what resolves the command
unshackle dl EXAMPLE is dispatched by the folder/class name, not by the name= in @click.command. A mismatched name= still loads and runs; it just produces confusing --help/usage output that names the wrong command. That confusing output is the reason to keep it in sync, not any dispatch requirement.
@staticmethod\n@click.command(name=\"EXAMPLE\", short_help=\"https://domain.com\", help=__doc__)\n@click.argument(\"title\", type=str)\n@click.option(\"-m\", \"--movie\", is_flag=True, default=False, help=\"Treat the title as a movie.\")\n@click.option(\"-d\", \"--device\", type=click.Choice([\"android_tv\", \"web\", \"ios\"]), default=\"android_tv\")\n@click.pass_context\ndef cli(ctx: click.Context, **kwargs: Any) -> EXAMPLE:\n return EXAMPLE(ctx, **kwargs)\n\ndef __init__(self, ctx: click.Context, title: str, movie: bool, device: str):\n self.title = title\n self.movie = movie\n self.device = device\n super().__init__(ctx) # wires up config, log, session, caches, track_request...\n self.cdm = ctx.obj.cdm\n
Your docstring becomes the CLI --help text (help=__doc__), so document the accepted URL/ID formats and options there.
CDM-aware behaviour
The resolved CDM is on ctx.obj.cdm (may be None for DRM-free runs). Use is_widevine_cdm() / is_playready_cdm() from unshackle.core.cdm.detect to classify it. These correctly handle local and remote CDMs, so never hand-roll an isinstance check. Services often pick a device profile or manifest endpoint based on which DRM the CDM speaks.
"},{"location":"dev/creating-a-service/#authenticate-optional-login","title":"authenticate: optional login","text":"Override to log in with cookies and/or credentials. Call super().authenticate() first: the base implementation loads the cookie jar into self.session.cookies and stores self.credential. Do all token fetching here; it runs before get_titles.
def authenticate(self, cookies=None, credential=None) -> None:\n super().authenticate(cookies, credential)\n self.session.headers.update({\"user-agent\": self.config[\"client\"][self.device][\"user_agent\"]})\n\n cache = self.cache.get(f\"tokens_{self.device}_{self.profile}\")\n if cache and cache.data.get(\"expires_in\", 0) > int(datetime.now().timestamp()):\n self.log.info(\" + Using cached tokens\")\n else:\n token = self.session.post(self.config[\"endpoints\"][\"login\"], data=body).json()\n cache.set(data=token, expiration=token.get(\"expires_in\"))\n self.token = cache.data[\"token\"]\n
Cookies come from files under directories.cookies; credentials from the credentials map in unshackle.yaml. For interactive prompts (OTP, captcha) use self.request_input(prompt), never a bare input(). Under serve mode there is no local terminal, so a bare input() would hang the server waiting on stdin that never arrives; request_input instead relays the prompt to the remote client through the attached InputBridge. Locally it routes through the shared Rich console (console.input) so the prompt renders correctly alongside progress and log output.
Key token caches by whatever varies per session
The cache key above is tokens_{device}_{profile} on purpose. A token cache must include every dimension that changes the token (device, profile, or credential.sha1), or two profiles/devices will share one cache entry and stomp each other's tokens. This isn't obvious from the caching API, which happily lets you use a single flat key.
Handle the credential carefully
Use the Credential only inside authenticate() to obtain tokens; don't stash it or the raw cookie jar elsewhere. The base class already caches identity for you.
"},{"location":"dev/creating-a-service/#get_titles-required","title":"get_titles: required","text":"Return a titles collection for the given ID. The return type depends on content:
Return Contains Use for Movies Movie objects Films Series Episode objects Shows (flatten seasons into episodes) Album Song objects Music Every title carries a language (its original recorded language) and an arbitrary data dict you can use to stash metadata for later methods. At least one title must be returned, or the ID is treated as invalid.
def get_titles(self) -> Titles_T:\n match = re.match(self.TITLE_RE, self.title)\n if not match:\n raise ValueError(\"Could not parse a title ID - is the URL/ID correct?\")\n metadata = self.session.get(\n self.config[\"endpoints\"][\"metadata\"].format(title_id=match.group(\"title_id\")),\n params={\"token\": self.token},\n ).json()\n original_lang = Language.find(metadata[\"languages\"][0])\n\n return Movies([\n Movie(\n id_=metadata[\"id\"],\n service=self.__class__,\n name=metadata[\"title\"],\n year=metadata.get(\"releaseYear\") or None,\n language=original_lang, # ORIGINAL audio language, not the -l preference\n data=metadata, # read later as title.data\n )\n ])\n
Common constructor fields:
MovieEpisodeSong Movie(id_, service, name, year=None, language=None, data=None, description=None)\n
Episode(id_, service, title, season, number, name=None, year=None,\n language=None, data=None, description=None, air_date=None,\n part=None)\n
Pass air_date for daily/sports content to name by date instead of SxxExx. Return a Series([...]) of episodes. Pass part only when the service splits one episode into several videos, see Split episodes.
Song(id_, service, name, artist, album, track, disc, year,\n language=None, data=None)\n
Return an Album([...]) of songs.
The ID must be unique and stable
id_ must be truthy and, if it has a length, at least 4 characters, to avoid clashes. Use the service's own stable IDs.
Title caching
You can call self.get_titles_cached() instead of get_titles() from the framework path to reuse cached results and gracefully fall back to stale data when the API errors. It also applies the per-service title_map config.
"},{"location":"dev/creating-a-service/#split-episodes","title":"Split episodes","text":"Some services split one logical episode into several separately playable videos, for example a variety show that ships episode 1 as Episode 1 (Part 1), (Part 2) and (Part 3). Episode.number stays an int. The optional part carries the index.
Episode(..., season=1, number=1, part=1, name=\"The Reckoning\")\nEpisode(..., season=1, number=1, part=2, name=\"The Reckoning\")\n
part counts from 1, contiguous. part=0 is rejected, and -w can only address parts up to 99. - Every part of one episode shares the same
season and number. - Each part is a separate
Episode with its own unique id_. - Unsplit episode: leave
part unset. Never pass part=1 for a whole episode. - Do not put the part in
name or number. Pass a clean name (or None) and set part. part is a split episode, not a split season. A service listing Season 1 Part 2 describes a group of episodes. Keep numbering those in sequence, the way the shipped services do.
part accepts an int or a digit string. A value that is not an integer raises TypeError. A value of 0 or less raises ValueError.
The framework never infers a part from the episode name
Rule 6 is the reason. Shipped services already use the word \"Part\" to mean a season part, so name-sniffing would misread every one of them. The part has to arrive structurally, as the part argument.
Clean the part out of the name
Episode discards any name that starts with Episode <number>, so a raw \"Episode 1 (Part 2): The Reckoning\" leaves you with no episode name at all. Strip the prefix before you pass it, or pass None.
Once part is set, the filename gains a part token (S01E01.Part.2, see Output & naming), -w S01E01.2 selects a single part while a bare -w S01E01 selects them all, and the --list-titles tree labels the episode 01.2. The REST API reports it as an extra part key on the serialized title, described in Endpoints.
Opt-in and additive
Leaving part unset costs you nothing: filenames, sort order, -w matching and the API JSON are identical to a plain episode, so a service that does not split episodes has nothing to adopt.
"},{"location":"dev/creating-a-service/#get_tracks-required","title":"get_tracks: required","text":"Given one title, return a Tracks object holding Video, Audio, and Subtitle tracks. In almost all cases you build these by parsing a manifest, not by hand.
The manifest parsers live in unshackle.core.manifests:
from unshackle.core.manifests import DASH, HLS, ISM\n
Each exposes from_url(url, session=...) (or from_text(text, url)) and then .to_tracks(language=...):
DASH (.mpd)HLS (.m3u8)ISM (Smooth Streaming) def get_tracks(self, title: Title_T) -> Tracks:\n return DASH.from_url(manifest_url, session=self.session) \\\n .to_tracks(language=title.language)\n
def get_tracks(self, title: Title_T) -> Tracks:\n return HLS.from_url(manifest_url, session=self.session) \\\n .to_tracks(language=title.language)\n
The URL must be a variant (master) playlist, not a media playlist.
def get_tracks(self, title: Title_T) -> Tracks:\n return ISM.from_url(manifest_url, session=self.session) \\\n .to_tracks(language=title.language)\n
Always pass language=
Pass the title's original language to to_tracks(language=...). For DASH and HLS this is required as a fallback. If a track's language can't be derived and no valid fallback was given, to_tracks raises ValueError. It's also what lets the parser flag is_original_lang on each track (via is_close_match), driving -l best/all selection and the filename language token.
Some services deliver a separate manifest per codec/range. The base class provides _get_tracks_for_variants(title, fetch_fn) to fan out over every codec\u00d7range in the TrackRequest, including HYBRID (fetch HDR10 + DV and merge) and --best-available skip-on-error handling:
def get_tracks(self, title: Title_T) -> Tracks:\n def _fetch_variant(title, codec, range_) -> Tracks:\n vcodec = \"H265\" if codec == Video.Codec.HEVC else \"H264\"\n return self._fetch_dash_manifest(title, vcodec=vcodec, range_=range_)\n return self._get_tracks_for_variants(title, _fetch_variant)\n
After parsing, you may correct track metadata the manifest gets wrong: stamp the real video.range, fix odd audio channel counts, mark descriptive audio, add hand-built subtitles or an Attachment (e.g. cover art). Do not filter for resolution/bitrate. See _fetch_dash_manifest in the EXAMPLE service for a thorough demonstration.
Flip HDR10 \u2192 HDR10+ by hand when you know the platform embeds it
HDR10+ is a bitstream (SEI) feature. The HLS parser intentionally does not sniff it from the stream, so a manifest that embeds HDR10+ SEI but labels the variant plain HDR10 will parse as HDR10. The label is the only signal the parser has, and it's wrong. Only the service author knows the platform embeds HDR10+, so it's on you to correct video.range to HDR10+ here for those services.
get_tracks is your only chance to capture license inputs
The license callbacks (get_widevine_license, etc.) fire much later, in the download/decrypt step, long after get_tracks has returned, and they receive only challenge, title, and track. Nothing else from the manifest response reaches them. So anything a license call needs (the license URL, dt-custom-data, a session token) has to be stashed now, during get_tracks, onto self.license_data or title.data. This is a lifecycle-timing consequence you can't infer from the callback signatures.
Provide the KID if you cheaply can
If you can obtain a track's Key ID (32-char hex) without downloading stream data, set track.kid; it speeds up the decryption/key-lookup path. And be sure encrypted tracks carry the correct drm so the core licenses them.
"},{"location":"dev/creating-a-service/#get_chapters-required","title":"get_chapters: required","text":"Return a Chapters object (0 or more Chapters). You don't need to number or sort them; Chapters does that automatically and inserts a 00:00:00.000 marker if missing. A Chapter timestamp accepts \"HH:MM:SS[.mmm]\", an int in milliseconds, or a float in seconds.
def get_chapters(self, title: Title_T) -> Chapters:\n chapters = Chapters()\n seen: set[int] = set()\n for ch in title.data.get(\"chapters\", []):\n chapter = Chapter(timestamp=ch[\"start\"], name=ch.get(\"name\"))\n if chapter.timestamp == 0 or chapter.timestamp in seen:\n continue # see the warning below\n seen.add(chapter.timestamp)\n chapters.add(chapter)\n return chapters\n
Skip markers at 0 and de-duplicate timestamps before add()
Chapters.add() auto-injects a Chapter(0) at 00:00:00.000 the first time you add any chapter, and it raises ValueError if a chapter already exists at an exact timestamp. So an API marker reported at 0 (an intro or recap that starts at the very beginning) collides with that auto-inserted opening chapter, and two markers sharing a timestamp collide with each other. Guard both cases: drop any marker at timestamp 0 and de-duplicate timestamps before adding, as above.
An unnamed chapter is the idiom for closing a named range
A Chapter with no name is perfectly valid. Inserting an unnamed marker is the standard way to close out a named range: e.g. add Chapter(name=\"Intro\") at the start and an unnamed Chapter where the intro ends, so the \"Intro\" label applies only to that span.
Don't invent chapter names
Never name chapters \"Chapter 1\" yourself. Leave unnamed markers unnamed; users who want generic names set chapter_fallback_name (e.g. \"Chapter {i:02}\") in their config.
"},{"location":"dev/creating-a-service/#handling-drm","title":"Handling DRM","text":"The core drives licensing. Your job is to answer the license challenges the CDM produces. Mark encrypted tracks with the right DRM in get_tracks (the manifest parsers do this automatically for PSSH/EXT-X-KEY found in the manifest), then implement the license callbacks you need. Each receives the challenge plus the current title and track:
Method For Returns get_widevine_service_certificate Widevine privacy mode The service certificate (bytes or base64 str), or None. get_widevine_license Widevine The raw license response (bytes or base64 str), unmodified. get_playready_license PlayReady The license response. Defaults to delegating to get_widevine_license. get_clearkey_license DASH org.w3.clearkey The JWK Set (dict/JSON/bytes), or None to let the framework POST the challenge to the manifest's Laurl. def get_widevine_license(self, *, challenge: bytes, title, track):\n response = self.session.post(\n self.license_data[\"url\"],\n data=challenge, # POST the challenge as-is\n headers={\"dt-custom-data\": self.license_data[\"data\"]},\n )\n response.raise_for_status()\n try:\n return response.json()[\"license\"] # some services wrap it in JSON\n except (ValueError, KeyError):\n return response.content # others return raw bytes\n
Return the license untouched
Do not base64-encode or decode the challenge or response. Pass them through verbatim. A malformed license request can get your CDM/device flagged, banned, or downgraded.
ClearKeyCENC (org.w3.clearkey) has three escalating integration levels
Pick the lowest level that works for the platform:
- The manifest carries a
<Laurl>: implement nothing. The framework POSTs the challenge to that URL itself. - A custom endpoint or extra headers are needed: override
get_clearkey_license to do the POST yourself. - Keys arrive obfuscated or through a bespoke channel: fetch and unwrap the key in
get_tracks and pre-populate drm.content_keys[kid] = key_hex on the track's ClearKeyCENC. The non-obvious payoff: when every KID is already keyed, the framework skips the license round-trip entirely.
Things you don't handle:
- HLS AES-128 (
ClearKey) and DRM-free content have no license callback. The key comes from the manifest and is applied directly. - Key vaults cache
KID:KEY pairs across runs, so repeat downloads skip the license round-trip entirely. - Decryption tooling (shaka-packager or mp4decrypt) and the choice of local vs remote CDM are user configuration, not service concerns.
See the DRM & CDM reference for the full picture.
"},{"location":"dev/creating-a-service/#optional-event-hooks","title":"Optional event hooks","text":"Override any of these to react to pipeline stages (all no-ops by default): on_segment_downloaded, on_track_downloaded, on_track_decrypted, on_track_repacked, on_track_multiplex. Also override search() to yield SearchResult objects for unshackle search.
def search(self) -> Generator[SearchResult, None, None]:\n results = self.session.get(self.config[\"endpoints\"][\"search\"],\n params={\"q\": self.title}).json()\n for r in results[\"entries\"]:\n yield SearchResult(id_=r[\"id\"], title=r[\"title\"],\n description=r.get(\"description\"), url=r.get(\"url\"))\n
"},{"location":"dev/creating-a-service/#minimal-service-skeleton","title":"Minimal service skeleton","text":"A complete, minimal single-manifest service. Save as services/MYSVC/__init__.py (class name MYSVC), with an optional services/MYSVC/config.yaml beside it.
services/MYSVC/__init__.pyfrom __future__ import annotations\n\nimport re\nfrom typing import Any, Optional\n\nimport click\n\nfrom unshackle.core.manifests import DASH\nfrom unshackle.core.service import Service\nfrom unshackle.core.titles import Movie, Movies, Title_T, Titles_T\nfrom unshackle.core.tracks import Chapters, Tracks\n\n\nclass MYSVC(Service):\n \"\"\"\n MyService - https://myservice.com\n\n \\b\n Usage: unshackle dl MYSVC <title-id-or-url>\n \"\"\"\n\n ALIASES = (\"MY\",)\n GEOFENCE = (\"US\",)\n TITLE_RE = r\"^(?:https?://myservice\\.com/watch/)?(?P<id>[\\w-]+)\"\n\n @staticmethod\n @click.command(name=\"MYSVC\", short_help=\"https://myservice.com\", help=__doc__)\n @click.argument(\"title\", type=str)\n @click.pass_context\n def cli(ctx: click.Context, **kwargs: Any) -> \"MYSVC\":\n return MYSVC(ctx, **kwargs)\n\n def __init__(self, ctx: click.Context, title: str):\n self.title = title\n super().__init__(ctx)\n\n def authenticate(self, cookies=None, credential=None) -> None:\n super().authenticate(cookies, credential)\n # obtain any tokens here, e.g. self.token = ...\n\n def get_titles(self) -> Titles_T:\n match = re.match(self.TITLE_RE, self.title)\n if not match:\n raise ValueError(\"Could not parse a title ID.\")\n meta = self.session.get(\n self.config[\"endpoints\"][\"metadata\"].format(id=match.group(\"id\"))\n ).json()\n return Movies([\n Movie(\n id_=meta[\"id\"],\n service=self.__class__,\n name=meta[\"title\"],\n year=meta.get(\"year\") or None,\n language=meta.get(\"originalLanguage\"),\n data=meta,\n )\n ])\n\n def get_tracks(self, title: Title_T) -> Tracks:\n playback = self.session.get(\n self.config[\"endpoints\"][\"playback\"].format(id=title.id)\n ).json()\n return DASH.from_url(\n url=playback[\"manifest_url\"], session=self.session\n ).to_tracks(language=title.language)\n\n def get_chapters(self, title: Title_T) -> Chapters:\n return Chapters()\n\n def get_widevine_license(self, *, challenge: bytes, title, track):\n res = self.session.post(self.config[\"endpoints\"][\"license\"], data=challenge)\n res.raise_for_status()\n return res.content\n
services/MYSVC/config.yamlendpoints:\n metadata: https://api.myservice.com/v1/metadata/{id}\n playback: https://api.myservice.com/v1/playback/{id}\n license: https://api.myservice.com/v1/license/widevine\n
Test it with:
unshackle dl MYSVC https://myservice.com/watch/abc123\n
"},{"location":"dev/creating-a-service/#checklist","title":"Checklist","text":" - Folder name, class name, and Click
name= all match the tag exactly. -
cli is a @staticmethod returning an instance; __init__ calls super().__init__(ctx). -
get_titles, get_tracks, get_chapters implemented; titles carry the original language. - Tracks come from a manifest parser with
to_tracks(language=title.language); no filtering by quality/language. - Encrypted tracks are marked with DRM; the license callbacks you need return responses unmodified.
- URLs, user agents, and certificates live in
config.yaml, read via self.config[...]. - Multi-file services import their own modules relatively (
from .helpers import x), so they work when loaded from a repo.
Read unshackle/services/EXAMPLE/ end to end; it annotates every feature touched above in one place.
"},{"location":"dev/service-architecture/","title":"Service Architecture","text":"This page is the conceptual map of the internal object model that a service author works against. It ties together the moving parts: the Service base class, the Title hierarchy a service returns, the Track model those titles carry, the manifest parsers that produce tracks, the DRM hooks that license and decrypt them, and the request/session objects wired up for you at construction time.
Who this page is for
This is a developer reference. If you only want to run downloads, you never touch these classes directly; see Downloading and the CLI reference instead. If you are writing or reading a service plugin, this page explains what each object is and how the pieces connect. For a guided, step-by-step build, see Creating a Service; for exact signatures, see the auto-generated Service API, Titles API, and Tracks API.
"},{"location":"dev/service-architecture/#the-shape-of-a-download","title":"The shape of a download","text":"A service is a translator between one streaming platform and unshackle's generic pipeline. It never downloads or decrypts anything itself; it just answers questions. The dl command drives the whole flow and only ever calls three required methods on your service, in order:
Service.get_titles \u2192 which movies / episodes / songs exist\n \u2502\nService.get_tracks \u2192 fetch a manifest for one title ...\n \u2502\nmanifest.to_tracks \u2192 ... parsed into Video / Audio / Subtitle tracks\n \u2502\nTrack.download \u2192 fetch + merge segments (core-driven)\n \u2502\nDRM license + decrypt \u2192 service license hooks \u2192 CDM \u2192 decrypt tool\n \u2502\nTracks.mux \u2192 mkvmerge \u2192 tagged .mkv\n
get_titles() returns a Titles collection: the movies, episodes, or songs the platform exposes for the requested ID. get_tracks(title) returns a Tracks object for a single chosen title. In practice this means fetching a manifest and handing it to a parser, which builds the Video/Audio/Subtitle objects. get_chapters(title) returns any chapter markers.
Everything after that (track selection, licensing, decryption, repacking, subtitle conversion, and muxing) is handled by the core using the objects your service returned. Your job is to produce accurate Title and Track objects; the framework does the rest.
"},{"location":"dev/service-architecture/#the-service-base-class","title":"The Service base class","text":"Every service subclasses unshackle.core.service.Service, an abstract base class. Two kinds of members matter: class variables that describe the service, and methods that the pipeline calls.
"},{"location":"dev/service-architecture/#class-variables","title":"Class variables","text":"Declared at the top of your service class, these are static descriptors:
Class variable Type Purpose ALIASES tuple[str, ...] Alternate tags the service answers to (resolved by Services.get_tag). GEOFENCE tuple[str, ...] ISO region codes the service requires; empty means no geofence. The first entry is treated as the \"main region\" for automatic proxy selection. VAULT_TAG Optional[str] Overrides the key-vault namespace; defaults to the service's own tag. AUTH_METHODS Optional[tuple[str, ...]] Accepted auth methods (\"cookies\" / \"credentials\"). When None, the REST /services endpoint infers them from authenticate(). ANIME bool The catalog is anime, so metadata lookups prefer AniList. A title's own anime flag overrides it. DAILY bool The catalog is daily/date-based, so episodes are named by air date. A title's own daily flag overrides it."},{"location":"dev/service-architecture/#required-methods","title":"Required methods","text":"These are abstract; a service will not load unless it implements all three:
get_titles() -> Titles_T: resolve the requested ID into a title collection. get_tracks(title) -> Tracks: build the track set for one title. get_chapters(title) -> Chapters: return chapter markers (may be empty).
"},{"location":"dev/service-architecture/#optional-methods-and-hooks","title":"Optional methods and hooks","text":" authenticate(cookies=None, credential=None): the base implementation loads a CookieJar into self.session.cookies and stores self.credential. Override it to perform a real login handshake. search(): raises NotImplementedError by default; implement it to support the search command. - DRM license hooks:
get_widevine_service_certificate, get_widevine_license, get_playready_license (delegates to Widevine by default), and get_clearkey_license (returns None by default). See DRM hooks below. request_input(prompt): prompts the user; routed through the serve-mode input bridge or the rich console. - Event callbacks:
on_segment_downloaded, on_track_downloaded, on_track_decrypted, on_track_repacked, on_track_multiplex let a service react to pipeline events (for example, MonaLisa decrypts each segment in on_segment_downloaded). resolve_server_keys(title): an escape hatch for services whose decryption keys are resolved server-side ahead of the normal per-track license flow. Unlike everything else here it is not declared on the Service base class at all: dl.py probes for it with hasattr and calls it (once, after track selection) only if your service defines it. Use it when keys must be fetched or injected in bulk before download, separate from the per-track license callbacks below.
"},{"location":"dev/service-architecture/#what-__init__-wires-up-for-you","title":"What __init__ wires up for you","text":"You rarely override __init__; the base constructor takes the Click context and sets up the objects your methods rely on:
Attribute What it is self.config The per-service config slice (see Request and session objects). self.log A logging.Logger named after your class. self.session A ready-to-use HTTP session (headers, retries, and proxy applied). self.cache A generic key/value Cacher namespaced to the service. self.title_cache A region- and account-aware TitleCacher. self.cache_dir config.directories.cache / <ServiceName>. self.credential The active Credential, or None until authenticated. self.track_request A TrackRequest describing the requested codecs, ranges, and best-available flag (see below). The constructor also resolves proxies: it reads --proxy / --proxy-query / --proxy-provider, consults the per-service proxy_map, and, if GEOFENCE is set and no explicit proxy was given, does a live IP check and auto-fetches a proxy to GEOFENCE[0] when it detects a geoblock. See Proxies and VPN.
TrackRequest
self.track_request is a small dataclass built from the CLI: codecs (from --vcodec, empty means \"accept any\"), ranges (from --range, default [Video.Range.SDR]), and best_available. Use it in get_tracks to decide which variants to fetch.
"},{"location":"dev/service-architecture/#titles-and-the-title-hierarchy","title":"Titles and the Title hierarchy","text":"get_titles() returns a title collection, one of the Titles_T types. Each collection holds one or more Title objects (Title_T). There are three parallel families, one per media kind:
Media kind Item (Title_T) Collection (Titles_T) Movies Movie Movies TV Episode Series Music Song Album, and Music (the base collection) All items subclass Title, which carries the shared identity: a unique id, the originating service class, an optional original language, arbitrary data, and a freshly-constructed self.tracks = Tracks() that get_tracks will fill. Collections are sorted lists, so titles stay in a stable order.
The unique ID is validated
Title.__init__ rejects a falsy id_, and rejects any ID shorter than 4 characters (\"The unique ID is not large enough, clash likely.\"). Pick a stable, sufficiently unique identifier from the platform.
"},{"location":"dev/service-architecture/#constructors-at-a-glance","title":"Constructors at a glance","text":"Each subclass adds its own required fields on top of id_ and service:
MovieEpisodeSong Movie(id_, service, name, year=None, language=None, data=None, description=None)\n
name is required; year is optional but must be a positive integer if given.
Episode(id_, service, title, season, number,\n name=None, year=None, language=None, data=None,\n description=None, air_date=None)\n
title is the show name; season and number are required (0 is allowed). An air_date is normalized to a date so the {date} naming token stays clean, and a placeholder episode name like \"Episode 4\" is discarded automatically.
Song(id_, service, name, artist, album, track, disc, year,\n language=None, data=None)\n
name, artist, album, track, disc, and year are all required and validated.
"},{"location":"dev/service-architecture/#title-remapping","title":"Title remapping","text":"After titles are fetched (and after any cache), the framework applies remap_titles(titles, title_map) using the per-service title_map config. This does an exact-string rename (Episodes are matched on their show title, Movies and Songs on their name), so a platform's odd naming can be normalized to what your library expects. Titles are also cache-wrapped via get_titles_cached(), which is region- and account-aware; see the TitleCacher notes.
The ordering here is deliberate on both sides. title_map is applied after the title cache so that editing the map takes effect immediately, without forcing a cache reset, and before metadata enrichment (--enrich) so that an explicit enrich override still wins over a mapped name.
"},{"location":"dev/service-architecture/#tracks-and-the-track-model","title":"Tracks and the Track model","text":"A Tracks object is the container get_tracks returns. It has typed buckets (videos, audio, subtitles, chapters, and attachments) plus a manifest_url. Iterating a Tracks yields only video, audio, and subtitle tracks (not chapters or attachments), and the canonical display/mux order is Video \u2192 Audio \u2192 Subtitle \u2192 Chapter \u2192 Attachment.
Track IDs must be deterministic
Tracks.add refuses duplicate IDs. Track IDs are CRC32-derived from the track's identity (URL, codec, language, bitrate) and must be stable across requests, never random. The manifest parsers already do this correctly; if you build tracks by hand, keep IDs deterministic or add will raise.
"},{"location":"dev/service-architecture/#the-base-track","title":"The base Track","text":"Track is the base for Video, Audio, and Subtitle. (Chapter, Chapters, and Attachment are standalone classes, not Track subclasses.) Every track knows how it is delivered, via Track.Descriptor:
Descriptor Delivery URL A single direct URL. HLS HTTP Live Streaming. DASH Dynamic Adaptive Streaming over HTTP. ISM Smooth Streaming. Key attributes include url (a string or list of strings), language (a langcodes.Language), drm (an optional iterable of DRM objects), needs_repack, and descriptor-specific state stashed in track.data[\"hls\"] / track.data[\"dash\"]. When name is omitted, a human-readable name is derived from the language automatically.
"},{"location":"dev/service-architecture/#video","title":"Video","text":"Video adds codec, range, bitrate, width, height, fps, scan_type, and closed-caption descriptors. The relevant enums:
Video.Codec: AVC (H.264), HEVC (H.265), VC1, VP8, VP9, AV1. Video.Range: SDR, HLG, HDR10, HDR10P (HDR10+), DV (Dolby Vision), and HYBRID. HYBRID is special: it tells the pipeline to select both an HDR10(+) base and a DV track and merge the DV RPU onto the HDR10 stream with dovi_tool (see Dolby Vision hybrid).
Bitrate units
Both Video and Audio store bitrate in bytes per second (rounded up), even though the string form displays kb/s.
"},{"location":"dev/service-architecture/#audio","title":"Audio","text":"Audio adds bitrate, channels, joc (Joint Object Coding, the Atmos indicator), and descriptive. Audio.Codec covers AAC, AC3 (DD), EC3 (DD+), AC4, OPUS, OGG (Vorbis), DTS, ALAC, and FLAC. The atmos property is True when joc is set (or when extra flags it).
"},{"location":"dev/service-architecture/#subtitle","title":"Subtitle","text":"Subtitle adds three mutually-constrained flags: cc (closed captions), sdh (subtitles for the deaf and hard-of-hearing), and forced. A track cannot be both cc and sdh, and forced cannot combine with either. Subtitle.Codec includes SubRip (SRT), WebVTT (VTT), SubStationAlpha/SubStationAlphav4 (SSA/ASS), TimedTextMarkupLang (TTML), SAMI, and the box-encapsulated DASH formats fTTML (STPP) and fVTT (WVTT). Subtitles receive post-download normalization (segment merging, WebVTT sanitization, format conversion, and optional SDH stripping), all documented for users in Subtitles.
"},{"location":"dev/service-architecture/#chapters-and-attachments","title":"Chapters and attachments","text":" Chapter(timestamp, name=None): a single marker; the timestamp accepts HH:MM:SS[.mmm], integer milliseconds, or float seconds. Chapters: a sorted collection that auto-inserts a 00:00:00.000 chapter and reads/writes OGM Simple format. Attachment: a font or image attached to the mux (e.g. fonts an ASS subtitle references), built from a local path or a url.
"},{"location":"dev/service-architecture/#selection-sorting-and-muxing","title":"Selection, sorting, and muxing","text":"Tracks is also the selection engine the dl command uses. It offers sort_videos / sort_audio / sort_subtitles (P2P-standard ordering), predicate-based select_* filters, by_resolutions, and by_language. When downloads finish, the same object multiplexes everything: Tracks.mux(title, ...) builds and runs an mkvmerge command, applying language, default-track, forced, and hearing-impaired flags, attaching chapters and fonts, and writing a .muxed.mkv (or .mka / .mks for audio- or subtitle-only sets). As a service author you never call mux yourself; you just return correct tracks.
"},{"location":"dev/service-architecture/#manifest-parsers","title":"Manifest parsers","text":"Most services do not build tracks by hand; they hand a manifest to one of three parsers in unshackle.core.manifests. Each parser has the same three-part contract:
Format Class Notes MPEG-DASH (.mpd) DASH Parses <MPD>; enumerates tracks from the first content period. HLS (.m3u8) HLS Requires a variant (master) playlist, not a media playlist. Smooth Streaming (.ism) ISM Synthesizes init segments from CodecPrivateData."},{"location":"dev/service-architecture/#the-parser-contract","title":"The parser contract","text":" - Construct:
DASH.from_url(url, session=...) or DASH.from_text(text, url) (and likewise for HLS / ISM). A url is always required, even for from_text, because relative segment paths are resolved against it. to_tracks(...): parses the manifest into a Tracks container of Video, Audio, and Subtitle objects, each carrying descriptor-specific state. download_track(...): a staticmethod the core calls to fetch, decrypt, and merge segments. You do not call it; you just return the tracks it will operate on.
A typical get_tracks therefore looks like fetching the manifest text with self.session and returning DASH.from_text(mpd, url).to_tracks(language=title.language).
Pass the original language
DASH and HLS to_tracks raise ValueError if a track's language cannot be derived and no valid fallback was supplied. Always pass the title's original recorded language. For HLS this fallback is used directly for the primary track, since HLS variants carry no language.
"},{"location":"dev/service-architecture/#segment-filtering-hooks","title":"Segment-filtering hooks","text":" - HLS: set
track.OnSegmentFilter(segment) to drop unwanted segments (ads/bumpers). - DASH: pass a
period_filter callable to to_tracks to drop whole periods. Caching-breadth periods some services emit and non-content segment types are dropped automatically.
"},{"location":"dev/service-architecture/#format-gotchas-worth-knowing","title":"Format gotchas worth knowing","text":" - DASH enumerates tracks from only the first content period, but downloads segments from all content periods matching the representation.
SegmentTemplate, SegmentList, and SegmentBase addressing and $Number%0Nd$ padding are handled. - HLS re-fetches the media playlist at download time, recovers
SUPPLEMENTAL-CODECS from raw text (python-m3u8 drops it) to detect Dolby Vision composite streams, and uses the media-sequence-number IV for AES-128 keys that omit an explicit IV. - ISM has no segment list and no init segments: segment URLs are synthesized from the
<c> fragment timeline, and a moov/ftyp init box is rebuilt from CodecPrivateData (supported for AVC, HEVC, DV-over-HEVC, AAC, EC-3, and TTML).
"},{"location":"dev/service-architecture/#drm-hooks","title":"DRM hooks","text":"If a track carries DRM, the framework licenses and decrypts it, but it needs your service to talk to the platform's license server. unshackle recognizes five DRM systems:
DRM class System License flow Widevine Widevine CDM challenge \u2192 license server \u2192 content keys (.wvd device) PlayReady PlayReady CDM challenge (WRM header) \u2192 license server \u2192 content keys (.prd device) ClearKeyCENC W3C org.w3.clearkey over CENC JSON KID request \u2192 JWK set; no CDM ClearKey HLS AES-128 key fetched directly from a URI; no CDM, no challenge MonaLisa Proprietary WASM DRM keys extracted locally; no license server ClearKey vs ClearKeyCENC
Despite the shared prefix these are entirely different systems. ClearKey is HLS AES-128, decrypted in-process. ClearKeyCENC is the W3C EME JWK flow over MPEG-CENC, decrypted with shaka-packager or mp4decrypt like Widevine.
"},{"location":"dev/service-architecture/#the-service-side-hooks","title":"The service-side hooks","text":"The framework supplies the CDM and the decryption tool; your service provides the network calls that the CDM's challenge must be sent through. Override the hooks that your platform needs:
Hook Return Purpose get_widevine_service_certificate(title, track, challenge) cert bytes Optional privacy-mode service certificate. get_widevine_license(title, track, challenge) license bytes POST the Widevine challenge to the license server, return the response. get_playready_license(title, track, challenge) license Same for PlayReady; defaults to delegating to Widevine. get_clearkey_license(title, track, challenge) dict / str / bytes / None For ClearKeyCENC; return None to let the framework POST to the manifest Laurl. You do not open CDM sessions or run shaka-packager yourself. The Widevine/PlayReady classes drive the CDM handshake and invoke the decryption tool. Your hook is just the \"send this challenge, give me back the license\" step.
"},{"location":"dev/service-architecture/#how-a-cdm-is-chosen-and-keys-are-found","title":"How a CDM is chosen and keys are found","text":"CDM selection is config-driven: the cdm map resolves a service tag (and optionally the track quality or DRM type) to a device name, which load_cdm instantiates as a local .wvd/.prd or a remote CDM. Before ever requesting a license, the framework tries an in-process key cache and the configured key vaults; only unresolved KIDs trigger an actual license request, and newly licensed keys are written back to every vault. This is why the same key is rarely re-licensed twice. Users configure all of this in DRM and CDM and Key vaults; the API surface is documented in the DRM API reference.
"},{"location":"dev/service-architecture/#dolby-vision-and-hybrid","title":"Dolby Vision and hybrid","text":"Two DV mechanisms sit on top of the track model. DV fixup re-exposes DV signaling on HLS streams that carry a valid DV RPU but advertise plain HEVC (dv_compatible_bitstream). Hybrid processing (Video.Range.HYBRID) selects an HDR10(+) base and a DV donor, extracts the RPU with dovi_tool, and injects it onto the base layer, producing a single stream with both HDR10 and DV metadata. Both are automatic once the right tracks are selected; a service only needs to expose accurate ranges.
"},{"location":"dev/service-architecture/#request-and-session-objects","title":"Request and session objects","text":"Everything a service does over the network flows through objects the base constructor prepared, all derived from the Click context (ctx) that dl passes in.
self.config: your service's slice of unshackle.yaml, i.e. config.services[<ServiceName>]. This is a plain dict; use it for per-service settings like base URLs, an API key, or a title_map. See Configuration. self.session: a requests.Session with config.headers applied, a retry policy (total=5, backoff, retry on 429/5xx), and any resolved proxy already mounted. Pass this session into the manifest parsers and DRM calls so headers, cookies, and proxies flow everywhere. (unshackle also ships an rnet-based TLS-fingerprinting session used by the newer session() factory and configured via the network config key.) self.credential: the active Credential (username:password[:extra]), populated by authenticate. Its sha1 is used as the account hash for cache keys. self.cache: a generic Cacher: JSON key/value storage under the service's cache dir, with CRC32 integrity checks, versioning, and optional expiry (auto-derived from a JWT exp claim when present). Good for auth tokens and API responses. self.title_cache: a TitleCacher that wraps get_titles results, keyed by title ID, region, and account hash. It serves fresh data within title_cache_time, falls back to stale data within title_cache_max_retention when an API call fails, and is bypassed by --no-cache. self.track_request: the TrackRequest describing what the user asked for (codecs, ranges, best-available), for use inside get_tracks.
A minimal service skeleton
services/EXMP/__init__.pyfrom unshackle.core.service import Service\nfrom unshackle.core.manifests import DASH\nfrom unshackle.core.titles import Movie, Movies\n\n\nclass EXMP(Service):\n ALIASES = (\"example\",)\n GEOFENCE = (\"us\",)\n\n def get_titles(self) -> Movies:\n data = self.session.get(f\"https://api.example.com/title/{self.title}\").json()\n return Movies([\n Movie(\n id_=data[\"id\"],\n service=self.__class__,\n name=data[\"name\"],\n year=data.get(\"year\"),\n language=data.get(\"language\"),\n )\n ])\n\n def get_tracks(self, title) -> \"Tracks\":\n mpd_url = self.session.get(...).json()[\"manifest\"]\n mpd = self.session.get(mpd_url).text\n return DASH.from_text(mpd, mpd_url).to_tracks(language=title.language)\n\n def get_chapters(self, title):\n return []\n\n def get_widevine_license(self, *, challenge, title, track):\n return self.session.post(\"https://lic.example.com/wv\", data=challenge).content\n
The directory name (EXMP) must exactly match the class name; that stem is the service tag. Put your service in one of the directories.services paths.
"},{"location":"dev/service-architecture/#where-to-go-next","title":"Where to go next","text":" - Creating a Service: a guided walkthrough that builds on this model.
- Service API: exact signatures for the
Service base class. - Titles API and Tracks API: the full object reference.
- DRM API: the DRM and CDM classes in detail.
- Downloading: how the flags a user passes map onto track selection.
"},{"location":"dev/rest-api/","title":"REST API Overview","text":"unshackle ships with a built-in HTTP server. Alongside the command-line tool, you can run unshackle serve and drive the same download engine over a REST API: searching services, listing titles and tracks, queueing downloads, watching live progress, and browsing history, all as JSON over HTTP.
This page explains what the API is for, how to start the server, where it listens, and how the endpoints are organised. Later pages cover each area in depth.
Who this section is for
The REST API is aimed at people building automation, dashboards, or remote clients on top of unshackle. If you only download the occasional title, the command-line tool does everything the API does and is simpler to use. Reach for the API when you want a long-running service, a job queue, or a web front-end.
"},{"location":"dev/rest-api/#what-the-api-is-for","title":"What the API is for","text":"The server exposes the same capabilities as the dl command, but as discrete HTTP calls you can script against:
- Discovery: list the services available on the server, run a service's search, and enumerate the titles and tracks behind a title ID.
- Downloading: submit a download as a background job, then follow its status and live progress (by polling, or by subscribing to its Server-Sent Events stream), cancel it, retry it, or bump it to the front of the queue.
- History and housekeeping: read the log of finished jobs, inspect the server's effective (redacted) configuration, check external tool versions, and clear caches or temp files.
- Remote-download sessions: a thin local client can authenticate against a service on the server, then pull titles, tracks, and segment info back, and proxy DRM licensing. This is how one machine with the credentials and CDMs can serve downloads to another. See Remote Sessions.
Because every download runs as a queued job in a separate worker process, the API is well suited to running unattended: submit work, come back later, and read the result.
"},{"location":"dev/rest-api/#running-the-server","title":"Running the server","text":"Start the server with the serve command:
Start the REST API on the default host and portunshackle serve\n
By default the server binds to 127.0.0.1:8786 and requires an API key on every request. It also serves the local Widevine and PlayReady CDM endpoints (for pywidevine / pyplayready clients) in addition to the REST API.
An API key is required by default
Unless you pass --no-key, the server needs an API secret configured under the serve section of your config. If none is set, unshackle serve refuses to start. See Authentication for how to set api_secret and how the X-Secret-Key header works.
"},{"location":"dev/rest-api/#common-options","title":"Common options","text":"The serve command accepts the following flags:
Option Default Effect -h, --host 127.0.0.1 Address to bind to. Use 0.0.0.0 to accept connections from other machines. -p, --port 8786 Port to bind to. --api-only off Serve only the REST API, without the Widevine/PlayReady CDM HTTP endpoints. --no-widevine off Disable the Widevine CDM endpoints. Cannot be combined with --api-only. --no-playready off Disable the PlayReady CDM endpoints. Cannot be combined with --api-only. --no-key off Disable API-key authentication entirely; every request is allowed. --remote-only off Expose only the remote-session subset of routes. Implies --api-only. --debug-api off Include tracebacks and worker stderr in error responses. --debug off Enable debug-level logging. --caddy off Also launch Caddy as a reverse proxy, using the Caddyfile in your user-config directory. Serve the REST API only, on all interfacesunshackle serve --api-only --host 0.0.0.0 --port 8786\n
Binding to a public interface
--no-key disables authentication completely. Anyone who can reach the port can search, download, and read your configuration. Only combine it with a host like 127.0.0.1, or keep the default key-based auth when binding to 0.0.0.0. Put a TLS-terminating reverse proxy (Caddy, nginx) in front for anything internet-facing.
"},{"location":"dev/rest-api/#server-modes","title":"Server modes","text":"The set of routes that is actually mounted depends on how you start the server:
Default (CDM + REST)--api-only--remote-only Both the pywidevine/pyplayready CDM endpoints and the full REST API are mounted. This is the mode used when you run unshackle serve with no mode flags.
Only the REST API is mounted; the CDM HTTP endpoints are omitted. Authentication uses the single configured API key (or none, with --no-key).
Only the remote-session subset of routes is exposed (health, services, search, and everything under /api/session/*). This mode implies --api-only, and the interactive Swagger UI is not mounted.
"},{"location":"dev/rest-api/#base-url","title":"Base URL","text":"All REST endpoints live under the /api prefix. With the default bind address the base URL is:
http://127.0.0.1:8786/api\n
Every path in this documentation is relative to the host and port. For example, the health check is GET http://127.0.0.1:8786/api/health.
Interactive API docs
Unless you started the server with --remote-only, an interactive Swagger UI is available at /api/docs/. It is generated from the same route table the server actually serves, so it stays in sync with the running build. Note that in --api-only mode the docs page is itself behind the API key; only /api/health is exempt from authentication.
A quick smoke test that needs no key:
Confirm the server is upcurl http://127.0.0.1:8786/api/health\n
Response{\n \"status\": \"ok\",\n \"version\": \"5.3.0\",\n \"update_check\": {\n \"update_available\": false,\n \"current_version\": \"5.3.0\",\n \"latest_version\": \"5.3.0\"\n }\n}\n
"},{"location":"dev/rest-api/#endpoint-map","title":"Endpoint map","text":"Every REST path uses JSON request and response bodies and lives under /api. The table below is a map of the surface; the Endpoints page documents each request and response in full.
"},{"location":"dev/rest-api/#discovery","title":"Discovery","text":"Method Path Purpose GET /api/health Liveness and version check. Never requires a key. GET /api/services List the services available on this server. POST /api/search Run a service's search for a query. POST /api/list-titles List the titles behind a service title ID. POST /api/list-tracks List the video, audio, and subtitle tracks for a title."},{"location":"dev/rest-api/#downloads-and-jobs","title":"Downloads and jobs","text":"Method Path Purpose POST /api/download Queue a download; returns a job_id. GET /api/download/jobs List jobs, with filtering and sorting. POST /api/download/jobs/clear-finished Remove all finished jobs. GET /api/download/jobs/{job_id} Full detail for one job. GET /api/download/jobs/{job_id}/events Stream one job's progress as Server-Sent Events. DELETE /api/download/jobs/{job_id} Cancel a running job, or remove a finished one. POST /api/download/jobs/{job_id}/retry Re-queue a finished job's parameters as a new job. POST /api/download/jobs/{job_id}/priority Move a queued job to the front."},{"location":"dev/rest-api/#history-and-server-info","title":"History and server info","text":"Method Path Purpose GET /api/history Read the persisted log of finished jobs. DELETE /api/history/{job_id} Delete a history entry. GET /api/profiles Named credential profiles per service. GET /api/config The server's effective, redacted configuration. GET /api/env/check Presence and versions of external tools."},{"location":"dev/rest-api/#maintenance","title":"Maintenance","text":"Method Path Purpose POST /api/maintenance/clear-cache Clear the cache directory. POST /api/maintenance/clear-temp Clear the temp directory. POST /api/maintenance/refresh-services git pull each configured service repository."},{"location":"dev/rest-api/#remote-download-sessions","title":"Remote-download sessions","text":"Method Path Purpose POST /api/session/create Start a server-side, authenticated service session. GET /api/session/{session_id} Session info and time-to-live. DELETE /api/session/{session_id} Tear down a session. GET /api/session/{session_id}/titles Titles for the session's title ID. POST /api/session/{session_id}/tracks Tracks (with URLs) for a chosen title. POST /api/session/{session_id}/segments Per-track segment/download details. POST /api/session/{session_id}/license Proxy or server-side DRM licensing. GET /api/session/{session_id}/prompt Poll for interactive auth status/prompt. POST /api/session/{session_id}/prompt Submit an answer to an auth prompt. Remote-only routes
The health, services, search, and /api/session/* routes are the ones exposed when the server runs with --remote-only. Everything else (downloads, jobs, history, config, maintenance) is only available in the full or --api-only modes.
"},{"location":"dev/rest-api/#authentication-in-one-line","title":"Authentication in one line","text":"With the default configuration, send your API secret in the X-Secret-Key header on every request except /api/health:
An authenticated requestcurl -X POST http://127.0.0.1:8786/api/search \\\n -H \"X-Secret-Key: your-api-secret\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"service\": \"EXAMPLE\", \"query\": \"example\"}'\n
A missing or unknown key returns 401. The full model, including per-key service allowlists, is covered in Authentication.
"},{"location":"dev/rest-api/#where-to-go-next","title":"Where to go next","text":" - Quick Start: start the server, set a key, and run your first end-to-end download over the API.
- Authentication: the
X-Secret-Key header, --no-key, per-key service allowlists, and CORS. - Endpoints: every request body, query parameter, and response shape, endpoint by endpoint.
- Job Lifecycle: how a download moves from
queued to a terminal state, live progress, cancellation, and retries. - Remote Sessions: the
/api/session/* flow for driving a service from a thin remote client. - Error Responses: the standard error envelope, error codes, and their HTTP statuses.
"},{"location":"dev/rest-api/authentication/","title":"REST API Authentication","text":"The unshackle REST API protects every endpoint (with one exception) behind a single API key. Clients present that key in a request header, and the server checks it against the keys defined in your configuration. This page explains how that check works, how to configure keys, how clients send them, and the extra controls that build on top of the key: per-key service allowlists, per-key CDM device access, and session IP binding.
The API is started with the unshackle serve command. Authentication is on by default: if you start the server without an API key configured, it refuses to launch.
Which server am I running?
unshackle serve can run in two shapes, and each uses a slightly different piece of code to check your key:
- The integrated server (the default) exposes the REST API and the pywidevine / pyplayready CDM HTTP endpoints.
- The API-only server (
--api-only, also implied by --remote-only) exposes only the REST API.
The header, the config, and the behaviour you see as a client are the same in both. The differences are internal and are called out in the developer notes below.
"},{"location":"dev/rest-api/authentication/#the-short-version","title":"The short version","text":" - Add an
api_secret to the serve section of your unshackle.yaml. - Start the server with
unshackle serve. - Send that secret in the
X-Secret-Key header on every request except GET /api/health.
A minimal authenticated requestcurl -H \"X-Secret-Key: your-secret-key-here\" \\\n http://127.0.0.1:8786/api/services\n
"},{"location":"dev/rest-api/authentication/#configuring-authentication","title":"Configuring authentication","text":"All authentication settings live under the serve section of your unshackle config file (unshackle.yaml).
"},{"location":"dev/rest-api/authentication/#a-single-api-key","title":"A single API key","text":"The simplest setup defines one shared secret with api_secret:
unshackle.yamlserve:\n api_secret: \"a-long-random-secret-string\"\n
This value becomes the one and only key that the server accepts. Requests that present it in the X-Secret-Key header are allowed; everything else is rejected.
The server will not start without a key
If you start unshackle serve with no api_secret configured, and you have not passed --no-key, the command fails immediately with:
API secret key is not configured. Please add 'api_secret' to the 'serve' section in your config.\n
Choose a long, unpredictable value. The key is the only thing standing between the internet and your CDM and download endpoints.
"},{"location":"dev/rest-api/authentication/#multiple-keys-with-users","title":"Multiple keys with users","text":"For finer control you can define named users, each with their own key. A user entry can also grant CDM device access and restrict which services that key may touch:
unshackle.yamlserve:\n api_secret: \"the-primary-admin-key\"\n users:\n a-secret-key-for-alice:\n username: alice\n devices: [\"my_widevine_device\"] # Widevine (.wvd) devices this key may use\n playready_devices: [\"my_playready_prd\"] # PlayReady (.prd) devices this key may use\n services: [\"EXAMPLE1\", \"EXAMPLE2\"] # optional per-key service allowlist\n a-secret-key-for-bob:\n username: bob\n devices: [\"my_widevine_device\"]\n
The key of each entry under users (for example a-secret-key-for-alice) is the secret that client sends in X-Secret-Key. The username is a human-readable label used in logs and in job/history metadata.
How api_secret and users combine (integrated server)
On the integrated server, the configured api_secret is automatically added as an extra accepted key alongside everyone in users. It is granted access to all loaded Widevine and PlayReady devices and is labelled api_user. So api_secret behaves as an admin-level key in addition to any named users you define.
On the API-only server, only api_secret is accepted: the single-key model. (See the developer notes for the exact difference.)
"},{"location":"dev/rest-api/authentication/#cdm-device-access-per-key","title":"CDM device access per key","text":"Under the integrated server, a key can only run DRM licensing through a CDM device that its user entry lists:
Config key Grants access to devices Widevine .wvd devices, by name playready_devices PlayReady .prd devices, by name PlayReady access is opt-in per user
If a user entry omits playready_devices, that key is given no PlayReady access, and the server logs a warning at startup. You must list PlayReady devices explicitly for any key that needs them. Widevine and PlayReady device files themselves are auto-loaded from your WVDs and PRDs directories.
Device lists are the server-side-decryption switch
There is no separate tier, capability flag, or permission toggle for whether the server will hand back keys (KID:KEY) for a session download. The presence of devices on the calling key decides it. With empty devices and playready_devices, the server can only proxy CDM challenges, so the client must run its own CDM. Once those lists are populated, the client may request mode:server_cdm and receive keys back. Don't go looking for a tier setting; configuring devices is what enables server-side decryption.
"},{"location":"dev/rest-api/authentication/#disabling-authentication","title":"Disabling authentication","text":"The --no-key flag turns authentication off entirely. Every request is allowed, X-Secret-Key is ignored, and no key needs to be configured.
unshackle serve --no-key\n
Only use --no-key on a trusted, isolated network
With --no-key there is nothing stopping any client that can reach the port from listing your services, starting downloads, or using your CDM devices. Never expose a --no-key server to an untrusted network.
"},{"location":"dev/rest-api/authentication/#how-clients-present-credentials","title":"How clients present credentials","text":"Clients authenticate by sending the secret in a single HTTP request header on every call:
X-Secret-Key: <your-secret>\n
The value must exactly match one of the configured keys (api_secret, or one of the keys under users). There is no login step, no session cookie, and no token exchange for API access. The same static key is sent on each request.
One route also accepts the key as a query parameter
The job event stream, GET /api/download/jobs/{job_id}/events, also accepts the key in a secret_key query parameter. This is for browsers: the EventSource API cannot set request headers. The exception applies to that one route only, and it works in both server modes.
new EventSource(`http://127.0.0.1:8786/api/download/jobs/${jobId}/events?secret_key=${key}`);\n
The header always wins. If the request has an X-Secret-Key header, the server checks that header and ignores the query parameter, so a wrong header is rejected even when the query parameter holds a valid key.
A key in a URL is easier to leak than a key in a header, because proxies and browser history can record it. Use the header wherever your client can set one.
curlPython (requests)JavaScript (fetch) curl -H \"X-Secret-Key: your-secret-key-here\" \\\n http://127.0.0.1:8786/api/services\n
import requests\n\nBASE = \"http://127.0.0.1:8786\"\nHEADERS = {\"X-Secret-Key\": \"your-secret-key-here\"}\n\nresp = requests.get(f\"{BASE}/api/services\", headers=HEADERS, timeout=30)\nresp.raise_for_status()\nprint(resp.json())\n
const resp = await fetch(\"http://127.0.0.1:8786/api/services\", {\n headers: { \"X-Secret-Key\": \"your-secret-key-here\" },\n});\nif (!resp.ok) throw new Error(`Server returned ${resp.status}`);\nconsole.log(await resp.json());\n
The reference client sets it for you
unshackle's own remote download client (RemoteService) sends X-Secret-Key automatically, reading the key from your remote_services config. It also sends a User-Agent of unshackle/<version>. You only deal with the header directly when writing your own client.
"},{"location":"dev/rest-api/authentication/#the-health-check-is-always-open","title":"The health check is always open","text":"One endpoint is exempt from authentication: GET /api/health. It never requires a key, so you can use it for liveness and version probes without embedding a secret in your monitoring:
No key neededcurl http://127.0.0.1:8786/api/health\n
Response{\n \"status\": \"ok\",\n \"version\": \"5.3.0\",\n \"update_check\": {\n \"update_available\": null,\n \"current_version\": \"5.3.0\",\n \"latest_version\": null\n }\n}\n
Every other endpoint (including the Swagger UI at /api/docs/ on the API-only server) requires a valid key.
"},{"location":"dev/rest-api/authentication/#authentication-failures","title":"Authentication failures","text":"When a request is rejected for authentication reasons, the response has an HTTP 401 Unauthorized status and a small JSON body.
Missing headerWrong key Sending a request with no X-Secret-Key header:
{\n \"status\": 401,\n \"message\": \"Secret Key is Empty.\"\n}\n
Sending a key that is not in your config:
{\n \"status\": 401,\n \"message\": \"Secret Key is Invalid.\"\n}\n
Auth errors use a different JSON shape than other errors (developer note)
The rest of the API reports errors with a structured body containing error_code, message, timestamp, and other fields (see Errors). The authentication middleware is different: it returns {\"status\": 401, \"message\": \"...\"}, where status is an integer, and there is no error_code or timestamp.
A robust client should treat any 401 as an authentication problem and be prepared to parse both shapes rather than assuming the standard error body is always present.
"},{"location":"dev/rest-api/authentication/#building-on-the-key-access-controls","title":"Building on the key: access controls","text":"Once a request is authenticated, several additional controls decide what that specific key is allowed to do.
"},{"location":"dev/rest-api/authentication/#per-key-service-allowlists","title":"Per-key service allowlists","text":"You can restrict which services a key may use. Two layers combine:
- Global allowlist:
serve.services, applied to every key. - Per-key allowlist:
services inside that key's users entry.
The effective allowlist for a request is:
Global set? Per-key set? Result Yes Yes The intersection of the two Yes No The global list No Yes The per-key list No No No restriction, all services allowed Restricting servicesserve:\n api_secret: \"admin-key\"\n services: [\"EXAMPLE1\", \"EXAMPLE2\", \"EXAMPLE3\"] # global ceiling for all keys\n users:\n limited-key:\n username: limited\n services: [\"EXAMPLE1\"] # this key ends up allowed only EXAMPLE1\n
Endpoints that name a service (listing services, searching, listing titles/tracks, downloading, profiles, history, and remote sessions) all filter against this allowlist. A request for a service outside the key's allowlist is rejected as an invalid service. Service tags are matched after normalization, so casing and aliases resolve consistently.
"},{"location":"dev/rest-api/authentication/#cdm-overrides-and-per-job-credentials","title":"CDM overrides and per-job credentials","text":"Two download-time capabilities are gated behind server config and, unless enabled, are refused with 403 Forbidden even for an authenticated key:
Config key Default Controls serve.cdm_overrides off Whether a request may pick a specific CDM device via the cdm field. Set to true to allow any, or to a list/set of device names to allow only those. serve.allow_job_credentials off Whether a request may pass its own credential / credentials login details for a download job. Opting in to per-request CDM and credentialsserve:\n api_secret: \"admin-key\"\n cdm_overrides: [\"my_widevine_device\"] # or true to allow any configured device\n allow_job_credentials: true\n
When to enable these gates
The two gates are fully independent and can be combined. The intended posture is that a locked-down or public-facing deployment leaves both unset (they default off and reject with 403) so client requests can never steer the server's CDM or feed it their own logins. Enabling them only makes sense for a trusted single-client deployment, where the caller and the operator are effectively the same party.
Client-supplied credentials get isolated token caches
When allow_job_credentials is enabled and a download job supplies its own credential / credentials, each distinct credential is given its own isolated token cache on the server. Client-supplied logins are never shared or merged with the server's configured-credential caches, so they cannot cross-contaminate or read the server's own token state, and they don't clobber each other.
See Downloads for how these fields are used on the download endpoint.
"},{"location":"dev/rest-api/authentication/#session-ip-binding-remote-sessions","title":"Session IP binding (remote sessions)","text":"The remote-download session endpoints (/api/session/*) add an extra check on top of the key. When a session is created, the server records the client's IP address. Any later request for that session must come from the same IP, or it is rejected with 403 Forbidden. This means a session is bound both to the key that created it and to the address it was created from.
"},{"location":"dev/rest-api/authentication/#transport-security-https","title":"Transport security (HTTPS)","text":"The API server speaks plain HTTP. Because your key is sent on every request, you should not expose it over an unencrypted connection on an untrusted network. To serve over HTTPS, run a reverse proxy in front of unshackle. The serve command can launch Caddy for you:
unshackle serve --caddy\n
With --caddy, unshackle also starts caddy run using the Caddyfile located next to your unshackle config, letting you terminate TLS and reverse-proxy to the API. Configuring Caddy itself (certificates, domains, upstream) is done in that Caddyfile.
"},{"location":"dev/rest-api/authentication/#quick-reference","title":"Quick reference","text":"Item Value Auth header X-Secret-Key Query-parameter alternative ?secret_key= on GET /api/download/jobs/{job_id}/events only Default bind address 127.0.0.1:8786 Exempt endpoint GET /api/health Failure status 401 Unauthorized Missing-header body {\"status\": 401, \"message\": \"Secret Key is Empty.\"} Invalid-key body {\"status\": 401, \"message\": \"Secret Key is Invalid.\"} Disable auth --no-key (all requests allowed) Config location serve.api_secret and serve.users in unshackle.yaml"},{"location":"dev/rest-api/authentication/#developer-notes-how-the-check-is-implemented","title":"Developer notes: how the check is implemented","text":"This section is for people writing or debugging the server, not for API consumers.
"},{"location":"dev/rest-api/authentication/#two-middlewares-one-header","title":"Two middlewares, one header","text":"The header and config are identical between modes, but the code that enforces them differs:
-
API-only server (--api-only, or --remote-only which forces API-only). An api_key_authentication middleware runs on every request. It lets GET /api/health through unconditionally, then reads X-Secret-Key. An empty header returns the \"Secret Key is Empty.\" body; a header that is not a configured user returns \"Secret Key is Invalid.\". In this mode the accepted-keys map holds your api_secret (labelled api_user, with no devices) plus every key under serve.users, so a per-user key authenticates here exactly as it does on the integrated server, and that user's services allowlist applies as usual. With --no-key, this middleware is simply not installed and the users map is empty.
-
Integrated server (default). A serve_authentication middleware wraps pywidevine's authentication (and pyplayready's for /playready* paths), and the REST routes live in the same application, guarded by the same check. Here the accepted-keys map is your full serve config: api_secret (auto-added with access to all loaded devices) plus every entry under users, each with its declared devices / playready_devices.
Because the API-only middleware guards all non-health paths, the Swagger UI at /api/docs/ is also behind X-Secret-Key there. In --remote-only mode the Swagger UI is not mounted at all.
Both middlewares read the caller's key through the shared request_secret_key() helper in unshackle/core/api/handlers.py, which returns the X-Secret-Key header and falls back to the secret_key query parameter only when the matched route is the job event stream. The integrated server needs one extra branch for this: pywidevine's authentication is header-only, so serve_authentication compares the query-parameter key itself when the header is absent, and delegates to pywidevine in every other case. That is why a present-but-wrong header is rejected in both modes.
"},{"location":"dev/rest-api/authentication/#cors-and-the-authorization-header","title":"CORS and the Authorization header","text":"A cors_middleware runs on every response in both modes (and answers OPTIONS preflight requests with an empty response). It emits:
Access-Control-Allow-Origin: *\nAccess-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS\nAccess-Control-Allow-Headers: Content-Type, X-Secret-Key, Authorization\nAccess-Control-Max-Age: 3600\n
The REST API authenticates with X-Secret-Key; Authorization is advertised because the co-hosted pywidevine / pyplayready CDM endpoints use it. PUT appears in the allowed methods list but no REST route uses it; only GET, POST, and DELETE are wired up.
"},{"location":"dev/rest-api/authentication/#where-device-access-is-read","title":"Where device access is read","text":"The map of accepted keys lives at request.app[\"config\"][\"users\"]. For DRM operations, a key's permitted Widevine devices come from its devices list and PlayReady devices from playready_devices; the first configured device is used when a request does not (or may not) override it. A server-wide config.cdm[<service>] mapping, if present, takes precedence over the key's device list when resolving which device to use.
"},{"location":"dev/rest-api/endpoints/","title":"REST API Endpoints","text":"This is the full endpoint reference for the unshackle REST API. It covers every route: its method and path, the request body it accepts, the response shape it returns, and the status codes it can produce. Routes are grouped into general, download jobs, server management, and remote-dl sessions.
Start the server with unshackle serve. By default it binds to 127.0.0.1:8786, and every route lives under the /api/ prefix. Authentication, server modes, and the X-Secret-Key header are covered in detail on the Authentication page. Read that first if you have not already. For an end-to-end walkthrough, see the Quickstart.
Base URL
All examples below assume a base URL of http://127.0.0.1:8786. Replace the host and port to match your own unshackle serve -h <host> -p <port> invocation.
Interactive docs
A live Swagger UI is mounted at /api/docs/ whenever the server is not running in --remote-only mode. Its title is \"Unshackle REST API\" and its version tracks the installed package version. In API-only mode with a key configured, the Swagger UI itself sits behind X-Secret-Key; only /api/health is exempt.
"},{"location":"dev/rest-api/endpoints/#conventions","title":"Conventions","text":" - Authentication. Every request except
GET /api/health requires the X-Secret-Key header when a key is configured. Starting the server with --no-key disables the check entirely. See Authentication. - Content type. Request bodies are JSON (
Content-Type: application/json); all success responses are JSON unless a 204 No Content is documented. - CORS. Every response carries permissive CORS headers, and
OPTIONS preflight requests are answered automatically. - Compression. JSON responses of 256 bytes or more are gzip-compressed when the client sends
Accept-Encoding: gzip. - Service allowlist. Service-facing endpoints are filtered by the effective allowlist for your API key (the intersection of the global
serve.services list and your per-key list). Services you are not allowed to use are treated as unknown.
Error responses have two shapes
Structured application errors (raised inside a handler) return {\"status\": \"error\", \"error_code\": \"...\", \"message\": \"...\", \"timestamp\": \"...\"}. The authentication middleware, however, returns a different shape: {\"status\": 401, \"message\": \"...\"} with an integer status and no error_code. Clients must handle both. The full error model is documented in the Error responses section at the bottom of this page.
"},{"location":"dev/rest-api/endpoints/#general-endpoints","title":"General endpoints","text":"These endpoints report server state and enumerate what is available. GET /api/health, GET /api/services, and POST /api/search are also exposed in --remote-only mode.
"},{"location":"dev/rest-api/endpoints/#get-apihealth","title":"GET /api/health","text":"Health check. This is the only route exempt from authentication: you can call it without an X-Secret-Key header. It reports the running version and a best-effort update check.
RequestResponse 200 curl http://127.0.0.1:8786/api/health\n
{\n \"status\": \"ok\",\n \"version\": \"5.3.0\",\n \"code_hash\": \"1d22a1e\",\n \"update_check\": {\n \"update_available\": false,\n \"current_version\": \"5.3.0\",\n \"latest_version\": \"5.3.0\"\n }\n}\n
code_hash fingerprints the framework source the server is running, so it identifies the code itself rather than the release. It is the same value for a git checkout and for an installed package built from that checkout, and it changes if anything under unshackle/core, unshackle/commands, unshackle/utils, or unshackle/vaults is edited. Service modules are not included. The value is null only when the source files cannot be read.
If the update check fails (for example, no network), update_available and latest_version are null while current_version still reports the installed version.
Status Meaning 200 Server is healthy."},{"location":"dev/rest-api/endpoints/#get-apiservices","title":"GET /api/services","text":"List the streaming services available on this server, filtered by your allowlist. Each entry describes the service's tag, matching rules, capability flags, and its CLI parameters (useful for building a UI that accepts service-specific options).
RequestResponse 200 curl -H \"X-Secret-Key: $KEY\" http://127.0.0.1:8786/api/services\n
{\n \"services\": [\n {\n \"tag\": \"EXAMPLE\",\n \"aliases\": [\"EX\"],\n \"geofence\": [\"US\"],\n \"title_regex\": \"^https?://example\\\\.com/.+\",\n \"url\": \"https://example.com\",\n \"help\": \"EXAMPLE\\nAuthorization: Cookies\\nSecurity: FHD@L3\",\n \"cli_params\": [\n {\"name\": \"title\", \"kind\": \"argument\", \"required\": true},\n {\n \"name\": \"movie\",\n \"kind\": \"option\",\n \"opts\": [\"-m\", \"--movie\"],\n \"is_flag\": true,\n \"default\": false,\n \"help\": \"Title is a movie.\",\n \"type\": \"boolean\",\n \"multiple\": false\n }\n ],\n \"needs_auth\": true,\n \"has_search\": true,\n \"has_drm\": true,\n \"auth_methods\": [\"cookies\"]\n }\n ]\n}\n
Field notes:
Field Description tag Canonical service tag; use this as the service value on other endpoints. aliases Alternate tags the service also answers to. geofence Region codes the service is geofenced to (may be empty). title_regex URL/ID pattern the service matches, as a string or list of strings (or null). url Service URL, taken from the service's CLI short help. help The service module's docstring. cli_params Array of parameter descriptors. Arguments have {name, kind: \"argument\", required}; options have {name, kind: \"option\", opts, is_flag, default, help, type, multiple} and, for choice options, a choices list. needs_auth Whether the service overrides authenticate(). has_search Whether the service supports POST /api/search. has_drm Whether the service overrides a Widevine or PlayReady license hook. auth_methods Declared or inferred auth methods, e.g. [\"cookies\"], [\"credentials\"]. Status Meaning 200 Service list returned. 500 Unexpected server error (INTERNAL_ERROR)."},{"location":"dev/rest-api/endpoints/#post-apisearch","title":"POST /api/search","text":"Search a service for titles by query string. The service must implement search() (see has_search above).
Request body
Field Type Required Default Description service string yes - Service tag. query string yes - Search query. profile string no null Credential/cookie profile to use. proxy string no null Proxy URI or country code. no_proxy boolean no false Force-disable all proxy use. RequestResponse 200 curl -X POST http://127.0.0.1:8786/api/search \\\n -H \"X-Secret-Key: $KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"service\": \"EXAMPLE\", \"query\": \"planet earth\"}'\n
{\n \"results\": [\n {\n \"id\": \"12345\",\n \"title\": \"Planet Earth\",\n \"description\": \"A nature documentary series.\",\n \"label\": \"Available\",\n \"url\": \"https://example.com/title/12345\"\n }\n ],\n \"count\": 1\n}\n
Status Error code Meaning 200 - Search completed. 400 INVALID_PARAMETERS Missing query. 400 INVALID_SERVICE Unknown or disallowed service. 400 INVALID_PROXY Proxy could not be resolved. 502 SERVICE_ERROR Service failed to initialize, or does not support search."},{"location":"dev/rest-api/endpoints/#post-apilist-titles","title":"POST /api/list-titles","text":"Resolve a title ID into its list of titles (episodes, movie, etc.) without downloading anything.
Request body
Field Type Required Description service string yes Service tag. title_id string yes Title identifier. profile string no Credential/cookie profile. proxy string no Proxy URI or country code. no_proxy boolean no Force-disable proxy. cdm_type string no Preferred CDM type. Service-specific CLI options may also be passed as additional keys.
RequestResponse 200 curl -X POST http://127.0.0.1:8786/api/list-titles \\\n -H \"X-Secret-Key: $KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"service\": \"EXAMPLE\", \"title_id\": \"12345\"}'\n
{\n \"titles\": [\n {\n \"type\": \"episode\",\n \"name\": \"Pilot\",\n \"id\": \"ep-1\",\n \"language\": \"en\",\n \"description\": \"The first episode.\",\n \"date\": \"2020-01-01\",\n \"cover_url\": \"https://example.com/cover.jpg\",\n \"year\": 2020,\n \"series_title\": \"Example Show\",\n \"season\": 1,\n \"number\": 1\n }\n ]\n}\n
Each serialized title carries type (\"episode\", \"movie\", or \"other\"), name, id, language, description, date, and cover_url. Episodes and movies add year; episodes additionally add series_title, season, and number.
These keys are sent only when the title carries them, so a title without them serializes exactly as before:
Key Type On Meaning part integer episode Part index of a split episode, counting from 1. air_date string episode ISO air date of dated content. unshackle names the episode by date instead of SxxExx. absolute integer episode Absolute episode number across all seasons. daily boolean episode The episode is daily/date-based. Set from the title, or from the service's DAILY class attribute. anime boolean episode, movie The title is anime, so metadata lookups prefer AniList. Set from the title, or from the service's ANIME class attribute. part on a split episode
A service that splits one episode into several separately playable videos reports each one as its own episode. Those titles share a season and number and add a part integer counting from 1. No other title carries the key, so a client that ignores it is unaffected. See Split episodes.
Status Error code Meaning 200 - Titles returned. 400 INVALID_INPUT Missing service or title_id. 400 INVALID_SERVICE Unknown or disallowed service. 401 AUTH_FAILED Authentication with the service failed. 404 NOT_FOUND Title not found. 500 INTERNAL_ERROR Unexpected error."},{"location":"dev/rest-api/endpoints/#post-apilist-tracks","title":"POST /api/list-tracks","text":"List the video, audio, and subtitle tracks for a title. For series, you can scope to specific episodes.
Spotting the original-language audio
Each audio track carries is_original, which marks the track the default lang of orig selects. Unshackle resolves it with the same language matcher the downloader uses, so it stays correct for regional variants a client cannot separate from tag text alone (pt marks pt-BR rather than pt-PT). Services need not report a title's language, so every track can legitimately come back false.
Request body
Field Type Required Description service string yes Service tag. title_id string yes Title identifier. wanted string no Episode/season range (e.g. \"S01E01-S01E03\"). season int/string no Season number (combined with episode). episode int/string no Episode number (combined with season). part int/string no Part index of a split episode (combined with season and episode). profile, proxy, no_proxy, cdm_type - no As on list-titles. When both season and episode are given, they are combined into a \"{season}x{episode}\" selector. Adding part narrows that to \"{season}x{episode}.{part}\", one part of a split episode; without it you get every part of the episode. part is read only when season and episode are both given, and wanted takes precedence over all three. To scope a part by range, put it in wanted instead (\"S01E01.2\").
Single title / movie: Response 200Multiple episodes: Response 200 {\n \"title\": { \"type\": \"movie\", \"name\": \"Example Movie\", \"id\": \"12345\", \"year\": 2021 },\n \"video\": [\n {\n \"id\": \"v-1\", \"codec\": \"H265\", \"codec_display\": \"HEVC\",\n \"bitrate\": 8000, \"width\": 3840, \"height\": 2160,\n \"resolution\": \"2160p\", \"fps\": \"24\", \"range\": \"HDR10\",\n \"range_display\": \"HDR10\", \"language\": \"en\", \"drm\": [], \"descriptor\": \"DASH\"\n }\n ],\n \"audio\": [\n {\n \"id\": \"a-1\", \"codec\": \"EC3\", \"codec_display\": \"DD+\",\n \"bitrate\": 640, \"channels\": \"5.1\", \"language\": \"en\", \"is_original\": true,\n \"atmos\": false, \"descriptive\": false, \"drm\": [], \"descriptor\": \"DASH\"\n }\n ],\n \"subtitles\": [\n { \"id\": \"s-1\", \"codec\": \"vtt\", \"language\": \"en\",\n \"forced\": false, \"sdh\": false, \"cc\": false, \"descriptor\": \"DASH\" }\n ]\n}\n
{\n \"episodes\": [\n {\n \"title\": { \"type\": \"episode\", \"name\": \"Pilot\", \"id\": \"ep-1\", \"season\": 1, \"number\": 1 },\n \"video\": [ ... ],\n \"audio\": [ ... ],\n \"subtitles\": [ ... ]\n }\n ],\n \"unavailable_episodes\": [\n { \"title\": { \"id\": \"ep-2\" }, \"error\": \"...\" }\n ]\n}\n
Tracks are sorted by bitrate, descending. Episodes that fail to resolve are collected into unavailable_episodes rather than failing the whole request. Track objects here do not include a download url; segment URLs are only exposed through the remote-dl session endpoints.
The drm array on a track is a list of {type, pssh?, kids?, content_keys?, license_url?} objects.
Status Error code Meaning 200 - Tracks returned. 400 INVALID_INPUT Missing service or title_id. 400 INVALID_PARAMETERS Invalid wanted range. 400 INVALID_SERVICE Unknown or disallowed service. 404 NO_CONTENT No matching episodes, or all matches failed."},{"location":"dev/rest-api/endpoints/#download-jobs","title":"Download jobs","text":"Downloads are asynchronous. POST /api/download enqueues a job and returns immediately with a job_id; the server runs each job in a background worker subprocess. You then poll the job endpoints for progress and results.
Job states
A job moves through queued \u2192 downloading \u2192 one of the terminal states completed, failed, or cancelled. Only queued jobs can be prioritized; only terminal jobs can be retried or removed.
"},{"location":"dev/rest-api/endpoints/#post-apidownload","title":"POST /api/download","text":"Create a download job. Requires service and title_id; every other field is an optional download parameter with a server-side default. On success this returns 202 Accepted, not 200.
RequestResponse 202 curl -X POST http://127.0.0.1:8786/api/download \\\n -H \"X-Secret-Key: $KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"service\": \"EXAMPLE\",\n \"title_id\": \"12345\",\n \"quality\": [2160],\n \"vcodec\": \"H265\",\n \"range\": [\"HDR10\"],\n \"wanted\": [\"S01E01\"]\n }'\n
{\n \"job_id\": \"b0f7c8e2-4a1d-4e9c-9b7e-2f3a1c0d5e6f\",\n \"status\": \"queued\",\n \"created_time\": \"2026-07-03T12:00:00+00:00\"\n}\n
Common parameters (all optional). These mirror the dl command flags:
Field Type Default Notes profile string null Credential/cookie profile. quality int[] [] Target resolution(s); best available if empty. vcodec string/string[] null Video codec filter. acodec string/string[] null Audio codec filter. vbitrate / abitrate int null Video / audio bitrate in kbps (positive). vbitrate_range / abitrate_range string null \"MIN-MAX\" bitrate window. range string[] [\"SDR\"] Dynamic range(s). channels number null Audio channel count. no_atmos boolean false Exclude Atmos tracks. wanted string[] [] Episode/season selectors. Accepts the part form, \"S01E01.2\", and the air-date form, \"2026-08-11\" or \"2026-08-01:2026-08-31\". latest_episode boolean false Only the newest episode. lang / v_lang / a_lang / s_lang string[] [\"orig\"] / [] / [] / [\"all\"] Language filters. require_subs string[] [] Required subtitle languages. forced_subs boolean false Include forced subtitles. forced_s_lang string[] [] Forced subtitle language(s); implies forced_subs. exact_lang boolean false Exact language matching. sub_format string null Output subtitle format. video_only / audio_only / subs_only / chapters_only boolean false Track-type restriction (at most one). no_subs / no_audio / no_chapters / no_video boolean false Skip a track type. audio_description boolean false Download audio description. slow boolean/string null Randomized delay between downloads. split_audio boolean null Separate files per audio codec. skip_dl boolean false Only fetch keys, do not download. export boolean false Export manifest/keys/subs to JSON. cdm_only boolean null Force CDM-only (true) or vault-only (false) key retrieval. proxy / no_proxy / no_proxy_download string / bool / bool null / false / false Proxy controls. no_folder / no_source / no_mux boolean false Output/mux controls. workers int null Threads per track. downloads int 1 Concurrent tracks. worst boolean false Lowest bitrate within quality. best_available boolean false Fall back to best available. repack boolean false Add REPACK tag. tag string null Release group tag. tmdb_id / imdb_id / tvdb_id / anilist_id - null External ID overrides. Each resolves its metadata directly instead of by a title search, and is used for tagging. Set enrich to also take the title, year and original language. Give at most one of tmdb_id, imdb_id and tvdb_id, since unshackle resolves the others from it. Sending two returns 400. anilist_id still combines with one of them. tmdb_id and tvdb_id must be positive integers, imdb_id must look like tt1375666, and anilist_id must be a positive integer or a string like mal:12345, or the request returns 400. An ID whose provider is unconfigured, such as tmdb_id with no tmdb_api_key, fails the job rather than returning 400. anilist_id needs no key. tvdb_order official, dvd, absolute, alternate, regional null Renumber episodes to a TVDB season order. Falls back to the tvdb_order config option. enrich boolean false Overwrite title, year and original language with the external source's. Needs one of tmdb_id, imdb_id, tvdb_id or anilist_id. Without one the job fails instead of returning 400. daily boolean false Treat the title as daily/date-based content and fill missing episode air dates from TVDB. The fill needs enrich and a TVDB ID. An air date the service already set is kept. output_dir string null Override output directory. no_cache / reset_cache boolean false Title cache controls. Validation. Invalid values return 400 INVALID_PARAMETERS. Notably: vcodec must be one of H264/H265/H.264/H.265/AVC/HEVC/VC1/VC-1/VP8/VP9/AV1; acodec one of AAC/AC3/EC3/EAC3/DD/DD+/AC4/OPUS/FLAC/ALAC/VORBIS/OGG/DTS; range one of SDR/HDR10/HDR10P/DV/HLG/HYBRID (HDR10+ is accepted); bitrate/worker/download counts must be positive integers; at most one of the *_only flags may be set; no_subs cannot be combined with subs_only, no_audio with audio_only, or s_lang with require_subs.
Gated parameters (developer)
A few keys are accepted but only permitted when the server config opts in, otherwise the request is rejected with 403 FORBIDDEN:
cdm (per-request CDM device name): allowed only if serve.cdm_overrides is true or a list containing that device name. credential (\"user:pass\") and credentials (a map): allowed only if serve.allow_job_credentials is truthy (off by default).
Server-side, effective parameters are merged as defaults \u2192 serve: config overrides \u2192 service-specific defaults \u2192 your request body.
Status Error code Meaning 202 - Job queued. 400 INVALID_INPUT Missing service/title_id or malformed JSON. 400 INVALID_PARAMETERS A parameter failed validation. 400 INVALID_SERVICE Unknown or disallowed service. 403 FORBIDDEN A gated parameter is not permitted."},{"location":"dev/rest-api/endpoints/#get-apidownloadjobs","title":"GET /api/download/jobs","text":"List download jobs, with optional filtering and sorting.
Query parameters
Param Values Default Description status queued, downloading, completed, failed, cancelled - Filter by status. service service tag - Filter by service. sort_by created_time, started_time, completed_time, progress, status, service created_time Sort field. sort_order asc, desc desc Sort direction. full true, false false Include full per-job details. RequestResponse 200 curl -H \"X-Secret-Key: $KEY\" \\\n \"http://127.0.0.1:8786/api/download/jobs?status=downloading&sort_by=progress&sort_order=desc\"\n
{\n \"jobs\": [\n {\n \"job_id\": \"b0f7c8e2-...\",\n \"status\": \"downloading\",\n \"created_time\": \"2026-07-03T12:00:00+00:00\",\n \"service\": \"EXAMPLE\",\n \"title_id\": \"12345\",\n \"title\": \"Example Show S01E01\",\n \"progress\": 42.5,\n \"phase\": \"downloading\",\n \"current_title\": \"Episode 1\",\n \"completed_tracks\": 1,\n \"total_tracks\": 3,\n \"active_tracks\": [\"v-1\"],\n \"track_progress\": [{\"label\": \"Video\", \"progress\": 42.5, \"speed\": \"5.2 MB/s\"}],\n \"segments_done\": 120,\n \"segments_total\": 300,\n \"speed\": \"5.2 MB/s\",\n \"skipped_subtitles\": []\n }\n ]\n}\n
With full=true, each job additionally includes parameters (with secrets redacted), started_time, completed_time, output_files, error_message, error_details, error_code, error_traceback, and worker_stderr.
Status Error code Meaning 200 - Jobs returned. 400 INVALID_PARAMETERS Invalid sort_by or sort_order."},{"location":"dev/rest-api/endpoints/#get-apidownloadjobsjob_id","title":"GET /api/download/jobs/{job_id}","text":"Fetch a single job with full details (equivalent to a full=true list entry).
Status Error code Meaning 200 - Job returned. 404 JOB_NOT_FOUND No such job."},{"location":"dev/rest-api/endpoints/#get-apidownloadjobsjob_idevents","title":"GET /api/download/jobs/{job_id}/events","text":"Stream the job's progress as Server-Sent Events. Use this instead of polling GET /api/download/jobs/{job_id}.
The response has the content type text/event-stream. Each event has this format:
event: <name>\ndata: <job JSON>\n
The data field of every event is the same job object that GET /api/download/jobs/{job_id} returns.
Event When it is sent snapshot Immediately, as the first event. Gives the job's current state. status The job leaves the queue and starts to download. progress The worker's progress record changed. The server reads that record every 0.5 seconds, so you get at most two of these events each second, and none while the record stays the same. completed The job finished. The server then closes the stream. failed The job failed. The server then closes the stream. cancelled The job was cancelled. The server then closes the stream. The server sends a : keep-alive comment each 15 seconds while the job is quiet, to keep proxies from closing an idle connection. Ignore these lines.
If the job is already in a terminal state, the server sends the snapshot event, then the terminal event, and closes the stream immediately.
Authentication from a browser
EventSource cannot set headers. For this endpoint only, the server also accepts the key in the secret_key query parameter. This works in both server modes, the default integrated server and --api-only:
const events = new EventSource(\n `http://127.0.0.1:8786/api/download/jobs/${jobId}/events?secret_key=${key}`\n);\nevents.addEventListener(\"progress\", (e) => console.log(JSON.parse(e.data).progress));\nevents.addEventListener(\"completed\", () => events.close());\n
The header always wins. Send the key in the query parameter or in the header, not both: if an X-Secret-Key header is present, the server checks that header and ignores the query parameter. See Authentication.
RequestResponse 200 curl -N -H \"X-Secret-Key: $KEY\" \\\n \"http://127.0.0.1:8786/api/download/jobs/b0f7c8e2-.../events\"\n
event: snapshot\ndata: {\"job_id\":\"b0f7c8e2-...\",\"status\":\"downloading\",\"progress\":42.5,...}\n\nevent: progress\ndata: {\"job_id\":\"b0f7c8e2-...\",\"status\":\"downloading\",\"progress\":48.0,...}\n\n: keep-alive\n\nevent: completed\ndata: {\"job_id\":\"b0f7c8e2-...\",\"status\":\"completed\",\"progress\":100.0,...}\n
Status Error code Meaning 200 - Event stream started. 404 JOB_NOT_FOUND No such job."},{"location":"dev/rest-api/endpoints/#delete-apidownloadjobsjob_id","title":"DELETE /api/download/jobs/{job_id}","text":"Cancel or remove a job. The behaviour depends on the job's current state:
- Terminal job (completed/failed/cancelled): removed from the manager, returning
204 No Content with an empty body. - Queued or downloading job: cancelled, returning
200 {\"status\": \"success\", \"message\": \"Job cancelled\"}.
Warning
A 204 response has no body. Do not attempt to parse JSON from it.
Status Error code Meaning 200 - Job cancelled. 204 - Terminal job removed (empty body). 400 INVALID_PARAMETERS Job is in a state that cannot be cancelled. 404 JOB_NOT_FOUND No such job."},{"location":"dev/rest-api/endpoints/#post-apidownloadjobsclear-finished","title":"POST /api/download/jobs/clear-finished","text":"Remove every terminal (completed/failed/cancelled) job from the manager. No request body.
Response 200 { \"removed\": 3 }\n
"},{"location":"dev/rest-api/endpoints/#post-apidownloadjobsjob_idretry","title":"POST /api/download/jobs/{job_id}/retry","text":"Enqueue a new job that reuses a terminal job's service, title, and parameters. The service allowlist and parameter gates are re-checked. Returns 202 with the new job's ID.
Retry reuses the original, unredacted parameters
Retry replays the finished job's stored parameters exactly as they were submitted, including any secrets such as credential/credentials, not the redacted parameters view that GET /api/download/jobs and GET /api/history expose. This is deliberate: it lets a retried job reproduce a credentialed download that the redacted job record alone never could. It also means anyone who can retry a job can re-run it with its original credentials, even though those credentials are masked everywhere else in the API.
Response 202 {\n \"job_id\": \"9c1e2f3a-...\",\n \"status\": \"queued\",\n \"created_time\": \"2026-07-03T12:05:00+00:00\"\n}\n
Status Error code Meaning 202 - New job queued. 404 JOB_NOT_FOUND No such job. 409 CONFLICT Job is not in a terminal state. 400 INVALID_SERVICE Service is no longer allowed. 403 FORBIDDEN A gated parameter is no longer permitted."},{"location":"dev/rest-api/endpoints/#post-apidownloadjobsjob_idpriority","title":"POST /api/download/jobs/{job_id}/priority","text":"Move a queued job to the front of the download queue.
Response 200 { \"job_id\": \"b0f7c8e2-...\", \"position\": \"front\" }\n
Status Error code Meaning 200 - Job moved to front. 404 JOB_NOT_FOUND No such job. 409 CONFLICT Job is not queued."},{"location":"dev/rest-api/endpoints/#server-management","title":"Server management","text":"Endpoints for profiles, effective configuration, download history, environment checks, and maintenance actions.
"},{"location":"dev/rest-api/endpoints/#get-apiprofiles","title":"GET /api/profiles","text":"List the named credential profiles configured per service (usable as the profile parameter elsewhere). Only services whose credentials are a mapping of profile name to credential are listed; services with a single unnamed credential are omitted. Filtered by your allowlist.
Response 200 {\n \"profiles\": {\n \"EXAMPLE\": [\"default\", \"premium\"],\n \"OTHER\": [\"us-account\"]\n }\n}\n
"},{"location":"dev/rest-api/endpoints/#get-apiconfig","title":"GET /api/config","text":"Return a read-only, redacted view of the effective server configuration, suitable for a settings page in a UI. Secrets are never emitted: keys matching secret, password, token, api_key, or credential, and any userinfo in proxy URLs, are masked.
Response 200 {\n \"config\": {\n \"dl\": { \"...\": \"redacted default dl parameters\" },\n \"serve\": {\n \"max_concurrent_downloads\": 2,\n \"job_retention_hours\": 24,\n \"history_limit\": 100,\n \"services\": null,\n \"remote_only\": false,\n \"cdm_overrides\": null,\n \"allow_job_credentials\": false\n },\n \"directories\": {\n \"downloads\": \"/home/user/Downloads/unshackle\",\n \"temp\": \"/home/user/.unshackle/temp\",\n \"cache\": \"/home/user/.unshackle/cache\"\n },\n \"services\": [\"EXAMPLE\", \"OTHER\"]\n }\n}\n
"},{"location":"dev/rest-api/endpoints/#get-apihistory","title":"GET /api/history","text":"Read the persisted history of jobs that reached a terminal state, newest first. History is stored in api_history.jsonl in the cache directory; corrupt lines are skipped and a missing file yields an empty list.
Query parameters
Param Type Default Description limit int (\u2265 1) 100 Maximum entries to return. service string - Case-insensitive service filter. Response 200 {\n \"history\": [\n {\n \"job_id\": \"b0f7c8e2-...\",\n \"service\": \"EXAMPLE\",\n \"title_id\": \"12345\",\n \"title\": \"Example Show S01E01\",\n \"status\": \"completed\",\n \"created_time\": \"2026-07-03T12:00:00+00:00\",\n \"completed_time\": \"2026-07-03T12:08:00+00:00\",\n \"output_files\": [\"/downloads/Example.S01E01.mkv\"],\n \"error_message\": null,\n \"parameters\": { \"...\": \"redacted\" }\n }\n ],\n \"count\": 1\n}\n
Status Error code Meaning 200 - History returned. 400 INVALID_PARAMETERS Invalid limit."},{"location":"dev/rest-api/endpoints/#delete-apihistoryjob_id","title":"DELETE /api/history/{job_id}","text":"Remove a single persisted history entry. Returns 204 No Content (empty body) on success.
Status Error code Meaning 204 - Entry removed (empty body). 404 NOT_FOUND No such entry (or outside your allowlist)."},{"location":"dev/rest-api/endpoints/#post-apimaintenanceclear-cache-and-post-apimaintenanceclear-temp","title":"POST /api/maintenance/clear-cache and POST /api/maintenance/clear-temp","text":"Delete and recreate the cache or temp directory. Neither takes a request body. Both are blocked with 409 CONFLICT if any job is currently downloading (the details include the offending active_jobs).
The 409 guard is about active file I/O, not an arbitrary lock: an in-flight job may still be reading cached title data under the cache directory, and active download workers are writing segment files under temp. Removing either directory mid-download would corrupt those running jobs, so the endpoints refuse until nothing is downloading.
clear-cache also wipes download history
The persistent history read by GET /api/history lives in api_history.jsonl inside the cache directory. Because clear-cache deletes and recreates that whole directory, calling it also erases your entire download history, not just cached title data. If you want to keep history, do not clear the cache; remove individual entries with DELETE /api/history/{job_id} instead.
Response 200 { \"cleared\": true, \"freed_bytes\": 1048576 }\n
Status Error code Meaning 200 - Directory cleared. 409 CONFLICT A download is currently active."},{"location":"dev/rest-api/endpoints/#post-apimaintenancerefresh-services","title":"POST /api/maintenance/refresh-services","text":"git-pull every service repository configured under directories.services. refreshed is true when all repos updated successfully (or none are configured).
Response 200 {\n \"refreshed\": true,\n \"repos\": [\n { \"spec\": \"github.com/example/services\", \"updated\": true, \"changes\": [\"a1b2c3d Update EXAMPLE\"] }\n ]\n}\n
"},{"location":"dev/rest-api/endpoints/#get-apienvcheck","title":"GET /api/env/check","text":"Report the install status of the external binaries unshackle depends on (the same set unshackle env check inspects), with best-effort version detection.
Response 200 {\n \"checks\": [\n { \"name\": \"ffmpeg\", \"installed\": true, \"version\": \"6.1.1\", \"required\": true },\n { \"name\": \"ccextractor\", \"installed\": false, \"version\": null, \"required\": false }\n ]\n}\n
"},{"location":"dev/rest-api/endpoints/#remote-dl-sessions","title":"Remote-dl sessions","text":"Developer feature
The /api/session/* endpoints power unshackle's remote download mode, where a thin local client authenticates once against a remote server, then fetches titles, tracks, and segments and proxies DRM licensing back through it. These endpoints are exposed in --remote-only mode, and they are what the built-in RemoteService client drives. Most end users never call them directly.
Sessions are held in memory. They expire after serve.session_ttl (default 300s), except while awaiting interactive authentication input, which uses a 600s window. A session is bound to the IP that created it, so a request from a different IP is rejected with 403 FORBIDDEN.
"},{"location":"dev/rest-api/endpoints/#session-lifecycle-overview","title":"Session lifecycle overview","text":"POST /api/session/create \u2192 returns session_id, status \"authenticating\"\nGET /api/session/{id}/prompt \u2192 poll until authenticated (submit answers via POST)\nGET /api/session/{id}/titles \u2192 list titles\nPOST /api/session/{id}/tracks \u2192 tracks + chapters (with URLs) for one title\nPOST /api/session/{id}/segments \u2192 resolve segment URLs / DRM / headers per track\nPOST /api/session/{id}/license \u2192 proxy or server-CDM DRM licensing\nDELETE /api/session/{id} \u2192 tear down, harvest updated cache\n
"},{"location":"dev/rest-api/endpoints/#post-apisessioncreate","title":"POST /api/session/create","text":"Create a session for a service and title. Authentication runs asynchronously in the background. This call returns immediately with status: \"authenticating\", and you then poll the prompt endpoint. The body accepts service and title_id (both required) plus a broad set of optional keys (additionalProperties is allowed): credentials ({username, password, extra?}), cookies (base64 of zlib-compressed Netscape cookie file), proxy, no_proxy, profile, cache (map of forwarded cache files), client_region, cdm_type, range_, vcodec, quality, best_available, and any service CLI options.
Response 200 {\n \"session_id\": \"d4e5f6a7-...\",\n \"service\": \"EXAMPLE\",\n \"status\": \"authenticating\"\n}\n
Status Error code Meaning 200 - Session created; authentication in progress. 400 INVALID_INPUT Missing service or title_id. 400 INVALID_SERVICE Unknown or disallowed service."},{"location":"dev/rest-api/endpoints/#get-apisessionsession_idprompt","title":"GET /api/session/{session_id}/prompt","text":"Poll for the authentication status and any pending interactive prompt (OTP, PIN, device code). Response variants:
AuthenticatingPending inputAuthenticatedFailed { \"status\": \"authenticating\" }\n
{ \"status\": \"pending_input\", \"prompt\": \"Enter the code sent to your email:\" }\n
{ \"status\": \"authenticated\" }\n
{ \"status\": \"failed\", \"error\": \"Invalid credentials\" }\n
Status Error code Meaning 200 - Status returned. 403 FORBIDDEN Request IP differs from the session creator. 404 SESSION_NOT_FOUND No such session."},{"location":"dev/rest-api/endpoints/#post-apisessionsession_idprompt","title":"POST /api/session/{session_id}/prompt","text":"Submit a response to a pending prompt. Body: { \"response\": \"...\" } (required).
Response 200 { \"status\": \"accepted\" }\n
Status Error code Meaning 200 - Response accepted. 400 INVALID_INPUT Missing response, or no prompt is pending. 404 SESSION_NOT_FOUND No such session."},{"location":"dev/rest-api/endpoints/#get-apisessionsession_idtitles","title":"GET /api/session/{session_id}/titles","text":"Fetch the titles for an authenticated session.
Response 200 {\n \"session_id\": \"d4e5f6a7-...\",\n \"titles\": [ { \"type\": \"episode\", \"name\": \"Pilot\", \"id\": \"ep-1\" } ]\n}\n
Requires an authenticated session; 404 SESSION_NOT_FOUND otherwise.
"},{"location":"dev/rest-api/endpoints/#post-apisessionsession_idtracks","title":"POST /api/session/{session_id}/tracks","text":"Get tracks and chapters for a specific title within the session. Body: { \"title_id\": \"...\" } (required). Unlike list-tracks, track objects here include download URLs, along with session headers/cookies and manifest data the client needs to download directly.
Response 200 {\n \"title\": { \"type\": \"episode\", \"name\": \"Pilot\", \"id\": \"ep-1\" },\n \"video\": [ { \"id\": \"v-1\", \"url\": \"https://...\", \"drm\": [ ... ] } ],\n \"audio\": [ { \"id\": \"a-1\", \"url\": \"https://...\" } ],\n \"subtitles\": [ { \"id\": \"s-1\", \"url\": \"https://...\" } ],\n \"chapters\": [ { \"timestamp\": \"00:00:00.000\", \"name\": \"Intro\" } ],\n \"attachments\": [ { \"url\": \"https://...\", \"name\": \"font.ttf\", \"mime_type\": \"font/ttf\", \"description\": \"\" } ],\n \"manifests\": [ { \"type\": \"dash\", \"url\": \"https://...\", \"data\": \"<base64 zlib xml>\" } ],\n \"session_headers\": { \"User-Agent\": \"...\" },\n \"session_cookies\": { \"sid\": \"...\" },\n \"server_cdm_type\": \"widevine\"\n}\n
Status Error code Meaning 200 - Tracks returned. 400 INVALID_INPUT Missing or unknown title_id. 404 SESSION_NOT_FOUND No such session."},{"location":"dev/rest-api/endpoints/#post-apisessionsession_idsegments","title":"POST /api/session/{session_id}/segments","text":"Resolve the download URL, DRM info, headers, cookies, and raw track data for selected tracks. Body: { \"track_ids\": [\"v-1\", \"a-1\"] } (required).
Response 200 {\n \"tracks\": {\n \"v-1\": { \"descriptor\": \"DASH\", \"url\": \"https://...\", \"drm\": [ ... ], \"headers\": { ... }, \"cookies\": { ... }, \"data\": { ... } }\n }\n}\n
Status Error code Meaning 200 - Segment info returned. 400 INVALID_INPUT Missing track_ids. 404 TRACK_NOT_FOUND An unknown track ID was requested."},{"location":"dev/rest-api/endpoints/#post-apisessionsession_idlicense","title":"POST /api/session/{session_id}/license","text":"Acquire DRM keys. Two modes are supported via the mode field.
Proxy mode (mode: \"proxy\", the default) forwards the client's CDM challenge to the service's license endpoint and returns the raw license bytes:
RequestResponse 200 { \"track_id\": \"v-1\", \"challenge\": \"<base64 CDM challenge>\", \"drm_type\": \"widevine\" }\n
{ \"license\": \"<base64 license bytes>\" }\n
Server-CDM mode (mode: \"server_cdm\") runs the full CDM flow on the server using the device configured for your API key, returning content keys directly. Use track_id for a single track or track_ids for a batch:
Single: Response 200Batch: Response 200 { \"keys\": { \"<kid_hex>\": \"<key_hex>\" } }\n
{ \"keys\": { \"v-1\": { \"<kid_hex>\": \"<key_hex>\" } }, \"drm_type\": \"widevine\" }\n
Field Description track_id / track_ids Single track, or a batch of tracks. challenge Base64 CDM challenge (proxy mode). pssh Base64 PSSH (server-CDM mode). drm_type widevine (default) or playready. mode proxy (default) or server_cdm. Status Error code Meaning 200 - License/keys returned. 400 INVALID_INPUT Missing challenge/PSSH, or no CDM device configured for the key. 400 INVALID_PARAMETERS Unsupported drm_type. 404 TRACK_NOT_FOUND Unknown track ID. 404 NO_CONTENT Server CDM produced no keys. 502 SERVICE_ERROR The service aborted during licensing."},{"location":"dev/rest-api/endpoints/#get-apisessionsession_id","title":"GET /api/session/{session_id}","text":"Return session metadata and validity.
Response 200 {\n \"session_id\": \"d4e5f6a7-...\",\n \"service\": \"EXAMPLE\",\n \"valid\": true,\n \"expires_in\": 300,\n \"track_count\": 4,\n \"title_count\": 10\n}\n
404 SESSION_NOT_FOUND if the session does not exist or has expired.
"},{"location":"dev/rest-api/endpoints/#delete-apisessionsession_id","title":"DELETE /api/session/{session_id}","text":"Tear down a session. Its input bridge is cancelled, any updated cache files are harvested, and the session plus its cache directory are deleted.
Response 200 {\n \"status\": \"ok\",\n \"cache\": { \"tokens.json\": \"<base64 zlib bytes>\" }\n}\n
The cache field is present only if the session produced cache files. 404 SESSION_NOT_FOUND if the session does not exist.
"},{"location":"dev/rest-api/endpoints/#error-responses","title":"Error responses","text":"Every structured error raised inside a handler is serialized to the same JSON envelope:
{\n \"status\": \"error\",\n \"error_code\": \"INVALID_PARAMETERS\",\n \"message\": \"Invalid video codec: XYZ\",\n \"timestamp\": \"2026-07-03T12:00:00+00:00\",\n \"details\": { \"field\": \"vcodec\" },\n \"retryable\": true,\n \"debug_info\": { \"exception_type\": \"APIError\", \"traceback\": \"...\" }\n}\n
details appears only when non-empty. retryable appears only when the error is retryable (e.g. network errors, rate limiting). debug_info appears only when the server was started with --debug-api.
Authentication errors differ
Errors raised by the API-key middleware use a distinct shape ({\"status\": 401, \"message\": \"Secret Key is Empty.\"} or {\"status\": 401, \"message\": \"Secret Key is Invalid.\"}) with an integer status and no error_code, timestamp, or details. A malformed JSON body on any POST, by contrast, returns the standard envelope with error_code: \"INVALID_INPUT\".
"},{"location":"dev/rest-api/endpoints/#error-codes-and-default-status","title":"Error codes and default status","text":"Error code HTTP status INVALID_INPUT 400 INVALID_SERVICE 400 INVALID_PROXY 400 INVALID_PARAMETERS 400 AUTH_FAILED 401 FORBIDDEN 403 GEOFENCE 403 NOT_FOUND 404 NO_CONTENT 404 JOB_NOT_FOUND 404 SESSION_NOT_FOUND 404 TRACK_NOT_FOUND 404 CONFLICT 409 RATE_LIMITED 429 INTERNAL_ERROR 500 DOWNLOAD_ERROR 500 WORKER_ERROR 500 SERVICE_ERROR 502 DRM_ERROR 502 NETWORK_ERROR 503 SERVICE_UNAVAILABLE 503 When a generic (uncategorized) exception reaches a handler, unshackle inspects its message and type to pick a code. For example, messages mentioning \"connection\" or \"timeout\" become NETWORK_ERROR (503, retryable), \"rate limit\" or \"429\" become RATE_LIMITED (429, retryable), and anything unrecognized collapses to INTERNAL_ERROR (500).
"},{"location":"dev/rest-api/endpoints/#see-also","title":"See also","text":" - REST API overview. What the server is and when to use it.
- Authentication. Keys, server modes, and the
X-Secret-Key header. - Quickstart. A first end-to-end request walkthrough.
- Downloading. The
dl flags mirrored by POST /api/download.
"},{"location":"dev/rest-api/errors/","title":"Error Responses","text":"Every endpoint of the unshackle REST API reports failures in a single, predictable shape. Whether a request was rejected because a field was missing, a service refused to authenticate, or the download worker crashed, the server answers with a JSON body that carries a machine-readable error code, a human-readable message, and a matching HTTP status. This page documents that format and catalogues every error code the server can emit.
If you are building a client against the API, read this page alongside the endpoint reference. Each endpoint lists the specific codes it raises, and the meanings of those codes are defined here.
Where this comes from
The error format is defined in unshackle/core/api/errors.py, and the wrapper that turns raised errors into responses lives in unshackle/core/api/routes.py. Nothing on this page is invented; it reflects the actual server behaviour.
"},{"location":"dev/rest-api/errors/#the-standard-error-shape","title":"The standard error shape","text":"A structured error is always a JSON object with status set to the literal string \"error\". The full set of fields:
Error response body{\n \"status\": \"error\",\n \"error_code\": \"INVALID_PARAMETERS\",\n \"message\": \"vcodec must be one of: H264, H265, ...\",\n \"timestamp\": \"2026-07-03T12:34:56.789012+00:00\",\n \"details\": { \"field\": \"vcodec\" },\n \"retryable\": true,\n \"debug_info\": {\n \"exception_type\": \"APIError\",\n \"traceback\": \"...\"\n }\n}\n
Field Type Always present? Meaning status string Yes Always the literal \"error\". Lets a client distinguish an error body from a success body at a glance. error_code string Yes A stable, machine-readable code from the catalogue below. Match on this, not on message. message string Yes A human-readable description of what went wrong. Wording may change between versions, so do not parse it. timestamp string Yes ISO 8601 timestamp (UTC) of when the error response was built. details object Only when non-empty Structured, error-specific context (for example the invalid field, a list of available titles, or the set of currently active jobs). retryable boolean Only when true Present and set to true when retrying the same request may succeed later (network blips, rate limiting, transient outages). Absent otherwise. debug_info object Only with --debug-api Technical diagnostics; see Debug information. Match on error_code, branch on HTTP status
Treat error_code as the primary signal your code reacts to, and the HTTP status as a coarse fallback for anything you do not recognise. The two are consistent: every code maps to a default status (see the catalogue), and the pairing is stable across releases.
"},{"location":"dev/rest-api/errors/#the-details-object","title":"The details object","text":"When present, details carries extra context specific to the error. It is only included when it is non-empty, so a client must treat a missing details as \"no extra information\". Some concrete examples you may encounter:
- A validation failure may include the offending field or the list of valid values.
- A \"title not found\" error on a remote-dl session includes
available_titles. - A maintenance conflict includes
active_jobs, the jobs blocking the operation. - Categorised exceptions add a
reason tag, such as \"network_connectivity\" or \"geofence_restriction\".
"},{"location":"dev/rest-api/errors/#the-retryable-hint","title":"The retryable hint","text":"The retryable field appears only when it is true. It is set for conditions the server believes are transient: network errors, rate limiting, and temporary service unavailability. When you see it, backing off and retrying is reasonable; when it is absent, assume the request will keep failing until you change something.
A retryable network error
{\n \"status\": \"error\",\n \"error_code\": \"NETWORK_ERROR\",\n \"message\": \"Network error occurred: Connection timed out\",\n \"timestamp\": \"2026-07-03T12:00:00+00:00\",\n \"details\": { \"reason\": \"network_connectivity\" },\n \"retryable\": true\n}\n
"},{"location":"dev/rest-api/errors/#error-code-catalogue","title":"Error code catalogue","text":"Every code below is a member of the APIErrorCode enumeration. The Status column is the default HTTP status the server returns for that code. A handler can override the status in special cases (for example, a network error is always forced to 503), but the defaults hold for the overwhelming majority of responses.
"},{"location":"dev/rest-api/errors/#client-errors-4xx","title":"Client errors (4xx)","text":"Code Status Meaning INVALID_INPUT 400 A required field is missing, or the request body could not be parsed. Also used as the generic \"your input was malformed\" code. INVALID_SERVICE 400 The named service is unknown, or is not permitted for your API key. INVALID_PROXY 400 The supplied proxy specification could not be resolved or is malformed. INVALID_PARAMETERS 400 One or more download or query parameters failed validation (bad codec, bitrate, sort field, and so on). AUTH_FAILED 401 Authentication with the streaming service failed (bad credentials or cookies). FORBIDDEN 403 The action is not allowed. Raised by server-side gates (per-key restrictions on CDM or credential overrides) and by session IP binding. GEOFENCE 403 The content is not available in the applicable region. NOT_FOUND 404 A requested resource (title, history entry, and so on) does not exist. NO_CONTENT 404 The request was valid but produced nothing: no matching titles, tracks, episodes, or keys. JOB_NOT_FOUND 404 The referenced download job does not exist. SESSION_NOT_FOUND 404 The remote-dl session does not exist or has expired. TRACK_NOT_FOUND 404 A referenced track ID is not present in the session. CONFLICT 409 The target resource is in a state that disallows the action (retrying a running job, prioritising a non-queued job, clearing cache during an active download). RATE_LIMITED 429 The streaming service is rate-limiting requests. Retryable."},{"location":"dev/rest-api/errors/#server-errors-5xx","title":"Server errors (5xx)","text":"Code Status Meaning INTERNAL_ERROR 500 An unexpected server-side error. This is the fallback for any exception the server could not categorise. DOWNLOAD_ERROR 500 The download process itself failed. WORKER_ERROR 500 The download worker subprocess errored out. SERVICE_ERROR 502 The streaming service's API returned an error, or the service does not support the requested operation. DRM_ERROR 502 DRM or license acquisition failed. Retryable. NETWORK_ERROR 503 A network connectivity problem (connection, timeout, DNS, TLS). Retryable. SERVICE_UNAVAILABLE 503 The service is temporarily unavailable or under maintenance. Retryable. Fallback status
If a code is ever emitted without a mapped status, the server falls back to 500. In practice every code above has an explicit mapping.
"},{"location":"dev/rest-api/errors/#how-errors-are-produced","title":"How errors are produced","text":"Understanding where an error comes from helps interpret it.
Explicitly raised errors. Most endpoints raise a structured APIError directly. For example, a missing service field yields INVALID_INPUT, and a disallowed service yields INVALID_SERVICE. These carry a precise code, message, and often a populated details object.
Categorised exceptions. When an endpoint calls into a streaming service and an uncategorised exception bubbles up, the server runs it through a classifier that inspects the exception type and message and maps it to the most appropriate code. The classification is keyword-based, checked in order:
If the error mentions... It becomes Retryable auth, login, credential, unauthorized, forbidden, token AUTH_FAILED (401) no connection, timeout, network, unreachable, socket, dns, resolve (or ConnectionError, TimeoutError, URLError, SSLError) NETWORK_ERROR (503) yes geofence, region, \"not available in\", territory GEOFENCE (403) no \"not found\", 404, \"does not exist\", \"invalid id\" NOT_FOUND (404) no rate limit, too many requests, 429, throttle RATE_LIMITED (429) yes drm, license, widevine, playready, decrypt DRM_ERROR (502) no \"service unavailable\", 503, maintenance, \"temporarily unavailable\" SERVICE_UNAVAILABLE (503) yes invalid, malformed, validation (or ValueError, ValidationError) INVALID_INPUT (400) no anything else INTERNAL_ERROR (500) no Categorised errors include a reason tag inside details (such as \"network_connectivity\" or \"drm_failure\") so a client can react without parsing the message text.
Invalid JSON bodies. If a POST endpoint receives a body that is not valid JSON, the server returns INVALID_INPUT (400) with the parse error in details.error:
Malformed request body{\n \"status\": \"error\",\n \"error_code\": \"INVALID_INPUT\",\n \"message\": \"Invalid JSON request body\",\n \"timestamp\": \"2026-07-03T12:00:00+00:00\",\n \"details\": { \"error\": \"Expecting value: line 1 column 1 (char 0)\" }\n}\n
"},{"location":"dev/rest-api/errors/#the-authentication-error-exception","title":"The authentication error exception","text":"There is one place where the response does not follow the standard shape: the API key authentication middleware. When you send a request to a protected endpoint without a valid X-Secret-Key header, the middleware short-circuits with a legacy body that uses an integer status and no error_code or timestamp.
Missing keyInvalid key { \"status\": 401, \"message\": \"Secret Key is Empty.\" }\n
{ \"status\": 401, \"message\": \"Secret Key is Invalid.\" }\n
Handle both shapes
A robust client must expect two error formats: the standard structured error (string status of \"error\", with error_code), and this authentication error (integer status, no error_code). The GET /api/health endpoint is exempt from authentication, so it never returns the key-error shape.
"},{"location":"dev/rest-api/errors/#responses-with-no-body","title":"Responses with no body","text":"Not every failure or completion has a JSON body. Some endpoints answer with 204 No Content and an empty body, notably deleting a terminal download job and deleting a history entry.
Do not parse 204 bodies
A 204 response has no body. Clients must check the status code before attempting to decode JSON, or the decode will fail on an empty string.
"},{"location":"dev/rest-api/errors/#debug-information","title":"Debug information","text":"By default, error responses never include tracebacks or internal diagnostics. If the server was started with the --debug-api flag, every structured error gains a debug_info object:
Error with --debug-api enabled{\n \"status\": \"error\",\n \"error_code\": \"INTERNAL_ERROR\",\n \"message\": \"An unexpected error occurred: ...\",\n \"timestamp\": \"2026-07-03T12:00:00+00:00\",\n \"debug_info\": {\n \"exception_type\": \"KeyError\",\n \"traceback\": \"Traceback (most recent call last): ...\"\n }\n}\n
The debug_info object always carries exception_type and, for exception-derived errors, a formatted traceback. Some endpoints add extra diagnostics such as captured worker stderr.
Developer use only
--debug-api exposes internal tracebacks in responses. Enable it only for local debugging, never on a server reachable by untrusted clients.
"},{"location":"dev/rest-api/errors/#client-handling-recommendations","title":"Client handling recommendations","text":"For anyone writing a consumer of the API, a durable strategy is:
- Read the HTTP status first. It tells you the broad class of outcome and lets you handle the
204/no-body and authentication-error cases before touching the body. - Branch on
error_code. For structured errors, this is the stable identifier to switch on. Do not match on message. - Respect
retryable. When it is true, back off and retry; the reference client retries 429, 500, 502, 503, and 504 with exponential backoff. When absent, fix the request rather than repeating it. - Fall back gracefully. Unrecognised codes should be surfaced using the
message plus the HTTP status.
Reference consumer
unshackle's own remote-download client treats any status of 400 or above as fatal: it logs Server error [<error_code>]: <message> and exits. It also wraps requests in a retry adapter (five attempts, backing off on 429, 500, 502, 503, and 504). This is a reasonable template for your own client's error handling.
"},{"location":"dev/rest-api/job-lifecycle/","title":"Download Job Lifecycle","text":"When you submit a download over the REST API, the server does not block the request until the file is written. Instead it creates a job, hands it back to you with an ID, and works through it in the background. Everything you do afterwards (watching progress, cancelling, retrying, reading the result) is done by referring to that job ID.
This page explains the states a job moves through, how live progress is reported, how to cancel or retry, and, in clearly marked developer sections, how the worker and manager behind the queue actually run the download.
Who this page is for
The first half is for anyone driving the API: the states, the fields you can read, and the calls that move a job around. The Under the hood sections near the end describe the internal worker/manager model and are aimed at contributors and people debugging the server. You do not need them to use the API.
For the full request and response shape of each endpoint, see Endpoints. For how errors are reported, see Error Responses.
"},{"location":"dev/rest-api/job-lifecycle/#the-five-states","title":"The five states","text":"A job is always in exactly one of five states, exposed as the status field:
Status Meaning queued Created and waiting for a free worker. No download has started yet. downloading A worker has picked the job up and the download is in progress. completed The download finished successfully; output_files lists the results. failed The download stopped with an error; error_message and error_code explain why. cancelled The job was cancelled before it could finish, from either the queued or downloading state. The last three (completed, failed, and cancelled) are terminal: once a job reaches one of them it never changes state again. Terminal jobs can be removed, retried, or read from history, but they will not resume.
"},{"location":"dev/rest-api/job-lifecycle/#how-a-job-moves-between-states","title":"How a job moves between states","text":"stateDiagram-v2\n [*] --> queued: POST /api/download\n queued --> downloading: a worker picks it up\n queued --> cancelled: DELETE (while queued)\n downloading --> completed: download succeeds\n downloading --> failed: download errors\n downloading --> cancelled: DELETE (while downloading)\n completed --> [*]\n failed --> [*]\n cancelled --> [*]\n\n completed --> queued: POST .../retry (as a NEW job)\n failed --> queued: POST .../retry (as a NEW job)\n cancelled --> queued: POST .../retry (as a NEW job)
The transitions in prose:
queued \u2192 downloading happens automatically. As soon as one of the server's workers is free, it takes the next job off the queue and starts it. You do not trigger this; you only observe it. downloading \u2192 completed is the happy path: the download engine wrote all the requested files and reported success. downloading \u2192 failed is any error during the download: a service failure, a network problem, a DRM error, and so on. The job records what went wrong. queued/downloading \u2192 cancelled happens when you send a DELETE for a job that has not finished (see Cancelling a job). - Retry does not move a terminal job back to
queued. Instead it creates a brand-new job that reuses the old job's service, title, and parameters. The original job stays in its terminal state (see Retrying a job).
Priority does not change status
Moving a job to the front of the queue with the priority endpoint reorders when it will start, but it stays queued until a worker actually picks it up. It is not a state change.
"},{"location":"dev/rest-api/job-lifecycle/#creating-a-job","title":"Creating a job","text":"A download is queued with POST /api/download. The request needs at least a service and a title_id, plus any download options you want to set. On success the server replies with 202 Accepted and the new job's identity:
Queue a downloadcurl -X POST http://127.0.0.1:8786/api/download \\\n -H \"X-Secret-Key: your-api-secret\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"service\": \"EXAMPLE\",\n \"title_id\": \"81234567\",\n \"quality\": [\"1080\"],\n \"range\": [\"HDR10\"]\n }'\n
202 Accepted{\n \"job_id\": \"b1e6c7de-6d0a-4f2f-9d1e-3a2b4c5d6e7f\",\n \"status\": \"queued\",\n \"created_time\": \"2026-07-03T12:00:00.000000\"\n}\n
The job starts in queued. Workers auto-start on the first job submitted, so there is nothing else to switch on. Keep the job_id; every later call is addressed to it.
The full parameter set
POST /api/download accepts the same download options as the dl command (quality, codecs, language selection, wanted episode ranges, and many more), with server-side defaults filling in anything you omit. The complete list, their defaults, and validation rules are documented on the Endpoints page.
"},{"location":"dev/rest-api/job-lifecycle/#reading-a-jobs-status-and-progress","title":"Reading a job's status and progress","text":"There are two ways to look at jobs.
One job in fullAll jobs (summaries) GET /api/download/jobs/{job_id} returns the complete detail for a single job, including parameters (with secrets redacted), timing, output files, and error information if it failed.
curl http://127.0.0.1:8786/api/download/jobs/b1e6c7de-6d0a-4f2f-9d1e-3a2b4c5d6e7f \\\n -H \"X-Secret-Key: your-api-secret\"\n
GET /api/download/jobs lists every job the manager currently holds. By default each entry is a summary. Pass full=true to get the same full detail as the single-job endpoint for every job.
curl \"http://127.0.0.1:8786/api/download/jobs?status=downloading&sort_by=progress&sort_order=desc\" \\\n -H \"X-Secret-Key: your-api-secret\"\n
The list can be filtered by status and service, and ordered with sort_by (created_time, started_time, completed_time, progress, status, or service) and sort_order (asc / desc). See Endpoints for the query-parameter reference.
"},{"location":"dev/rest-api/job-lifecycle/#the-progress-fields","title":"The progress fields","text":"While a job is downloading, the following fields update roughly twice a second so a client can poll and render live progress. They are present on both the summary and the full detail:
Field Type Meaning progress number 0 to 100 Overall percent complete. phase string Human-readable phase, e.g. downloading video 1080p HDR10, audio en 5.1. title string Title being processed. current_title string The specific episode/movie in progress (for a batch). completed_tracks int Number of tracks finished so far. total_tracks int Total tracks selected for the download. active_tracks list of strings Labels of the tracks downloading right now. track_progress list Per-active-track {label, progress, speed} entries. segments_done number Segments downloaded for the track in progress. segments_total number Total segments for the track in progress. speed string Transfer speed of the track in progress, e.g. 4.20 MB/s. skipped_subtitles list Subtitles that were skipped as non-fatal failures (when skip_subtitle_errors is set). The full detail adds started_time, completed_time, output_files, the redacted parameters, and, for failed jobs, error_message, error_details, error_code, error_traceback, and worker_stderr.
A typical polling loop
JOB=b1e6c7de-6d0a-4f2f-9d1e-3a2b4c5d6e7f\nwhile true; do\n curl -s \"http://127.0.0.1:8786/api/download/jobs/$JOB\" \\\n -H \"X-Secret-Key: your-api-secret\" \\\n | python -c 'import sys,json; d=json.load(sys.stdin); print(d[\"status\"], d[\"progress\"], d.get(\"phase\"))'\n sleep 1\ndone\n
Or subscribe instead of polling
GET /api/download/jobs/{job_id}/events pushes the same job object as Server-Sent Events, and closes the stream when the job ends. See Endpoints.
What the percentage means
progress climbs from 0 toward 90 while tracks download, then the final stretch to 100 covers post-download work such as decryption cleanup, repackaging, and muxing. A job that has finished downloading but is still muxing can sit near 90 for a moment before completing. On success the manager sets it to exactly 100.
For a batch, progress is per-episode, not whole-batch
A multi-episode request (a wanted range) is a single job that processes its episodes sequentially. As each new episode begins, progress and the track counts (completed_tracks / total_tracks) reset to zero and count up again for that episode alone. The top-level bar therefore tracks the current episode, not the batch as a whole. To derive true overall completion, compare the growing output_files array against parameters.wanted rather than trusting progress.
"},{"location":"dev/rest-api/job-lifecycle/#terminal-states-in-detail","title":"Terminal states in detail","text":""},{"location":"dev/rest-api/job-lifecycle/#completed","title":"Completed","text":"A completed job has its output_files populated with the paths of the muxed results, and progress is 100. This is the state you are waiting for.
"},{"location":"dev/rest-api/job-lifecycle/#failed","title":"Failed","text":"A failed job carries the error. error_message is the short human message, error_code is one of the API's structured error codes (for example NETWORK_ERROR, DRM_ERROR, AUTH_FAILED, or DOWNLOAD_ERROR), and error_details gives more context. When the server was started with --debug-api, error_traceback and worker_stderr are also filled in for diagnosis.
Secrets are scrubbed from error text
A service can echo a credential or a proxy URL into its output. The server masks any known secret values out of error_message, error_details, error_traceback, and worker_stderr before returning them, so it is safe to log these fields.
The error-code catalogue and how each maps to an HTTP status is on the Error Responses page.
"},{"location":"dev/rest-api/job-lifecycle/#cancelled","title":"Cancelled","text":"A cancelled job was stopped on request before finishing. It may have partial output, but it is not considered complete. See below.
"},{"location":"dev/rest-api/job-lifecycle/#cancelling-a-job","title":"Cancelling a job","text":"Send DELETE /api/download/jobs/{job_id}. The behaviour depends on the job's current state, and this is important because the response differs:
Job state when you DELETE What happens Response queued or downloading The job is cancelled. A running download's worker process is terminated. 200 with {\"status\": \"success\", \"message\": \"Job cancelled\"} completed, failed, or cancelled (terminal) The job is removed from the manager entirely. 204 No Content, empty body Not found - 404 with error code JOB_NOT_FOUND In other words, DELETE on a live job cancels it, while DELETE on an already finished job deletes it from the list. This dual meaning is deliberate: one verb both stops in-flight work and tidies up afterwards.
Do not parse the body of a 204
Removing a terminal job returns 204 No Content with an empty body. A client must not try to decode JSON from it; check the status code first.
Cancel a running jobcurl -X DELETE http://127.0.0.1:8786/api/download/jobs/b1e6c7de-6d0a-4f2f-9d1e-3a2b4c5d6e7f \\\n -H \"X-Secret-Key: your-api-secret\"\n
To remove all terminal jobs at once, use POST /api/download/jobs/clear-finished, which returns {\"removed\": <count>}.
"},{"location":"dev/rest-api/job-lifecycle/#retrying-a-job","title":"Retrying a job","text":"POST /api/download/jobs/{job_id}/retry re-runs a finished job by creating a new job with the same service, title, and parameters. The original job is untouched and stays in its terminal state; the response gives you the new job's ID with 202.
- Only terminal jobs can be retried. Retrying a
queued or downloading job returns 409 with error code CONFLICT. - A missing job returns
404 (JOB_NOT_FOUND). - The service allowlist and any download gates are re-checked for the new job, exactly as they were for the original submission, so a retry can be rejected with
INVALID_SERVICE or FORBIDDEN if permissions changed.
202 Accepted, a fresh job{\n \"job_id\": \"c2f7d8ef-7e1b-5a3a-ae2f-4b3c5d6e7f80\",\n \"status\": \"queued\",\n \"created_time\": \"2026-07-03T12:30:00.000000\"\n}\n
"},{"location":"dev/rest-api/job-lifecycle/#prioritising-a-job","title":"Prioritising a job","text":"POST /api/download/jobs/{job_id}/priority moves a queued job to the front of the line, so it will be the next one a free worker picks up.
- The job must be
queued. Prioritising a job in any other state returns 409 (CONFLICT); a missing job returns 404. - On success the response is
{\"job_id\": \"<id>\", \"position\": \"front\"}.
This only reorders the queue; the job's status stays queued until a worker starts it.
"},{"location":"dev/rest-api/job-lifecycle/#history-and-retention","title":"History and retention","text":"Every job that reaches a terminal state is also written once to a persistent history log, so it survives even after the job is removed from the live list.
- Read it with
GET /api/history (newest first; supports limit and service filters). - Delete a single entry with
DELETE /api/history/{job_id} (returns 204).
The live in-memory jobs are separate from this on-disk history. A background cleanup task periodically drops old terminal jobs from the live list based on a retention period; the history file keeps them for later reference. See Endpoints for the history response shape.
Why the history file lives under the cache directory
api_history.jsonl is placed under the cache directory on purpose, so it stays out of the config/data tree. It is disposable runtime state, not user configuration or curated data, which is exactly why clearing the cache is a safe operation against it.
"},{"location":"dev/rest-api/job-lifecycle/#under-the-hood-the-workermanager-model","title":"Under the hood: the worker/manager model","text":"Developer section
Everything below describes server internals. It is useful for contributors and for anyone debugging the server, but it is not needed to use the API.
"},{"location":"dev/rest-api/job-lifecycle/#the-manager-and-its-queue","title":"The manager and its queue","text":"A single process-wide download queue manager owns all jobs. It holds them in memory keyed by ID, backs the wait list with a FIFO queue, and keeps a separate priority deque for jobs that have been bumped to the front. On the first job, it starts a fixed pool of worker tasks plus one cleanup task; starting is idempotent, so later jobs reuse the same pool.
- Concurrency is capped by
max_concurrent_downloads (default 2). That many jobs can be in downloading at once; the rest wait in queued. - Retention is governed by
job_retention_hours (default 24). An hourly cleanup task removes terminal jobs whose completion time (or, if never started, creation time) is older than the retention window.
Two concurrency knobs that are easy to conflate
max_concurrent_downloads caps how many separate download jobs run at once. The separate serve.downloads setting controls how many tracks download in parallel within a single job. They are independent, and raising one does not affect the other.
Concurrency and retention config keys
The manager reads these as top-level config attributes (config.max_concurrent_downloads and config.download_job_retention_hours). Those keys are not part of the standard config schema, so in practice the built-in defaults of 2 and 24 apply. The live values are surfaced by GET /api/config. The related serve.history_limit (default 100) instead controls how many entries the on-disk history file keeps.
Each worker loop drains the priority deque first, then the FIFO queue. A job that was promoted has its stale FIFO copy skipped so it does not run twice. When a worker takes a job it flips it to downloading, stamps started_time, runs it, and on finish stamps completed_time and records history.
"},{"location":"dev/rest-api/job-lifecycle/#each-job-runs-in-its-own-subprocess","title":"Each job runs in its own subprocess","text":"The manager does not run the download in the server's event loop or even in the server process. For each job it spawns a separate Python subprocess:
python -m unshackle.core.api.download_worker <payload.json> <result.json> <progress.json>\n
Three temporary files bridge the parent and the worker:
payload.json: written by the manager before launch; contains the job ID, service, title ID, and merged parameters. progress.json: written continuously by the worker as the download proceeds. result.json: written by the worker at the end.
Running each download out-of-process isolates the server from crashes, memory growth, and blocking work inside the download engine, and makes hard cancellation (killing the process) reliable.
The worker's exit codes are 0 for success, 1 for a download error, and 2 for bad arguments. Its result.json is either {\"status\": \"success\", \"output_files\": [...]} or {\"status\": \"error\", \"message\", \"error_details\", \"error_code\", \"traceback\"}. If the process exits non-zero, or the result is missing or not a success, the parent marks the job failed and adopts the worker's error_code (or derives one by categorising the exception).
"},{"location":"dev/rest-api/job-lifecycle/#how-progress-reaches-the-api","title":"How progress reaches the API","text":"While the worker runs, the manager polls progress.json every 0.5 s and copies the fields it finds onto the live DownloadJob. That is what the GET endpoints return.
Inside the worker, progress is aggregated across all selected tracks. Each track's completion fraction is weighted by its bitrate, so video and audio dominate the bar and subtitles barely move it (subtitles get a small fixed weight). Track fractions are kept monotonic: the percentage only ever climbs. The download portion is scaled to fill 0 to 90; the remaining 90 to 100 is driven by the download engine as it repackages and muxes. A track counts as done when it reaches a terminal state such as Downloaded, Decrypted, or a skipped subtitle.
"},{"location":"dev/rest-api/job-lifecycle/#how-cancellation-actually-works","title":"How cancellation actually works","text":"Each job carries a cancellation event. When you DELETE a live job:
- If it is
queued, it flips straight to cancelled, its history is recorded immediately (it never reaches a worker), and the worker loop skips it if it later pops it off the queue. - If it is
downloading, the manager sets the cancellation event and flips the job to cancelled. The polling loop notices the event, terminate()s the worker subprocess (escalating to kill() if it does not exit within 5 seconds), and raises a cancellation so the task unwinds. The subprocess model is what makes this a clean, immediate stop rather than a cooperative one.
"},{"location":"dev/rest-api/job-lifecycle/#secret-handling-in-serialized-jobs","title":"Secret handling in serialized jobs","text":"Job parameters may carry secrets: a raw user:pass credential, or a proxy URL with embedded userinfo. Whenever the manager serializes a job (for an API response or the history file), it masks the sensitive keys (credential, credentials, password, token, api_key) and strips userinfo from proxy URLs. Free-text fields that a service might have leaked a secret into (error_message, error_details, error_traceback, worker_stderr, and worker stdout/stderr in the logs) are scrubbed of the job's known secret values, longest-match first, before they are ever returned or logged.
"},{"location":"dev/rest-api/job-lifecycle/#see-also","title":"See also","text":" - Endpoints: the full request/response reference for every job endpoint.
- Error Responses: the error envelope, codes, and HTTP statuses a failed job reports.
- Authentication: the
X-Secret-Key header and per-key service allowlists that gate job creation and retry. - Downloading: the equivalent options on the
dl command line.
"},{"location":"dev/rest-api/quickstart/","title":"REST API Quick Start","text":"Unshackle ships with a built-in REST API that exposes the same download engine you drive from the command line. It is served by the unshackle serve command and lets you queue downloads, poll their progress, browse services, and manage history over HTTP instead of the terminal.
The full loop runs end to end: start the server, authenticate, submit a download job, and poll it to completion, using real curl commands built directly from the API routes.
Who this page is for
This is a developer guide. It assumes you are comfortable with HTTP, JSON, and a tool like curl. If you just want to download something, the command line is simpler. The REST API is meant for building tools, UIs, and automation on top of unshackle.
"},{"location":"dev/rest-api/quickstart/#before-you-begin","title":"Before you begin","text":"You need a working unshackle install with services and CDM devices already configured, exactly as you would for a normal command-line download. If unshackle dl works for you, the API will too, since it runs the identical download pipeline.
Installed as a toolFrom a clone uv tool install git+https://github.com/unshackle-dl/unshackle.git\nunshackle --help\n
uv run unshackle --help\n
"},{"location":"dev/rest-api/quickstart/#step-1-configure-an-api-secret","title":"Step 1: Configure an API secret","text":"Every request (except the health check) is authenticated with a secret key sent in the X-Secret-Key header. The server reads this key from the serve section of your unshackle.yaml config.
At minimum you must set api_secret:
unshackle.yamlserve:\n api_secret: \"choose-a-long-random-secret\"\n
If you want the API to be able to license and download DRM-protected content, you also need to grant CDM devices to the key. Configure that under serve.users, keyed by the secret:
unshackle.yamlserve:\n api_secret: \"choose-a-long-random-secret\"\n users:\n choose-a-long-random-secret:\n devices: [\"your_widevine_device\"] # Widevine .wvd stems\n playready_devices: [\"your_playready_device\"] # PlayReady .prd stems\n username: api_user\n
The secret must exist
If api_secret is not set and you do not pass --no-key, unshackle serve refuses to start and raises an error. Treat the secret like a password. Anyone who has it can queue downloads on your machine.
Disabling auth for local testing
Passing --no-key to serve turns authentication off entirely: every request is allowed and the X-Secret-Key header is ignored. This is convenient for a throwaway local test, but never expose a --no-key server to a network you do not fully trust.
"},{"location":"dev/rest-api/quickstart/#step-2-start-the-server","title":"Step 2: Start the server","text":"Start the REST API with the serve command:
unshackle serve --api-only\n
By default the server binds to 127.0.0.1:8786. The --api-only flag serves just the REST API and omits the Widevine/PlayReady CDM HTTP endpoints; drop it if you also want to expose those.
Common options:
Option Default Effect -h, --host 127.0.0.1 Address to bind to -p, --port 8786 Port to bind to --api-only off Serve only the REST API, no CDM endpoints --no-key off Disable API-key authentication (allow all requests) --debug-api off Include tracebacks and stderr in error responses --debug off Enable DEBUG-level logging --remote-only off Expose only the remote-session subset of routes --caddy off Also launch Caddy as a reverse proxy (for HTTPS) Once it is up you will see the API mounted under /api/ and, unless you started in remote-only mode, an interactive Swagger UI:
REST API endpoints available at http://127.0.0.1:8786/api/\nSwagger UI available at http://127.0.0.1:8786/api/docs/\n
Swagger UI is also behind auth
In --api-only mode (without --no-key), the Swagger UI at /api/docs/ requires the X-Secret-Key header just like every other route. Only /api/health is exempt from authentication.
For the rest of this page we will assume the server is reachable at http://127.0.0.1:8786 and store the secret in a shell variable:
export SECRET=\"choose-a-long-random-secret\"\nexport BASE=\"http://127.0.0.1:8786\"\n
"},{"location":"dev/rest-api/quickstart/#step-3-confirm-the-server-is-alive","title":"Step 3: Confirm the server is alive","text":"The health endpoint is the one route that never requires a key. Use it to confirm the server is running and to read its version:
curl \"$BASE/api/health\"\n
200 OK{\n \"status\": \"ok\",\n \"version\": \"5.3.0\",\n \"update_check\": {\n \"update_available\": false,\n \"current_version\": \"5.3.0\",\n \"latest_version\": \"5.3.0\"\n }\n}\n
The update_check block is best effort. If unshackle cannot reach the update source, update_available and latest_version come back as null rather than failing the request.
"},{"location":"dev/rest-api/quickstart/#step-4-authenticate-a-real-request","title":"Step 4: Authenticate a real request","text":"Every other endpoint needs the X-Secret-Key header. A quick way to prove your key works is to list the services the server can see:
curl \"$BASE/api/services\" \\\n -H \"X-Secret-Key: $SECRET\"\n
200 OK (truncated){\n \"services\": [\n {\n \"tag\": \"EXAMPLE\",\n \"aliases\": [\"EX\"],\n \"geofence\": [],\n \"title_regex\": null,\n \"url\": \"https://example.com\",\n \"help\": \"EXAMPLE service documentation...\",\n \"needs_auth\": true,\n \"has_search\": true,\n \"has_drm\": true,\n \"auth_methods\": [\"cookies\", \"credentials\"],\n \"cli_params\": []\n }\n ]\n}\n
If the key is missing or wrong, you get a 401 with a plain body:
401 Unauthorized{ \"status\": 401, \"message\": \"Secret Key is Invalid.\" }\n
Two error shapes exist
The authentication middleware returns the short {\"status\": 401, \"message\": \"...\"} shape shown above. Every other error the API produces uses the richer structured shape (error_code, timestamp, details, ...) described in Error handling. A robust client should handle both.
The tag value for the service you want is the identifier you pass as service in the calls below.
"},{"location":"dev/rest-api/quickstart/#step-5-submit-a-download-job","title":"Step 5: Submit a download job","text":"Downloads are asynchronous. You POST a job to /api/download, the server immediately hands back a job_id, and the actual download runs in the background so your request never blocks.
The only required fields are service and title_id. Everything else mirrors the flags of the dl command and has a sensible default, so a minimal job looks like this:
curl -X POST \"$BASE/api/download\" \\\n -H \"X-Secret-Key: $SECRET\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"service\": \"EXAMPLE\",\n \"title_id\": \"12345\"\n }'\n
A more realistic job pins the quality and codecs and asks for specific episodes:
curl -X POST \"$BASE/api/download\" \\\n -H \"X-Secret-Key: $SECRET\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"service\": \"EXAMPLE\",\n \"title_id\": \"12345\",\n \"quality\": [1080],\n \"vcodec\": \"H265\",\n \"range\": [\"SDR\"],\n \"wanted\": [\"S01E01\", \"S01E02\"]\n }'\n
On success the server responds with 202 Accepted and the identifier you will poll:
202 Accepted{\n \"job_id\": \"3f9c1e0a-8d2b-4e6f-9a1c-77b0f2a1c4d5\",\n \"status\": \"queued\",\n \"created_time\": \"2026-07-03T12:00:00.000000+00:00\"\n}\n
Capture the job_id for the next step:
JOB=$(curl -s -X POST \"$BASE/api/download\" \\\n -H \"X-Secret-Key: $SECRET\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"service\":\"EXAMPLE\",\"title_id\":\"12345\",\"quality\":[1080]}' \\\n | python -c \"import sys, json; print(json.load(sys.stdin)['job_id'])\")\n
"},{"location":"dev/rest-api/quickstart/#frequently-used-download-fields","title":"Frequently used download fields","text":"The request body accepts the full download parameter set. A few of the most useful:
Field Default Meaning service - Service tag (required) title_id - Title identifier (required) profile null Named credential/cookie profile to use quality [] Resolution(s), e.g. [1080]; empty means best available vcodec null Video codec, e.g. \"H265\" acodec null Audio codec, e.g. \"EC3\" range [\"SDR\"] Dynamic range: SDR, HDR10, HDR10+, DV, HLG, HYBRID wanted [] Episodes to fetch, e.g. [\"S01E01\"]; empty means all lang [\"orig\"] Video/audio language(s) s_lang [\"all\"] Subtitle language(s) downloads 1 Number of tracks to download concurrently proxy null Proxy URI or country code Validation is strict: invalid codecs, subtitle formats, dynamic-range names, non-positive counts, or mutually exclusive flags (for example both video_only and audio_only) come back as 400 with error code INVALID_PARAMETERS. See the full reference on the download reference page for every accepted field.
Gated fields
Passing a per-request CDM (cdm) or inline credentials (credential / credentials) is refused with 403 FORBIDDEN unless the server operator has explicitly opted in via serve.cdm_overrides and serve.allow_job_credentials. Both are off by default.
"},{"location":"dev/rest-api/quickstart/#step-6-poll-the-job-to-completion","title":"Step 6: Poll the job to completion","text":"A job moves through these states:
queued \u2192 downloading \u2192 completed | failed | cancelled\n
The last three are terminal. Poll the job detail endpoint until you reach one of them. This endpoint always returns the full job record:
curl \"$BASE/api/download/jobs/$JOB\" \\\n -H \"X-Secret-Key: $SECRET\"\n
While the download is running you will see status: \"downloading\" and a progress value climbing from 0 toward 100:
200 OK (in progress){\n \"job_id\": \"3f9c1e0a-8d2b-4e6f-9a1c-77b0f2a1c4d5\",\n \"status\": \"downloading\",\n \"progress\": 42.7,\n \"phase\": \"downloading\",\n \"service\": \"EXAMPLE\",\n \"title_id\": \"12345\",\n \"title\": \"Example Show S01E01\",\n \"current_title\": \"Example Show S01E01\",\n \"completed_tracks\": 1,\n \"total_tracks\": 3,\n \"segments_done\": 512,\n \"segments_total\": 1200,\n \"speed\": \"8.4 MiB/s\",\n \"created_time\": \"2026-07-03T12:00:00.000000+00:00\",\n \"started_time\": \"2026-07-03T12:00:03.000000+00:00\"\n}\n
When it finishes you will see status: \"completed\" and the produced files under output_files:
200 OK (completed){\n \"job_id\": \"3f9c1e0a-8d2b-4e6f-9a1c-77b0f2a1c4d5\",\n \"status\": \"completed\",\n \"progress\": 100.0,\n \"output_files\": [\"/path/to/downloads/Example Show S01E01.mkv\"],\n \"completed_time\": \"2026-07-03T12:04:31.000000+00:00\"\n}\n
A simple polling loop in shell:
poll.shwhile true; do\n STATUS=$(curl -s \"$BASE/api/download/jobs/$JOB\" \\\n -H \"X-Secret-Key: $SECRET\" \\\n | python -c \"import sys, json; print(json.load(sys.stdin)['status'])\")\n echo \"job status: $STATUS\"\n case \"$STATUS\" in\n completed|failed|cancelled) break ;;\n esac\n sleep 3\ndone\n
Polling cadence
A few seconds between polls is plenty. The server updates a job's progress roughly twice a second internally, and each poll reads the current snapshot.
Or let the server push the updates
GET /api/download/jobs/{job_id}/events streams the same job record as Server-Sent Events, and closes the stream when the job ends. It removes the polling loop above. See Endpoints.
curl -N \"$BASE/api/download/jobs/$JOB/events\" \\\n -H \"X-Secret-Key: $SECRET\"\n
If a job ends in failed, the full record includes error_message, error_code, and (when the server was started with --debug-api) error_traceback and worker_stderr to help you diagnose it. Sensitive values in these fields are scrubbed before they are returned.
"},{"location":"dev/rest-api/quickstart/#step-7-clean-up","title":"Step 7: Clean up","text":"Once you have collected the output you can remove the finished job. DELETE on a job has two behaviors depending on its state:
curl -X DELETE \"$BASE/api/download/jobs/$JOB\" \\\n -H \"X-Secret-Key: $SECRET\"\n
- If the job is terminal (completed/failed/cancelled) it is removed from the manager and you get
204 No Content with an empty body. - If the job is still queued or downloading it is cancelled instead, and you get
200 with {\"status\": \"success\", \"message\": \"Job cancelled\"}.
Do not parse JSON on 204
A 204 response has no body. This applies to terminal-job removal here and to deleting history entries; check the status code before attempting to decode JSON.
To clear every finished job at once instead of one at a time:
curl -X POST \"$BASE/api/download/jobs/clear-finished\" \\\n -H \"X-Secret-Key: $SECRET\"\n
200 OK{ \"removed\": 3 }\n
"},{"location":"dev/rest-api/quickstart/#managing-the-queue","title":"Managing the queue","text":"A handful of endpoints round out job control. All require the X-Secret-Key header.
Method Path Purpose GET /api/download/jobs List all jobs, with optional filtering and sorting GET /api/download/jobs/{job_id} Full detail for one job DELETE /api/download/jobs/{job_id} Cancel a running job, or remove a terminal one POST /api/download/jobs/{job_id}/retry Re-queue a new job from a terminal one POST /api/download/jobs/{job_id}/priority Move a queued job to the front of the queue POST /api/download/jobs/clear-finished Remove all terminal jobs Listing supports query parameters for filtering by status or service, sorting via sort_by (created_time, started_time, completed_time, progress, status, service) and sort_order (asc / desc), and requesting full per-job detail with full=true:
curl \"$BASE/api/download/jobs?status=downloading&sort_by=progress&sort_order=desc\" \\\n -H \"X-Secret-Key: $SECRET\"\n
200 OK{\n \"jobs\": [\n {\n \"job_id\": \"3f9c1e0a-8d2b-4e6f-9a1c-77b0f2a1c4d5\",\n \"status\": \"downloading\",\n \"service\": \"EXAMPLE\",\n \"title_id\": \"12345\",\n \"title\": \"Example Show S01E01\",\n \"progress\": 42.7,\n \"phase\": \"downloading\",\n \"completed_tracks\": 1,\n \"total_tracks\": 3\n }\n ]\n}\n
Retrying reuses the original job's service, title, and parameters to enqueue a brand new job (returning 202 with a fresh job_id); it only works on terminal jobs and returns 409 CONFLICT otherwise. Prioritizing only works on jobs still in the queued state.
"},{"location":"dev/rest-api/quickstart/#beyond-a-single-download","title":"Beyond a single download","text":"The download loop above is the core of the API, but the same server exposes more:
- Discovery:
POST /api/search finds titles by query, and POST /api/list-titles / POST /api/list-tracks inspect what a title offers before you commit to downloading it. See list titles and tracks. - History:
GET /api/history reads a persisted log of jobs that reached a terminal state (newest first), and DELETE /api/history/{job_id} removes a single entry. See history. - Introspection:
GET /api/profiles lists configured credential profiles per service, GET /api/config returns a redacted view of the effective server config, and GET /api/env/check reports which external binaries are installed. - Maintenance:
POST /api/maintenance/clear-cache, .../clear-temp, and .../refresh-services perform housekeeping (the cache/temp clears are blocked with 409 while a download is active). - Remote sessions: the
/api/session/* endpoints drive the thin-client \"remote download\" flow, where the server authenticates and licenses on behalf of a local client. See remote sessions.
The interactive Swagger UI at /api/docs/ documents every route and lets you try them in the browser (send your X-Secret-Key there too).
"},{"location":"dev/rest-api/quickstart/#error-handling","title":"Error handling","text":"Apart from the auth middleware's short form, every error the API returns uses a single structured envelope:
{\n \"status\": \"error\",\n \"error_code\": \"INVALID_PARAMETERS\",\n \"message\": \"Human-readable description\",\n \"timestamp\": \"2026-07-03T12:00:00Z\",\n \"details\": { }\n}\n
details appears only when there is something to add, retryable: true appears only for transient failures, and a debug_info block (exception type and traceback) is included only when the server runs with --debug-api.
The error_code maps to an HTTP status. The ones you will meet most often:
Error code HTTP Typical cause INVALID_INPUT 400 Malformed JSON body or missing required field INVALID_SERVICE 400 Unknown or disallowed service tag INVALID_PARAMETERS 400 A download field failed validation AUTH_FAILED 401 Service login/credential failure FORBIDDEN 403 A gated field was used without server opt-in NOT_FOUND 404 Title or resource does not exist JOB_NOT_FOUND 404 No job with that job_id CONFLICT 409 Illegal state transition (e.g. retry a running job) RATE_LIMITED 429 Upstream throttling (retryable) INTERNAL_ERROR 500 Unexpected server-side failure SERVICE_ERROR 502 The service raised an error NETWORK_ERROR 503 Connection/timeout reaching the service (retryable) Retry politely
Codes marked retryable (NETWORK_ERROR, RATE_LIMITED, SERVICE_UNAVAILABLE) reflect transient conditions. Back off and retry rather than hammering the server.
"},{"location":"dev/rest-api/remote-sessions/","title":"Remote Service Sessions","text":"A remote service session lets one machine do the hard, service-specific work (authenticating to a streaming service, listing titles, resolving tracks and manifests, and, optionally, DRM licensing) while another machine runs the actual download, decryption, and muxing locally.
This is the mechanism behind unshackle's client/server \"remote download\" workflow. A thin local client (RemoteService) talks to an unshackle serve instance over HTTP. The server keeps an authenticated service instance alive between requests so the client can authenticate once and then make many follow-up calls against the same session.
Why you'd use this
- The server has service accounts, cookies, or a CDM you don't want to copy to every machine.
- You want to run downloads on a fast/local machine but keep credentials and licensing centralized.
- A service is region-locked to where the server lives, and the client isn't.
"},{"location":"dev/rest-api/remote-sessions/#the-big-picture","title":"The big picture","text":"\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 HTTP (X-Secret-Key) \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 Local machine (client) \u2502 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u25b6 \u2502 unshackle serve \u2502\n\u2502 \u2502 \u2502 \u2502\n\u2502 unshackle dl --server \u2502 POST /api/session/create \u2502 authenticated Service \u2502\n\u2502 \u2502 GET /api/session/{id}/titles \u2502 instance, kept alive in \u2502\n\u2502 \u2022 track selection \u2502 POST /api/session/{id}/tracks \u2502 an in-memory session \u2502\n\u2502 \u2022 download / decrypt \u2502 POST /api/session/{id}/segments \u2502 store (TTL-based) \u2502\n\u2502 \u2022 mux \u2502 POST /api/session/{id}/license \u2502 \u2502\n\u2502 \u2502 DELETE /api/session/{id} \u2502 \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n
The client implements the same interface dl expects from a normal service, but every service-facing method proxies to the server. Everything after track selection (downloading segments, decrypting, and muxing) runs locally.
Migration: remote_dl is gone
The RemoteService adapter lives inside the ordinary dl command and replaces the old standalone remote_dl command. If you have prior scripts or docs that invoke remote_dl, use dl with a configured remote server instead. The functionality now rides on dl via RemoteService.
For the auth-facing endpoints, header format, and error shapes referenced below, see Authentication and the endpoint reference in the REST API index.
"},{"location":"dev/rest-api/remote-sessions/#configuring-a-remote-server-client-side","title":"Configuring a remote server (client side)","text":"On the client, add a remote_services block to your unshackle.yaml. Each entry names a server and provides its URL and API key.
unshackle.yaml (client)remote_services:\n my_server:\n url: \"https://my-box.example:8786\"\n api_key: \"your-api-key\"\n # Optional: let the server's CDM do the licensing instead of a local CDM\n server_cdm: false\n # Optional: per-service config overrides applied locally (title_map, cdm, etc.)\n services:\n EXAMPLE:\n title_map:\n \"0ABCDEF\": \"The Show (Renamed Locally)\"\n
Key Required Meaning url yes Base URL of the unshackle serve instance (no trailing slash needed) api_key no Sent as the X-Secret-Key header on every request server_cdm no When true, DRM keys are resolved by the server's CDM rather than a local device (default false) services no Per-service local config overrides, keyed by service tag Port default
The unshackle serve default port is 8786 (not 8080). Use the port your server actually binds. Some older example snippets show 8080, which is not the default.
"},{"location":"dev/rest-api/remote-sessions/#selecting-a-server","title":"Selecting a server","text":"If exactly one server is configured, unshackle uses it implicitly:
unshackle dl EXAMPLE 0ABCDEF\n
If more than one server is configured, you must pick one with --server:
unshackle dl --server my_server EXAMPLE 0ABCDEF\n
With no remote_services configured at all, unshackle raises a clear error telling you to add the block shown above.
"},{"location":"dev/rest-api/remote-sessions/#what-happens-during-a-download","title":"What happens during a download","text":"From your point of view the command looks like an ordinary download. Under the hood the client walks a session through its lifecycle.
1. Create + authenticate2. Interactive prompts (if needed)3. Titles + tracks4. Licensing5. Download + close The client calls POST /api/session/create, forwarding whatever it can so the server doesn't have to prompt:
- Local credentials for the service/profile (
{username, password, extra?}) - Local cookies, compressed and base64-encoded
- A resolved proxy, or, if you didn't set one, your detected client region so the server can auto-proxy to match
- Track-selection hints (
range_, vcodec, quality, best_available) so the server fetches the right manifests - Your local per-service cache files (e.g. refreshed tokens)
The server responds immediately with a session ID and a status. Authentication runs in the background on the server.
Some services need a one-time code, PIN, or device confirmation. When the server's auth flow asks for input, the session enters pending_input. The client polls, displays the prompt to you locally, collects your answer, and sends it back. The server's auth thread resumes with your response.
Enter the 6-digit code sent to your email: 483920\n
Once authenticated, the client fetches the title list, then the tracks for the chosen title. The tracks come back with playback URLs, and DASH/ISM manifests are shipped as compressed XML so the client can re-parse them locally for downloading. Any session_headers / session_cookies the server used are merged into the client's local HTTP session.
For DRM content the client either proxies its CDM challenge through the server, or, if server_cdm: true, asks the server to run the full CDM flow and return the content keys directly. See Server-side vs. proxied CDM below.
The client downloads, decrypts, and muxes locally. On completion it deletes the session. If the server has updated cache files (e.g. a refreshed token), they're returned on delete and saved locally so the next session can skip interactive auth.
Renaming remote titles locally
You can rename titles for a remote service you don't have installed locally by adding a title_map under that service in your client remote_services.<name>.services config. The server sends raw titles; your local map wins.
This is deliberate: the server does no title_map remapping of its own and sends titles exactly as the service returns them. All remapping happens on the client, applied to the titles the server sends back. It keeps the final output name fully under the client's control and lets you rename titles even for services you have no local install of.
"},{"location":"dev/rest-api/remote-sessions/#server-side-vs-proxied-cdm","title":"Server-side vs. proxied CDM","text":"There are two ways DRM keys get resolved, chosen by the client's server_cdm flag.
Proxied CDM (default)Server-side CDM (server_cdm: true) Your local CDM builds the license challenge. The client sends that challenge to POST /api/session/{id}/license, the server forwards it to the service, and the raw license bytes come back for your local CDM to parse.
- Keeps your CDM local; the server just relays the license request.
- Used when
server_cdm is false (the default).
The server's CDM does everything. The client sends track IDs (or a PSSH), the server checks its key vaults, loads the device configured for your API key, runs the CDM flow, and returns KID:KEY pairs directly. No CDM is needed on the client.
- Enable with
server_cdm: true in the server entry. - The server tells the client which DRM type it actually used (
widevine or playready).
"},{"location":"dev/rest-api/remote-sessions/#session-lifecycle-and-expiry","title":"Session lifecycle and expiry","text":"Sessions live in an in-memory store on the server and expire on a timer. You generally never touch this directly, but it explains behavior you might observe.
Behavior Value Notes Idle session TTL 300s (5 min) default Refreshed on every request to the session Max concurrent sessions 100 default Oldest (least recently used) is evicted when full Auth/input timeout 600s (10 min) Sessions still authenticating or awaiting a prompt use this longer window instead of the TTL Cleanup sweep every 60s Expired sessions are removed and their input prompts cancelled Auth is never rushed
A session that is still authenticating or waiting on pending_input is not subject to the short 300s TTL; it gets the full 600s auth window. This gives you time to enter an interactive code without the session vanishing mid-prompt.
The TTL and max-session limits are read from server config:
unshackle.yaml (server)serve:\n session_ttl: 300 # seconds a session may sit idle\n max_sessions: 100 # cap on concurrent sessions\n
"},{"location":"dev/rest-api/remote-sessions/#security-properties-worth-knowing","title":"Security properties worth knowing","text":" - API key required. Every session request carries
X-Secret-Key; requests without a valid key are rejected (health check aside). See Authentication. - IP binding. A session records the creator's IP at create time. If a later request for the same session comes from a different IP, the server returns
403 FORBIDDEN. Sessions are not portable between hosts. - Namespaced, isolated cache. Each session gets its own cache directory, namespaced by a hash of the API key and the session ID, so sessions can't read each other's cached tokens. The directory is deleted when the session ends.
- Secrets are scrubbed from logs. Session IDs, service tags, and other user values are sanitized before logging.
"},{"location":"dev/rest-api/remote-sessions/#developer-reference","title":"Developer reference","text":"This section is for developers
The material below documents the server-side implementation and the HTTP contract. End users configuring a client can stop at the sections above.
"},{"location":"dev/rest-api/remote-sessions/#session-endpoints","title":"Session endpoints","text":"All routes are exposed even in --remote-only server mode. Paths use the session_id returned by create.
Why --remote-only exists
--remote-only narrows the server to just the health, services, search, and session subset (and emits CORS headers) precisely so it is safe to sit behind Cloudflare or serve cross-origin browser clients. That trimmed surface, rather than the full --api-only mode, is what makes a CORS/Cloudflare-fronted deployment practical: reach for it when the server is public-facing or accessed from a browser origin, not when you simply want the local HTTP API.
Method Path Purpose POST /api/session/create Build + authenticate a service instance; returns a session ID immediately GET /api/session/{id}/titles Fetch and cache the title list POST /api/session/{id}/tracks Resolve tracks, manifests, chapters, headers/cookies for a title POST /api/session/{id}/segments Resolve per-segment/track download descriptors POST /api/session/{id}/license DRM licensing (proxy or server CDM) GET /api/session/{id}/prompt Poll interactive auth status / pending prompt POST /api/session/{id}/prompt Submit an answer to a pending prompt GET /api/session/{id} Session info (validity, TTL, counts) DELETE /api/session/{id} Close the session, return updated cache, clean up"},{"location":"dev/rest-api/remote-sessions/#create-request-and-response","title":"Create: request and response","text":"POST /api/session/create requires service and title_id. It also accepts credentials, cookies (base64 of zlib-compressed Netscape cookie file), proxy, no_proxy, profile, cache (a map of filename \u2192 base64(zlib(bytes))), client_region, cdm_type, and the track-selection hints range_, vcodec, quality, best_available, plus arbitrary service CLI options (additionalProperties: true).
The response returns before authentication finishes:
{ \"session_id\": \"...uuid4...\", \"service\": \"EXAMPLE\", \"status\": \"authenticating\" }\n
Authentication runs on a background thread (asyncio.to_thread(authenticate, ...)). The session starts in AUTHENTICATING; the client must poll the prompt endpoint until it reaches authenticated (or failed).
"},{"location":"dev/rest-api/remote-sessions/#interactive-auth-the-inputbridge","title":"Interactive auth: the InputBridge","text":"When the service calls its input function during authenticate() on the server thread, an InputBridge pauses that thread and exposes the prompt to the HTTP layer.
AuthStatus values: authenticating, pending_input, authenticated, failed. InputBridge.request_input(prompt, timeout=600) blocks the sync auth thread on a threading.Event until submit_response() or cancel() fires. - A timeout raises
TimeoutError and marks the session FAILED. AUTH_INPUT_TIMEOUT = 600.0 seconds. This is also the TTL granted to AUTHENTICATING / PENDING_INPUT sessions in the store.
GET /api/session/{id}/prompt returns one of:
{ \"status\": \"authenticated\" }\n{ \"status\": \"authenticating\" }\n{ \"status\": \"pending_input\", \"prompt\": \"Enter code: \" }\n{ \"status\": \"failed\", \"error\": \"...message...\" }\n
A missing session returns 404 SESSION_NOT_FOUND; an IP mismatch returns 403 FORBIDDEN. POST /api/session/{id}/prompt takes { \"response\": \"...\" } and returns { \"status\": \"accepted\" }; posting with no pending prompt is an INVALID_INPUT error.
"},{"location":"dev/rest-api/remote-sessions/#sessionstore-internals","title":"SessionStore internals","text":"Source: unshackle/core/api/session_store.py. A singleton obtained via get_session_store().
- Config-driven limits.
serve.session_ttl (default 300) and serve.max_sessions (default 100) are read as properties, so config changes take effect without recreating the store. create() evicts the least-recently-accessed session when at capacity, then stores a new SessionEntry (defaulting auth_status to AUTHENTICATED; the create handler overrides it to AUTHENTICATING). get() refreshes last_accessed via touch(). It returns None (and deletes the entry) if an authenticated session has been idle longer than the TTL. Sessions in AUTHENTICATING / PENDING_INPUT are exempt from TTL expiry. cleanup_expired() runs every 60s: authenticated sessions expire at ttl, in-flight-auth sessions expire at AUTH_INPUT_TIMEOUT. Removing a session cancels its InputBridge and deletes its cache directory (pruning empty parent dirs up to, but not including, the cache root). cancel_all_bridges() is called on server shutdown to unblock any waiting auth threads.
SessionEntry fields:
Field Description session_id UUID4 string service_tag Normalized service tag service_instance The authenticated service object kept alive between calls titles / title_map Result of get_titles() and a title_id \u2192 Title map tracks / tracks_by_title / chapters_by_title Cached resolved tracks and chapters creator_ip IP recorded at create time for IP-binding checks cache_tag Per-session cache directory tag input_bridge InputBridge for interactive auth, if any auth_status / auth_error Current AuthStatus and last error message created_at / last_accessed Timestamps; last_accessed drives TTL and LRU eviction"},{"location":"dev/rest-api/remote-sessions/#per-session-cache-namespacing","title":"Per-session cache namespacing","text":"The create handler builds a Cacher namespaced as:
_sessions/<pbkdf2_hmac(sha256, X-Secret-Key, \"unshackle-session-ns\", 100000)[:12]>/<session_id>/<service>\n
Forwarded cache files are written into that directory before authentication. On DELETE, the handler harvests updated cache files (compressing each with zlib and base64-encoding, excluding titles_* files) and returns them under a cache key so the client can persist refreshed tokens:
{ \"status\": \"ok\", \"cache\": { \"tokens\": \"...base64(zlib(bytes))...\" } }\n
"},{"location":"dev/rest-api/remote-sessions/#session-info-response","title":"Session info response","text":"GET /api/session/{id}:
{\n \"session_id\": \"...\",\n \"service\": \"EXAMPLE\",\n \"valid\": true,\n \"expires_in\": 300,\n \"track_count\": 12,\n \"title_count\": 40\n}\n
expires_in is the configured TTL, not time remaining
The expires_in value reports the store's configured session_ttl, not the seconds left before this specific session expires.
"},{"location":"dev/rest-api/remote-sessions/#reference-client-remoteservice","title":"Reference client: RemoteService","text":"Source: unshackle/core/remote_service.py. This is the canonical consumer of the session API and a good template for any client.
RemoteClient._request sets X-Secret-Key and User-Agent: unshackle/<version>, uses a 120s timeout for POST and 30s for GET/DELETE, and treats any status_code >= 400 as fatal: it logs Server error [<error_code>]: <message> and raises SystemExit(1). - Retries. The download-side HTTP session mounts an adapter with
Retry(total=5, backoff_factor=0.2, status_forcelist=[429, 500, 502, 503, 504]). - Flow.
authenticate() \u2192 create (+ poll prompt every 2s up to a 600s deadline, answering pending_input prompts) \u2192 get_titles() \u2192 get_tracks() (merging returned session_headers/session_cookies, re-parsing manifests) \u2192 license via proxy or server_cdm \u2192 close() (DELETE, saving any returned cache). - Server resolution.
resolve_server() reads config.remote_services.<name> into {url, api_key, services, server_cdm}, injecting the server_cdm flag into the services map as _server_cdm.
"},{"location":"dev/rest-api/remote-sessions/#troubleshooting","title":"Troubleshooting","text":"\\\"Could not connect to remote server ... Is it running?\\\"
The client couldn't reach the URL. Confirm unshackle serve is running on the server, the url/port in remote_services are correct (default port 8786), and any firewall or reverse proxy allows the connection.
403 FORBIDDEN mid-download
The request came from a different IP than the one that created the session. Sessions are IP-bound. Don't move between networks (or NAT egress IPs) during a remote download.
Auth times out or the prompt never resolves
Interactive auth allows up to 600s. If you miss that window the session is marked failed and cleaned up; just re-run the download. If the server never prompts you, check that credentials/cookies forwarded from the client are valid for the service.
\\\"Multiple remote services configured. Use --server ...\\\"
You have more than one entry under remote_services. Pass --server <name> to pick one.
"},{"location":"getting-started/configuration-file/","title":"The Configuration File","text":"Almost everything about how unshackle behaves is controlled by a single YAML file named unshackle.yaml: where it saves downloads, which CDM it uses, your credentials, proxies, filename templates, and more. This page covers where that file lives, how unshackle finds it, the shape of its contents, the directory layout unshackle builds around it, and how to edit values from the command line.
Where the exhaustive key list lives
This page is an overview. For the complete, key-by-key breakdown of every setting, its type, and its default, see the Configuration Reference.
"},{"location":"getting-started/configuration-file/#the-config-filename","title":"The config filename","text":"The main configuration file is always named unshackle.yaml. It is a plain YAML file. You can open and edit it in any text editor, or manage individual values with the unshackle cfg command.
If you have never created one, that is fine: unshackle runs entirely on built-in defaults when no config file exists. The file only needs to contain the keys you want to change from their defaults.
"},{"location":"getting-started/configuration-file/#where-the-config-file-lives","title":"Where the config file lives","text":"When unshackle starts, it searches a fixed list of locations and uses the first one that exists. In order of priority:
# Location Typical path 1 The unshackle package folder .../site-packages/unshackle/unshackle.yaml 2 The parent of the package folder .../site-packages/unshackle.yaml 3 Your OS user-config directory see the table below The third location, your per-user config directory, is the recommended place for most installations, because it lives outside the package and survives reinstalls and upgrades. Its exact path depends on your operating system:
LinuxWindowsmacOS ~/.config/unshackle/unshackle.yaml\n
%LOCALAPPDATA%\\unshackle\\unshackle.yaml\n
~/Library/Application Support/unshackle/unshackle.yaml\n
Not sure which file is being used?
Run unshackle env info. It prints the path the config was loaded from (or tells you none was found), along with every directory unshackle is currently using.
The config is read exactly once, when unshackle starts. If you edit the file, the change takes effect on the next command you run.
"},{"location":"getting-started/configuration-file/#top-level-structure","title":"Top-level structure","text":"unshackle.yaml is a flat map of top-level keys. Each key configures one area of the program. You only include the keys you want to change; everything else falls back to its default.
Here is a small, realistic example that sets a download location, a release tag, a default CDM, and one service's credentials:
unshackle.yaml# Where finished downloads are written\ndirectories:\n downloads: ~/Videos/unshackle\n\n# Release group tag appended to filenames\ntag: MYGRP\n\n# Default Widevine/PlayReady device to use, with per-service overrides\ncdm:\n default: my_device_l3\n EXAMPLE: my_device_l1\n\n# Per-service login details (service tag -> \"username:password\")\ncredentials:\n EXAMPLE: my_email@example.com:hunter2\n\n# Default HTTP headers merged into every request\nheaders:\n Accept-Language: en-US,en;q=0.9\n
The top-level keys group loosely into these areas:
Area Example keys Downloading & DRM dl, cdm, remote_cdm, decryption Networking network, headers Credentials & cookies credentials, firefox_cookies Tracks & muxing subtitle, audio, muxing, language_tags Key vaults key_vaults, vault_timeout Proxies & remote proxy_providers, remote_services, serve, services Naming & tagging tag, output_template, chapter_fallback_name External API keys tmdb_api_key, tvdb_api_key, simkl_client_id, ipinfo_api_key, metadata_providers Behavior & logging update_checks, redact_paths, debug, unicode_filenames Paths directories, filenames For the full list with types and defaults, see the Configuration Reference.
Unknown keys are silently ignored
unshackle does not validate your config against a schema. If you misspell a key, it is simply skipped and the default is used. You will not get an error. Double-check key names (and their nesting) if a setting does not seem to apply.
"},{"location":"getting-started/configuration-file/#setting-download-defaults-dl","title":"Setting download defaults (dl:)","text":"If you always pass the same dl flags (a language, a resolution, a codec), put them under a dl: key once and unshackle applies them to every download. Any flag from Downloading works here; the key is the flag's long name with dashes turned into underscores (--best-available \u2192 best_available).
unshackle.yamldl:\n lang: [en] # -l en\n quality: [1080] # -q 1080\n vcodec: [H.265] # -v H.265\n sub_format: srt # convert subtitles to SRT\n downloads: 2 # two tracks at once\n
You can still override any of these on the command line for a one-off; an explicit flag always beats the config default. You can also scope defaults to a single service by nesting a dl: block under it:
Per-service defaultsdl:\n lang: [en] # default for everything\nservices:\n EXAMPLE:\n dl:\n lang: [en, ja] # Example downloads English + Japanese\n
A few keys are named after the flag's internal name
Most keys are obvious, but set these exact ones: range (-r), list (--list), tmdb_id (--tmdb), imdb_id (--imdb), tvdb_id (--tvdb), no_atmos (--noatmos), and output_dir (-o). The Configuration Reference has the full list and every available key.
"},{"location":"getting-started/configuration-file/#the-directory-layout","title":"The directory layout","text":"The directories key controls where unshackle reads and writes its various files. Each directory has a sensible default, and you can override most of them by giving a new path. Paths support ~ for your home directory.
unshackle.yamldirectories:\n downloads: ~/Videos/unshackle\n temp: /mnt/fast/unshackle-temp\n cache: ~/.cache/unshackle\n
The directories unshackle uses:
Name Purpose Overridable downloads Default output folder for finished downloads Yes temp Temporary working files during a download Yes cache Cache store (title cache, update checks, service caches) Yes cookies Per-service cookie files Yes logs Log files Yes exports Export JSONs Yes wvds Widevine devices (.wvd files) Yes prds PlayReady devices (.prd files) Yes dcsl DCSL data Yes services Search paths for service code (see below) Yes commands CLI command modules Yes vaults Vault modules Yes fonts Bundled fonts Yes Some directories cannot be moved
unshackle protects five internal entries: app_dirs, core_dir, namespace_dir, user_configs, and data. If you list any of them under directories, unshackle ignores the override. This is intentional; those paths are tied to where the package is installed.
"},{"location":"getting-started/configuration-file/#the-services-directory-is-special","title":"The services directory is special","text":"Unlike the other entries, services is a list, and each entry can be either a local folder or a remote repository. This lets you mix your own local service code with services pulled from Git:
unshackle.yamldirectories:\n services:\n - you/your-services # a GitHub owner/repo shorthand\n - https://example.com/private-services.git\n - ~/code/local-services # a local folder\n
unshackle searches entries in the order listed, and the first source to define a given service tag wins, so put local folders last if you want them to act as fallbacks rather than overrides. unshackle clones remote repositories on first use and refreshes them at most once a day. See Creating a Service for how service discovery and repositories work.
"},{"location":"getting-started/configuration-file/#the-filenames-key","title":"The filenames key","text":"Alongside directories, the filenames key lets you override the templates unshackle uses when naming its own working and log files (for example the log filename or the temporary chapters file). Most users never need to touch this. The available names and their default templates are listed in the Configuration Reference.
"},{"location":"getting-started/configuration-file/#editing-the-config-with-unshackle-cfg","title":"Editing the config with unshackle cfg","text":"You do not have to edit unshackle.yaml by hand. The unshackle cfg command reads and writes individual values for you, creating the config file (and its parent directory) if it does not exist yet.
Read a single value by passing its key. Nested keys use dot notation:
$ unshackle cfg tag\n$ unshackle cfg cdm.default\n
Set a value by passing a key and a value:
$ unshackle cfg tag MYGRP\n$ unshackle cfg cdm.default my_device_l3\n
unshackle parses the value as a Python literal, so write booleans as True/False and quote the strings inside a list (\"['en']\"). Anything that is not valid Python literal syntax is stored as a plain string:
$ unshackle cfg update_checks False\n$ unshackle cfg vault_timeout 30\n
Remove a value with --unset:
$ unshackle cfg cdm.default --unset\n
List everything currently set with --list:
$ unshackle cfg --list\n
When it writes, unshackle cfg targets the config file that was loaded. If none exists yet, it creates unshackle.yaml inside the unshackle package folder (search location 1), not your OS user-config directory. To keep the config outside the package, create the file at the user-config path yourself first, then unshackle cfg writes to it.
Editing with cfg strips comments
Because unshackle cfg rewrites the whole file when it saves, any comments in unshackle.yaml are removed by a write. If you keep important notes as comments, edit the file by hand instead, or keep those notes elsewhere.
"},{"location":"getting-started/configuration-file/#next-steps","title":"Next steps","text":" - Browse the Configuration Reference for every key and default.
- Set up your first download in Downloading.
- Learn how services are discovered and updated in Creating a Service.
"},{"location":"getting-started/installation/","title":"Installation","text":"unshackle is a modular archival tool for movies, TV, and music. It ships as a single Python package named unshackle that installs one console command, also called unshackle. This page covers installing that command, the Python version it needs, and the external command-line tools it expects to find on your PATH.
What you get
Installing the package registers a single entry point, the unshackle command. It covers everything: downloading, key-vault management, device provisioning, the REST server, and the helper utilities.
"},{"location":"getting-started/installation/#requirements-at-a-glance","title":"Requirements at a glance","text":"Requirement Recommended version Needed for Python 3.11 to 3.14 Running unshackle itself uv \u2265 0.5 Installing and running unshackle FFmpeg \u2265 6.0 Media processing and analysis MKVToolNix \u2265 80 MKV muxing and metadata editing shaka-packager 2.6.1 DRM decryption Bento4 \u2265 1.6.0-639 Alternate DRM decryption (mp4decrypt) dovi_tool \u2265 2.1 Dolby Vision handling SubtitleEdit \u2265 5.0 Subtitle conversion (optional) The sections below explain each of these and how to confirm your setup is complete.
"},{"location":"getting-started/installation/#python-version-support","title":"Python version support","text":"unshackle requires Python 3.11 or newer, up to and including 3.14 (requires-python = \">=3.11,<3.15\"). Anything older than 3.11 or 3.15 and later is unsupported and the installer will refuse to build the environment.
You do not usually need to install a matching Python interpreter by hand, since uv can download and manage one for you. If you are working inside an existing virtual environment, make sure it is on a supported version.
"},{"location":"getting-started/installation/#installing-with-uv","title":"Installing with uv","text":"The recommended way to install unshackle is as a uv tool. This creates an isolated environment for the package and puts the unshackle command on your PATH without touching your system Python.
Install as a uv tooluv tool install git+https://github.com/unshackle-dl/unshackle.git\n
Once it finishes, the unshackle command is available globally:
unshackle --help\n
Upgrading and removing
Because it is installed from a git URL, re-run the same uv tool install command (uv will update in place) to pull a newer build, and use uv tool uninstall unshackle to remove it.
"},{"location":"getting-started/installation/#running-from-a-clone","title":"Running from a clone","text":"If you would rather work from a checkout of the repository (for example to try an in-development branch or to keep local service code alongside unshackle), use uv run inside the clone. This keeps the project's virtual environment active for the duration of the command, so you do not have to activate it yourself.
Run from a git clonegit clone https://github.com/unshackle-dl/unshackle.git\ncd unshackle\nuv run unshackle --help\n
Every command shown elsewhere in these docs works the same way from a clone, just prefix it with uv run:
uv run unshackle env check\n
Developer note
Contributors will also want the development and test dependency groups. The project defines dev (linters, type checkers) and test (pytest and friends) groups in pyproject.toml; install them with uv sync --group dev --group test inside the clone. End users can ignore these entirely.
"},{"location":"getting-started/installation/#external-tools-on-your-path","title":"External tools on your PATH","text":"unshackle shells out to a number of external command-line programs for media processing and DRM work. It looks for each one first in the binaries/ folder inside the installed package, and then falls back to your system PATH, so installing these tools system-wide (or dropping them into that folder) both work.
"},{"location":"getting-started/installation/#required-tools","title":"Required tools","text":"These must be present for core downloading, decryption, and muxing to work:
Tool Binary looked up Why it is needed FFmpeg ffmpeg Media processing: remuxing, conversion, stream handling FFprobe ffprobe Media analysis: inspecting tracks and container details MKVToolNix mkvmerge Muxing downloaded tracks into a final MKV mkvpropedit mkvpropedit Editing MKV metadata after muxing shaka-packager shaka-packager / packager Decrypting DRM-protected content (the default decryptor) shaka-packager is the default decryptor
unshackle decrypts with shaka-packager unless you explicitly configure a different decryptor. If it is missing, protected downloads will fail at the decryption step. See the configuration reference for the decryption key if you want to switch to mp4decrypt.
"},{"location":"getting-started/installation/#recommended-and-optional-tools","title":"Recommended and optional tools","text":"These extend unshackle's capabilities. Install the ones relevant to what you download. For example, dovi_tool only matters if you handle Dolby Vision.
Tool Binary looked up Why it is needed Bento4 (mp4decrypt) mp4decrypt Alternative DRM decryptor to shaka-packager dovi_tool dovi_tool Dolby Vision metadata handling HDR10Plus_tool hdr10plus_tool HDR10+ metadata handling SubtitleEdit seconv, SubtitleEdit Subtitle conversion (the SeConv CLI from 5.x) CCExtractor ccextractor Extracting closed captions FFplay ffplay Simple preview player MPV mpv Advanced preview player (used by util crop/util range previews) HolaProxy hola-proxy Hola proxy provider support Caddy caddy Optional reverse proxy for unshackle serve --caddy Docker docker Gluetun VPN proxy support Git git Fetching and updating remote service repositories SubtitleEdit specifics
unshackle looks for seconv first, the batch CLI shipped with SubtitleEdit 5.x, before falling back to a SubtitleEdit (4.x) binary. The 5.0 GUI has no batch mode, so the SeConv CLI is what you want for automated subtitle conversion.
"},{"location":"getting-started/installation/#verifying-your-installation","title":"Verifying your installation","text":"After installing, confirm the command runs and that unshackle can see the tools it depends on.
"},{"location":"getting-started/installation/#confirm-the-command-is-available","title":"Confirm the command is available","text":"unshackle --help\n
This prints the list of available commands (dl, env, kv, serve, wvd, prd, search, util, cfg, import). If your shell reports that unshackle is not found, the uv tool bin directory is probably not on your PATH. Run uv tool update-shell and open a new terminal.
"},{"location":"getting-started/installation/#check-external-dependencies","title":"Check external dependencies","text":"The env check subcommand inspects your environment and reports which tools it found:
unshackle env check\n
OutputFrom a clone It prints a table grouped by category (Core, DRM, HDR, Subtitle, Player, Network) with a status column: a green check for tools that resolved and a red cross for those it could not find, plus a summary line such as All required tools installed \u2713 or Missing required: <names>.
uv run unshackle env check\n
If a required tool shows as missing, install it and put it on your PATH (or in the package's binaries/ folder), then re-run the check.
"},{"location":"getting-started/installation/#inspect-environment-paths","title":"Inspect environment paths","text":"To see where unshackle will read its configuration from and where it stores downloads, caches, cookies, and devices, use:
unshackle env info\n
If no configuration file exists yet, this command lists the locations unshackle searches for one, so you know where to create it. See the configuration guide for the details of that file.
"},{"location":"getting-started/installation/#next-steps","title":"Next steps","text":" - Set up your configuration file (
unshackle.yaml). - Start downloading content once your tools check out.
"},{"location":"getting-started/quickstart/","title":"Quick Start","text":"This page takes you from a fresh install to your first downloaded file. You will create the one piece of configuration unshackle insists on, supply the service module and CDM it needs to fetch and decrypt media, run a basic unshackle dl command, and learn where the finished file lands.
Before you start
This guide assumes unshackle is already installed and on your PATH. If unshackle --help does not print a help screen, work through Installation first.
unshackle includes no services
unshackle handles manifests, DRM, downloading, and muxing. It does not include a module for any streaming platform, so you write that part yourself (step 3). EXAMPLE in this guide stands in for the tag you give yours.
"},{"location":"getting-started/quickstart/#1-check-your-environment","title":"1. Check your environment","text":"unshackle drives several external tools (FFmpeg, MKVToolNix, shaka-packager, and others) to decrypt, repack, and mux media. Confirm they are visible before you download anything:
unshackle env check\n
This prints a dependency table. The required tools (FFmpeg, FFprobe, MKVToolNix, mkvpropedit, and shaka-packager) must show a green check. Optional tools such as dovi_tool (Dolby Vision) and CCExtractor (closed captions) only matter for the features that use them.
Tip
The summary line at the bottom reports installed/total and lists anything required that is still missing, so you know exactly what to install next.
"},{"location":"getting-started/quickstart/#2-create-a-minimal-config","title":"2. Create a minimal config","text":"unshackle reads a single YAML file named unshackle.yaml. To see where it looks for that file, and where it would accept one if you have not made it yet, run:
unshackle env info\n
If no config exists, this prints the candidate locations. In search order they are:
unshackle.yaml inside the unshackle package folder. unshackle.yaml in that folder's parent. unshackle.yaml in your OS user-config directory (~/.config/unshackle/ on Linux, %LOCALAPPDATA%\\unshackle\\ on Windows, ~/Library/Application Support/unshackle/ on macOS).
The first file that exists wins. Create unshackle.yaml in one of those locations.
"},{"location":"getting-started/quickstart/#the-one-key-you-must-set","title":"The one key you must set","text":"unshackle refuses to start a download unless it knows how to name the output file. That means output_template is the one setting a first run genuinely requires. Everything else has a sensible default. A minimal, working config looks like this:
unshackle.yamloutput_template:\n movies: \"{title} ({year}) {quality} {source}\"\n series: \"{title} {season_episode} {episode_name?} {quality} {source}\"\n
Each {variable} is filled in from the title and the tracks you downloaded. The full set of valid variables, including resolution, video, audio, hdr, edition, tag, and more, is documented in Output and Naming.
Spaces or dots?
unshackle auto-detects your naming style from the template: if the separators between variables are mostly spaces, it uses spaces; if they are mostly dots, it produces scene-style Title.S01E01.1080p names. Write the template in the style you want the filenames to look.
Editing config from the CLI strips comments
You can read and set keys with unshackle cfg (for example unshackle cfg tag MYGROUP or unshackle cfg --list), but writing a value rewrites the file and removes any comments it contained. If you keep notes in your config, edit the file by hand instead.
"},{"location":"getting-started/quickstart/#3-write-a-service","title":"3. Write a service","text":"The service tag you pass to dl (like EXAMPLE) maps to a service module, the plugin that talks to one streaming platform. unshackle includes none, so you write the one you need. Creating a Service covers how.
Put the finished module in your directories.services folder (unshackle env info shows the path). The tag then works with dl, search, and the other service commands.
"},{"location":"getting-started/quickstart/#4-add-a-cdm-for-drm","title":"4. Add a CDM for DRM","text":"Most streaming content is encrypted. To fetch decryption keys, unshackle needs a CDM, a Widevine device (.wvd) or a PlayReady device (.prd). Register a Widevine device you already have with:
unshackle wvd add /path/to/device.wvd\n
This validates the file and moves it into your WVDs directory. Then point services at it in your config. The cdm map is keyed by service tag, with a default that covers everything else:
unshackle.yamlcdm:\n default: my_device # the .wvd file's name, without the extension\n EXAMPLE: my_other_device # override for a specific service\n
PlayReady works the same way with .prd files created and managed by the prd command. If a title is DRM-free, no CDM is needed.
Tip
Run unshackle wvd parse my_device to inspect a device's security level and contents, and unshackle env info to confirm where WVDs and PRDs are stored.
"},{"location":"getting-started/quickstart/#5-provide-authentication","title":"5. Provide authentication","text":"Services that require a login read either cookies or credentials.
-
Cookies: export the service's cookies to a Netscape-format text file and place it in your cookies directory. unshackle looks for, in order: cookies/{SERVICE}.txt, then cookies/{SERVICE}/{profile}.txt, then cookies/{SERVICE}/default.txt. So a file at cookies/EXAMPLE.txt is picked up automatically for the EXAMPLE service.
-
Credentials: store a username and password per service in your config:
unshackle.yamlcredentials:\n EXAMPLE: \"email@example.com:your-password\"\n
Use the -p/--profile flag to switch between multiple accounts for the same service. Whether a given service needs cookies, credentials, or nothing at all depends on the service module.
"},{"location":"getting-started/quickstart/#6-find-a-title-optional","title":"6. Find a title (optional)","text":"If you have a URL or ID already, skip this. Otherwise, search the service for a title and note the id it prints, since that is what you feed to dl:
unshackle search EXAMPLE \"My Show\"\n
You can also list what a service exposes for a given title without downloading:
unshackle dl --list-titles EXAMPLE 81234567 # show seasons/episodes\nunshackle dl --list EXAMPLE 81234567 # show available tracks\n
"},{"location":"getting-started/quickstart/#7-run-your-first-download","title":"7. Run your first download","text":"A download always has three parts:
unshackle dl <FLAGS> <SERVICE-TAG> <TITLE>\n
dl carries every quality, language, track, and output flag. <SERVICE-TAG> picks which service to talk to (e.g. EXAMPLE). <TITLE> is the URL, ID, or slug the service understands.
A good first command asks for 1080p with English audio:
Your first downloadunshackle dl -q 1080 -l en EXAMPLE 81234567\n
unshackle will fetch the title, select the tracks matching your flags, acquire keys through your CDM (and any key vaults), then decrypt, mux, and tag the result.
"},{"location":"getting-started/quickstart/#handy-first-flags","title":"Handy first flags","text":"Flag Meaning -q, --quality Target resolution(s), e.g. -q 1080 or -q 1080,720. Defaults to best available. -l, --lang Language(s) for video and audio, e.g. -l en or -l orig,en. orig = the title's original language. Defaults to orig. -sl, --s-lang Subtitle language(s); defaults to all. -v, --vcodec Video codec, e.g. -v H.265. Defaults to any. -r, --range Dynamic range, e.g. -r HDR10 or -r DV. Defaults to SDR. -w, --wanted Which episodes, e.g. -w S01 or -w S01E01-S01E03. -o, --output Override the output directory for this run. --list List the tracks that would be downloaded, then stop. A few realistic variations
# A whole first season in the best available quality\nunshackle dl -w S01 EXAMPLE 81234567\n\n# 2160p HDR10 with the original-language audio plus English subtitles\nunshackle dl -q 2160 -r HDR10 -l orig -sl en EXAMPLE 81234567\n\n# Just the newest episode of an ongoing show\nunshackle dl --latest-episode EXAMPLE 81234567\n
See Downloading for the complete flag reference, including codec, bitrate, channel-layout, and track-type selection.
"},{"location":"getting-started/quickstart/#8-where-the-output-lands","title":"8. Where the output lands","text":"By default, unshackle writes finished files to the downloads directory (unshackle env info shows its exact path; the built-in default is a downloads folder one level above the installed unshackle package). Override it per run with -o:
unshackle dl -q 1080 -o /mnt/media/incoming EXAMPLE 81234567\n
- unshackle writes movies as a single
.mkv file named from your movies template. - TV episodes are grouped into a per-show / per-season folder (from your
series template) unless you pass --no-folder. - unshackle builds the filename from your
output_template and adds IMDb/TMDB/TVDB IDs to the file's metadata tags when available.
To change the default output location permanently, set it in your config:
unshackle.yamldirectories:\n downloads: /mnt/media/incoming\n
"},{"location":"getting-started/quickstart/#where-to-go-next","title":"Where to go next","text":" - Downloading. The full
dl command: quality, codecs, languages, track selection, hybrid Dolby Vision, and output control. - Configuration. Every
unshackle.yaml key, plus directories, key vaults, proxies, and naming templates. - REST API. Run the
serve server to drive downloads over HTTP.
You are never far from help
unshackle --help, unshackle dl --help, and unshackle <command> --help list every option. When in doubt, add --list to a dl command to preview what it would do before it downloads anything.
"},{"location":"guide/cli-reference/","title":"CLI Reference","text":"A complete reference for every unshackle command, subcommand, and flag. Commands are grouped by purpose:
dl: the download pipeline (the command you use most). search: find titles on a service. import: rebuild a download from an export file. cfg, env: manage configuration and the environment. kv: Key Vault operations. wvd, prd: Widevine / PlayReady device management. serve: run the local CDM + REST API server. util: helper media utilities.
Getting help on any command
Every command accepts -h, --help, or -?. For example unshackle dl --help or unshackle wvd new --help. Service-specific arguments for dl and search (such as a title ID or search query) are defined by each service, so check unshackle dl SERVICE --help for those.
"},{"location":"guide/cli-reference/#root-command","title":"Root command","text":"unshackle [OPTIONS] COMMAND [ARGS]...\n
unshackle: Modular Movie, TV, and Music Archival Software.
Every invocation prints a banner and, when update_checks is enabled in your config, checks for a newer release.
Option Description -v, --version Print version information and exit. -d, --debug Enable DEBUG-level logs and JSON debug logging. Silences noisy HTTP libraries unless debug_requests is set in config. -h, --help, -? Show help and exit. Commands are auto-discovered, so third-party command modules dropped into the commands directory appear automatically.
"},{"location":"guide/cli-reference/#dl","title":"dl","text":"The download command. It is itself a group whose subcommands are the installed streaming services, so an invocation always has three layers:
unshackle dl [OPTIONS] SERVICE [SERVICE ARGS...]\n
OPTIONS: every flag below, parsed at the dl level. 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.
Typical downloads
# Best available 1080p SDR of a title\nunshackle dl -q 1080 EXAMPLE 81234567\n\n# 4K HDR10, English audio + subs, from a URL\nunshackle dl -q 2160 -r HDR10 -l en EXAMPLE \"https://www.example.com/...\"\n\n# A season range, HEVC video, EC3 audio\nunshackle dl -w S01-S03 -v H.265 -a EC3 SERVICE TITLE-ID\n
Config-driven defaults
Any dl flag can be given a default under the dl: section of unshackle.yaml, and per-service under services.<TAG>.dl. Explicit command-line values (and environment values) always win over both; service defaults only fill in options you did not set. See the configuration file guide.
"},{"location":"guide/cli-reference/#quality-codec-bitrate-range","title":"Quality, codec, bitrate & range","text":"Flag Default Description -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. -r, --range SDR Colour range(s): SDR, HLG, HDR10, HDR10P (HDR10+), DV, HYBRID. -c, --channels - Audio channels; matches sub-layouts (5.1 \u2248 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. -rvb, --real-video-bitrate off Probe real media size for true video bitrates, overriding the manifest. -rab, --real-audio-bitrate off Same for audio (slower). HYBRID range
-r HYBRID fetches both HDR10/HDR10+ and Dolby Vision and merges them with dovi_tool. It requires the dovi_tool binary on your PATH.
"},{"location":"guide/cli-reference/#language-subtitles","title":"Language & subtitles","text":"Flag Default Description -l, --lang orig Language(s) for both video and audio. orig = the title's original language; e.g. orig,en. A - prefix excludes, e.g. all,-es. -vl, --v-lang - Video-only language (overrides -l for video). A - prefix excludes. -al, --a-lang - Audio-only language (overrides -l for audio). A - prefix excludes. -sl, --s-lang all Subtitle language(s). A - prefix excludes, e.g. all,-es. --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. -fsl, --forced-s-lang none Language(s) wanted for forced subtitles; implies -fs. A - prefix excludes. --exact-lang off Exact matching only: -l es-419 matches es-419, not es-ES. Applies to selection and to sort order. --sub-format - Output subtitle format (SRT/srt, VTT/webvtt, ASS/ssa, TTML, SMI, ...), or original to keep the source format. These flags select languages, they do not order them
Naming languages with -l or -sl removes the others. To keep every language but put your preferred ones first, use audio.language_priority and subtitle.language_priority in your configuration file.
The special language tokens orig, all, and best are honoured everywhere a language is expected.
"},{"location":"guide/cli-reference/#title-episode-selection","title":"Title & episode selection","text":"Flag Description -w, --wanted Wanted episodes, e.g. S01-S05,S07, S01E01-S02E03. Supports exclusions with a leading - (e.g. -S03). For a split episode, .N picks one part (S01E01.2), a range must stay inside the episode (S01E01.1-S01E01.3), and S01E01 on its own takes every part. For dated content, a token can also be an ISO air date (2026-08-11) or a date range with a colon (2026-08-01:2026-08-31). --select-titles Interactively select what to download: episodes of a series, or films when a title has more than one. Cannot combine with -w. --latest-episode Download only the single most recent episode. --list-titles List titles only; do not download."},{"location":"guide/cli-reference/#track-type-inclusion-exclusion","title":"Track-type inclusion / exclusion","text":"Keep only certain track types, or skip certain track types. Attachments are always kept.
Include-only Skip -V, --video-only -nv, --no-video -A, --audio-only -na, --no-audio -S, --subs-only -ns, --no-subs -C, --chapters-only -nc, --no-chapters Flag Description -ad, --audio-description Include descriptive (audio-description) tracks. --skip-subtitle-errors Skip a failed subtitle instead of aborting the title. Video/audio failures remain fatal."},{"location":"guide/cli-reference/#output-muxing-files","title":"Output, muxing & files","text":"Flag Description --split-audio Write a separate output file per audio codec instead of merging. Defaults to config muxing.merge_audio. --merge-video Mux all selected video tracks into one file. Defaults to config muxing.merge_video. -o, --output Override the output directory for this run. --no-folder Disable folder creation for TV shows. --no-source Remove the source tag from the filename/path. --no-mux Do not mux; keep individual track files. --tag Group tag override. --repack Add a REPACK tag to the filename."},{"location":"guide/cli-reference/#metadata-tagging","title":"Metadata & tagging","text":"Flag Description --tmdb TMDB ID (integer). Used for the tags. Skips the title search. --enrich reads it too. Needs tmdb_api_key. --imdb IMDb ID, e.g. tt1375666. Used for the tags. Skips the title search. --enrich reads it too. Needs no key. --tvdb TVDB ID (integer). Used for the tags. Skips the series lookup that --tvdb-order would otherwise do. --enrich reads it too. Needs tvdb_api_key. --anilist AniList ID (integer), e.g. --anilist 21. A MyAnimeList ID is accepted as mal:12345 and resolved to the AniList entry. Used for the tags. Skips the title search. --enrich reads it too. Needs no key. --enrich Overwrite show title, year and original language with the external source's. Requires one of --tmdb, --imdb, --tvdb, or --anilist. --daily Treat the title as daily/date-based content and fill missing episode air dates from TVDB. The fill needs --enrich and a TVDB ID. See Daily and date-based content. --tvdb-order Renumber episodes to a TVDB season order: official (aired), dvd, absolute, alternate, or regional. Needs tvdb_api_key. One ID at a time
--tmdb, --imdb and --tvdb cannot be combined. Give one and unshackle resolves the others from it, writing all three to the tags. --anilist is outside that rule and pairs with one of them, which is how you attach a western ID to an anime title. An ID with no provider that can resolve it, such as --tmdb with no tmdb_api_key, fails before the download starts. --anilist needs no key, so it never fails this check.
"},{"location":"guide/cli-reference/#episode-ordering","title":"Episode ordering","text":"A service does not always number a series the way TVDB's aired order does. Some services list Futurama in TVDB's alternate (Streaming) order, for example. --tvdb-order works out which order the service used, then renumbers the episodes into the order you asked for.
Orders that do not cover the whole series
An order can omit episodes the service carries; TVDB's dvd order for Futurama leaves out the four movies. Those episodes keep their original numbering. If that would give two episodes the same season/episode slot, and so the same filename, unshackle logs an error and keeps the service's numbering unchanged. Pick an order that covers the whole series.
"},{"location":"guide/cli-reference/#drm-keys-decryption","title":"DRM, keys & decryption","text":"Flag Description --cdm-only / --vaults-only Use only the CDM, or only Key Vaults, for key acquisition. --skip-dl Skip the download but still retrieve keys. --export Export track info and keys to a JSON file in the exports directory."},{"location":"guide/cli-reference/#network-proxy","title":"Network & proxy","text":"Flag Description --proxy Proxy URI, a 2-letter country code resolved from configured providers, or provider:region (e.g. nordvpn:ca, gluetun:us, protonvpn:de:berlin). --no-proxy Force-disable all proxy use. --no-proxy-download Bypass the proxy for all downloads (manifest, licence, and auth stay proxied). --remote Use a remote unshackle server. --server Name a remote server from the remote_services config."},{"location":"guide/cli-reference/#concurrency-caching-listing","title":"Concurrency, caching & listing","text":"Flag Default Description --workers downloader default Per-track download threads. --downloads 1 Number of tracks downloaded concurrently. --speed-limit unlimited Cap total download speed across all threads and tracks, e.g. 500k, 5M, 1.5G or plain bytes/sec. Values are bytes, not bits (5M = 5.0 MB/s). off disables a configured limit. --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). 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.
"},{"location":"guide/cli-reference/#search","title":"search","text":"Search a service for titles. Like dl, search is a group whose subcommands are the installed services, and it reuses dl's authentication, cookie, and proxy machinery.
unshackle search [OPTIONS] SERVICE [QUERY...]\n
The query syntax is defined per service. Results are printed as a tree of titles with their service IDs. Feed an ID straight into dl.
Option Description -p, --profile Profile for credentials and cookies. --proxy Proxy URI, 2-letter country code, or provider:region. --no-proxy Force-disable all proxy use. Example
unshackle search EXAMPLE \"My Show\"\nunshackle search -p myprofile EXAMPLE \"Another Show\"\nunshackle search --proxy nordvpn:ca SERVICE \"query\"\n
"},{"location":"guide/cli-reference/#import","title":"import","text":"Reconstruct a download (download \u2192 decrypt \u2192 mux) from an --export JSON file without re-contacting the service. It re-fetches the manifest, injects the stored keys, and then runs the normal dl pipeline. The service tag is read from the export file, not passed by you.
unshackle import EXPORT_FILE [DL_ARGS...]\n
Any dl options placed after the file are forwarded verbatim, so you can override quality, range, proxy, and so on.
Example
unshackle import export.json\nunshackle import export.json -r HDR10 --proxy US\n
The export file must be a valid v2 export (created by a current build of unshackle via dl --export) and must contain a service tag.
"},{"location":"guide/cli-reference/#cfg","title":"cfg","text":"Read, set, delete, or list configuration values in unshackle.yaml without hand-editing YAML.
unshackle cfg [KEY] [VALUE] [OPTIONS]\n
Argument / Option Description KEY Dotted path into the config, e.g. tag, serve.api_secret, directories.downloads. VALUE Value to set. Parsed as a Python literal when possible, so true, 123, ['a','b'], and {'k':1} become real types; bare words stay strings. --unset Remove the configuration value. --list List all set configuration values. Comments are stripped
Writing a value through cfg rewrites the YAML file and removes all comments from it. If your config relies on comments, edit it by hand instead. Setting a value and using --unset together is an error.
Example
unshackle cfg --list # print entire config\nunshackle cfg serve.api_secret # read a nested key\nunshackle cfg tag MYGROUP # set a string\nunshackle cfg update_checks false # literal \u2192 boolean False\nunshackle cfg directories.downloads /mnt/dl # set a nested path\nunshackle cfg tag --unset # remove a key\n
"},{"location":"guide/cli-reference/#env","title":"env","text":"Inspect and manage the project environment.
unshackle env COMMAND [ARGS]...\n
"},{"location":"guide/cli-reference/#env-check","title":"env check","text":"unshackle env check\n
Prints a dependency table (Category / Tool / Status / Required / Purpose) showing which external tools resolve on your PATH, and a summary of how many required tools are installed.
Category Tools Core FFmpeg*, FFprobe*, MKVToolNix*, mkvpropedit* DRM shaka-packager*, mp4decrypt HDR dovi_tool, HDR10Plus_tool Subtitle SubtitleEdit, CCExtractor Player FFplay, MPV Network HolaProxy, Caddy, Docker, git * required; all others are optional.
"},{"location":"guide/cli-reference/#env-info","title":"env info","text":"unshackle env info\n
Shows where the config was loaded from (or lists the candidate config locations if none was found) and prints a table of every configured directory (downloads, temp, cache, cookies, logs, exports, WVDs, PRDs, services, and more).
"},{"location":"guide/cli-reference/#env-clear","title":"env clear","text":"Clear an environment directory. The directory is emptied and recreated, and the number of files and bytes freed is reported.
Command Description unshackle env clear cache [SERVICE] Clear the cache directory, or just one service's cache subdirectory. unshackle env clear temp Clear the temp directory. Example
unshackle env check\nunshackle env info\nunshackle env clear cache\nunshackle env clear cache EXAMPLE # one service only\nunshackle env clear temp\n
"},{"location":"guide/cli-reference/#kv","title":"kv","text":"Manage Key Vaults. Vaults are configured under key_vaults in unshackle.yaml, each with a name, a type (sqlite, mysql, http, api), and type-specific options. Service tags are normalised automatically.
unshackle kv COMMAND [ARGS]...\n
"},{"location":"guide/cli-reference/#kv-copy-to_vault-from_vault","title":"kv copy TO_VAULT FROM_VAULT...","text":"Copy keys from one or more source vaults into a destination vault. Rows with matching KIDs are skipped unless the existing row has no key; existing data is never altered or deleted.
Option Description -s, --service Only copy data for a specific service. -l, --local-only Only copy data for services installed locally. Mutually exclusive with --service."},{"location":"guide/cli-reference/#kv-sync-vault","title":"kv sync VAULT...","text":"Ensure two or more vaults hold the same set of keys, essentially a chained bidirectional copy. Requires more than one vault. Accepts the same --service / --local-only options as copy.
"},{"location":"guide/cli-reference/#kv-add-file-service-vault","title":"kv add FILE SERVICE VAULT...","text":"Add content keys to one or more vaults for a service. FILE contains one KID:KEY pair per line (32 hex : 32 hex, UTF-8). Lines that don't match are skipped.
"},{"location":"guide/cli-reference/#kv-search-kid","title":"kv search KID","text":"Search configured vaults for a KID (32 hex characters, no dashes) and report any matching key.
Option Description -s, --service Limit the search to a specific service tag. -v, --vault Limit the search to a specific configured vault by name. Note
Remote vaults cannot be enumerated without a service, so pass --service when searching them.
"},{"location":"guide/cli-reference/#kv-prepare-vault","title":"kv prepare VAULT...","text":"Create service tables on vaults that use tables, for every installed service, where they don't already exist.
Example
unshackle kv copy main backup1 backup2\nunshackle kv copy main backup -s EXAMPLE\nunshackle kv sync main mysql_vault\nunshackle kv add keys.txt EXAMPLE main backup\nunshackle kv search 0123456789abcdef0123456789abcdef -s EXAMPLE -v main\nunshackle kv prepare main mysql_vault\n
"},{"location":"guide/cli-reference/#wvd","title":"wvd","text":"Manage Widevine Device (.wvd) files. Devices live in the configured WVDs directory.
unshackle wvd COMMAND [ARGS]...\n
Command Description wvd add PATHS... Validate and move one or more .wvd files into the WVDs directory. wvd delete NAMES... Delete .wvd files by name (without extension). Prompts for confirmation. wvd parse PATH Parse a .wvd and print its System ID, security level, type, flags, and client info. Relative paths resolve against the WVDs directory. wvd dump WVD_PATHS... OUT_DIR Extract a device's contents (metadata, private key, client ID, VMP) into OUT_DIR/<name>/. With no paths, dumps every WVD in the WVDs directory. wvd new NAME PRIVATE_KEY CLIENT_ID [FILE_HASHES] Create a new .wvd from a PEM private key and a ClientIdentification blob, optionally with a VMP (FileHashes) blob. wvd new options:
Option Default Description -t, --type Android Device type. -l, --level 1 Security level (1-3). -o, --output WVDs dir Output directory. Example
unshackle wvd add device.wvd\nunshackle wvd parse nexus_6p_1234_l1\nunshackle wvd dump device.wvd ./out_dir\nunshackle wvd new \"Nexus 6P\" private_key.pem client_id.bin vmp.bin -t ANDROID -l 3\n
"},{"location":"guide/cli-reference/#prd","title":"prd","text":"Manage PlayReady Device (.prd) files. Devices live in the configured PRDs directory. Built on pyplayready.
unshackle prd COMMAND [ARGS]...\n
"},{"location":"guide/cli-reference/#prd-new-paths","title":"prd new PATHS...","text":"Create a new .prd. Provide either a single folder containing zgpriv.dat (group key) and bgroupcert.dat (group certificate), or two file paths (group key, then group certificate).
Option Description -e, --encryption_key Optional device ECC private encryption key (generated if omitted). -s, --signing_key Optional device ECC private signing key (generated if omitted). -o, --output Output directory or .prd file path. prd new will not overwrite an existing file.
"},{"location":"guide/cli-reference/#prd-reprovision-prd_path","title":"prd reprovision PRD_PATH","text":"Reprovision an existing device by replacing its leaf certificate and keys. The device must support reprovisioning (version 3 or higher). Accepts the same -e / -s / -o options; defaults to overwriting the device in place.
"},{"location":"guide/cli-reference/#prd-test-device","title":"prd test DEVICE","text":"Test a device against the Microsoft PlayReady demo server and print the returned keys.
Option Default Description -c, --ckt aesctr Content key encryption type: aesctr or aescbc. -sl, --security-level 2000 Minimum security level: 150, 2000, or 3000. Example
unshackle prd new ./device_folder/\nunshackle prd new zgpriv.dat bgroupcert.dat -o /out/dir\nunshackle prd reprovision mydevice.prd\nunshackle prd test mydevice.prd -sl 3000 -c aescbc\n
"},{"location":"guide/cli-reference/#serve","title":"serve","text":"Serve your local Widevine/PlayReady devices and the REST API for remote access. Built on aiohttp.
unshackle serve [OPTIONS]\n
Option Default Description -h, --host 127.0.0.1 Host to serve from. -p, --port 8786 Port to serve from. --caddy off Also serve through Caddy (requires the caddy binary and a Caddyfile). --api-only off Serve only the REST API, not the CDM endpoints. Incompatible with --no-widevine/--no-playready. --no-widevine off Disable the Widevine CDM endpoints. --no-playready off Disable the PlayReady CDM endpoints. --no-key off Disable API-key authentication (allows all requests). --debug-api off Include tracebacks/stderr in API error responses. --debug off Enable debug logging for API operations. --remote-only off Expose only the remote service session endpoints (health, services, search, session). Implies --api-only. WVD files in the WVDs directory and PRD files in the PRDs directory are auto-loaded. The REST API lives under http://<host>:<port>/api/, with Swagger UI at /api/docs/ and a health check at /api/health (exempt from auth).
Configure api_secret first
Unless you pass --no-key, serve.api_secret must be set in your config. Requests authenticate with the X-Secret-Key header. Running with --no-key disables authentication entirely and should only be used on a trusted, private network.
unshackle.yamlserve:\n api_secret: \"your-api-secret\"\n users:\n your-secret-key:\n devices: [\"device_name\"] # Widevine devices\n playready_devices: [\"device_name\"] # PlayReady devices\n username: user\n
Example
unshackle serve\nunshackle serve -h 0.0.0.0 -p 8080\nunshackle serve --api-only\nunshackle serve --remote-only\nunshackle serve --caddy\n
"},{"location":"guide/cli-reference/#util","title":"util","text":"Various helper media utilities. When a command is given a directory, it processes every .mkv/.mp4 inside it in natural (S01E01 before S01E10) order.
unshackle util COMMAND [ARGS]...\n
"},{"location":"guide/cli-reference/#util-refresh-services","title":"util refresh-services","text":"unshackle util refresh-services\n
Force a refresh (git pull / hard reset) of all service repos configured under directories.services.
"},{"location":"guide/cli-reference/#util-crop-path-aspect","title":"util crop PATH ASPECT","text":"Losslessly crop H.264/H.265 video at the bitstream level. ASPECT is a W:H target such as 2.39:1.
Option Default Description --letter / --pillar --letter Crop top/bottom (--letter) or the sides (--pillar). -o, --offset 0 Fine-tune the computed crop area if not perfectly centred. -p, --preview off Preview the crop in MPV (or FFplay) instead of writing a file."},{"location":"guide/cli-reference/#util-range-path","title":"util range PATH","text":"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. -p, --preview off Preview instead of writing a file."},{"location":"guide/cli-reference/#util-test-path","title":"util test PATH","text":"Decode an entire video with FFmpeg and report any corruptions or errors. All streams are tested by default; subtitles cannot be tested.
Option Default Description -m, --map 0 Test specific streams via FFmpeg's -map, e.g. 0:v:0 or 0:a. Example
unshackle util refresh-services\nunshackle util crop video.mkv 2.39:1\nunshackle util crop ./season/ 16:9 --pillar -o 10\nunshackle util range video.mkv --full\nunshackle util test video.mkv -m 0:v:0\n
"},{"location":"guide/cli-reference/#see-also","title":"See also","text":" - Installation: install unshackle and its dependencies.
- Quickstart: your first download.
- Configuration file: set defaults for any
dl flag and configure vaults, proxies, and directories.
"},{"location":"guide/downloading/","title":"Downloading","text":"unshackle dl is the main download command: it fetches a title's manifest from a streaming service, selects exactly the tracks you asked for, acquires and applies decryption keys, and muxes everything into a finished Matroska file. The flags below cover quality and codec selection, language and subtitle handling, episode ranges, and the download flow itself.
Where the full flag list lives
This page covers the flags you will use most and explains how they interact. It is not an exhaustive enumeration. For the complete, always-current list of every option and its exact wording, run unshackle dl --help. Every flag shown here can also be given a default in your configuration file. See Configuration file.
"},{"location":"guide/downloading/#command-shape","title":"Command shape","text":"A download is always three layers: the root CLI, the dl command (which is where all the flags below are parsed), and a service tag subcommand that carries the title argument.
unshackle dl [OPTIONS] SERVICE [SERVICE ARGUMENTS]\n
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 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.
A minimal downloadunshackle dl EXAMPLE 81234567\n
Flags belong to dl, the title belongs to the serviceunshackle dl -q 1080 -v H.265 -r HDR10 --lang en EXAMPLE 'https://www.example.com/...'\n
See what a service accepts
Because the positional argument is defined by each service, run unshackle dl SERVICE --help (e.g. unshackle dl EXAMPLE --help) to see that service's own argument and any service-specific options.
"},{"location":"guide/downloading/#the-download-flow","title":"The download flow","text":"When you run a download, dl moves through the pipeline below. Understanding the order helps explain why, for example, --list stops early and why keys are fetched even with --skip-dl.
- Setup: load the DRM CDM, key vaults, proxy providers, cookies, and credentials.
- Authenticate: sign in to the service with your profile's cookies/credentials.
- Fetch titles: retrieve the movie, episode list, or album (cached unless
--no-cache/--reset-cache). - Filter titles: apply
--wanted, --latest-episode, or --select-titles. - Get tracks: parse the manifest into video, audio, subtitle, and chapter tracks.
- Select tracks: narrow each type down using your quality, codec, range, language, and bitrate flags.
- Download: pull segments for the selected tracks (concurrently per
--downloads). - License & decrypt: acquire content keys (vault \u2192 CDM), decrypt each track.
- Post-process: extract closed captions, convert subtitles, repack, mux with
mkvmerge, and move the finished file to your downloads directory.
--list prints the tracks a title exposes and stops before selection. --list-titles prints every title the service returned and stops before --wanted/--latest-episode filtering. --skip-dl runs the license step but skips the actual segment download.
"},{"location":"guide/downloading/#choosing-quality","title":"Choosing quality","text":""},{"location":"guide/downloading/#resolution","title":"Resolution","text":"-q / --quality takes one or more target resolutions (heights) as a comma-separated list. Without it, unshackle picks the best available resolution.
Single resolutionunshackle dl -q 1080 EXAMPLE 81234567\n
Multiple resolutions in one rununshackle dl -q 2160,1080,720 EXAMPLE 81234567\n
16:9 canvas matching
Resolution matching is by track height first. If no track has an exact matching height, unshackle falls back to a 16:9-canvas match, so -q 1080 will also match an anamorphic 1920\u00d7804 track (computed as int(width \u00d7 9 / 16)).
"},{"location":"guide/downloading/#best-available-and-worst","title":"Best-available and worst","text":"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 video/audio/subtitle languages and hybrid fallbacks. --worst Within the specified quality, pick the lowest bitrate rendition. Requires -q/--quality. Never fail on a missing resolutionunshackle dl -q 2160 --best-available EXAMPLE 0ABC123\n
"},{"location":"guide/downloading/#video-codec-and-color-range","title":"Video codec and color range","text":""},{"location":"guide/downloading/#codec","title":"Codec","text":"-v / --vcodec selects one or more video codecs; the default is any codec. It accepts either enum names or their values, comma-separated.
Name Value AVC H.264 HEVC H.265 VC1 VC-1 VP8 VP8 VP9 VP9 AV1 AV1 Either spelling worksunshackle dl -v HEVC EXAMPLE 81234567\nunshackle dl -v H.265 EXAMPLE 81234567\nunshackle dl -v hevc,avc EXAMPLE 81234567\n
"},{"location":"guide/downloading/#color-range","title":"Color range","text":"-r / --range selects one or more color ranges; the default is SDR.
Range Meaning SDR Standard dynamic range (default) HLG Hybrid Log-Gamma HDR10 HDR10 HDR10P HDR10+ DV Dolby Vision HYBRID Fetch both an HDR10/HDR10+ base and a DV track, then merge them Grab HDR10 and Dolby Vision in one rununshackle dl -q 2160 -r HDR10,DV EXAMPLE '...'\n
HYBRID requires dovi_tool
-r HYBRID produces a single hybrid stream by injecting the Dolby Vision RPU metadata onto an HDR10/HDR10+ base layer using dovi_tool. It requires the dovi_tool binary, resolved from unshackle's binaries/ folder or your PATH. The normal case is a DV track plus an HDR10 or HDR10+ base. With HDR10+ and no DV, unshackle converts the HDR10+ metadata to DV instead, which also needs hdr10plus_tool. With no DV and no HDR10+, the title fails. When HDR10+ is present it is preferred over HDR10 as the base layer.
To keep an HDR10+ deliverable alongside the hybrid, request both ranges: -r HYBRID,HDR10P. -r HYBRID on its own muxes only the merged hybrid.
"},{"location":"guide/downloading/#bitrate-selection","title":"Bitrate selection","text":"By default unshackle keeps the highest-bitrate rendition for each selected resolution/codec/range/language combination. You can constrain this.
Flag Purpose -vb / --vbitrate Exact video bitrate in kbps. -ab / --abitrate Exact audio bitrate in kbps. -vb-range / --vbitrate-range Video bitrate range in kbps, e.g. 6000-7000; picks the highest within it. -ab-range / --abitrate-range Audio bitrate range in kbps, e.g. 128-256. Exact vs range are mutually exclusive
--vbitrate cannot be combined with --vbitrate-range, and likewise for the audio pair. Pick one form.
"},{"location":"guide/downloading/#real-bitrate-probing","title":"Real bitrate probing","text":"Manifest-declared bitrates are sometimes rounded or wrong. Services often advertise a peak or nominal bandwidth that is far from the stream's real average. This matters because a track's bitrate drives the track listing, the sort order, and the --vbitrate/--vbitrate-range selection above. A bogus declared value therefore makes unshackle pick the wrong track. These flags probe the actual media size to compute a true bitrate for the top renditions, overriding the manifest value:
-rvb / --real-video-bitrate: probe real video bitrates (per codec/range). -rab / --real-audio-bitrate: probe real audio bitrates (per codec/channels/ language). Slower than the video variant because there are more renditions to probe.
Reading the probed numbers
A single-file track (DASH SegmentBase/BaseURL) is measured exactly. A multi-segment track (most HLS) is a sampled estimate, normally within a few percent of the true value. For MPEG-TS HLS the probed figure also reads a few percent above the demuxed elementary stream, because the segment bytes include container overhead. That is the real delivered size, not an over-report or a bug.
Why not every rendition is probed
Probing does not touch every rendition. Only the five highest declared-bitrate renditions of each quality tier are probed in parallel (video grouped by codec and range, audio by codec, channels, language, and descriptive flag), and unshackle extends a group downward while a lower unprobed rendition could still outrank a probed one. This keeps probing fast even when a service exposes dozens of renditions. Tracks whose duration can't be determined fall back to ffprobe, and a probe failure is non-fatal: the manifest value is simply left in place.
"},{"location":"guide/downloading/#audio-codec-channels-and-atmos","title":"Audio codec, channels, and Atmos","text":""},{"location":"guide/downloading/#codec_1","title":"Codec","text":"-a / --acodec selects one or more audio codecs (comma-separated); the default is any.
Name Value Codec AAC AAC Advanced Audio Coding AC3 DD Dolby Digital EC3 DD+ Dolby Digital Plus AC4 AC-4 Dolby AC-4 OPUS OPUS Opus OGG VORB Vorbis DTS DTS DTS ALAC ALAC Apple Lossless FLAC FLAC FLAC Names, values, and a few aliases are accepted: eac3 and ddp both resolve to EC3, and vorbis resolves to OGG.
Prefer Dolby Digital Plus, fall back to AACunshackle dl -a EC3,AAC EXAMPLE 81234567\n
"},{"location":"guide/downloading/#channels-and-atmos","title":"Channels and Atmos","text":" -c / --channels: desired channel layout, e.g. 5.1 or 2.0. Matching is by ceiling, so 5.1 implicitly matches a 6.0-reported layout. -naa / --noatmos: exclude Dolby Atmos audio tracks from selection.
5.1 audio, no Atmosunshackle dl -c 5.1 --noatmos EXAMPLE 0ABC123\n
"},{"location":"guide/downloading/#languages","title":"Languages","text":""},{"location":"guide/downloading/#video-and-audio-language","title":"Video and audio language","text":"-l / --lang sets the wanted language(s) for both video and audio. The default is orig, the title's original language.
Original language plus Englishunshackle dl -l orig,en EXAMPLE 81234567\n
The special token orig is resolved to the title's actual original language everywhere it is used. You can override each stream type independently:
-vl / --v-lang: language for video only (overrides -l for video). Useful when the burned-in video language differs from the audio you want. -al / --a-lang: language for audio only (overrides -l for audio).
English audio over the original videounshackle dl -al en EXAMPLE 81234567\n
"},{"location":"guide/downloading/#excluding-languages","title":"Excluding languages","text":"Put a - in front of a language to remove it from the selection. This works on -l, -vl, -al, -sl, and -fsl, in the same way as it works on -w / --wanted.
Every subtitle language except Spanishunshackle dl -sl all,-es EXAMPLE 81234567\n
The exclusions are subtracted from the languages the flag would otherwise select, and the order of the values does not matter. If you give only exclusions, the flag keeps its own default: -sl -es is the same as -sl all,-es, and -l -es is the same as -l orig,-es.
Only exclusions# every subtitle language except Spanish and French\nunshackle dl -sl -es,-fr EXAMPLE 81234567\n\n# the original audio language, unless it is Spanish\nunshackle dl -l -es EXAMPLE 81234567\n
-vl and -al have no default of their own: they cascade to -l. An override that names a language replaces -l for that stream type, exclusions included. An override that gives only exclusions adds them to the ones from -l.
Cascade# video keeps the Japanese track; only the audio drops Spanish\nunshackle dl -l en,-es -vl ja EXAMPLE 81234567\n
Rules for exclusion tokens
orig is resolved per title, so -sl -orig drops the subtitles in the title's original language. --exact-lang applies to exclusions with the same matching rules as selection: -es removes exactly the tracks that es would select. Without it, -es also removes es-419 and es-ES. - Tracks with no language tag are never excluded.
-all is rejected. Name the languages you do not want instead. - The comma form is the documented style.
-sl -es also works, but a shell can read a lone -es as another option.
"},{"location":"guide/downloading/#sort-order","title":"Sort order","text":"-l and -sl select languages: naming some removes the rest. To keep every language but decide which ones come first, set a priority list in your configuration file:
unshackle.yamlaudio:\n language_priority: [orig, en]\nsubtitle:\n group_by: language\n language_priority: [en, es, fr]\n
Subtitles then start with English, Spanish, and French. The languages you leave out follow alphabetically, after the title's original language. Nothing is removed. Audio works the same way, but the languages you leave out keep their bitrate and codec order instead of an alphabetical one.
See subtitle.language_priority for the details.
"},{"location":"guide/downloading/#exact-vs-fuzzy-matching","title":"Exact vs fuzzy matching","text":"By default language matching is fuzzy: -l en also accepts en-US, en-GB, etc. (up to a small distance). Pass --exact-lang to require an exact match: with it, -l es-419 matches only es-419, not es-ES.
The all and best tokens
For audio, the tokens all and best bypass the usual one-track-per-language pick and instead select the best track for each language present. For subtitles, all keeps every subtitle language.
"},{"location":"guide/downloading/#subtitles","title":"Subtitles","text":""},{"location":"guide/downloading/#selecting-subtitle-languages","title":"Selecting subtitle languages","text":"-sl / --s-lang sets the wanted subtitle language(s). The default is all, meaning every available subtitle language is downloaded.
Only English and Spanish subtitlesunshackle dl -sl en,es EXAMPLE 81234567\n
To keep the default and drop one language, use the - prefix described in Excluding languages. -sl all,-es keeps every subtitle language except Spanish, forced Spanish subtitles included.
"},{"location":"guide/downloading/#requiring-subtitles","title":"Requiring subtitles","text":"--require-subs takes a list of languages that must exist. If they all exist, all subtitles are downloaded; if any is missing, the title fails. This is useful for gating a download on the presence of a specific subtitle track.
Cannot combine with --s-lang
--require-subs and --s-lang are mutually exclusive. Use one or the other.
"},{"location":"guide/downloading/#forced-subtitles-and-output-format","title":"Forced subtitles and output format","text":" -fs / --forced-subs: include forced subtitle tracks (signs/foreign dialogue). Without this flag, forced subs are dropped from selection. -fsl / --forced-s-lang: keep forced subtitles only in these languages (implies -fs). Works independently of --s-lang, so -sl all -fsl en grabs every full subtitle but only the English forced track. It accepts exclusions too: -fsl all,-es keeps every forced subtitle except the Spanish one, and -fsl -es means the same. --sub-format: set the output subtitle format, converting only when necessary. Accepts codec names/values and common aliases (srt, vtt, ass, ssa, ttml, etc.), or the literal original to keep the source format.
Value Format SRT / srt SubRip VTT / vtt WebVTT ASS / ass Advanced SubStation Alpha SSA / ssa SubStation Alpha TTML / ttml Timed Text Markup original Keep the source format, no conversion Convert subtitles to SRTunshackle dl --sub-format srt EXAMPLE 81234567\n
SDH stripping happens by default
When a subtitle track is marked SDH (for the deaf/hard-of-hearing) and there is no plain same-language subtitle, unshackle produces a stripped, non-SDH variant automatically. This behaviour is governed by the subtitle.strip_sdh config option (default on).
"},{"location":"guide/downloading/#selecting-episodes","title":"Selecting episodes","text":"For series, several flags control which episodes are downloaded. By default, all episodes are downloaded.
"},{"location":"guide/downloading/#wanted-ranges","title":"Wanted ranges","text":"-w / --wanted accepts flexible season/episode ranges, comma-separated. Prefix a token with - to exclude it.
Whole seasonsSpecific episodesExclusionsSplit episodesAir dates unshackle dl -w S01 EXAMPLE 81234567\nunshackle dl -w S01-S03 EXAMPLE 81234567\nunshackle dl -w S01,S03,S05 EXAMPLE 81234567\n
unshackle dl -w S01E01 EXAMPLE 81234567\nunshackle dl -w S01E01-S01E05 EXAMPLE 81234567\nunshackle dl -w S01E01-S02E03 EXAMPLE 81234567\n
# Seasons 1 through 5, but not season 3\nunshackle dl -w S01-S05,-S03 EXAMPLE 81234567\n
# Part 2 of episode 1 only\nunshackle dl -w S01E01.2 EXAMPLE 81234567\n\n# Parts 1 through 3 of episode 1\nunshackle dl -w S01E01.1-S01E01.3 EXAMPLE 81234567\n\n# All of season 1 except episode 1 part 2\nunshackle dl -w S01,-S01E01.2 EXAMPLE 81234567\n
# One day\nunshackle dl -w 2026-08-11 EXAMPLE 81234567\n\n# A range of days, colon separated\nunshackle dl -w 2026-08-01:2026-08-31 EXAMPLE 81234567\n\n# August, but not the 15th\nunshackle dl -w 2026-08-01:2026-08-31,-2026-08-15 EXAMPLE 81234567\n
"},{"location":"guide/downloading/#daily-and-date-based-content","title":"Daily and date-based content","text":"Talk shows, news and sports have no official episode numbering, so unshackle names them by air date. An episode that carries an air date is written as Show.YYYY.MM.DD instead of SxxExx, the {date} token holds the ISO date, and the season folder becomes the year.
A service that only carries this kind of content sets DAILY = True on its class, and a service can set air_date on each episode itself. Add --daily to mark any other title as date-based:
unshackle dl --daily --tvdb 73871 --enrich EXAMPLE 81234567\n
With --enrich and a TVDB ID, --daily fills in the air date of every episode that has none. An air date the service already set is kept. Dates before 1970 and dates in the future are skipped, because TVDB carries placeholder schedule dates for episodes that have not aired. Without --enrich unshackle has no source to fill from, and says so.
A dated episode answers to its air date in -w, as well as to its SxxExx key. A date token is a plain ISO date (2026-08-11). A date range uses a colon (2026-08-01:2026-08-31), because the dashes in a date are part of the date. A range cannot span more than 1000 days. Date tokens and SxxExx tokens can be mixed in one -w.
"},{"location":"guide/downloading/#split-episodes","title":"Split episodes","text":"A few services split one episode into several separately playable videos. Where a service reports that, add .N after the episode to pick one part.
Token Selects S01E01 All parts of episode 1. S01E01.2 Part 2 of episode 1, and nothing else. S01E01.1-S01E01.3 Parts 1 to 3 of episode 1. S01,-S01E01.2 All of season 1 except episode 1 part 2. Parts 1 and 3 are kept. A season token such as S01 covers every episode and every part, so you only need .N when you want a part on its own.
A part range stays inside one episode
S01E01.1-S01E01.3 is valid. S01E01.1-S01E02.3 is rejected, because the parser cannot know how many parts episode 1 has and so cannot work out where the range ends. To span episodes, list the parts you want as separate tokens: -w S01E01.2,S01E02.1.
A part of an unsplit episode selects nothing
-w S01E02.2 on an episode that was never split is deliberately empty: it tells you the episode has no parts, rather than quietly handing you the whole episode. Run --list-titles to see which episodes carry parts.
"},{"location":"guide/downloading/#other-selection-flags","title":"Other selection flags","text":"Flag Behaviour --select-titles Interactively pick episodes of a series, or films when a title has more than one. Cannot combine with -w. --latest-episode Download only the single most recent episode. --list-titles List every title the service returned, then stop. -w/--latest-episode are not applied to this listing. Grab just the newest episodeunshackle dl --latest-episode EXAMPLE 81234567\n
"},{"location":"guide/downloading/#including-and-excluding-track-types","title":"Including and excluding track types","text":"You can restrict a download to certain track categories, either positively (only these) or negatively (everything but these).
Only these types (*-only flags):
-V / --video-only -A / --audio-only -S / --subs-only -C / --chapters-only
Skip these types (no-* flags):
-nv / --no-video -na / --no-audio -ns / --no-subs -nc / --no-chapters
Additional track-type flags:
-ad / --audio-description: include descriptive (audio-description) tracks, which are dropped by default. --skip-subtitle-errors: if a subtitle fails to download, skip it and continue rather than aborting the whole title. Video and audio failures remain fatal.
Subtitles onlyunshackle dl -S -sl en EXAMPLE 81234567\n
Everything except chaptersunshackle dl -nc EXAMPLE 81234567\n
Note
You can combine an *-only flag with no-* flags to fine-tune: *-only chooses the starting set of categories, then no-* subtracts from it. Attachments (e.g. fonts) are always kept.
"},{"location":"guide/downloading/#listing-and-dry-runs","title":"Listing and dry runs","text":"Before committing to a long download, inspect what unshackle would do:
Flag Effect --list List the tracks the service exposes for each title, then stop. No selection, no download. --list-titles List every title the service returned, then stop. -w/--latest-episode are not applied to this listing. --skip-dl Skip downloading but still acquire the decryption keys. See the track selection without downloadingunshackle dl -q 1080 -v H.265 -r HDR10 --list EXAMPLE 81234567\n
"},{"location":"guide/downloading/#output-and-muxing","title":"Output and muxing","text":""},{"location":"guide/downloading/#output-location-and-folders","title":"Output location and folders","text":" -o / --output: override the output directory for this run (otherwise the configured downloads directory is used). --no-folder: do not create a per-show folder for TV downloads. --no-source: remove the service source tag from the filename and path.
Send this download somewhere specificunshackle dl -o ~/Videos/incoming EXAMPLE 81234567\n
"},{"location":"guide/downloading/#muxing-behaviour","title":"Muxing behaviour","text":"By default, tracks are muxed into a single Matroska (.mkv) file with mkvmerge. These flags change how the output is assembled:
Flag Behaviour Default source --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 video tracks that share a height, range, and codec into one file, so only language varies inside a file. config muxing.merge_video (off)"},{"location":"guide/downloading/#naming-tags","title":"Naming tags","text":" --tag: set the release group tag (overrides the configured tag). --repack: add a REPACK tag to the output filename.
Custom group tag and a REPACK labelunshackle dl --tag MYGRP --repack EXAMPLE 81234567\n
"},{"location":"guide/downloading/#proxies","title":"Proxies","text":"--proxy accepts a full proxy URI, a 2-letter country code (resolved through your configured proxy providers), or a provider:region form.
Proxy formsunshackle dl --proxy us EXAMPLE 81234567\nunshackle dl --proxy nordvpn:ca EXAMPLE 81234567\nunshackle dl --proxy 'http://user:pass@host:8080' EXAMPLE 81234567\n
Two related flags:
--no-proxy: force-disable all proxy use for this run. --no-proxy-download: bypass the proxy for all downloads. The manifest, license, and authentication requests still go through the proxy. This is useful when the proxy is only needed to satisfy geo-checks, not to move the bulk of the data.
"},{"location":"guide/downloading/#performance-and-caching","title":"Performance and caching","text":"Flag Purpose --workers N Threads used per track for segment downloads. Default depends on the downloader. --downloads N Number of tracks downloaded concurrently. Default 1. --slow [MIN-MAX] Add a delay between titles to look more like a real device. --slow alone means 60-120s; --slow 20-40 sets a custom range. Minimum 20s. --no-cache Bypass the title cache for this download. --reset-cache Clear the title cache before fetching. Two tracks at a time, eight threads eachunshackle dl --downloads 2 --workers 8 EXAMPLE 81234567\n
Space out a season downloadunshackle dl -w S01 --slow 30-60 EXAMPLE 81234567\n
"},{"location":"guide/downloading/#keys-vaults-and-export","title":"Keys, vaults, and export","text":"By default, unshackle checks your key vaults first and only asks a CDM to license a key when the vault misses. You can force one side or the other:
--cdm-only: only use the CDM (skip vaults). --vaults-only: only use key vaults (never license via the CDM); a missing key fails.
Fetch keys only, no downloadunshackle dl --skip-dl EXAMPLE 81234567\n
--export writes a JSON file, into the configured exports directory, containing track info and the acquired content keys for each title. This is the format consumed by unshackle import to reconstruct a download later.
Export track info and keysunshackle dl --skip-dl --export EXAMPLE 81234567\n
Region is recorded only with a proxy
When --proxy is used, the export records the region so an import can reproduce the correct geofence. Without a proxy, no region is stored.
Some DASH and Smooth exports need a title language
An import re-fetches the DASH or ISM manifest and parses it again. Most manifests label their own streams, and those import fine. When a manifest labels nothing, the parse falls back to the title's original language, which comes from Title.language on the exporting service. If a service never sets it, importing that export fails with a message naming the service. Neither end guesses a language for you, so the fix belongs in the service.
"},{"location":"guide/downloading/#metadata-and-tagging","title":"Metadata and tagging","text":"unshackle looks up metadata automatically, but you can override the identifiers used for tagging and naming. An ID you give is authoritative: unshackle looks it up directly instead of searching by title, and the ID stays in the tags. An ID only settles which title this is. To also take that source's title, year and original language, add --enrich. Give at most one of --tmdb, --imdb and --tvdb; unshackle works the rest out from it. To stop the automatic lookups but keep the IDs you give, set disable_metadata in your config.
Flag Example Purpose --tmdb --tmdb 27205 Use this TMDB ID instead of an automatic search. Needs tmdb_api_key. --imdb --imdb tt1375666 Use this IMDb ID instead of an automatic search. Needs no key. --tvdb --tvdb 73871 Use this TVDB ID instead of looking the series up. Needs tvdb_api_key. --anilist --anilist 21 Use this AniList ID instead of an automatic search. mal:12345 is also accepted. Needs no key. --enrich - Overwrite the show title, year and original language with the external source's. Requires one of --tmdb, --imdb, --tvdb, or --anilist. --tvdb-order --tvdb-order dvd Renumber episodes to a TVDB season order. Needs tvdb_api_key. Force the right IMDb match, and keep the service's own namingunshackle dl --imdb tt1375666 EXAMPLE 81234567\n
Force the match and take IMDb's title, year and language toounshackle dl --imdb tt1375666 --enrich EXAMPLE 81234567\n
"},{"location":"guide/downloading/#what-each-metadata-provider-supplies","title":"What each metadata provider supplies","text":"Providers differ in what they answer with, so the fields --enrich can replace depend on which ID you gave. Every provider except imdb only runs when its key is configured:
Provider Config key Title and year Original language External IDs it returns TMDB tmdb_api_key yes yes, alpha-2 such as ko IMDb, TMDB, TVDB TVDB tvdb_api_key yes yes, alpha-3 such as kor TMDB, TVDB, sometimes IMDb OMDb omdb_api_key yes yes, as an English name such as Korean IMDb IMDb (no key needed) yes yes, alpha-2 such as ko IMDb SIMKL simkl_client_id yes no, it publishes a country and no language IMDb, TMDB, TVDB AniList (no key needed) yes yes, worked out from the country of origin, such as ja AniList Whatever the tag looks like, unshackle normalises it before use, so ko, kor and Korean all end up as the same language.
AniList only answers for anime
It is last in the default provider order and returns nothing for a title that is not anime. The only cost is one search that misses. Put anilist earlier in metadata_providers if you mostly download anime. Which of the three AniList title variants is used is set by anilist_title_language.
A service can also mark its titles as anime. For those titles unshackle tries AniList first. When AniList has no match, it falls through to the normal order.
Each ID goes to the providers that read that kind of ID, in metadata_providers order:
--tmdb reads TMDB. --imdb reads IMDb, then falls back to OMDb. IMDb needs no key, so this path works out of the box, and an OMDb key only matters when IMDb has no answer. --tvdb reads TVDB. It needs tvdb_api_key. --anilist reads AniList, which needs no key. It supplies the title, the year and the original language, and writes an AniList tag. It knows no TMDB, IMDB or TVDB ID, so pair it with --tmdb, --imdb or --tvdb when you want those tags as well.
Give one of --tmdb, --imdb and --tvdb. They cannot be combined, because one ID does the job on its own: unshackle resolves the other two from it and writes all three to the tags. Passing two is an error rather than a silent choice between them. --anilist is the exception and still pairs with one of the three, which is how you tag an anime title with a western ID.
The ID you give must have a provider that can actually resolve it, so unshackle checks that before it downloads anything. --tmdb without tmdb_api_key, or an ID whose providers your metadata_providers list leaves out, fails immediately with a message naming what to set. --imdb needs no key, since the imdb provider is keyless and in the default order.
With --enrich, the title, year and original language are all replaced with the external source's values, whether or not the service already filled them in. A field the source does not answer with is left alone, and unshackle logs which fields those were, so a provider with a thin record cannot blank out what the service told you.
--enrich also fills in the absolute episode number of each episode from TVDB's absolute order, for any series that has one and where the service did not supply it. Anime is the usual beneficiary, but no part of this is limited to anime. This only adds the {absolute} naming variable. The season and episode numbers are never changed.
With --daily, --enrich also fills in the air date of each episode from TVDB. See Daily and date-based content.
--enrich replaces the original language, which affects more than the filename
Track selection reads the original language, so replacing it changes which audio is treated as the original. That is the point when a service mislabels it, but it means a wrong ID can pick the wrong audio track and not only write a wrong name. Without --enrich the service's own value is kept and track selection is untouched.
"},{"location":"guide/downloading/#episode-ordering","title":"Episode ordering","text":"A service does not always number a series the way TVDB's aired order does. Some services list Futurama in TVDB's alternate (Streaming) order, for example, while TVDB's official order holds back four season-one episodes to the start of season two. --tvdb-order works out which order the service used, then renumbers the episodes into the order you asked for:
Download Futurama in DVD orderunshackle dl --tvdb-order dvd EXAMPLE 81234567\n
Available orders are official (aired), dvd, absolute, alternate, and regional. Set tvdb_order in your config to apply one by default.
Orders that do not cover the whole series
An order can leave out episodes the service carries. TVDB's dvd order does not list Futurama's four movies. Those episodes keep their original numbering. If that would give two episodes the same season/episode slot, and so the same filename, unshackle logs an error and leaves the numbering untouched. Pick an order that covers the whole series.
"},{"location":"guide/downloading/#configuration-defaults","title":"Configuration defaults","text":"Every flag on this page can be set as a default in your configuration file so you don't have to type it each time, and defaults can be scoped per service. See Configuration file for the dl: block and per-service overrides.
Explicit flags always win
A value you pass on the command line (or via an environment variable) always takes precedence over both the global dl: defaults and any per-service dl: overrides. Config only fills in options you didn't set explicitly.
"},{"location":"guide/downloading/#quick-reference","title":"Quick reference","text":"A condensed lookup of the most-used options. Run unshackle dl --help for the complete, authoritative list.
Flag Short Meaning --quality -q Target resolution(s), e.g. 1080,720. --vcodec -v Video codec(s). --acodec -a Audio codec(s). --range -r Color range(s); default SDR. --channels -c Audio channel layout. --noatmos -naa Exclude Atmos audio. --lang -l Video + audio language(s); default orig. - excludes, e.g. all,-es. --a-lang -al Audio-only language override. - excludes. --v-lang -vl Video-only language override. - excludes. --s-lang -sl Subtitle language(s); default all. - excludes, e.g. all,-es. --forced-subs -fs Include forced subtitles. --forced-s-lang -fsl Forced subtitle language(s); implies -fs. - excludes. --sub-format Output subtitle format. --wanted -w Episode/season range, or an air date. --daily Fill missing air dates from TVDB during --enrich. --select-titles Interactively pick episodes or films. --latest-episode Only the newest episode. --video-only / --audio-only / --subs-only -V / -A / -S Restrict track types. --no-video / --no-audio / --no-subs / --no-chapters -nv / -na / -ns / -nc Skip track types. --worst Lowest bitrate within -q. --best-available Degrade gracefully instead of failing. --output -o Output directory for this run. --split-audio / --merge-video / --no-mux Muxing behaviour. --proxy / --no-proxy / --no-proxy-download Proxy control. --workers / --downloads / --slow Concurrency and pacing. --list / --list-titles / --skip-dl Dry runs. --cdm-only / --vaults-only Key source control. --export Export track info and keys to JSON. --tmdb / --imdb / --tvdb / --anilist / --enrich Metadata overrides. --tvdb-order Renumber episodes to a TVDB season order."},{"location":"guide/drm-and-cdm/","title":"DRM & CDM Setup","text":"Most commercial streaming content is protected by a Digital Rights Management (DRM) system. To download and play it back, unshackle needs to obtain the content keys for the encrypted tracks and then decrypt them. This page explains the DRM systems unshackle supports, how to provide a device so it can talk to a license server, the difference between local and remote CDMs, and how to choose a decryption backend.
Who this page is for
This is a practical configuration guide for end users of the unshackle CLI. A few sections are marked Developer. They describe internal behaviour that is useful when writing a service, but you do not need them to download protected content.
"},{"location":"guide/drm-and-cdm/#key-concepts","title":"Key concepts","text":"Before configuring anything, it helps to know the vocabulary. unshackle draws a firm line between two things:
- A CDM (Content Decryption Module) is the trusted \"black box\" that turns a license challenge into content keys. It is backed by a device, a file containing the cryptographic identity of a real client (a phone, a browser, etc.).
- A DRM system is the wrapper around a CDM that parses the protection metadata, drives the challenge \u2192 license \u2192 keys handshake, and finally invokes an external tool to physically decrypt the downloaded file.
A handful of other terms show up throughout:
Term Meaning PSSH Protection System Specific Header. The per-system init data extracted from the file's init segment or the manifest. KID Key ID: a 16-byte UUID identifying which key a track needs. CEK Content Encryption Key: the actual AES key, mapped from its KID. Challenge The license request the CDM produces, sent to the service's license server. License The server's response, which the CDM parses to recover the content keys. For a Widevine or PlayReady title, the flow is: unshackle reads the PSSH and KIDs from the track, asks the CDM for a license challenge, sends that challenge to the service's license server, hands the response back to the CDM to extract the keys, and then decrypts the file with those keys.
"},{"location":"guide/drm-and-cdm/#supported-drm-systems","title":"Supported DRM systems","text":"unshackle implements five DRM systems. Only the first two need a device file and a license-server handshake; the rest are special cases.
System Device file License flow Decryption Widevine (Google) .wvd CDM challenge \u2192 license server \u2192 CDM parses \u2192 keys shaka-packager / mp4decrypt PlayReady (Microsoft) .prd CDM challenge (WRM header) \u2192 license server \u2192 CDM parses \u2192 keys shaka-packager / mp4decrypt ClearKey (CENC), W3C EME org.w3.clearkey none JSON {\"kids\":[...],\"type\":\"temporary\"} \u2192 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 Two different ClearKeys
\"ClearKey (HLS)\" and \"ClearKey (CENC)\" are entirely different systems that happen to share a name. The HLS variant is the AES-128 key referenced by an EXT-X-KEY tag and is decrypted in-process. The CENC variant is the W3C EME org.w3.clearkey scheme, delivers keys as a JWK Set, and is decrypted through shaka-packager or mp4decrypt just like Widevine. Neither involves a device file.
"},{"location":"guide/drm-and-cdm/#widevine-vs-playready","title":"Widevine vs PlayReady","text":"These are the two \"real\" DRMs, and in unshackle they behave almost identically: the same PSSH/KID handling, the same decryption backends, and the same key-caching flow. The differences that matter to you are:
- Device file: Widevine uses a
.wvd; PlayReady uses a .prd. - Availability: some services offer only one system, some offer both. When a manifest carries both, unshackle picks the appropriate CDM for each track (see 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 unshackle automatically selects the appropriate one for each track.
Obtaining device files
A .wvd or .prd encodes the private keys of a real client device. unshackle does not ship with one and cannot generate the underlying secret material for you. You must supply your own. The wvd and prd commands below manage and provision device files once you have the raw key material.
"},{"location":"guide/drm-and-cdm/#providing-a-device-with-the-wvd-and-prd-commands","title":"Providing a device with the wvd and prd commands","text":"Device files live in dedicated directories, by default:
Kind Default directory Widevine .wvd data/WVDs PlayReady .prd data/PRDs You can move these with the directories section of your unshackle.yaml:
unshackle.yamldirectories:\n wvds: /home/george/unshackle-data/WVDs\n prds: /home/george/unshackle-data/PRDs\n
"},{"location":"guide/drm-and-cdm/#widevine-devices-the-wvd-command","title":"Widevine devices: the wvd command","text":"The wvd command group manages .wvd files.
Add an existing deviceInspect a deviceCreate from raw materialDelete / dump $ unshackle wvd add ./nexus_6p.wvd\n
The file is validated and moved into the WVDs directory. You can add several at once by passing multiple paths.
$ unshackle wvd parse nexus_6p\n
Prints the System ID, Security Level, Type, Flags, and whether a Private Key, Client ID, and VMP are present. A bare name (no extension) is resolved inside the WVDs directory; a path with an extension is used as-is.
$ unshackle wvd new \"Nexus 6P\" ./private_key.pem ./client_id.bin ./vmp.bin -t ANDROID -l 3\n
Builds a .wvd from a PEM private key, a Client ID blob, and an optional FileHashes (VMP) blob. -t/--type is the device type (default Android) and -l/--level is the security level 1 to 3 (default 1). The VMP argument is optional.
$ unshackle wvd delete nexus_6p\n$ unshackle wvd dump nexus_6p ./out\n
delete removes a device (with a confirmation prompt). dump extracts a device's contents (metadata, private key, Client ID, VMP) into a folder.
v1 WVD files must be migrated
If loading a device fails with a message about a \"v1 WVD file\", it was made with an older format. Migrate it with pywidevine before use:
$ pywidevine migrate --help\n
"},{"location":"guide/drm-and-cdm/#playready-devices-the-prd-command","title":"PlayReady devices: the prd command","text":"The prd command group creates and maintains .prd files.
Create a new deviceReprovisionTest against Microsoft's demo server $ unshackle prd new ./zgpriv.dat ./bgroupcert.dat\n
Provision a new device from a group key and group certificate. You can instead point at a single folder that contains zgpriv.dat and bgroupcert.dat:
$ unshackle prd new ./device_folder/\n
A fresh leaf certificate is generated and the device is written to the PRDs directory (or to -o/--output). Use -e/--encryption_key and -s/--signing_key to supply your own ECC keys instead of generating them.
$ unshackle prd reprovision ./data/PRDs/mydevice.prd\n
Regenerates the leaf certificate and encryption/signing keys of an existing device in place. The device must support reprovisioning (a group key must be present, i.e. a version 3 or higher device).
$ unshackle prd test ./data/PRDs/mydevice.prd -sl 2000 -c aesctr\n
Runs a full challenge/license/keys cycle against Microsoft's public PlayReady test server and prints the returned keys, confirming the device works. -sl/--security-level accepts 150, 2000, or 3000 (default 2000); -c/--ckt accepts aesctr or aescbc.
"},{"location":"guide/drm-and-cdm/#selecting-which-device-to-use-the-cdm-config","title":"Selecting which device to use: the cdm config","text":"Having a device on disk is not enough; you must tell unshackle which device to use for which service. That is the job of the cdm section, which maps a service tag to a device name (the file's name without extension). A default entry acts as the fallback:
unshackle.yamlcdm:\n default: nexus_6p_26830_l3\n EXAMPLE1: chromecdm_l3\n EXAMPLE2: sm_g935f_l1\n
Lookups are case-insensitive.
The device name is the file name
A device named chromecdm_l3 in the config resolves to chromecdm_l3.wvd in the WVDs directory (or chromecdm_l3.prd in the PRDs directory). It may also be the name of a remote CDM defined in remote_cdm. See Remote CDMs below.
"},{"location":"guide/drm-and-cdm/#advanced-selection-by-quality-drm-or-profile","title":"Advanced selection: by quality, DRM, or profile","text":"Instead of a plain name, a cdm entry can be a nested mapping, letting unshackle pick a different device based on the track quality, the DRM system, or the active service profile.
By qualityBy DRM systemBy profile Keys are matched against the video height. You can use exact heights or comparisons (>=1080, >720, <=576, <480). More specific matches win.
cdm:\n SOMESERVICE:\n \">=1080\": sm_g935f_l1\n default: chromecdm_l3\n
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.
cdm:\n SOMESERVICE:\n widevine: sm_g935f_l1\n playready: mydevice_sl3\n
Keys are matched against the active profile, with a default fallback.
cdm:\n SOMESERVICE:\n premium: sm_g935f_l1\n default: chromecdm_l3\n
"},{"location":"guide/drm-and-cdm/#local-vs-remote-cdms","title":"Local vs remote CDMs","text":"A CDM can run in two places:
- A local CDM loads a
.wvd or .prd device from your machine and performs the challenge/license/keys handshake locally. This is what the cdm mapping above resolves to by default. - A remote CDM delegates the CDM operations to an HTTP API. Your device (and its secrets) live on the remote server; unshackle sends it PSSH/challenge data and receives keys back. This is useful for sharing a high-security-level device, using a hosted key service, or keeping device secrets off your download machine.
The device name in your cdm mapping resolves to a remote CDM whenever it matches the name of an entry in the remote_cdm list. Otherwise it falls back to a local .prd/.wvd file of that name.
"},{"location":"guide/drm-and-cdm/#remote-cdms","title":"Remote CDMs","text":"Remote CDMs are declared as a list under remote_cdm. Each entry has a name (which you then reference from the cdm mapping) and a shape that depends on its type. There are four kinds.
"},{"location":"guide/drm-and-cdm/#widevine-remote-cdm-pywidevine-serve","title":"Widevine remote CDM (pywidevine serve)","text":"With no type field and a non-PlayReady device type, the entry is treated as a standard pywidevine \"serve\" endpoint:
unshackle.yamlremote_cdm:\n - name: my_wv_remote\n device_type: ANDROID\n system_id: 26830\n security_level: 3\n host: https://cdm.example.com\n secret: your-api-secret\n device_name: sm_g935f_l1\n\ncdm:\n default: my_wv_remote\n
security_level defaults to 3000 if omitted. Field names are read case-insensitively, so Device Type/device_type, System ID/system_id, etc. are all accepted.
"},{"location":"guide/drm-and-cdm/#playready-remote-cdm","title":"PlayReady remote CDM","text":"If the device type is PLAYREADY, the entry is routed to the pyplayready remote CDM:
unshackle.yamlremote_cdm:\n - name: my_pr_remote\n device_type: PLAYREADY\n security_level: 3000\n host: https://cdm.example.com\n secret: your-api-secret\n device_name: mydevice_sl3\n
The host must end in /playready
For a PlayReady remote CDM the host URL has to include the trailing /playready path segment (e.g. https://cdm.example.com/playready). pyplayready's RemoteCdm treats host as a base and appends its own endpoint paths on top of it, so a host without the suffix points the requests at the wrong location. The symptom is 404 responses from the server rather than an obvious configuration error.
"},{"location":"guide/drm-and-cdm/#decrypt-labs-hosted-keyxtractor-api","title":"Decrypt Labs (hosted KeyXtractor API)","text":"Set type: decrypt_labs to use Decrypt Labs' hosted KeyXtractor service, which includes an intelligent key-caching layer:
unshackle.yamlremote_cdm:\n - name: decryptlabs\n type: decrypt_labs\n device_name: L1 # ChromeCDM, L1, L2 (Widevine) or SL2, SL3 (PlayReady)\n # host: https://keyxtractor.decryptlabs.com # optional, this is the default\n # secret: ... # optional if decrypt_labs_api_key is set globally\n\ndecrypt_labs_api_key: your-decrypt-labs-key\n
The API key is sent as the decrypt-labs-api-key header. If you omit secret on the entry, unshackle falls back to the global decrypt_labs_api_key value; if neither is present it raises an error. The host defaults to https://keyxtractor.decryptlabs.com. PlayReady devices (SL2/SL3) default to security level 2000/3000 respectively; Widevine devices default to system_id: 26830, security_level: 3.
"},{"location":"guide/drm-and-cdm/#custom-api-custom_api","title":"Custom API (custom_api)","text":"For an arbitrary CDM HTTP API, type: custom_api provides a fully configurable client that adapts to almost any request/response shape through YAML alone. A minimal example:
unshackle.yamlremote_cdm:\n - name: my_custom\n type: custom_api\n device:\n name: ChromeCDM\n type: CHROME # CHROME, ANDROID, or PLAYREADY\n system_id: 26830\n security_level: 3\n auth:\n type: bearer # header, bearer, or basic\n key: your-token\n endpoints:\n get_request:\n path: /get-challenge\n method: POST\n decrypt_response:\n path: /get-keys\n method: POST\n timeout: 30\n
The custom_api type supports much more: request/response field remapping, transforms (base64/hex/JSON encoding, kid:key parsing), conditional parameters, success/error detection, and vault-backed caching. Its full schema is aimed at developers integrating a bespoke key server; see the developer note below.
Developer: full custom_api schema
A custom_api entry accepts these sub-objects: device (name, type, system_id, security_level); auth (type of header/bearer/basic with the matching credential fields, plus custom_headers); endpoints (get_request and decrypt_response, each {path, method, timeout}); per-endpoint request_mapping (param_names, static_params, conditional_params, transforms, nested_params, exclude_params) and response_mapping (fields, transforms, response_types, success_conditions, error_fields, key_fields); caching (enabled, use_vaults, check_cached_first); and legacy/timeout. PlayReady mode is inferred when device.type == PLAYREADY or the device name is SL2/SL3.
"},{"location":"guide/drm-and-cdm/#decryption-backends","title":"Decryption backends","text":"Once the content keys are known, the encrypted file (for Widevine, PlayReady, and ClearKey-CENC) is decrypted by one of two external tools:
- shaka-packager is the default.
- mp4decrypt is part of the Bento4 tools.
Both must be discoverable in unshackle's bundled binaries/ directory or on your PATH. If the selected tool's binary is missing at decrypt time, unshackle raises an \"executable not found but is required\" error. See dependencies for installation.
You choose the backend with the decryption key. It can be a single value applied globally, or a per-service mapping:
Global defaultPer service unshackle.yamldecryption: shaka\n
unshackle.yamldecryption:\n default: shaka\n SOMESERVICE: mp4decrypt\n
How the value is interpreted
The default is shaka. Only the exact value mp4decrypt selects mp4decrypt; anything else (including shaka) selects shaka-packager. Per-service lookups are case-insensitive, with the default/DEFAULT entry used as the fallback.
The two backends produce equivalent output for standard CENC content. shaka-packager is generally preferred; switch a specific service to mp4decrypt if you hit a track that shaka-packager cannot handle.
ClearKey (HLS) does not use these tools
HLS AES-128 ClearKey content is decrypted in-process using AES-CBC and ignores the decryption setting entirely. There is nothing to configure.
"},{"location":"guide/drm-and-cdm/#clearkey","title":"ClearKey","text":""},{"location":"guide/drm-and-cdm/#clearkey-over-hls-aes-128","title":"ClearKey over HLS (AES-128)","text":"When an HLS playlist references a key with an EXT-X-KEY tag, unshackle fetches the key directly (from the given URI or an inline data: URL) and decrypts each segment in-process with AES-CBC. There is no device, no CDM, and no license challenge, so there is nothing to set up. Such tracks are handled automatically during download.
"},{"location":"guide/drm-and-cdm/#clearkey-over-cenc-w3c-eme-orgw3clearkey","title":"ClearKey over CENC (W3C EME org.w3.clearkey)","text":"The CENC ClearKey scheme is a proper license flow, but the \"license server\" simply returns the keys as a JWK Set rather than a DRM-wrapped blob. unshackle builds a W3C EME request of the form:
{\"kids\": [\"<base64url-kid>\", ...], \"type\": \"temporary\"}\n
It sends this to the manifest's license URL (or to whatever a service's ClearKey license hook returns) and reads the JSON Web Key set back. No device file is needed. The decrypted content still goes through shaka-packager or mp4decrypt like any other CENC track, so the decryption setting applies.
"},{"location":"guide/drm-and-cdm/#widevine-and-playready-on-the-same-title","title":"Widevine and PlayReady on the same title","text":"A single manifest can advertise both Widevine and PlayReady. unshackle picks the CDM per track: if a track needs a system that your loaded CDM does not provide, it transparently switches to the appropriate device for that track using your cdm mapping. If it cannot find a matching device, it fails with a clear message such as:
Title needs a Widevine CDM but SOMESERVICE is configured with PlayReady.\n
Use the cdm mapping's DRM-based selection to configure a device for each system; unshackle selects the right one per track automatically.
"},{"location":"guide/drm-and-cdm/#keys-vaults-and-caching","title":"Keys, vaults, and caching","text":"Fetching a license is comparatively slow and, for some services, rate-limited. unshackle therefore resolves each key ID in this order before ever contacting a license server:
- Keys already known for the current DRM object.
- An in-process license-key cache (for the running session).
- Your configured key vaults.
- Only if still missing, a live license request.
After a successful license request, the recovered keys are written back to all configured vaults so future downloads can skip the license server. Two flags let you control this trade-off on the dl command:
Flag Effect --vaults-only Never send a license request; use only cached/vault keys. Fails if a key is missing. --cdm-only Ignore vaults for sourcing keys and always perform a fresh license request. See key vaults for how to configure vault backends.
"},{"location":"guide/drm-and-cdm/#debugging-drm-issues","title":"Debugging DRM issues","text":"A few configuration toggles help when a title will not decrypt:
Config key Default Effect debug false Global debug output. debug_keys false Log decryption keys. debug_requests false Log HTTP requests. unshackle.yamldebug_keys: true\ndebug_requests: true\n
Keys and paths in logs
When key logging is enabled, content keys (KID:key pairs) are written to the logs. Treat those logs as sensitive. The unrelated redact_paths option (default true) masks local directory paths in log output.
"},{"location":"guide/drm-and-cdm/#see-also","title":"See also","text":" - Downloading: the
dl command and its DRM-related flags. - Key vaults: caching content keys across downloads.
- Configuration: the full
unshackle.yaml reference. - Dependencies: installing shaka-packager and mp4decrypt.
"},{"location":"guide/output-and-naming/","title":"Output & Naming","text":"This page explains where unshackle writes finished downloads and how it names them. You control naming with output templates (the filename) and folder templates (the directory the file lands in), using a set of {variable} placeholders that unshackle fills in from the media it just downloaded. It also covers muxing options, release-group tagging, Unicode filenames, and how to relocate unshackle's working directories.
If you have not configured unshackle.yaml yet, read Configuration File first. Everything below lives in that file.
"},{"location":"guide/output-and-naming/#how-an-output-path-is-assembled","title":"How an output path is assembled","text":"When a download finishes muxing, unshackle builds the final path from three pieces:
<output directory> / <folder template> / <filename template><extension>\n
- 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, or a single-string folder template that applies to all title kinds (see 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.
A typical episode path
/home/user/Downloads/\n \u2514\u2500 Show Name S01 1080p EXAMPLE WEB-DL DDP5.1 H.264/\n \u2514\u2500 Show.Name.S01E01.Pilot.1080p.EXAMPLE.WEB-DL.DDP5.1.H.264-TAG.mkv\n
The folder name comes from the series folder template and the file from the series output template. Two command-line flags change this layout for a single run:
Flag Effect -o, --output Use this directory instead of directories.downloads. --no-folder Skip the per-title subfolder; write the file directly into the output directory. --no-source Omit the service source tag from both the filename and folder (the {source} variable resolves to empty)."},{"location":"guide/output-and-naming/#output-templates","title":"Output templates","text":"Filename templates live under the output_template key, keyed by title kind. Each value is a format string built from {variable} placeholders.
unshackle.yamloutput_template:\n movies: \"{title}.{year}.{quality}.{source}.WEB-DL.{audio_full}.{video}-{tag}\"\n series: \"{title}.{season_episode}.{episode_name?}.{quality}.{source}.WEB-DL.{audio_full}.{video}-{tag}\"\n songs: \"{track_number}. {title}\"\n
The recognized kinds are:
Kind Applies to Default when unset 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) - 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.
"},{"location":"guide/output-and-naming/#required-vs-optional-variables","title":"Required vs. optional variables","text":" {variable}: required. If it is missing from the download's context, formatting raises an error. (Note that most variables are always present in context but resolve to an empty string when not applicable, so being \"present but empty\" is different from \"missing\".) {variable?}: optional. A trailing ? marks the variable conditional. When it resolves to empty, unshackle removes the placeholder and one adjacent separator (., -, or space), so the surrounding text collapses cleanly.
Use ? for anything that isn't always there
Episode names, editions, tags, HDR labels and audio extras aren't present on every title. Mark them optional so you never get doubled dots or dangling separators:
series: \"{title}.{season_episode}.{episode_name?}.{quality}.{hdr?}.{source}-{tag?}\"\n
For an episode with no on-screen name, {episode_name?} and its adjacent dot simply disappear."},{"location":"guide/output-and-naming/#separator-style-dots-vs-spaces","title":"Separator style (dots vs. spaces)","text":"unshackle looks at the characters you place between variables to decide the filename's separator style. If spaces outnumber dots in your template, it sanitizes with spaces; otherwise it uses dots (the scene-style default). This is also applied to the auto-generated date separators for daily/dated content, and to every segment of a nested folder template.
Scene style (dots)Readable style (spaces) output_template:\n movies: \"{title}.{year}.{quality}.{source}.WEB-DL.{audio_full}.{video}-{tag}\"\n
The.Movie.2024.1080p.EXAMPLE.WEB-DL.DDP5.1.H.264-TAG.mkv\n
output_template:\n movies: \"{title} ({year}) {quality} {source} WEB-DL {audio_full} {video}\"\n
The Movie (2024) 1080p EXAMPLE WEB-DL DDP5.1 H.264.mkv\n
"},{"location":"guide/output-and-naming/#template-variables","title":"Template variables","text":"Every variable below is valid in both output and folder templates. Values are derived from the downloaded media (via MediaInfo), from the title metadata, or from your config. Variables that don't apply to a given title resolve to an empty string.
"},{"location":"guide/output-and-naming/#common-video","title":"Common / video","text":"Variable Meaning Example title Title name (movie/show/song name; $ is rendered as S) The Show title_type Media kind of the title movie, series, music year Release year 2024 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 hdr Dynamic-range label HDR, HDR10P, DV, DV.HDR, DV.HDR10P, HLG hfr High frame rate marker (frame rate > 30) HFR edition Edition label from the track, if any Directors Cut tag Release-group tag (from config.tag or --tag) TAG repack REPACK when the --repack flag is used REPACK lang_tag Result of your language_tags rules, if configured MULTi How language_tags rules match
Rules are tried in order, but language comparison is fuzzy: a rule condition of en matches en-US, en-GB, and any other en-* variant. Within a single rule, all conditions must match (AND logic) for it to apply. The first rule that fully matches wins; if none match, {lang_tag?} is removed from the filename cleanly.
"},{"location":"guide/output-and-naming/#episode-specific","title":"Episode-specific","text":"Variable Meaning Example season Zero-padded season (S%02d) S01 episode Zero-padded episode (E%02d) E05 season_episode Combined S01E05 episode_name Episode title Pilot part Part index of a split episode, empty otherwise 2 absolute Absolute episode number, zero-padded to 3 digits, empty when unknown 007 date ISO air date for daily/dated content 2024-06-01 Daily & sports content
When an episode has an air date, unshackle switches to date-based naming automatically: season and season_episode become the formatted air date, episode and year are cleared, and {date} holds the ISO date. The date's internal separator (dots or spaces) follows your series template style.
"},{"location":"guide/output-and-naming/#split-episodes","title":"Split episodes","text":"A few services split one episode into several separately playable videos. Where a service reports that, the part index is folded into {episode} and {season_episode}, so the stock series template names it with no change to your config.
Show.Name.S01E01.Part.2.1080p.EXAMPLE.WEB-DL.DDP5.1.H.264-TAG.mkv\n
The part token sits immediately after the episode token, before the quality tags, and the group tag stays last. Its separator follows your template's own style, the same way dated content does, so a spaced template gives S01E01 Part 2 instead.
The part is left out of the folder name, so every part of an episode lands in the same season folder:
The.Show.S01.1080p.EXAMPLE.WEB-DL.DDP5.1.H.264-TAG/\n \u251c\u2500 The.Show.S01E01.Part.1.1080p.EXAMPLE.WEB-DL.DDP5.1.H.264-TAG.mkv\n \u251c\u2500 The.Show.S01E01.Part.2.1080p.EXAMPLE.WEB-DL.DDP5.1.H.264-TAG.mkv\n \u2514\u2500 The.Show.S01E02.1080p.EXAMPLE.WEB-DL.DDP5.1.H.264-TAG.mkv\n
The same holds for a folder template of your own: {season} never picks up the part, and {season_episode} drops it inside a folder template. An episode with no part is named like any other episode.
What media servers do with the part token
The form above follows scene practice for genuinely split episodes. Kodi stacks the parts back into one playable episode automatically. Jellyfin lists two versions of S01E01, still correct and playable, because its stacking pattern expects the part marker at the end of the name. Plex's scanner is closed-source, so its handling of text after the part token is unverified.
For custom templates there is also a standalone {part} variable. You rarely want it, see the caveat on that page.
"},{"location":"guide/output-and-naming/#audio","title":"Audio","text":"Variable Meaning Example audio Audio codec DDP, DD, AAC audio_channels Channel layout 5.1, 2.0 audio_full Codec + channels combined DDP5.1 atmos Atmos when any track carries JOC Atmos dual DUAL for two audio languages (see dual_multi_mode) DUAL multi MULTi for more than two audio languages MULTi dubbed DUBBED for a single audio language that is not the original (strict mode only) DUBBED"},{"location":"guide/output-and-naming/#music-specific","title":"Music-specific","text":"Variable Meaning track_number Zero-padded track number artist / album_artist Artist / album artist (falls back to artist) album Album name disc Disc number (only shown when greater than 1) track_total / disc_total Totals release_type album, single, ep, etc. genre Genre explicit Explicit when flagged isrc / upc / label ISRC, UPC and record label Unknown variables are ignored, not errors
If you use a variable that isn't in the list above, unshackle emits a warning at startup but continues. Double-check spelling. A typo'd {quailty} will simply be left in the filename literally or dropped, not filled in.
"},{"location":"guide/output-and-naming/#folder-templates","title":"Folder templates","text":"Folder templates are nested under a special folder key inside output_template. This key is handled separately from the filename kinds. You can give it a single string (used for all title kinds) or a per-kind map.
Single folder templatePer-kind folder templates unshackle.yamloutput_template:\n movies: \"{title}.{year}.{quality}.{source}-{tag}\"\n series: \"{title}.{season_episode}.{quality}.{source}-{tag}\"\n folder: \"{title}.{year}\"\n
unshackle.yamloutput_template:\n movies: \"{title}.{year}.{quality}.{source}-{tag}\"\n series: \"{title}.{season_episode}.{quality}.{source}-{tag}\"\n folder:\n movies: \"{title} ({year})\"\n series: \"{title} ({year})/Season {season}\"\n albums: \"{artist} - {album} ({year})\"\n
The per-kind folder keys are movies, series, songs, and albums. Any other key produces a startup warning. Path separators (/ or \\) are allowed in folder templates. Each segment is formatted independently, so you can build nested directory structures like Show/Season 01. unshackle reads the separator style from the whole template, so every segment is spaced alike.
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 (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}).
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 (or a single-string folder template, which folders every kind). Set one if you want each movie in its own directory.
"},{"location":"guide/output-and-naming/#muxing-options","title":"Muxing options","text":"Muxing (combining video, audio, subtitle, chapter and attachment tracks into a single Matroska file with mkvmerge) is configured under the muxing key.
unshackle.yamlmuxing:\n set_title: true\n merge_video: false\n merge_audio: true\n default_language:\n audio: en\n subtitle: en\n
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 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. When no preferred language is matched, unshackle applies sensible defaults: the video default falls back to the title language / original-language track / first track; the audio default is the original-language track; and the subtitle default is a forced track matching the first audio's language.
default_language only sets the default-track flag
default_language controls which track carries the MKV --default-track flag. Nothing else. It does not change track selection (that stays with -l/--alang and friends), and it does not touch which track is marked as original: --original-flag still tags the true original-audio track. That is the point: you can make your player default to, say, Polish audio on an English-original title without altering the original marker. When the configured language isn't present in the manifest, each track type falls back to its normal default rule described above.
merge_video collapses only the language dimension
merge_video groups video tracks by (resolution, range, codec) and merges them so that only language differs within a single file, with no re-encode and no concatenation. Different resolutions, ranges (SDR / HDR10 / HDR10+ / DV / HYBRID), and codecs (H.264 / H.265) always land in separate files. So -r HYBRID,DV,HDR10,SDR --merge-video produces one file per range (never a single fused file), whereas English + French video of identical resolution, range, and codec yields one file containing both video tracks. merge_audio works the same way for audio languages.
A few muxing behaviors are automatic and not configurable:
--no-date is always passed for privacy (no timestamps embedded). - Descriptive audio gets the visually-impaired flag; SDH subtitles get the hearing-impaired flag; forced subtitles get the forced flag.
- Chapters are written from the title's chapter list (see Chapters below).
The --no-mux flag skips muxing entirely and writes the individual track files, each with a track-type suffix, into the same output/folder structure.
"},{"location":"guide/output-and-naming/#tags-group-naming","title":"Tags & group naming","text":"The release-group tag is the -TAG portion at the end of scene-style names, produced by the {tag} variable. It can be swapped per release with tag_rules: ordered rules that match on the release-attribute variables only (quality, resolution, hdr, source, lang_tag, title_type, and so on), first match wins. The title's own fields, such as title and season_episode, cannot be matched. See the reference page for the exact list.
unshackle.yamltag: \"MYGROUP\"\ntag_group_name: true\ntag_imdb_tmdb: true\n
Key Type Default Effect tag str \"\" The release-group tag used for {tag}. tag_group_name bool true Write the group name (config.tag) into the MKV Group metadata tag. tag_imdb_tmdb bool true Look up and embed IMDb / TMDB / TVDB external-ID tags in the MKV metadata (uses tmdb_api_key / tvdb_api_key / simkl_client_id when available). The IDs are written as the Matroska IMDB, TMDB, and TVDB2 tags. TVDB2 values carry the entity prefix the Matroska tagging spec requires: series/73871 for a show, movies/113 for a film.
You can override the tag per run without editing config:
unshackle dl --tag OTHERGROUP EXAMPLE \"...\"\n
The --repack flag adds the REPACK marker (surfaced through the {repack} variable) to the filename for that run:
unshackle dl --repack EXAMPLE \"...\"\n
Empty tags collapse cleanly
If config.tag is empty and your template ends with -{tag?}, the trailing - and the placeholder are both removed. Use the optional form {tag?} if you sometimes download without a tag set.
"},{"location":"guide/output-and-naming/#unicode-filenames","title":"Unicode filenames","text":"By default unshackle transliterates non-ASCII characters (Korean, Japanese, Chinese, accented Latin, etc.) into ASCII equivalents, which is safest for older tools, DDL, and P2P sharing. Set unicode_filenames to keep the native characters.
unshackle.yamlunicode_filenames: true\n
Value Result false (default) \uae30\uc0dd\ucda9 \u2192 transliterated ASCII; accents stripped. true Native characters preserved in filenames and folders. Regardless of this setting, a set of filesystem-unsafe and structural characters is always removed or replaced during sanitization (for example / and ; become &, and characters like \\ * ! ? , ' \" < > | $ # ~ are stripped). This is why you should never put path-unsafe characters directly in a template. unshackle warns about templates containing < > : \" / \\ | ? * at startup (folder templates are checked per path segment, so / is allowed there as a directory separator).
"},{"location":"guide/output-and-naming/#directory-filename-configuration","title":"Directory & filename configuration","text":"The directories key relocates unshackle's working folders. Only the folders listed here can be moved; core package paths are protected and silently ignored if you try to override them.
unshackle.yamldirectories:\n downloads: \"~/Media/unshackle\"\n temp: \"/mnt/fast/unshackle-temp\"\n cache: \"~/.cache/unshackle\"\n cookies: \"~/.config/unshackle/cookies\"\n logs: \"~/.config/unshackle/logs\"\n
Name Purpose downloads Default output directory for finished files. temp Working directory for in-progress downloads, muxing, and intermediate files. cache Title/HTTP cache and update-check state. cookies Per-service cookie files. logs Log files. exports Export JSONs. services, vaults, fonts, commands Search paths for services, key-vault backends, bundled fonts, and CLI commands. wvds, prds, dcsl Widevine devices, PlayReady devices, and DCSL data. Paths support ~ expansion. The names app_dirs, core_dir, namespace_dir, user_configs, and data are protected and cannot be changed.
"},{"location":"guide/output-and-naming/#filename-patterns","title":"Filename patterns","text":"The filenames key overrides the naming patterns for a handful of internal files. These are not your media output names. They are logs, temp files, and per-service configs.
unshackle.yamlfilenames:\n log: \"unshackle_{name}_{time}.log\"\n chapters: \"Chapters_{title}_{random}.txt\"\n subtitle: \"Subtitle_{id}_{language}.srt\"\n
Name Default Where log unshackle_{name}_{time}.log directories.logs debug_log unshackle_debug_{service}_{time}.jsonl directories.logs config config.yaml per-service directory root_config unshackle.yaml main config filename chapters Chapters_{title}_{random}.txt directories.temp subtitle Subtitle_{id}_{language}.srt directories.temp"},{"location":"guide/output-and-naming/#chapters-naming","title":"Chapters naming","text":"When a title has chapters, they are written to an OGM chapters file (filenames.chapters) and passed to the muxer. Unnamed chapters use the chapter_fallback_name template:
unshackle.yamlchapter_fallback_name: \"Chapter {i:02}\"\n
The fallback supports two placeholders: {i} (chapter number, starting at 1) and {j} (which increments only for unnamed chapters). Standard format directives like {i:02} are allowed.
"},{"location":"guide/output-and-naming/#removed-migrated-options","title":"Removed & migrated options","text":"scene_naming was removed
The old scene_naming option no longer exists. If it is present in your config, unshackle exits immediately with an error asking you to configure output_template instead. Migrate any scene-naming setup to the templates described on this page.
"},{"location":"guide/output-and-naming/#worked-examples","title":"Worked examples","text":"Each tab shows a template and what it produces for a few different downloads, so you can see how the optional (?) variables appear and disappear.
MoviesTV seriesMulti-languageDaily showsMusic output_template:\n movies: \"{title}.{year}.{edition?}.{quality}.{source}.WEB-DL.{audio_full}.{atmos?}.{video}.{hdr?}-{tag?}\"\n folder:\n movies: \"{title} ({year})\"\n
A 4K Atmos HDR download fills every placeholder:
The Movie (2024)/\n \u2514\u2500 The.Movie.2024.2160p.EXAMPLE.WEB-DL.DDP5.1.Atmos.H.265.HDR-TAG.mkv\n
The same template on a plain 1080p SDR stereo download, where {edition?}, {atmos?} and {hdr?} collapse along with their dots:
The Movie (2024)/\n \u2514\u2500 The.Movie.2024.1080p.EXAMPLE.WEB-DL.AAC2.0.H.264-TAG.mkv\n
A director's cut fills {edition?}:
The Movie (2024)/\n \u2514\u2500 The.Movie.2024.Directors.Cut.2160p.EXAMPLE.WEB-DL.DDP5.1.Atmos.H.265.DV.HDR-TAG.mkv\n
output_template:\n series: \"{title}.{season_episode}.{episode_name?}.{quality}.{source}.WEB-DL.{audio_full}.{video}-{tag}\"\n folder:\n series: \"{title} ({year})/Season {season}\"\n
The Show (2023)/Season S02/\n \u2514\u2500 The.Show.S02E04.The.Reckoning.2160p.EXAMPLE.WEB-DL.DDP5.1.H.265-TAG.mkv\n
unshackle decides the separator style once for the whole folder template, and every segment then follows it. Spaces outnumber dots in {title} ({year})/Season {season}, so Season {season} keeps its space even though it holds only one variable.
When an episode has no on-screen name, {episode_name?} disappears cleanly:
The Show (2023)/Season S02/\n \u2514\u2500 The.Show.S02E05.2160p.EXAMPLE.WEB-DL.DDP5.1.H.265-TAG.mkv\n
Place {dual?}, {multi?} and {dubbed?} next to each other. At most one of them is ever set (see dual_multi_mode), and the empty ones collapse:
output_template:\n movies: \"{title}.{year}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{dubbed?}.{audio_full}.{video}-{tag}\"\n
Original English audio plus a French dub (-l en,fr):
The.Movie.2024.1080p.EXAMPLE.WEB-DL.DUAL.DDP5.1.H.264-TAG.mkv\n
Original plus several dubs (-l en,fr,de,es):
The.Movie.2024.1080p.EXAMPLE.WEB-DL.MULTi.DDP5.1.H.264-TAG.mkv\n
Only a dub, original audio left out (-l de on an English-original title):
The.Movie.2024.1080p.EXAMPLE.WEB-DL.DUBBED.DDP5.1.H.264-TAG.mkv\n
Two dialects of one language (en-US + en-GB) count as a single language, so none of the three variables is set:
The.Movie.2024.1080p.EXAMPLE.WEB-DL.DDP5.1.H.264-TAG.mkv\n
For finer control (e.g. a SUBBED tag driven by subtitle languages), use {lang_tag?} with language_tags rules instead.
Dated content needs no special template. When an episode carries an air date, {season_episode} becomes the date automatically and {episode} and {year} clear:
output_template:\n series: \"{title}.{season_episode}.{episode_name?}.{quality}.{source}.WEB-DL.{audio_full}.{video}-{tag}\"\n
A normal episode and a dated one, same template:
The.Daily.Show.S28E101.1080p.EXAMPLE.WEB-DL.DDP5.1.H.264-TAG.mkv\nThe.Daily.Show.2024.06.01.1080p.EXAMPLE.WEB-DL.DDP5.1.H.264-TAG.mkv\n
The date separator follows your template style: dots here, spaces if your series template uses spaces.
output_template:\n songs: \"{track_number}. {title}\"\n folder:\n albums: \"{album_artist} - {album} ({year})\"\n
The Artist - The Album (2024)/\n \u2514\u2500 01.Opening.Track.mka\n
In \"{track_number}. {title}\" dots and spaces are tied, and ties go to dot style; add more spaces between variables (as below) if you want space-separated names.
A multi-disc album with explicit tracks, using more of the music variables. {disc} is empty on disc 1, so {disc?} and its separator only appear from disc 2 onward:
output_template:\n songs: \"{disc?}-{track_number}. {title} {explicit?}\"\n folder:\n albums: \"{album_artist} - {album} ({year}) [{label?}]\"\n
The Artist - The Album (2024) [The Label]/\n \u251c\u2500 01. Opening Track.mka\n \u2514\u2500 02-03. Closing Track Explicit.mka\n
"},{"location":"guide/output-and-naming/#see-also","title":"See also","text":" - Configuration File: the full
unshackle.yaml structure. - Downloading: the
dl command and per-run flags like -o, --no-folder, --tag, and --repack.
"},{"location":"guide/proxies-and-vpn/","title":"Proxies & VPN","text":"Most streaming services are geo-restricted, region-priced, or simply refuse to hand over a manifest from the \"wrong\" country. unshackle routes its traffic through a proxy so the service sees the location you choose instead of your own. You can point it at a single proxy URL, or configure one of several proxy providers (commercial VPNs and self-hosted tunnels), then ask for a country by its two-letter code and let unshackle pick a working server for you.
The --proxy flag controls proxying, and a query is resolved to a real proxy URL by the providers unshackle supports: basic static proxies, Gluetun (documented in the most detail, since it is the most flexible), ExpressVPN, NordVPN, Proton VPN, Hola, Surfshark, and Windscribe.
Where proxies are configured
All provider configuration lives under the top-level proxy_providers: key in your unshackle.yaml. Each provider has its own sub-key, and the keys inside it map directly onto that provider's settings. See the Configuration file page for where the file lives and how it is loaded.
"},{"location":"guide/proxies-and-vpn/#the-proxy-flag","title":"The --proxy flag","text":"Proxying is controlled at download time by three options on the dl command:
Option Effect --proxy The proxy to use. Either an explicit URI, or a query that unshackle resolves against your configured providers. --no-proxy Force all proxy use off. No providers are initialised and no proxy query is resolved. --no-proxy-download Use the proxy for the manifest, licence, and authentication, but bypass it for the downloads themselves. Explicit proxy URIunshackle dl --proxy http://user:pass@1.2.3.4:8080 EXAMPLE 81234567\n
Resolve a US server from your configured providersunshackle dl --proxy us EXAMPLE 81234567\n
Target a specific providerunshackle dl --proxy nordvpn:us EXAMPLE 81234567\n
Set a default proxy in config
Like every dl flag, --proxy can be given a default in your configuration so you do not have to pass it on every command. See Downloading for how dl options and config defaults interact.
"},{"location":"guide/proxies-and-vpn/#-no-proxy-download","title":"--no-proxy-download","text":"Segment downloads are usually the largest share of traffic, and a residential-grade VPN proxy is often the slowest link in the chain. --no-proxy-download lets you keep the proxy where it matters (passing the geo-check for the manifest and the licence) while pulling the files themselves over your normal connection at full speed.
Proxy the manifest and licence, download segments directunshackle dl --proxy gb --no-proxy-download EXAMPLE 10a1234\n
When this is unsafe
Some services tie segment delivery (CDN tokens, per-segment geo-checks) to the same region as the manifest. On those services, bypassing the proxy for downloads will cause segment fetches to fail or return the wrong region. If downloads break with --no-proxy-download, remove it.
"},{"location":"guide/proxies-and-vpn/#how-proxy-resolution-works","title":"How proxy resolution works","text":"The value you pass to --proxy is interpreted in one of three ways. unshackle checks them in order.
flowchart TD\n A[\"--proxy value\"] --> B{\"Looks like a URI?<br/>http:// https:// socks\"}\n B -- yes --> C[\"Use it verbatim\"]\n B -- no --> D{\"Has a provider prefix?<br/>provider:query\"}\n D -- yes --> E[\"Ask that one provider<br/>for the query\"]\n D -- no --> F[\"Ask every provider in order,<br/>first match wins\"]
- 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. - A provider-prefixed query:
provider:query, for example nordvpn:us or gluetun:windscribe:us. unshackle finds the provider whose name matches the prefix (case-insensitive) and asks only that provider to resolve the remainder. If no such provider is loaded, or it returns no proxy, the download errors. - A bare query: a region like
us, gb, us:seattle, or us1234. unshackle asks each loaded provider in order and uses the first proxy any of them returns.
The query is matched against a region grammar (a two-letter country code, optionally followed by a server number and/or a :city or -city part), lowercased, and then handed to the provider. The exact forms each provider accepts are documented in its section below.
"},{"location":"guide/proxies-and-vpn/#provider-load-order","title":"Provider load order","text":"When you run a download without --no-proxy, unshackle instantiates every configured provider once, in this fixed order, and that same order is used for bare-query resolution:
Basic \u2192 ExpressVPN \u2192 NordVPN \u2192 Proton VPN \u2192 Surfshark \u2192 Windscribe \u2192 Gluetun \u2192 Hola
So if you have both Basic and NordVPN configured and run --proxy us, a us entry in your Basic config wins because Basic is tried first. To skip ahead to a specific provider, prefix the query (--proxy nordvpn:us).
Auto-loading providers
Most providers only load when you configure them under proxy_providers:. Three are 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.
"},{"location":"guide/proxies-and-vpn/#connection-feedback","title":"Connection feedback","text":"After a provider resolves a query, unshackle prints a line so you can confirm where you came out:
- Gluetun reports the verified exit IP, country, and city of the container.
- ExpressVPN and Proton VPN print a location summary such as
(USA - New York, #3 of 5): .214. - Other providers log the resolved proxy URL.
"},{"location":"guide/proxies-and-vpn/#basic-static-proxies","title":"Basic (static proxies)","text":"The Basic provider is pure static configuration, with no accounts and no network calls. You list proxy URLs per country code, and unshackle serves them back when you query that country. This is the right choice when you already have proxy endpoints (from a proxy seller, your own servers, or a corporate gateway).
Under each two-letter country code you can put either a single proxy string or a list of them:
unshackle.yamlproxy_providers:\n basic:\n us: http://user:pass@1.2.3.4:8080\n de:\n - http://a.example:8080\n - socks5://b.example:1080\n
Query forms:
Query Meaning us A proxy for the US. If several are listed, one is chosen at random. us2 The 2nd entry in the US list (1-based). - If a country has a single proxy string, that proxy is always returned.
- If it has a list and you give a plain country code, one is picked at random; append an index (
de1, de2) to pin a specific entry. - Each URL is normalised (a missing scheme defaults to
http) and validated, so a malformed URI is rejected up front rather than failing mid-download.
unshackle dl --proxy de2 EXAMPLE 81234567\n
"},{"location":"guide/proxies-and-vpn/#gluetun","title":"Gluetun","text":"Gluetun is unshackle's most flexible proxy backend, and the one to reach for if you want one configuration to cover many VPN providers. Instead of returning a remote proxy URL, Gluetun launches a local Docker container running the qmcgaw/gluetun image. That container establishes a WireGuard or OpenVPN tunnel to your chosen VPN provider and exposes a plain HTTP proxy on localhost, which unshackle then routes traffic through. When the download finishes, the container is torn down automatically.
This means you get access to the 50+ VPN providers that Gluetun supports, using your own VPN subscription, through a single uniform interface, with no per-provider integration in unshackle itself.
Docker is required
Gluetun needs a working Docker installation on your PATH. If Docker is not found, the provider fails to load with an error linking to the install docs. The container also needs the NET_ADMIN capability and access to /dev/net/tun to bring up the tunnel. unshackle requests both automatically.
"},{"location":"guide/proxies-and-vpn/#query-format","title":"Query format","text":"Gluetun uses a nested selector: the provider prefix picks Gluetun, and the rest names the VPN provider and the region.
gluetun:<vpn-provider>:<region>\n
A Windscribe US exit via Gluetununshackle dl --proxy gluetun:windscribe:us EXAMPLE 81234567\n
A NordVPN Germany exit via Gluetununshackle dl --proxy gluetun:nordvpn:de EXAMPLE 81234567\n
After unshackle strips the leading gluetun:, the provider receives exactly provider:region (two colon-separated parts; anything else is an error). The <vpn-provider> must be one you have configured under gluetun.providers, and friendly names are normalised to Gluetun's identifiers (for example pia and privateinternetaccess both map to private internet access).
"},{"location":"guide/proxies-and-vpn/#configuration","title":"Configuration","text":"Each VPN provider you want to use gets an entry under providers:, carrying its VPN type, credentials, and how your region aliases map onto Gluetun's server selectors.
unshackle.yamlproxy_providers:\n gluetun:\n providers:\n windscribe:\n vpn_type: wireguard\n credentials:\n private_key: YOUR_WIREGUARD_PRIVATE_KEY\n addresses: YOUR_WIREGUARD_ADDRESS\n preshared_key: YOUR_PRESHARED_KEY\n server_countries:\n us: US\n uk: GB\n server_cities: {}\n server_hostnames: {}\n extra_env: {}\n nordvpn:\n vpn_type: wireguard\n credentials:\n private_key: YOUR_WIREGUARD_PRIVATE_KEY\n server_countries:\n us: US\n de: DE\n\n # Global settings (all optional)\n base_port: 8888\n auto_cleanup: true\n container_prefix: unshackle-gluetun\n auth_user: null\n auth_password: null\n verify_ip: true\n
Per-provider keys:
Key Purpose vpn_type wireguard (default) or openvpn. credentials The secrets for the tunnel; see the credential rules below. server_countries Maps your region alias (e.g. us) to Gluetun's country value (e.g. US). server_cities Optional. Maps an alias to a Gluetun city. server_hostnames Optional. Maps an alias directly to a specific server hostname. extra_env Optional. Raw environment variables merged into the container last, for any Gluetun setting not covered above. Credential rules by VPN type:
WireGuardOpenVPN private_key is always required. surfshark, mullvad, and ivpn also require addresses. windscribe additionally requires both addresses and preshared_key (preshared_key may be an empty string, but the key must be present). - NordVPN and Proton VPN need only
private_key.
- Requires
username and password.
Which vpn_type should I pick?
WireGuard is the default because it can be faster, but it needs the fiddlier credential setup above (private keys, and for some providers addresses and a preshared key). OpenVPN is the simplest to configure (just a username and password) at the cost of some speed. If you are getting started or hitting credential trouble, OpenVPN is the path of least resistance; switch to WireGuard when you want the extra throughput.
Global keys:
Key Default Purpose base_port 8888 The first local port to expose proxies on. Additional containers take the next free ports. auto_cleanup true Remove containers on exit. If false, containers are only stopped, not deleted. container_prefix unshackle-gluetun Prefix for Docker container names. auth_user / auth_password null Optional HTTP-proxy credentials. If set, the local proxy requires auth and the credentials are embedded in the proxy URL. verify_ip true After connecting, verify the container's real exit IP and region."},{"location":"guide/proxies-and-vpn/#region-selection","title":"Region selection","text":"For a given query region, Gluetun resolves the server like this:
- An explicit entry in
server_hostnames, server_cities, or server_countries for that alias always wins. - A
us1239-style query (country code + number) with no explicit mapping is turned into a provider-specific server hostname (for example us1239.nordvpn.com for NordVPN, us-1239.prod.surfshark.com for Surfshark). - A bare two-letter code is expanded to the full country name Gluetun expects.
A few providers (Windscribe, VyprVPN, VPN Secure) select by region rather than country, and Windscribe region names are translated automatically. For instance us becomes US East and uk/gb become United Kingdom.
"},{"location":"guide/proxies-and-vpn/#container-lifecycle","title":"Container lifecycle","text":"Understanding what Gluetun does behind the scenes helps when something goes wrong:
- Image check.
qmcgaw/gluetun:latest is pulled if it is not already present. - Reuse. If a container for this exact
provider:region is already running (from a concurrent unshackle session), it is adopted instead of starting a new one. Stopped or dead containers with the same name are pruned first.
Expect a slow first request, then instant reuse
Bringing up a fresh tunnel takes roughly 10 to 30 seconds for the first request to a given provider:region. This is the VPN handshake and readiness wait, not a hang. Every subsequent request that reuses that same container is effectively instant. A slow first run is normal.
- Port allocation. A free local port is chosen starting from
base_port. - Start. The container is launched bound to
127.0.0.1:{port}. Credentials are passed through a temporary, 0600-permission env-file (never as -e flags), which is overwritten and deleted immediately after. - Readiness wait. unshackle polls the container logs (up to 60 seconds) until both the HTTP proxy is listening and the VPN reports a completed connection. Fatal errors like
authentication failed or invalid credentials abort early, and recent logs are attached to the error to help you diagnose it. - IP verification. When
verify_ip is on, unshackle queries the container's exit IP and checks the country matches what you asked for, raising a region mismatch error if it does not. - Proxy handed back. You get
http://localhost:{port} (or with auth credentials if configured).
Cleanup and Ctrl+C
Containers are cleaned up via an atexit handler on normal exit. Gluetun deliberately does not install its own signal handlers, so Ctrl+C continues to work as you expect. If a run is killed hard (e.g. kill -9) a container can be left behind; with auto_cleanup: true the next run adopting that name will still work, or you can remove it manually with docker rm -f.
"},{"location":"guide/proxies-and-vpn/#troubleshooting","title":"Troubleshooting","text":"Common Gluetun issues
RuntimeError about Docker not found. Install Docker and confirm docker is on your PATH. invalid credentials / authentication failed in the readiness wait. Your WireGuard/OpenVPN credentials are wrong or in the wrong fields. Double-check private_key, addresses, and (for Windscribe) preshared_key. - Region mismatch error. The VPN connected but exited in a different country than requested. Check your
server_countries mapping, or set verify_ip: false if you are intentionally using a nearby region. - Timeout after 60 seconds. The tunnel never came up. Inspect the container logs (
docker logs <container>) for the underlying VPN error.
"},{"location":"guide/proxies-and-vpn/#expressvpn","title":"ExpressVPN","text":"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.
Enable it and run a download interactively once:
unshackle.yamlproxy_providers:\n expressvpn:\n enable: true\n
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:
unshackle.yaml (optional)proxy_providers:\n expressvpn:\n region_map:\n us: ny-02 # prefer New York, server 02 for the US\n gb: # empty = smart random location\n server_map:\n myserver: usny-newyork-2\n
Default file location:
File Default path Token cache {cache}/vpn/expressvpn_tokens.json Where {cache} is your configured cache directory.
Query forms:
Query Meaning us A smart, random US location. us-ny US, New York. us-ny-2 / us-ny2 US, New York, the 2nd server. usny-newyork-2 A full location slug. hostname.expressprovider.com A direct endpoint hostname. The resolved proxy is an HTTPS proxy on port 443. On success, unshackle prints a location summary such as (USA - New York, #3 of 5): .214.
Cache safety
ExpressVPN's token cache is written with 0600 permissions so the refresh token is never briefly world-readable.
"},{"location":"guide/proxies-and-vpn/#nordvpn","title":"NordVPN","text":"NordVPN uses your Service Credentials, not your normal NordVPN login. Generate them from the NordVPN dashboard under manual setup; they are a long random username/password pair, not your email and password.
unshackle.yamlproxy_providers:\n nordvpn:\n username: YOUR_SERVICE_USERNAME\n password: YOUR_SERVICE_PASSWORD\n server_map: # optional: pin specific server IDs\n us: 1234\n us:seattle: 5678\n
Required keys: username, password. unshackle validates them up front: the 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.
server_map is optional and maps a region (or code:city) to a pinned NordVPN server ID. Its main use is tuning: for a given streaming service one specific server often performs noticeably better than NordVPN's recommended pick, so hard-coding that server for a day or two is a handy workaround until it degrades and you swap in another.
Use gb, not uk, for the United Kingdom
Query the UK as gb. unshackle uses the Alpha-2 country code gb deliberately to stay consistent with its other regional/country-code systems and avoid the language-vs- country-code confusion that uk invites.
Query forms:
Query Meaning us A recommended US server. us1234 The specific server us1234. us:seattle A recommended server in Seattle. 228 A NordVPN numeric country ID. The returned proxy is HTTPS on port 89 (NordVPN disabled its plain-HTTP proxies on port 80 in 2021).
unshackle dl --proxy nordvpn:us:seattle EXAMPLE 81234567\n
"},{"location":"guide/proxies-and-vpn/#proton-vpn","title":"Proton VPN","text":"Proton VPN supports two authentication methods, and unshackle can work with either. Note that free Proton accounts work but are limited to Proton's free-tier exit countries.
TV login (recommended)Exported cookies A self-sustaining, refreshable session obtained through Proton's TV device-login flow. This is the only method that can refresh itself headlessly, so it keeps working without re-exporting anything. Enable it and run a download interactively once:
unshackle.yamlproxy_providers:\n protonvpn:\n enable: true\n
With enable: true and an interactive terminal, unshackle prints a code and asks you to enter it at account.proton.me/vpn/tv/code. Once approved, the session is cached and reused automatically.
Export your account.proton.me session cookies (the AUTH-<UID> cookie) to the Proton cookie file. This method is access-only: it cannot refresh, so you must re-export when it expires.
The AUTH-<UID> cookie is HttpOnly
This is the single most common cause of a failed Proton cookie import: the required AUTH-<UID> cookie is HttpOnly, and many one-click cookie-export extensions silently skip HttpOnly cookies by default. If your import \"doesn't work,\" use an exporter that explicitly includes HttpOnly cookies.
Why this session can't refresh, and lapses after ~24h
Proton rotates browser refresh tokens and ties them to the live browser, so a headless refresh is impossible. unshackle therefore deliberately uses only the cookie's short-lived access token and never attempts a refresh, which is why the session lapses at Proton's ~24h TTL and must be re-exported. This is exactly why TV login is preferred for set-and-forget use.
Default file locations:
File Default path Cookies {cookies}/vpn/protonvpn.txt Session cache {cache}/vpn/protonvpn.json Proton VPN auto-loads if its cookie file exists. Like ExpressVPN, its cached session is written with 0600 permissions.
Cache vs cookie precedence
If both a TV-login cache and a cookie file exist, the cache is preferred unless the cookie file is newer. Cookies are read fresh every run and never written back to the cache. So to deliberately force use of the cookie session, just re-save (re-export) the cookie file so its modification time is newer than the cache.
Query forms (after the protonvpn: prefix):
Query Meaning us A random US server (paid tiers preferred over free). us12 Proton US server #12. us:ny A server in New York. The returned proxy is HTTPS on port 4443 (or port 443 for Secure Core servers). Secure Core and Tor servers are excluded from selection because they are too slow for a plain proxy. On success, unshackle prints a summary such as (Name - City): server-host.
Proton server numbers aren't sequential
When pinning a server with protonvpn:deNN, don't guess a low number. A country may expose non-contiguous IDs like #203, #813, and so on. Copy the number from a previous run's log line (e.g. DE#203) instead.
"},{"location":"guide/proxies-and-vpn/#hola","title":"Hola","text":"Hola requires no configuration at all. It uses the hola-proxy binary, and auto-loads whenever that binary is found on your PATH. If you query Hola but the binary is missing, the provider raises an error telling you to install it.
Install hola-proxy, then just query a countryunshackle dl --proxy hola:us EXAMPLE 81234567\n
Query forms: a two-letter country code (e.g. us, gb). unshackle asks hola-proxy for available proxies in that country and picks one at random, returning an HTTP proxy.
Temporary bans
Hola's free tier can rate-limit or temporarily ban you if queried too aggressively. If you see a temporary ban detected error, wait before retrying. Hola currently uses only datacenter proxies.
"},{"location":"guide/proxies-and-vpn/#surfshark","title":"Surfshark","text":"Surfshark, like NordVPN, uses Service Credentials rather than your login. Generate them from the Surfshark manual-setup page.
unshackle.yamlproxy_providers:\n surfsharkvpn:\n username: YOUR_SERVICE_USERNAME\n password: YOUR_SERVICE_PASSWORD\n server_map: # optional\n us: 1234\n
Required keys: username, password, validated with the same 48-character rule as NordVPN (combined username+password must be 48 alphanumeric characters, case-insensitive, no @). server_map optionally pins server IDs per region.
Query forms:
Query Meaning us A random US server. us-bos A specific server. us:seattle A random server in Seattle. The returned proxy is HTTPS on port 443.
"},{"location":"guide/proxies-and-vpn/#windscribe","title":"Windscribe","text":"Windscribe uses Service Credentials from its OpenVPN config page. Only the presence of a username and password is checked (there is no 48-character rule).
unshackle.yamlproxy_providers:\n windscribevpn:\n username: YOUR_SERVICE_USERNAME\n password: YOUR_SERVICE_PASSWORD\n server_map: # optional: alias -> hostname\n us: us-east.totallyacdn.com\n us:seattle: us-seattle.totallyacdn.com\n
Required keys: username, password. server_map optionally maps a region (or region:city) directly to a hostname.
Query forms:
Query Meaning us A random US server. us150 / sg007 A specific numbered server. us:seattle A random server in Seattle. The returned proxy is HTTPS on port 443.
Windscribe is not loaded by the REST API resolver
The standalone Windscribe provider (and Gluetun) are loaded by the dl command, but not by the proxy resolver used by the REST API and remote-download client. If you drive downloads through the API and need Windscribe or Gluetun exits, resolve the proxy on the CLI side or use a different provider for API jobs. See the developer note below.
"},{"location":"guide/proxies-and-vpn/#developer-notes","title":"Developer notes","text":"Two resolution paths (developers)
unshackle has two proxy-resolution code paths that are intentionally not identical:
unshackle/commands/dl.py inlines its own logic and loads all eight providers, including Windscribe and Gluetun. unshackle/core/proxies/resolve.py::initialize_proxy_providers() is the shared resolver used by the REST API handlers and the remote-service client. It omits Windscribe and Gluetun.
Both paths accept the same query grammar (a direct URI, a provider:query, or a bare country query tried against providers in order), but the set of available providers differs. Keep this in mind when adding a provider: wiring it into dl.py does not make it available over the API, and vice versa.
Implementing a new provider (developers)
Every provider subclasses unshackle.core.proxies.proxy.Proxy and implements three methods:
__init__(self, **kwargs) receives its proxy_providers sub-dict splatted as keyword arguments, so YAML keys map 1:1 to constructor parameters. Do authentication and catalog pre-fetching here. __repr__(self) is a human summary, conventionally \"{n} Countries ({m} Servers)\". get_proxy(self, query) returns a Requests-compatible proxy URI, or None only when the query was valid but no proxy exists. Raise on genuine errors or bad queries.
Optionally expose get_connection_info(query) (as Gluetun does) or last_connection_display() (as ExpressVPN and Proton VPN do) to have dl print a friendly connection line instead of the raw proxy URL.
"},{"location":"guide/proxies-and-vpn/#see-also","title":"See also","text":" - Downloading: the full
dl command guide, including where proxy flags sit in the download flow. - Configuration file: the
unshackle.yaml reference, including directories for cookies and cache used by the cookie-based providers. - Configuration reference: the developer-facing configuration API, including the
proxy_providers structure.
"},{"location":"guide/subtitles/","title":"Subtitles","text":"Subtitles are first-class tracks in unshackle. When you download a title, subtitle tracks are parsed from the manifest alongside video and audio, downloaded, cleaned up, optionally converted to the format you want, and then either muxed into the final Matroska file or written out as sidecar files next to it. The sections below cover the supported formats, how tracks are normalized after download, how to select and filter subtitles, and every subtitle-related configuration option.
Most of this is controlled with a handful of dl flags and a small subtitle block in your configuration file. See Downloading for how subtitle flags fit into a full download command.
"},{"location":"guide/subtitles/#supported-formats","title":"Supported formats","text":"unshackle understands the following subtitle codecs. The Format column is the value shown in track listings; the Extension column is the file extension used on disk.
Codec (internal name) Format Extension Notes SubRip SRT srt SubRip, the most widely compatible plain-text format SubStationAlpha SSA ssa SubStation Alpha (styled) SubStationAlphav4 ASS ass Advanced SubStation Alpha (styled) TimedTextMarkupLang TTML ttml Timed Text Markup Language (IMSC/DFXP) WebVTT VTT vtt WebVTT SAMI SMI smi Synchronized Accessible Media Interchange MicroDVD SUB sub MicroDVD MPL2 MPL2 mpl2 MPL2 TMP TMP tmp TMP fTTML STPP stpp MPEG-DASH box-encapsulated TTML (IMSC1) fVTT WVTT wvtt MPEG-DASH box-encapsulated WebVTT The two boxed formats convert automatically
fTTML (STPP) and fVTT (WVTT) are the fragmented, MP4-box-encapsulated forms that DASH and HLS deliver. You never keep these as-is: right after download they are unwrapped into their plain counterparts (fTTML becomes TTML and fVTT becomes WebVTT), so you always end up with an editable text subtitle. Note that this is a one-way, source-only relationship: you can convert from these boxed formats, but no backend can produce them as an output target, so they can never be a conversion destination.
"},{"location":"guide/subtitles/#what-happens-to-a-subtitle-after-download","title":"What happens to a subtitle after download","text":"A subtitle download does more than save a file. Once the bytes are on disk, unshackle runs format-appropriate clean-up to produce a single, valid file:
- Encoding: non-boxed subtitles are forced to UTF-8, and stray
‎ / ‏ bidirectional entities are unescaped. - Segment merging: when a WebVTT or boxed subtitle arrives as many DASH/HLS segments, the segments are stitched back into one continuous document with correct timing.
- WebVTT sanitization: merged WebVTT runs through a repair pipeline that clamps negative timestamps to
00:00:00.000, removes stray cue identifiers (like Q0, Q1) that confuse downstream parsers, and merges multi-line cues that a service split into separate overlapping cues. By default the original styling and layout are preserved (see preserve_formatting). - TTML for muxing: Matroska cannot carry TTML. If you do not request a specific output format, any plain TTML track is automatically converted to WebVTT before muxing (the closest lossless equivalent). Boxed
fTTML is likewise unwrapped to TTML first.
You normally do not need to think about any of this. It just produces a clean file.
"},{"location":"guide/subtitles/#selecting-and-filtering-subtitles","title":"Selecting and filtering subtitles","text":"Subtitle selection happens through dl flags. The most important is language selection.
Flag Purpose -sl, --s-lang Language(s) wanted for subtitles. Defaults to all. A - prefix excludes, e.g. all,-es. --require-subs Require these languages to exist; if present, download all subtitles. Cannot be combined with --s-lang. -fs, --forced-subs Include forced subtitle tracks (excluded by default). -fsl, --forced-s-lang Language(s) wanted for forced subtitles; implies -fs. A - prefix excludes. --exact-lang Exact language matching, with no regional variants. -S, --subs-only Download only subtitle tracks. -ns, --no-subs Do not download subtitle tracks at all. --sub-format Output subtitle format / conversion target (see conversion). --skip-subtitle-errors Skip a subtitle that fails to download instead of aborting the whole title."},{"location":"guide/subtitles/#choosing-languages","title":"Choosing languages","text":"By default (--s-lang all) unshackle keeps every subtitle language the service offers. To narrow it down, pass a comma-separated list of language tags:
English and Spanish subtitles onlyunshackle dl --s-lang en,es EXAMPLE 81234567\n
Language matching is fuzzy by default: en will match en-US, en-GB, and similar variants. If a requested language is missing but others were found, unshackle continues with whatever remains and tells you which languages it dropped.
For strict matching, where es-419 matches only es-419 and never es-ES, add --exact-lang:
Only Latin-American Spanish, no other Spanish variantsunshackle dl --s-lang es-419 --exact-lang EXAMPLE 'https://www.example.com/...'\n
"},{"location":"guide/subtitles/#requiring-languages-before-downloading","title":"Requiring languages before downloading","text":"--require-subs is a gate rather than a filter. It downloads all available subtitles, but only if every language you list is present; if any is missing the title errors out. This is useful for batch jobs where a title without your must-have language should fail loudly rather than silently produce a partial result.
Fail unless both English and French subs exist, then grab everythingunshackle dl --require-subs en,fr EXAMPLE B0ABCDEFGH\n
--require-subs and --s-lang are mutually exclusive
Because --require-subs forces downloading of all subtitle languages, it cannot be combined with --s-lang. Choose one or the other.
"},{"location":"guide/subtitles/#forced-subtitles","title":"Forced subtitles","text":"Forced tracks carry only the important on-screen signs and foreign-dialogue lines meant to display even when you are watching in your own language. They are excluded by default. Add -fs / --forced-subs to include them:
Include forced tracks alongside full subtitlesunshackle dl --s-lang en --forced-subs EXAMPLE 81234567\n
-fs keeps forced tracks in every selected language. If you only want forced tracks in certain languages, use -fsl / --forced-s-lang instead. It implies -fs, so you do not need both:
All full subtitles, but only the English forced trackunshackle dl --s-lang all --forced-s-lang en EXAMPLE 81234567\n
If you pass both flags, -fsl wins and keeps only forced tracks in its languages. It also understands orig (the title's original language) and all (every forced track, same as plain -fs).
"},{"location":"guide/subtitles/#track-types-forced-sdh-and-cc","title":"Track types: forced, SDH, and CC","text":"Every subtitle track carries flags describing what kind of captions it holds. These flags drive selection, sort order, output filenames, and the Matroska track flags.
- Forced: only signs and foreign dialogue; meant to play when the audio language matches. Off by default; enable with
--forced-subs. - SDH (Subtitles for the Deaf and Hard-of-Hearing; also called HOH): a full transcript that includes both dialogue and non-speech sound cues.
- CC (Closed Captions): captions originating from an EIA-608/708 broadcast stream, often uppercase with
>>> speaker markers and sound cues.
SDH and CC are treated together for sorting and stripping
unshackle orders subtitles from fewest to most captions: Forced \u2192 Normal \u2192 SDH/CC. For the purposes of hearing-impaired handling, SDH and CC tracks are grouped as the \"most captions\" variant.
By default this type order is the primary sort, so all forced tracks come first, then all normal tracks, then all SDH/CC tracks, each block sorted by language. To keep each language next to its own variants instead, set subtitle.group_by to language. Use subtitle.type_priority to change the Forced \u2192 Normal \u2192 SDH/CC order itself.
A single track cannot be both CC and SDH, and a forced track cannot also be flagged CC or SDH. These combinations are rejected as invalid.
"},{"location":"guide/subtitles/#closed-captions-embedded-in-video","title":"Closed captions embedded in video","text":"Some services do not ship subtitles as separate tracks but instead embed EIA-608/708 captions inside the video stream. After a video track is downloaded and decrypted, unshackle probes it for these captions and, when found, extracts them into a SubRip (SRT) subtitle flagged as CC. This runs automatically unless you disabled subtitles (--no-subs) or video (--no-video / --video-only semantics), and it requires the CCExtractor tool on your PATH.
"},{"location":"guide/subtitles/#automatic-sdh-stripping","title":"Automatic SDH stripping","text":"When a language has an SDH track but no plain (non-SDH, non-forced) track, unshackle can generate a clean non-SDH version for you by stripping the hearing-impaired cues: sound effects, speaker labels, music notes, and similar. This is on by default and controlled by the strip_sdh config key.
The check is deliberately loose: if a close-enough plain track already exists (for example an en-GB non-SDH track when the SDH track is en-US), no stripped copy is created. When a stripped copy is created, the original SDH track is kept as well, so you end up with both.
The stripping engine itself is chosen by the sdh_method config key. The default (auto) prefers the subby cleaner for SRT, falls back to SubtitleEdit when it is installed, and otherwise uses the built-in filter-subs approach.
Why convert_before_strip exists
subby's SDH stripper only operates on SRT. Handed any other codec it returns silently without stripping anything, not an error but a no-op that leaves the SDH cues in place. This is the whole reason for convert_before_strip: non-SubtitleEdit engines need the subtitle converted to SRT first, or the hearing-impaired cues survive the strip untouched.
"},{"location":"guide/subtitles/#converting-subtitle-formats","title":"Converting subtitle formats","text":"By default unshackle keeps each subtitle in its downloaded format (only auto-converting TTML to WebVTT for muxing, as noted above). To force a specific output format, use --sub-format:
Convert every subtitle to SRTunshackle dl --s-lang en,es --sub-format srt EXAMPLE 81234567\n
--sub-format accepts the common format names and aliases, including srt, vtt, ssa, ass, and ttml. Pass original to explicitly keep the source format and skip all conversion:
Keep whatever the service deliveredunshackle dl --sub-format original EXAMPLE 'https://www.example.com/...'\n
Conversion only runs when the source format differs from the target, so --sub-format srt on a track that is already SRT is a no-op.
Down-converting styled subtitles is lossy
Converting styled SSA/ASS subtitles to SRT throws away positioning, colours, and italics. unshackle never does this automatically. It only happens when you explicitly ask for it via --sub-format. If you want to preserve styling, convert to a format that supports it (or keep original).
Note that this automatic protection guards only the default muxed track: there the conversion is skipped and the styled original is kept. It does not cover sidecars. A sidecar_format: srt will still lossily flatten styled subtitles, dropping positioning, colours, and styling, and a per-download --sub-format srt forces the muxed track to convert as well. To keep raw styled sidecars, set sidecar_format: original.
"},{"location":"guide/subtitles/#how-the-conversion-backend-is-chosen","title":"How the conversion backend is chosen","text":"Under the hood unshackle picks the highest-fidelity converter that supports the source\u2192target pair. Which converter runs depends on the conversion_method config key (default auto) and the 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 - 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.
"},{"location":"guide/subtitles/#what-each-backend-actually-preserves","title":"What each backend actually preserves","text":"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; \u2014 means the backend cannot handle that pair.
Conversion subtitleedit pysubs2 subby pycaption WebVTT \u2192 SRT italic, bold, underline, position italic, underline italic, position (strips all) WebVTT \u2192 ASS italic, bold, underline, position italic, bold, underline \u2014 \u2014 WebVTT \u2192 TTML italic, bold, underline, position italic, bold, underline position (strips all) ASS/SSA \u2192 SRT all (+ colour) italic, underline \u2014 \u2014 ASS/SSA \u2192 TTML position only italic, bold, underline \u2014 \u2014 ASS/SSA \u2192 VTT position only italic, underline \u2014 \u2014 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 \u2192 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\u2194ASS. subby: reads only WebVTT/fVTT/SAMI (never ASS) and is tuned for \u2192 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.
What auto picks
auto prefers subtitleedit first when it is installed, then falls back to subby for \u2192 SRT and pysubs2 otherwise. The one exception comes from the table above: for ASS/SSA \u2192 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 (\u2192 SRT) or pysubs2. Install SubtitleEdit / seconv if you need colour or the highest \u2192 SRT fidelity.
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 conversion_method in unshackle.yaml always overrides that per-track preference, so your config takes precedence. This only matters when a service ships a non-default preference; leave conversion_method at auto to let the service's hint stand.
"},{"location":"guide/subtitles/#muxed-vs-sidecar-output","title":"Muxed vs. sidecar output","text":"By default subtitles are muxed into the final .mkv. You can instead (or additionally) write them as separate sidecar files next to the video, controlled by the output_mode config key:
mux (default)sidecarboth unshackle.yamlsubtitle:\n output_mode: mux\n
Subtitles are embedded in the Matroska file. Nothing is written separately.
unshackle.yamlsubtitle:\n output_mode: sidecar\n sidecar_format: srt\n
Subtitles are written as standalone files and not muxed into the video (when there is a video or audio track to sit beside).
unshackle.yamlsubtitle:\n output_mode: both\n sidecar_format: srt\n
Subtitles are muxed into the video and written as sidecar files.
Sidecar files are named after the output file with the language and flags encoded in the name, so players and media managers can match them automatically:
Show.S01E01.1080p.WEB.en.srt\nShow.S01E01.1080p.WEB.en.forced.srt\nShow.S01E01.1080p.WEB.en.sdh.srt\n
The pattern is {base}.{language}[.forced][.sdh].{extension}. The sidecar file format is set by sidecar_format; use a format name like srt or vtt, or original to keep each subtitle in its downloaded format.
"},{"location":"guide/subtitles/#fonts-for-styled-subtitles","title":"Fonts for styled subtitles","text":"Styled SSA/ASS subtitles reference specific fonts by name. When unshackle muxes such a subtitle it scans it for the fonts it uses and attaches the matching font files to the Matroska output so the styling renders correctly on playback. If a referenced font is not available locally, unshackle logs the missing font names and suggests installing the relevant font packages.
"},{"location":"guide/subtitles/#subtitle-configuration","title":"Subtitle configuration","text":"All of the behaviour above can be defaulted in the subtitle block of your configuration file:
unshackle.yamlsubtitle:\n strip_sdh: true\n preserve_formatting: true\n conversion_method: auto\n sdh_method: auto\n convert_before_strip: true\n output_mode: mux\n sidecar_format: srt\n
Key Default Values Purpose strip_sdh true true / false Auto-generate a stripped non-SDH copy when a language has only an SDH track. preserve_formatting true true / false Keep original WebVTT styling and layout during sanitization. When false, WebVTT is re-encoded more aggressively (merging identical cues, dropping empty ones). conversion_method auto auto, subby, pysubs2, subtitleedit, pycaption Which conversion backend to prefer. auto ranks them per conversion. sdh_method auto auto, subby, subtitleedit, filter-subs Engine used to strip hearing-impaired cues. convert_before_strip true true / false Convert a subtitle to SRT before stripping SDH (for non-SubtitleEdit engines). output_mode mux mux, sidecar, both Whether subtitles are embedded, written as sidecars, or both. sidecar_format srt a format name (e.g. srt, vtt) or original Format used for sidecar files. group_by type type, language Whether all tracks of one type stay together, or each language stays next to its own variants. language_priority (unset) a list of languages Languages to sort to the top. The rest keep the default order. Nothing is removed. SubtitleEdit is optional but recommended
Several high-fidelity paths (the best-quality conversions and the SubtitleEdit SDH stripper) only activate when the SubtitleEdit CLI is installed and on your PATH. See Installation for how to add it. Without it, unshackle falls back to its built-in Python backends.
"},{"location":"guide/subtitles/#developer-reference","title":"Developer reference","text":"Audience
This section is for people extending unshackle or writing services. Everyday users can stop here.
The subtitle model lives in unshackle.core.tracks.Subtitle. A few internals worth knowing when working with subtitle tracks in code:
- Flags and validation:
Subtitle(codec=..., cc=..., sdh=..., forced=...). A track cannot be both cc and sdh, and forced cannot be combined with either; both raise ValueError at construction. convert(codec, *, forced=False): converts the downloaded file in place through the backend chain in subtitle_convert.run_conversion. forced=True (what --sub-format sets) is the only way lossy styled down-converts such as ASS\u2192SRT are permitted. Raises NotImplementedError if no backend supports the pair and RuntimeError if all attempts fail. strip_hearing_impaired(): removes SDH cues using the engine selected by config.subtitle[\"sdh_method\"]. reverse_rtl(): fixes right-to-left sentence-ending positioning via SubtitleEdit (requires the binary). parse(data, codec): the central parser, returning a pycaption.CaptionSet; it handles the boxed DASH formats, SAMI, and the WebVTT repair helpers. extract_fonts(text): returns the set of font names referenced by an ASS/SSA subtitle, used to decide which fonts to attach at mux time.
The conversion backends (SubtitleEditBackend, SubbyBackend, Pysubs2Backend, PycaptionBackend) each implement a small protocol (is_available(), can_convert(), rank(), convert()) and are tried in rank order, lowest rank first. See the Tracks API reference for the full model.
"},{"location":"guide/troubleshooting/","title":"Troubleshooting & Debugging","text":"When a download fails, a service misbehaves, or a key won't resolve, unshackle gives you three tools to find out why:
- Debug logging: verbose
DEBUG-level console output plus a structured, machine-readable log file. unshackle env: verify that the external tools unshackle depends on are installed, and see exactly which directories and config file are in use. - Targeted toggles:
debug_keys and debug_requests for the two areas (DRM keys and raw HTTP traffic) that are deliberately quiet by default.
The sections below cover how to enable each of them, how to read what they produce, and which diagnostic fits which failure mode.
"},{"location":"guide/troubleshooting/#turning-on-debug-logging","title":"Turning on debug logging","text":"There are two independent ways to enable debug logging, and they do slightly different things.
The -d / --debug flagThe debug config key The root-level flag turns on verbose logging for a single invocation:
unshackle -d dl SERVICE TITLE_ID\n
It must come before the subcommand, because it is an option on the root unshackle command, not on dl.
-d does three things:
- Sets the console log level to
DEBUG, so every module logs its full detail. - Enables the structured JSON debug log file (see below).
- Adds source-path annotations to console log lines (which file and line each message came from).
To make debug logging the default for every run without typing -d each time, set it in your unshackle.yaml:
debug: true\n
or via the cfg command:
unshackle cfg debug true\n
debug: true is not identical to -d
The debug config key enables the JSON debug log file, but it does not raise the console to DEBUG level and does not add source-path annotations. Those only happen when you pass the -d flag. Use the flag when you want to watch the verbose output live; use the config key when you just want the log file written every time. Passing -d is the most complete option, and you can combine both.
Debug logging can be noisy and revealing
Debug output is verbose and includes request metadata, service internals, and stack traces. Secrets, URLs, and local paths are automatically redacted (see Redaction), but you should still glance over a log before sharing it publicly.
"},{"location":"guide/troubleshooting/#the-structured-json-debug-log","title":"The structured JSON debug log","text":"Beyond the console output, unshackle writes a structured log in JSON Lines format: one complete JSON object per line. This file is designed to be filtered, searched, and analysed with standard tooling (jq, grep, a text editor), and it is safe to attach to a bug report because it is redacted.
"},{"location":"guide/troubleshooting/#where-it-is-written","title":"Where it is written","text":"The debug log is created during a dl run (and by import, which wraps dl). The path is:
<logs directory>/unshackle_debug_<service>_<timestamp>.jsonl\n
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.
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 and read the Directories table.
"},{"location":"guide/troubleshooting/#what-a-log-entry-looks-like","title":"What a log entry looks like","text":"Every line is a self-contained JSON object. The first line of a session records the environment:
{\"timestamp\":\"2026-07-03T14:25:30.123456+00:00\",\"session_id\":\"a1b2c3d4\",\"level\":\"INFO\",\"operation\":\"session_start\",\"message\":\"Debug logging session started\",\"context\":{\"unshackle_version\":\"5.3.0\",\"unshackle_code_hash\":\"1d22a1e\",\"python_version\":\"3.12.4 ...\",\"platform\":\"Linux-6.18.5-x86_64\",\"platform_system\":\"Linux\",\"platform_release\":\"6.18.5\"}}\n
Quote unshackle_code_hash in a bug report. It fingerprints the framework source you are running, so it tells us the exact code state even when you did not install from git. A hash that matches no release means the source was edited locally.
Subsequent lines describe operations: service calls, DRM licence requests, vault lookups, downloader progress, and errors. Common fields include:
Field Meaning timestamp UTC ISO-8601 time of the entry. session_id Short random ID shared by every entry in one run, handy for correlating lines. level DEBUG, INFO, WARNING, or ERROR. operation The action being logged, e.g. service_call, download_init, drm_get_widevine_license, vault_get_key, session_end. message Human-readable description (redacted). service Service tag the entry belongs to, when applicable. context Arbitrary structured detail for the operation. request / response HTTP method/URL and response metadata for network operations. duration_ms How long a timed operation took. success Whether the operation succeeded. error On failures: the exception type, message, and a full traceback array (all redacted)."},{"location":"guide/troubleshooting/#reading-and-filtering-the-log","title":"Reading and filtering the log","text":"Because each line is independent JSON, jq is the natural tool:
# Show only error-level entries\njq 'select(.level == \"ERROR\")' unshackle_debug_EXAMPLE_20260703-142530.jsonl\n\n# List every operation and whether it succeeded\njq -c '{operation, success}' unshackle_debug_EXAMPLE_20260703-142530.jsonl\n\n# Pull the traceback out of the first error\njq -r 'select(.error) | .error.traceback[]' unshackle_debug_EXAMPLE_20260703-142530.jsonl\n\n# Show only DRM / licensing operations\njq 'select(.operation | startswith(\"drm_\"))' unshackle_debug_EXAMPLE_20260703-142530.jsonl\n
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.
"},{"location":"guide/troubleshooting/#debugging-drm-keys-with-debug_keys","title":"Debugging DRM keys with debug_keys","text":"By default, content-decryption keys are redacted in the JSON debug log. Any field that looks like a key is written as [REDACTED]. This keeps logs shareable. When you are specifically debugging a key-retrieval or decryption problem and need to see the actual KID/KEY values that were fetched, enable debug_keys:
debug_keys: true\n
unshackle cfg debug_keys true\n
With debug_keys: true, the debug log stops masking key fields, so you can confirm which keys came back from a licence server or vault and compare them against what decryption expected.
Keys are sensitive, turn this back off
debug_keys writes real decryption keys into the log file in cleartext. Only enable it while actively diagnosing a key issue, never share a log produced with it on, and set it back to false when you're done. Note that debug_keys only affects the JSON debug log file. It does not change what the console prints.
Fields that are always redacted regardless of debug_keys (anything whose name contains password, token, secret, auth, or cookie) stay masked, so credentials never leak even in a keys-on log.
"},{"location":"guide/troubleshooting/#debugging-http-traffic-with-debug_requests","title":"Debugging HTTP traffic with debug_requests","text":"When you pass -d, unshackle raises its own modules to DEBUG but deliberately silences the noisy low-level HTTP libraries (urllib3, requests, rnet, httpx, httpcore, hpack, h2) back down to WARNING. Without this, a single download would bury the useful output under thousands of connection-pool and HTTP/2 frame messages.
If you need that low-level traffic (for example when a request is failing at the TLS or connection layer and you need to see the underlying library's own logs), enable debug_requests:
debug_requests: true\n
unshackle cfg debug_requests true\n
With debug_requests: true, those libraries are left at DEBUG level when you run with -d, so their request/response chatter appears in the console.
debug_requests needs -d to have any effect
The debug_requests toggle only decides whether the HTTP libraries are silenced when the -d flag is active. If you are not running with -d (verbose console logging), it changes nothing. Combine them: unshackle -d dl ... with debug_requests: true in config.
"},{"location":"guide/troubleshooting/#what-gets-redacted","title":"What gets redacted","text":"unshackle redacts logged strings so that debug output is safe to share. Three passes are applied to every logged message, error, and structured value:
- Secrets: passwords, tokens, API secrets, auth values, and cookies are replaced with
[REDACTED]. - URLs: every
http(s) URL is collapsed to redacted (keeping only the file extension, so a manifest still shows as redacted.mpd and a segment as redacted.m4s). This hides content, CDN, manifest, and licence-server locations while preserving the type of resource. - Local paths: install-root, virtualenv, and home-directory prefixes are replaced with tokens, so your username and machine layout don't appear in logs.
Path redaction is controlled by the redact_paths config key, which is on by default:
redact_paths: true # default - mask local base directories in logged paths\n
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.
"},{"location":"guide/troubleshooting/#checking-your-environment","title":"Checking your environment","text":"Many \"download failed\" problems are really missing external tools or a config file that isn't where you think it is. The unshackle env command group answers both questions before you dig into logs.
"},{"location":"guide/troubleshooting/#env-check","title":"env check","text":"unshackle env check verifies every external binary unshackle can use and prints a table of what's installed:
unshackle env check\n
It reports each tool's status (\u2713 installed / \u2717 missing), whether it is required or optional, and what it's for. A summary line tells you the installed-vs-total count and, if anything mandatory is absent, exactly which required tools are missing.
The required tools (unshackle will not get far without them) are:
Tool Purpose FFmpeg Media processing FFprobe Media analysis MKVToolNix (mkvmerge) MKV muxing mkvpropedit MKV metadata Shaka Packager DRM decryption Optional tools unlock extra capabilities. For example, mp4decrypt (alternative decryptor), dovi_tool / HDR10Plus_tool (Dolby Vision / HDR10+), SubtitleEdit / CCExtractor (subtitle conversion and CC extraction), MPV / FFplay (playback preview), and git (service repositories) or docker (Gluetun VPN, see Proxies & VPN).
First thing to run when something breaks
If a decryption step errors, a mux fails, or a Dolby Vision title won't process, run unshackle env check first. A red \u2717 next to the relevant tool is the answer far more often than a bug in unshackle.
"},{"location":"guide/troubleshooting/#env-info","title":"env info","text":"unshackle env info shows where unshackle is reading configuration from and where every working directory lives:
unshackle env info\n
- If a config file was found, it prints the path it was loaded from. If none was found, it lists every location it would accept one (see Config file discovery below), so you can create the file in the right place.
- It then prints a Directories table for every configured path:
downloads, temp, cache, cookies, logs, exports, wvds, prds, the services search paths, and more.
This is the authoritative way to answer \"where are my logs / downloads / cookies actually going?\" The defaults are relative to the installed package, and any directories: overrides in your config are reflected here.
"},{"location":"guide/troubleshooting/#env-clear","title":"env clear","text":"Stale cache or leftover temp files can cause confusing behaviour (an old cached title list, a half-written temp file). env clear wipes those directories safely:
unshackle env clear cache # clear the whole cache directory\nunshackle env clear cache EXAMPLE # clear just one service's cache subdirectory\nunshackle env clear temp # clear the temp working directory\n
Each command reports how many files it removed and how much space it freed, then recreates the empty directory.
Cache-related weirdness
If a service keeps returning stale titles or metadata even after the source changed, clear that service's cache with unshackle env clear cache <SERVICE>. See Vaults for the separate, persistent key-vault store, which env clear does not touch.
"},{"location":"guide/troubleshooting/#common-failure-modes-and-how-to-inspect-them","title":"Common failure modes and how to inspect them","text":"A required tool is missing
Symptom: decryption, muxing, or analysis fails early, sometimes with a \"binary not found\" style error.
Inspect: run unshackle env check and look for a red \u2717 on a required row. Install the missing tool and re-run.
Decryption produces garbage or fails
Symptom: the download completes but the file won't play, or the decrypt step errors.
Inspect: enable debug_keys and run with -d, then check the JSON debug log's drm_* and vault_* operations to confirm which keys were fetched and from where. Also confirm Shaka Packager (or mp4decrypt) shows a sane version in the download_init binary-versions entry. See DRM & CDM.
A network request fails at the connection or TLS layer
Symptom: timeouts, connection resets, or handshake errors that unshackle's own logs don't fully explain.
Inspect: set debug_requests: true and run with -d to surface the underlying urllib3 / rnet / httpx logs. In the JSON debug log, filter for service_call operations to see which request failed. If a proxy is involved, cross-check Proxies & VPN.
The wrong titles/metadata keep coming back
Symptom: a service returns outdated titles even after the source changed.
Inspect: the title cache is likely serving stale data. Clear it with unshackle env clear cache <SERVICE>, or bypass it for one run using the dl no-cache / reset-cache options (see Downloading and the CLI Reference).
A config value doesn't seem to apply
Symptom: a setting you edited has no effect.
Inspect: run unshackle env info to confirm which config file is actually loaded, and unshackle cfg --list to dump the effective values. It's common to edit a config file in a location unshackle isn't reading from. See below.
"},{"location":"guide/troubleshooting/#why-is-my-config-not-being-picked-up","title":"Why is my config not being picked up?","text":"unshackle loads the first unshackle.yaml it finds, in this order:
unshackle.yaml inside the package's namespace directory. unshackle.yaml in the parent of that directory. unshackle.yaml in your OS user-config directory: on Linux ~/.config/unshackle/unshackle.yaml, on Windows %LOCALAPPDATA%\\unshackle\\unshackle.yaml, on macOS ~/Library/Application Support/unshackle/unshackle.yaml.
If you have config in more than one of these, an earlier one wins and your edits to a later file are silently ignored. unshackle env info prints the path that was actually loaded (or, if none exists, lists all three candidate locations so you can create the file correctly). See Configuration for editing values with the cfg command.
Developer note: structured logging in code
Service and command code emits entries through the shared log_event(...) helper (and the timed_operation(...) context manager for timing a block) from unshackle.core.utilities. These are no-ops when debug logging is disabled, so they are always safe to leave in place. When adding a new feature, call log_event(\"my_feature_event\", message=\"...\", context={...}) rather than reimplementing the \"is the logger enabled?\" guard. The redaction and JSON-serialisation are handled for you, and any field whose name looks like a key/secret/token is redacted automatically (keys only appear when the run has debug_keys enabled).
"},{"location":"guide/troubleshooting/#logging-conventions-when-adding-entries","title":"Logging conventions when adding entries","text":"The debug log is tuned for developers troubleshooting pipeline flow (maximum signal, minimum noise), not for end users. A few conventions keep it that way. They are not enforced by the log_event signature, so they are easy to miss and worth stating explicitly.
Log levels are a flow skeleton, not just severity
Treat level as structure, not decoration. Emit exactly one INFO milestone per pipeline stage so that jq 'select(.level==\"INFO\")' reads back the whole end-to-end flow of a run. Everything internal goes to DEBUG; failures go to ERROR. Keeping INFO sparse is the whole point. It is the line a developer skims first to see where a run got to before it broke.
# Read the pipeline's high-level flow, one line per stage\njq 'select(.level == \"INFO\")' unshackle_debug_EXAMPLE_20260703-142530.jsonl\n
No raw dumps
Never log a full Tracks object, an MPD/manifest body, or a response payload. Log only counts, ids, sizes, and safe_display_url(url). This keeps the log low-noise and prevents leaking content or manifest data into the file before redaction even runs. Redaction is a backstop, not a licence to dump raw objects.
Operation names: <area>_<event>, no registry
Operation names are plain lowercase strings following an <area>_<event> convention: manifest_dash_parse, drm_decrypt, vault_get_key, tool_run. They are written inline at each call site on purpose: there is no central registry or enum, and you should not go hunting for one or create one. The absence is a deliberate design choice, not an omission.
Message reads alone; data lives in fields
Give every entry a single-sentence message that stands on its own, and put all structured data (context, counts, ids, duration_ms) in fields rather than baking it into the prose. This separation is what lets the log be both human-scannable and machine-filterable; a message like \"Parsed DASH manifest\" with context={\"video\": 4, \"audio\": 2} beats \"Parsed manifest with 4 video and 2 audio tracks\".
Let tool runs log themselves
For external-tool calls (FFmpeg, mkvmerge, dovi_tool, and friends), route through run_step / ffprobe, which auto-emit a tool_run entry capturing the binary's version, return code, and duration consistently. Only fall back to calling log_tool_run manually for a direct subprocess.run you genuinely can't route through run_step. Auto-logging is the preferred path precisely because it captures those fields the same way every time.
"},{"location":"guide/vaults/","title":"Key Vaults","text":"A key vault is a store of DRM content keys that unshackle remembers between downloads. Once a title's keys have been recovered, unshackle saves them to your configured vaults. The next time you download something that reuses those same keys, unshackle finds them in a vault instead of performing another license exchange. The download starts faster and puts less load on the service.
Vaults are entirely optional, but if you download regularly they are one of the biggest quality-of-life improvements you can configure.
Vaults save license calls even on the very first download
The benefit is not limited to repeat downloads. Within a single run, a service often issues the same Key ID and content key for both the video and audio tracks, and for several resolutions or bitrates of the same title. Once unshackle has recovered a key it writes it to your vaults immediately, so the moment the next track presents a KID it has already seen, the key is served from the vault instead of triggering another license exchange. A single fetched key can therefore satisfy many tracks in one run.
"},{"location":"guide/vaults/#what-a-vault-stores","title":"What a vault stores","text":"Every key is stored as a KID \u2192 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
EXAMPLE, not ExampleService) that namespaces the key. Each service gets its own logical table.
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.
Null keys are ignored everywhere
A key made entirely of zeroes (00000000000000000000000000000000) is treated as \"no key\". Vault lookups skip it, and every backend raises an error if you try to add one. This prevents a placeholder key from masking a real one.
"},{"location":"guide/vaults/#how-lookups-and-caching-work","title":"How lookups and caching work","text":"When unshackle needs a key during a download it asks your vaults in the order they are listed in your config. It returns the first non-null key it finds and stops searching. If a vault raises a permission error or is unreachable, that vault is skipped and the search continues with the next one.
When new keys are recovered from a license, unshackle pushes them to all configured vaults, so every vault gradually converges on the same set of keys. Two rules apply to pushing:
- Keys that already exist in a vault are skipped (existing data is never overwritten or deleted).
- A vault marked
no_push: true receives lookups but is never written to. This is useful for read-only or shared upstream vaults you do not own.
A good two-vault setup
A common arrangement is a fast local SQLite vault plus a shared remote vault (MySQL, HTTP, or API). The local vault answers instantly and works offline; the remote vault lets you share keys across machines or with a group.
"},{"location":"guide/vaults/#configuring-vaults","title":"Configuring vaults","text":"Vaults are declared under the key_vaults list in your unshackle config file. Each entry is a mapping with two required fields, type (which backend to use) and name (a label you choose), plus whatever settings that backend needs.
unshackle.yamlkey_vaults:\n - type: SQLite\n name: local\n path: ~/.local/share/unshackle/vaults/local.db\n - type: MySQL\n name: shared\n host: vault.example.com\n database: unshackle\n username: reader\n password: \"s3cret\"\n
The type value is matched to a vault backend by name. The four backends that ship with unshackle are SQLite, MySQL, HTTP, and API, described below.
SQLite vaults are loaded critically
During a download, a SQLite vault that fails to load will abort the run. The assumption is that a local database you configured should always be available. MySQL, HTTP, and API vaults are loaded leniently: if one fails to load it is logged and skipped, and the download continues with the remaining vaults.
"},{"location":"guide/vaults/#vault_timeout","title":"vault_timeout","text":"Network-backed vaults (HTTP and API) accept an optional per-vault timeout in seconds. If you omit it, unshackle injects the global default:
unshackle.yamlvault_timeout: 10.0 # default, in seconds\n
A timeout set inline on an individual vault always wins over this global value. The SQLite and MySQL backends do not take a timeout field.
"},{"location":"guide/vaults/#sqlite-backend","title":"SQLite backend","text":"A locally-accessed SQLite database file. This is the simplest and fastest option and needs no server.
unshackle.yamlkey_vaults:\n - type: SQLite\n name: local\n path: ~/.local/share/unshackle/vaults/local.db\n
Field Required Description type yes Must be SQLite. name yes A label for this vault. path yes Path to the database file. ~ is expanded. The file (and its tables) are created automatically on first use. no_push no If true, keys are read but never written. Defaults to false. Each service gets its own table (named after the service tag), created on demand with kid and key_ columns. Connections use WAL journaling with a 30-second busy timeout, so the same database file can be used safely from multiple threads.
Moving the .db file silently forks your vault
Because the file is created on demand, if you relocate an existing .db without updating path to match, unshackle will simply create a fresh empty database at the old location, leaving you with two divergent vaults and no error to warn you. Always update the config path whenever you move the file.
Keep a local SQLite vault even when you share a MySQL one
The SQLite vault is best thought of as an offline-only backup should anything ever happen to a shared MySQL vault. Each member of a group should keep their own local SQLite vault alongside the shared database, so no one loses their keys if the shared vault goes down or away.
"},{"location":"guide/vaults/#mysql-backend","title":"MySQL backend","text":"A remotely-accessed MySQL/MariaDB database, ideal for sharing keys across several machines. It connects via pymysql. MariaDB is recommended over MySQL where you have the choice.
The vault connects directly to the database server
A MySQL vault (like SQLite) speaks the database wire protocol directly to the host or IP you configure. It cannot sit behind a PHP API or any other application layer. A common real-world snag: many hosting providers firewall their MySQL server to their own internal network, so a database on shared hosting is frequently unreachable from the outside world even when your credentials are perfectly correct. If connections time out, confirm the server accepts remote connections from your address before suspecting the config.
unshackle.yamlkey_vaults:\n - type: MySQL\n name: shared\n host: vault.example.com\n database: unshackle\n username: unshackle\n password: \"your-password\"\n port: 3306\n
Field Required Description type yes Must be MySQL. name yes A label for this vault. host yes Database server hostname or IP. database yes Database (schema) name. username yes Login user. no_push no If true, keys are read but never written. Any additional fields you provide (such as password, port, or ssl) are passed straight through to pymysql.connect, so you can use the full range of its connection options.
Account security is on you, not on unshackle
unshackle enforces none of this. It is operational policy for a database that may be shared with a group. Never connect with the root account, not even for yourself. Give every account a password, and never let two users share a username/password. Restrict each account's access to only the unshackle database, and grant normal users just SELECT and INSERT. Reserve the CREATE grant for a single trusted user who bootstraps the service tables (see kv prepare); everyone else can insert new keys but should not be creating tables.
Grants determine what the vault can do
On connection the backend reads its own SHOW GRANTS. It requires at least SELECT to load at all (a missing SELECT grant makes the vault fail to load). Writing keys additionally needs INSERT, and creating a new service table needs CREATE. A read-only account still works fine as a lookup source. It simply cannot push new keys. Because MySQL is loaded leniently during downloads, a permission problem will not abort the run.
"},{"location":"guide/vaults/#http-backend","title":"HTTP backend","text":"A flexible HTTP vault that speaks one of three wire protocols, selected with api_mode. Use this to integrate with a community or self-hosted vault service.
api_mode Transport Authentication Notes query (default) GET with query parameters username + password Full read and write. json POST with a JSON payload password used as a token Read and write per key; cannot bulk-enumerate keys. decrypt_labs POST in DecryptLabs format api_key sent as a header Read-only. no_push is forced on. query modejson modedecrypt_labs mode unshackle.yamlkey_vaults:\n - type: HTTP\n name: community\n host: https://vault.example.com/api\n api_mode: query\n username: myuser\n password: \"your-password\"\n
username is required in query mode. Both the username and password are sent as query parameters on each request.
unshackle.yamlkey_vaults:\n - type: HTTP\n name: jsonvault\n host: https://vault.example.com/rpc\n api_mode: json\n password: \"your-api-token\"\n
Requests are POSTed as {\"method\": ..., \"params\": ..., \"token\": ...} using methods like GetKey, InsertKey, and GetServices. This mode cannot list all keys in bulk, so a kv copy from a json vault relies on the remote server's own duplicate handling.
unshackle.yamlkey_vaults:\n - type: HTTP\n name: decrypt_labs\n host: https://api.decryptlabs.example/\n api_mode: decrypt_labs\n api_key: \"your-decrypt-labs-key\"\n
A read-only lookup source. The key is sent in a decrypt-labs-api-key header and the vault refuses all writes.
Field Required Description type yes Must be HTTP. name yes A label for this vault. host yes Base URL of the vault API. api_mode no query (default), json, or decrypt_labs. username for query Required in query mode; ignored otherwise. password one of these Password (query mode) or token (json mode). api_key one of these Alternative to password; used for decrypt_labs. If both are given, api_key wins. no_push no Read-only when true (always true for decrypt_labs). timeout no Request timeout in seconds; defaults to vault_timeout. You must supply either password or api_key, or the vault will fail to load.
"},{"location":"guide/vaults/#api-backend","title":"API backend","text":"A simple RESTful HTTP vault with a fixed, well-defined protocol. Authentication is a bearer token.
unshackle.yamlkey_vaults:\n - type: API\n name: myapi\n uri: https://vault.example.com/api\n token: \"your-bearer-token\"\n headers:\n CF-Access-Client-Id: \"CLIENT_ID_HERE\"\n CF-Access-Client-Secret: \"CLIENT_SECRET_HERE\"\n
Field Required Description type yes Must be API. name yes A label for this vault. uri yes Base URI of the vault API (a trailing slash is trimmed). token yes Bearer token, sent as Authorization: Bearer <token>. no_push no If true, keys are read but never written. timeout no Request timeout in seconds; defaults to vault_timeout. headers no Extra HTTP headers sent with every request, for example the Cloudflare Access service-token headers CF-Access-Client-Id and CF-Access-Client-Secret. The backend talks to endpoints under uri (for example GET {uri}/{service}/{kid} to look up a single key) and interprets a numeric code field in each JSON response to detect errors such as an invalid token, rate limiting, or an invalid service tag.
This is unshackle's own protocol, not a generic vault client
The API backend expects unshackle's specific HTTP request and response shape. An API or HTTP key-vault endpoint built for a different project or service will generally not be compatible. You cannot simply point this at an arbitrary third-party vault API and expect it to work. To bridge another service, put an adapter in front of it that speaks this format, or use the HTTP backend if one of its wire protocols matches. When pushing many keys at once it batches requests and automatically shrinks the batch size if the server rejects an over-large request.
DecryptLabs shortcut
An API vault whose name contains decrypt_labs will have its token filled in automatically from a global decrypt_labs_api_key in your config if you leave token unset. This lets you keep the key in one place.
"},{"location":"guide/vaults/#managing-vaults-with-unshackle-kv","title":"Managing vaults with unshackle kv","text":"The kv command group manages the contents of your configured vaults. Every subcommand refers to vaults by the name you gave them in key_vaults; an unknown name is an error.
$ unshackle kv --help\n
"},{"location":"guide/vaults/#kv-add-import-keys-from-a-file","title":"kv add: import keys from a file","text":"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.
$ unshackle kv add keys.txt EXAMPLE local shared\n
keys.txt9a04f07998404286ab92e65be0885f95:b1c2d3e4f5a6978012345678deadbeef\n0e7cc1a4e2f9411db0f24a3fbb8f8b21:00112233445566778899aabbccddeeff\n
The service argument (EXAMPLE above) is normalized to its canonical tag automatically. Keys that already exist in a vault are reported as skipped.
"},{"location":"guide/vaults/#kv-search-find-a-key-by-kid","title":"kv search: find a key by KID","text":"Search your vaults for a KID and print any matching key.
$ unshackle kv search 9a04f07998404286ab92e65be0885f95\n
The KID must be 32 hex characters (dashes are stripped for you). By default every configured vault and every service table is scanned; narrow the search with options:
Option Description -s, --service Limit the search to a single service tag. -v, --vault Limit the search to one configured vault by name. Note
Remote vaults that cannot enumerate their service tables (for example an HTTP vault in json or decrypt_labs mode) can only be searched when you also pass --service. Without it, they are skipped.
"},{"location":"guide/vaults/#kv-copy-copy-keys-between-vaults","title":"kv copy: copy keys between vaults","text":"Copy every key from one or more source vaults into a single destination vault. Existing rows are never altered; only genuinely new KIDs are added, and null keys are skipped.
$ unshackle kv copy shared local\n
The first argument is the destination; the rest are sources. You can supply several sources at once:
$ unshackle kv copy local shared community\n
Option Description -s, --service Only copy the given service. -l, --local-only Only copy services you have installed locally, skipping tables for unknown services. --service and --local-only are mutually exclusive.
"},{"location":"guide/vaults/#kv-sync-mirror-vaults-both-ways","title":"kv sync: mirror vaults both ways","text":"Ensure two or more vaults each end up with every key the others have. This is effectively a two-way copy between each pair, so afterwards all listed vaults hold the same set of keys.
$ unshackle kv sync local shared\n
It accepts the same --service and --local-only options as copy, and requires at least two vaults.
"},{"location":"guide/vaults/#kv-prepare-pre-create-service-tables","title":"kv prepare: pre-create service tables","text":"Create the per-service tables on table-based vaults (SQLite, MySQL) ahead of time, for every service you have installed. Vaults that do not use tables (the network-backed HTTP and API backends) are skipped.
$ unshackle kv prepare shared\n
This is mainly useful with a MySQL vault where you want the tables created once by an account that has the CREATE grant.
"},{"location":"guide/vaults/#see-also","title":"See also","text":" - Downloading: how keys are recovered and used during a download.
"},{"location":"reference/api/config/","title":"Config API","text":"Auto-generated reference for the Config object that models the unshackle configuration file. For a human-friendly walkthrough of every key, see the Configuration Reference.
"},{"location":"reference/api/config/#unshackle.core.config.Config","title":"Config","text":"Config(**kwargs)\n
"},{"location":"reference/api/config/#unshackle.core.config.Config.get_folder_template","title":"get_folder_template","text":"get_folder_template(kind)\n
Resolve the folder template for the given title kind.
kind: one of \"movies\", \"series\", \"songs\", \"albums\". Falls back to the legacy single-string folder template, then \"\".
"},{"location":"reference/api/config/#unshackle.core.config.Config.get_template_separator","title":"get_template_separator","text":"get_template_separator(template_type='movies')\n
Get the filename separator for the given template type.
Analyzes the active template to determine whether it uses dots or spaces between variables. Falls back to dot separator (scene-style) by default.
Parameters:
Name Type Description Default template_type str One of \"movies\", \"series\", or \"songs\".
'movies'"},{"location":"reference/api/config/#unshackle.core.config.resolve_decryption","title":"resolve_decryption","text":"resolve_decryption(decryption_map, default, service)\n
Pick the decryption tool for a service (case-insensitive), falling back to default.
"},{"location":"reference/api/config/#unshackle.core.config.resolve_cdm_name","title":"resolve_cdm_name","text":"resolve_cdm_name(cdm, service, override=None)\n
Resolve a service's top-level CDM entry (case-insensitive), with default fallback.
"},{"location":"reference/api/config/#unshackle.core.config.get_config_path","title":"get_config_path","text":"get_config_path()\n
Get Path to Config from any one of the possible locations.
Returns None if no config file could be found.
"},{"location":"reference/api/drm/","title":"DRM API","text":"Auto-generated reference for the DRM systems: Widevine, PlayReady, ClearKey, and related helpers. For configuring DRM as a user, see DRM & CDM Setup.
"},{"location":"reference/api/drm/#unshackle.core.drm.ClearKey","title":"ClearKey","text":"ClearKey(key, iv=None)\n
AES Clear Key DRM System.
Generally IV should be provided where possible. If not provided, it will be set to \u0000 of the same bit-size of the key.
"},{"location":"reference/api/drm/#unshackle.core.drm.ClearKey.decrypt","title":"decrypt","text":"decrypt(path)\n
Decrypt a Track with AES Clear Key DRM.
"},{"location":"reference/api/drm/#unshackle.core.drm.ClearKey.from_m3u_key","title":"from_m3u_key classmethod","text":"from_m3u_key(m3u_key, session=None)\n
Load a ClearKey from an M3U(8) Playlist's EXT-X-KEY.
Parameters:
Name Type Description Default m3u_key Key A Key object parsed from a m3u(8) playlist using the m3u8 library.
required session Optional[Session] Optional session used to request external URIs with. Useful to set headers, proxies, cookies, and so forth.
None"},{"location":"reference/api/drm/#unshackle.core.drm.ClearKeyCENC","title":"ClearKeyCENC","text":"ClearKeyCENC(kids, laurl=None, content_keys=None, **kwargs)\n
W3C EME ClearKey (org.w3.clearkey) DRM System over MPEG-CENC content.
Distinct from the HLS AES-128 ClearKey class: keys here are delivered by a license server as a JWK Set keyed by KID, and content is standard CENC (decrypted with shaka-packager/mp4decrypt KID:KEY pairs, same as Widevine).
"},{"location":"reference/api/drm/#unshackle.core.drm.ClearKeyCENC.kid","title":"kid property","text":"kid\n
Get first Key ID, if any.
"},{"location":"reference/api/drm/#unshackle.core.drm.ClearKeyCENC.Exceptions","title":"Exceptions","text":""},{"location":"reference/api/drm/#unshackle.core.drm.ClearKeyCENC.Exceptions.KIDNotFound","title":"KIDNotFound","text":" Bases: Exception
KID (Encryption Key ID) was not found.
"},{"location":"reference/api/drm/#unshackle.core.drm.ClearKeyCENC.Exceptions.CEKNotFound","title":"CEKNotFound","text":" Bases: Exception
CEK (Content Encryption Key) for KID was not found in License.
"},{"location":"reference/api/drm/#unshackle.core.drm.ClearKeyCENC.Exceptions.EmptyLicense","title":"EmptyLicense","text":" Bases: Exception
License returned no Content Encryption Keys.
"},{"location":"reference/api/drm/#unshackle.core.drm.ClearKeyCENC.to_dict","title":"to_dict","text":"to_dict()\n
Serialise this DRM instance for export/import (KIDs + license URL).
Content keys are stored once at the export's track level, not duplicated here.
"},{"location":"reference/api/drm/#unshackle.core.drm.ClearKeyCENC.get_license_challenge","title":"get_license_challenge","text":"get_license_challenge()\n
Build the W3C EME ClearKey JSON license request for the unkeyed KIDs.
"},{"location":"reference/api/drm/#unshackle.core.drm.ClearKeyCENC.get_content_keys","title":"get_content_keys","text":"get_content_keys(*, licence, session=None)\n
Obtain Content Keys for this DRM Instance from a ClearKey license server.
The licence param is expected to be a function and will be provided with the W3C JSON license request as challenge. It may return the JWK Set license as a dict, JSON str, or bytes. If it returns None and the manifest provided a Laurl, the challenge is POSTed there directly instead.
"},{"location":"reference/api/drm/#unshackle.core.drm.ClearKeyCENC.decrypt","title":"decrypt","text":"decrypt(path)\n
Decrypt a Track with ClearKey DRM (standard CENC). Args: path: Path to the encrypted file to decrypt Raises: EnvironmentError if the required decryption executable could not be found. ValueError if the track has not yet been downloaded. SubprocessError if the decryption process returned a non-zero exit code.
"},{"location":"reference/api/drm/#unshackle.core.drm.ClearKeyCENC.decrypt_with_mp4decrypt","title":"decrypt_with_mp4decrypt","text":"decrypt_with_mp4decrypt(path)\n
Decrypt using mp4decrypt
"},{"location":"reference/api/drm/#unshackle.core.drm.ClearKeyCENC.decrypt_with_shaka_packager","title":"decrypt_with_shaka_packager","text":"decrypt_with_shaka_packager(path)\n
Decrypt using Shaka Packager
"},{"location":"reference/api/drm/#unshackle.core.drm.MonaLisa","title":"MonaLisa","text":"MonaLisa(ticket, aes_key, device_path, **kwargs)\n
MonaLisa DRM System.
Unlike Widevine/PlayReady, MonaLisa does not use a challenge/response flow with a license server. Instead, the PSSH value (ticket) is provided directly by the service API, and keys are extracted locally via a WASM module.
Decryption is performed in two stages: 1. ML-Worker binary: Removes MonaLisa encryption layer (bbts -> ents) 2. AES-ECB decryption: Final decryption with service-provided key
Initialize MonaLisa DRM.
Parameters:
Name Type Description Default ticket Union[str, bytes] PSSH value from service API (base64 string or raw bytes).
required aes_key Union[str, bytes] AES-ECB key for second-stage decryption (hex string or bytes).
required device_path Path Path to the CDM device file (.mld).
required **kwargs Any Additional metadata stored in self.data.
{} Raises:
Type Description TicketNotFound If ticket/PSSH is empty.
KeyExtractionFailed If key extraction fails.
"},{"location":"reference/api/drm/#unshackle.core.drm.MonaLisa.kid","title":"kid property","text":"kid\n
Get the Key ID.
"},{"location":"reference/api/drm/#unshackle.core.drm.MonaLisa.key","title":"key property","text":"key\n
Get the content key as hex string.
"},{"location":"reference/api/drm/#unshackle.core.drm.MonaLisa.pssh","title":"pssh property","text":"pssh\n
Get the raw PSSH/ticket value as a string.
Returns:
Type Description str The raw PSSH value as a base64 string.
"},{"location":"reference/api/drm/#unshackle.core.drm.MonaLisa.content_id","title":"content_id property","text":"content_id\n
Extract the Content ID from the PSSH for display.
The PSSH contains an embedded Content ID at bytes 21-75 with format: H5DCID-V3-P1-YYYYMMDD-HHMMSS-MEDIAID-TIMESTAMP-SUFFIX
Returns:
Type Description Optional[str] The Content ID string if extractable, None otherwise.
"},{"location":"reference/api/drm/#unshackle.core.drm.MonaLisa.content_keys","title":"content_keys property","text":"content_keys\n
Get content keys in the same format as Widevine/PlayReady.
Returns:
Type Description dict[UUID, str] Dictionary mapping KID to key hex string.
"},{"location":"reference/api/drm/#unshackle.core.drm.MonaLisa.Exceptions","title":"Exceptions","text":""},{"location":"reference/api/drm/#unshackle.core.drm.MonaLisa.Exceptions.TicketNotFound","title":"TicketNotFound","text":" Bases: Exception
Raised when no PSSH/ticket data is provided.
"},{"location":"reference/api/drm/#unshackle.core.drm.MonaLisa.Exceptions.KeyExtractionFailed","title":"KeyExtractionFailed","text":" Bases: Exception
Raised when key extraction from the ticket fails.
"},{"location":"reference/api/drm/#unshackle.core.drm.MonaLisa.Exceptions.WorkerNotFound","title":"WorkerNotFound","text":" Bases: Exception
Raised when the ML-Worker binary is not found.
"},{"location":"reference/api/drm/#unshackle.core.drm.MonaLisa.Exceptions.DecryptionFailed","title":"DecryptionFailed","text":" Bases: Exception
Raised when segment decryption fails.
"},{"location":"reference/api/drm/#unshackle.core.drm.MonaLisa.from_ticket","title":"from_ticket classmethod","text":"from_ticket(ticket, aes_key, device_path)\n
Create a MonaLisa DRM instance from a PSSH/ticket.
Parameters:
Name Type Description Default ticket Union[str, bytes] PSSH value from service API.
required aes_key Union[str, bytes] AES-ECB key for second-stage decryption.
required device_path Path Path to the CDM device file (.mld).
required Returns:
Type Description MonaLisa MonaLisa DRM instance with extracted keys.
"},{"location":"reference/api/drm/#unshackle.core.drm.MonaLisa.decrypt_segment","title":"decrypt_segment","text":"decrypt_segment(segment_path)\n
Decrypt a single segment using two-stage decryption.
Stage 1: ML-Worker binary (bbts -> ents) Stage 2: AES-ECB decryption (ents -> ts)
Parameters:
Name Type Description Default segment_path Path Path to the encrypted segment file.
required Raises:
Type Description WorkerNotFound If ML-Worker binary is not available.
DecryptionFailed If decryption fails at any stage.
"},{"location":"reference/api/drm/#unshackle.core.drm.MonaLisa.decrypt","title":"decrypt","text":"decrypt(_path)\n
MonaLisa uses per-segment decryption during download via the on_segment_downloaded callback. By the time this method is called, the content has already been decrypted and muxed into a container.
Parameters:
Name Type Description Default _path Path Path to the file (ignored).
required"},{"location":"reference/api/drm/#unshackle.core.drm.PlayReady","title":"PlayReady","text":"PlayReady(pssh, kid=None, pssh_b64=None, **kwargs)\n
PlayReady DRM System.
"},{"location":"reference/api/drm/#unshackle.core.drm.PlayReady.to_dict","title":"to_dict","text":"to_dict()\n
Serialise this DRM instance for export/import (PSSH + KIDs).
Content keys are stored once at the export's track level, not duplicated here.
"},{"location":"reference/api/drm/#unshackle.core.drm.PlayReady.decrypt","title":"decrypt","text":"decrypt(path)\n
Decrypt a Track with PlayReady DRM. Args: path: Path to the encrypted file to decrypt Raises: EnvironmentError if the required decryption executable could not be found. ValueError if the track has not yet been downloaded. SubprocessError if the decryption process returned a non-zero exit code.
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine","title":"Widevine","text":"Widevine(pssh, kid=None, **kwargs)\n
Widevine DRM System.
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine.pssh","title":"pssh property","text":"pssh\n
Get Protection System Specific Header Box.
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine.kid","title":"kid property","text":"kid\n
Get first Key ID, if any.
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine.kids","title":"kids property","text":"kids\n
Get all Key IDs from PSSH, falling back to the externally provided KID.
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine.Exceptions","title":"Exceptions","text":""},{"location":"reference/api/drm/#unshackle.core.drm.Widevine.Exceptions.PSSHNotFound","title":"PSSHNotFound","text":" Bases: Exception
PSSH (Protection System Specific Header) was not found.
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine.Exceptions.KIDNotFound","title":"KIDNotFound","text":" Bases: Exception
KID (Encryption Key ID) was not found.
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine.Exceptions.CEKNotFound","title":"CEKNotFound","text":" Bases: Exception
CEK (Content Encryption Key) for KID was not found in License.
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine.Exceptions.EmptyLicense","title":"EmptyLicense","text":" Bases: Exception
License returned no Content Encryption Keys.
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine.from_track","title":"from_track classmethod","text":"from_track(track, session=None)\n
Get PSSH and KID from within the Initiation Segment of the Track Data. It also tries to get PSSH and KID from other track data like M3U8 data as well as through ffprobe.
Create a Widevine DRM System object from a track's information. This should only be used if a PSSH could not be provided directly. It is rare to need to use this.
You may provide your own requests session to be able to use custom headers and more.
Raises:
Type Description PSSHNotFound If the PSSH was not found within the data.
KIDNotFound If the KID was not found within the data or PSSH.
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine.from_init_data","title":"from_init_data classmethod","text":"from_init_data(init_data)\n
Get PSSH and KID from within Initialization Segment Data.
This should only be used if a PSSH could not be provided directly. It is rare to need to use this.
Raises:
Type Description PSSHNotFound If the PSSH was not found within the data.
KIDNotFound If the KID was not found within the data or PSSH.
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine.to_dict","title":"to_dict","text":"to_dict()\n
Serialise this DRM instance for export/import (PSSH + KIDs).
Content keys are stored once at the export's track level, not duplicated here.
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine.get_content_keys","title":"get_content_keys","text":"get_content_keys(cdm, certificate, licence)\n
Create a CDM Session and obtain Content Keys for this DRM Instance. The certificate and license params are expected to be a function and will be provided with the challenge and session ID.
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine.get_NF_content_keys","title":"get_NF_content_keys","text":"get_NF_content_keys(cdm, certificate, licence)\n
Create a CDM Session and obtain Content Keys for this DRM Instance. The certificate and license params are expected to be a function and will be provided with the challenge and session ID.
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine.decrypt","title":"decrypt","text":"decrypt(path)\n
Decrypt a Track with Widevine DRM. Args: path: Path to the encrypted file to decrypt Raises: EnvironmentError if the required decryption executable could not be found. ValueError if the track has not yet been downloaded. SubprocessError if the decryption process returned a non-zero exit code.
"},{"location":"reference/api/drm/#unshackle.core.drm.drm_from_dict","title":"drm_from_dict","text":"drm_from_dict(data)\n
Reconstruct a Widevine/PlayReady/ClearKeyCENC DRM instance from its to_dict() form.
Rebuilds the PSSH from the stored base64 (KIDs for ClearKey, which has no PSSH) and re-injects any saved content keys so the resulting object can decrypt without contacting a license server.
"},{"location":"reference/api/service/","title":"Service API","text":"Auto-generated reference for the Service base class that every service plugin subclasses. See Creating a Service for a guided walkthrough and Service Architecture for the wider object model.
"},{"location":"reference/api/service/#unshackle.core.service.TrackRequest","title":"TrackRequest dataclass","text":"TrackRequest(\n codecs=list(),\n ranges=(lambda: [SDR])(),\n best_available=False,\n)\n
Holds what the user requested for video codec and range selection.
Services read from this instead of ctx.parent.params for vcodec/range.
Attributes:
Name Type Description codecs list[Codec] Requested codecs from CLI. Empty list means no filter (accept any).
ranges list[Range] Requested ranges from CLI. Defaults to [SDR].
"},{"location":"reference/api/service/#unshackle.core.service.Service","title":"Service","text":"Service(ctx)\n
The Service Base Class.
Source code in unshackle/core/service.py def __init__(self, ctx: click.Context):\n console.print(Padding(Rule(f\"[rule.text]Service: {self.__class__.__name__}\"), (1, 2)))\n\n self.config = ctx.obj.config\n\n self.log = logging.getLogger(self.__class__.__name__)\n\n self.session = self.get_session()\n self.cache = Cacher(self.__class__.__name__)\n self.title_cache = TitleCacher(self.__class__.__name__)\n self.cache_dir = config.directories.cache / self.__class__.__name__\n\n # Store context for cache control flags and credential\n self.ctx = ctx\n self.credential = None # Will be set in authenticate()\n self.current_region = None # Will be set based on proxy/geolocation\n self._input_bridge: Optional[InputBridge] = None\n\n # Set track request from CLI params - services can read/override in their __init__\n vcodec = ctx.parent.params.get(\"vcodec\") if ctx.parent else None\n range_ = ctx.parent.params.get(\"range_\") if ctx.parent else None\n best_available = ctx.parent.params.get(\"best_available\", False) if ctx.parent else False\n self.track_request = TrackRequest(\n codecs=list(vcodec) if vcodec else [],\n ranges=list(range_) if range_ else [Video.Range.SDR],\n best_available=bool(best_available),\n )\n\n if not ctx.parent or not ctx.parent.params.get(\"no_proxy\"):\n if ctx.parent:\n proxy = ctx.parent.params[\"proxy\"]\n proxy_query = ctx.parent.params.get(\"proxy_query\")\n proxy_provider_name = ctx.parent.params.get(\"proxy_provider\")\n else:\n proxy = None\n proxy_query = None\n proxy_provider_name = None\n\n # Check for service-specific proxy mapping\n service_name = self.__class__.__name__\n service_config_dict = config.services.get(service_name, {})\n proxy_map = service_config_dict.get(\"proxy_map\", {})\n\n if proxy_map and proxy_query:\n # Build the full proxy query key (e.g., \"nordvpn:ca\" or \"us\")\n if proxy_provider_name:\n full_proxy_key = f\"{proxy_provider_name}:{proxy_query}\"\n else:\n full_proxy_key = proxy_query\n\n # Check if there's a mapping for this query\n mapped_value = proxy_map.get(full_proxy_key)\n if mapped_value:\n self.log.info(\n f\"Found service-specific proxy mapping: {full_proxy_key} -> {sanitize_proxy_for_log(mapped_value)}\"\n )\n # Query the proxy provider with the mapped value\n if proxy_provider_name:\n # Specific provider requested\n proxy_provider = next(\n (x for x in ctx.obj.proxy_providers if x.__class__.__name__.lower() == proxy_provider_name),\n None,\n )\n if proxy_provider:\n mapped_proxy_uri = proxy_provider.get_proxy(mapped_value)\n if mapped_proxy_uri:\n proxy = mapped_proxy_uri\n self.log.info(\n f\"Using mapped proxy from {proxy_provider.__class__.__name__}: {sanitize_proxy_for_log(proxy)}\"\n )\n else:\n self.log.warning(\n f\"Failed to get proxy for mapped value '{sanitize_proxy_for_log(mapped_value)}', using default\"\n )\n else:\n self.log.warning(f\"Proxy provider '{proxy_provider_name}' not found, using default proxy\")\n else:\n # No specific provider, try all providers\n for proxy_provider in ctx.obj.proxy_providers:\n mapped_proxy_uri = proxy_provider.get_proxy(mapped_value)\n if mapped_proxy_uri:\n proxy = mapped_proxy_uri\n self.log.info(\n f\"Using mapped proxy from {proxy_provider.__class__.__name__}: {sanitize_proxy_for_log(proxy)}\"\n )\n break\n else:\n self.log.warning(\n f\"No provider could resolve mapped value '{sanitize_proxy_for_log(mapped_value)}', using default\"\n )\n\n if not proxy:\n # don't override the explicit proxy set by the user, even if they may be geoblocked\n with console.status(\"Checking if current region is Geoblocked...\", spinner=\"dots\"):\n if self.GEOFENCE:\n # Service has geofence - need fresh IP check to determine if proxy needed\n try:\n current_region = get_ip_info(self.session)[\"country\"].lower()\n if any(x.lower() == current_region for x in self.GEOFENCE):\n self.log.info(\"Service is not Geoblocked in your region\")\n else:\n requested_proxy = self.GEOFENCE[0] # first is likely main region\n self.log.info(\n f\"Service is Geoblocked in your region, getting a Proxy to {requested_proxy}\"\n )\n for proxy_provider in ctx.obj.proxy_providers:\n proxy = proxy_provider.get_proxy(requested_proxy)\n if proxy:\n self.log.info(f\"Got Proxy from {proxy_provider.__class__.__name__}\")\n break\n except Exception as e:\n self.log.warning(f\"Failed to check geofence: {e}\")\n current_region = None\n else:\n self.log.info(\"Service has no Geofence\")\n\n if proxy:\n self.session.proxies.update({\"all\": proxy})\n # Don't set Proxy-Authorization manually: both rnet (Proxy.all) and\n # requests authenticate from the credentials embedded in the proxy URL.\n # A manual header here was malformed (no \"Basic \" scheme) and broke\n # plaintext-http forward-proxy requests with HTTP 407.\n # Always verify proxy IP - proxies can change exit nodes\n try:\n proxy_ip_info = get_ip_info(self.session)\n self.current_region = proxy_ip_info.get(\"country\", \"\").lower() if proxy_ip_info else None\n except Exception as e:\n self.log.warning(f\"Failed to verify proxy IP: {e}\")\n # Fallback to extracting region from proxy config\n self.current_region = get_region_from_proxy(proxy)\n else:\n # No proxy, use cached IP info for title caching (non-critical)\n try:\n ip_info = get_ip_info(self.session, cached=True)\n self.current_region = ip_info.get(\"country\", \"\").lower() if ip_info else None\n except Exception as e:\n self.log.debug(f\"Failed to get cached IP info: {e}\")\n self.current_region = None\n
"},{"location":"reference/api/service/#unshackle.core.service.Service.get_session","title":"get_session staticmethod","text":"get_session()\n
Creates a Python-requests Session, adds common headers from config, cookies, retry handler, and a proxy if available. :returns: Prepared Python-requests Session
Source code in unshackle/core/service.py @staticmethod\ndef get_session() -> requests.Session:\n \"\"\"\n Creates a Python-requests Session, adds common headers\n from config, cookies, retry handler, and a proxy if available.\n :returns: Prepared Python-requests Session\n \"\"\"\n session = requests.Session()\n session.headers.update(config.headers)\n session.mount(\n \"https://\",\n HTTPAdapter(\n max_retries=Retry(total=5, backoff_factor=0.2, status_forcelist=[429, 500, 502, 503, 504]),\n pool_maxsize=64,\n pool_block=True,\n ),\n )\n session.mount(\"http://\", session.adapters[\"https://\"])\n return session\n
"},{"location":"reference/api/service/#unshackle.core.service.Service.authenticate","title":"authenticate","text":"authenticate(cookies=None, credential=None)\n
Authenticate the Service with Cookies and/or Credentials (Email/Username and Password).
This is effectively a login() function. Any API calls or object initializations needing to be made, should be made here. This will be run before any of the following abstract functions.
You should avoid storing or using the Credential outside this function. Make any calls you need for any Cookies, Tokens, or such, then use those.
The Cookie jar should also not be stored outside this function. However, you may load the Cookie jar into the service session.
Source code in unshackle/core/service.py def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:\n \"\"\"\n Authenticate the Service with Cookies and/or Credentials (Email/Username and Password).\n\n This is effectively a login() function. Any API calls or object initializations\n needing to be made, should be made here. This will be run before any of the\n following abstract functions.\n\n You should avoid storing or using the Credential outside this function.\n Make any calls you need for any Cookies, Tokens, or such, then use those.\n\n The Cookie jar should also not be stored outside this function. However, you may load\n the Cookie jar into the service session.\n \"\"\"\n if cookies is not None:\n if not isinstance(cookies, CookieJar):\n raise TypeError(f\"Expected cookies to be a {CookieJar}, not {cookies!r}.\")\n self.session.cookies.update(cookies)\n\n # Store credential for cache key generation\n self.credential = credential\n
"},{"location":"reference/api/service/#unshackle.core.service.Service.request_input","title":"request_input","text":"request_input(prompt)\n
Request interactive input from the user.
When running locally (CLI), prompts via the shared rich console so the prompt renders correctly alongside Live progress / log handlers. When running in serve mode with an :class:InputBridge attached, delegates to the bridge which relays the prompt to the remote client.
Source code in unshackle/core/service.py def request_input(self, prompt: str) -> str:\n \"\"\"Request interactive input from the user.\n\n When running locally (CLI), prompts via the shared rich console so the\n prompt renders correctly alongside Live progress / log handlers.\n When running in serve mode with an :class:`InputBridge` attached,\n delegates to the bridge which relays the prompt to the remote client.\n \"\"\"\n if self._input_bridge is not None:\n return self._input_bridge.request_input(prompt)\n indent = \" \" * 5\n padded = indent + prompt.replace(\"\\n\", \"\\n\" + indent)\n return console.input(Text(padded, style=\"text\"))\n
"},{"location":"reference/api/service/#unshackle.core.service.Service.search","title":"search","text":"search()\n
Search by query for titles from the Service.
The query must be taken as a CLI argument by the Service class. Ideally just re-use the title ID argument (i.e. self.title).
Search results will be displayed in the order yielded.
Source code in unshackle/core/service.py def search(self) -> Generator[SearchResult, None, None]:\n \"\"\"\n Search by query for titles from the Service.\n\n The query must be taken as a CLI argument by the Service class.\n Ideally just re-use the title ID argument (i.e. self.title).\n\n Search results will be displayed in the order yielded.\n \"\"\"\n raise NotImplementedError(f\"Search functionality has not been implemented by {self.__class__.__name__}\")\n
"},{"location":"reference/api/service/#unshackle.core.service.Service.get_widevine_service_certificate","title":"get_widevine_service_certificate","text":"get_widevine_service_certificate(\n *, challenge, title, track\n)\n
Get the Widevine Service Certificate used for Privacy Mode.
:param challenge: The service challenge, providing this to a License endpoint should return the privacy certificate that the service uses. :param title: The current Title from get_titles that is being executed. This is provided in case it has data needed to be used, e.g. for a HTTP request. :param track: The current Track needing decryption. Provided for same reason as title. :return: The Service Privacy Certificate as Bytes or a Base64 string. Don't Base64 Encode or Decode the data, return as is to reduce unnecessary computations.
Source code in unshackle/core/service.py def get_widevine_service_certificate(\n self, *, challenge: bytes, title: Title_T, track: AnyTrack\n) -> Union[bytes, str]:\n \"\"\"\n Get the Widevine Service Certificate used for Privacy Mode.\n\n :param challenge: The service challenge, providing this to a License endpoint should return the\n privacy certificate that the service uses.\n :param title: The current `Title` from get_titles that is being executed. This is provided in\n case it has data needed to be used, e.g. for a HTTP request.\n :param track: The current `Track` needing decryption. Provided for same reason as `title`.\n :return: The Service Privacy Certificate as Bytes or a Base64 string. Don't Base64 Encode or\n Decode the data, return as is to reduce unnecessary computations.\n \"\"\"\n
"},{"location":"reference/api/service/#unshackle.core.service.Service.get_widevine_license","title":"get_widevine_license","text":"get_widevine_license(*, challenge, title, track)\n
Get a Widevine License message by sending a License Request (challenge).
This License message contains the encrypted Content Decryption Keys and will be read by the Cdm and decrypted.
This is a very important request to get correct. A bad, unexpected, or missing value in the request can cause your key to be detected and promptly banned, revoked, disabled, or downgraded.
:param challenge: The license challenge from the Widevine CDM. :param title: The current Title from get_titles that is being executed. This is provided in case it has data needed to be used, e.g. for a HTTP request. :param track: The current Track needing decryption. Provided for same reason as title. :return: The License response as Bytes or a Base64 string. Don't Base64 Encode or Decode the data, return as is to reduce unnecessary computations.
Source code in unshackle/core/service.py def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]:\n \"\"\"\n Get a Widevine License message by sending a License Request (challenge).\n\n This License message contains the encrypted Content Decryption Keys and will be\n read by the Cdm and decrypted.\n\n This is a very important request to get correct. A bad, unexpected, or missing\n value in the request can cause your key to be detected and promptly banned,\n revoked, disabled, or downgraded.\n\n :param challenge: The license challenge from the Widevine CDM.\n :param title: The current `Title` from get_titles that is being executed. This is provided in\n case it has data needed to be used, e.g. for a HTTP request.\n :param track: The current `Track` needing decryption. Provided for same reason as `title`.\n :return: The License response as Bytes or a Base64 string. Don't Base64 Encode or\n Decode the data, return as is to reduce unnecessary computations.\n \"\"\"\n
"},{"location":"reference/api/service/#unshackle.core.service.Service.get_playready_license","title":"get_playready_license","text":"get_playready_license(*, challenge, title, track)\n
Get a PlayReady License message by sending a License Request (challenge).
This License message contains the encrypted Content Decryption Keys and will be read by the CDM and decrypted.
This is a very important request to get correct. A bad, unexpected, or missing value in the request can cause your key to be detected and promptly banned, revoked, disabled, or downgraded.
:param challenge: The license challenge from the PlayReady CDM. :param title: The current Title from get_titles that is being executed. This is provided in case it has data needed to be used, e.g. for a HTTP request. :param track: The current Track needing decryption. Provided for same reason as title. :return: The License response as Bytes or a Base64 string. Don't Base64 Encode or Decode the data, return as is to reduce unnecessary computations.
Source code in unshackle/core/service.py def get_playready_license(\n self, *, challenge: bytes, title: Title_T, track: AnyTrack\n) -> Optional[Union[bytes, str]]:\n \"\"\"\n Get a PlayReady License message by sending a License Request (challenge).\n\n This License message contains the encrypted Content Decryption Keys and will be\n read by the CDM and decrypted.\n\n This is a very important request to get correct. A bad, unexpected, or missing\n value in the request can cause your key to be detected and promptly banned,\n revoked, disabled, or downgraded.\n\n :param challenge: The license challenge from the PlayReady CDM.\n :param title: The current `Title` from get_titles that is being executed. This is provided in\n case it has data needed to be used, e.g. for a HTTP request.\n :param track: The current `Track` needing decryption. Provided for same reason as `title`.\n :return: The License response as Bytes or a Base64 string. Don't Base64 Encode or\n Decode the data, return as is to reduce unnecessary computations.\n \"\"\"\n # Delegates license handling to the Widevine license method by default if a service-specific PlayReady implementation is not provided.\n return self.get_widevine_license(challenge=challenge, title=title, track=track)\n
"},{"location":"reference/api/service/#unshackle.core.service.Service.get_clearkey_license","title":"get_clearkey_license","text":"get_clearkey_license(*, challenge, title, track)\n
Get a W3C ClearKey License (JWK Set) by sending a License Request (challenge).
Used for DASH org.w3.clearkey content. No CDM is involved: the challenge is the W3C EME JSON license request, e.g. {\"kids\": [\"<base64url>\"], \"type\": \"temporary\"}, and the license is a JWK Set, e.g. {\"keys\": [{\"kty\": \"oct\", \"k\": \"...\", \"kid\": \"...\"}]}.
:param challenge: The JSON license request bytes to POST to the license server. :param title: The current Title from get_titles that is being executed. This is provided in case it has data needed to be used, e.g. for a HTTP request. :param track: The current Track needing decryption. Provided for same reason as title. :return: The JWK Set license as a dict, JSON str, or raw bytes. Return None (the default) to let the framework POST the challenge to the manifest-provided Laurl, if any. Services with no license server can instead pre-populate the DRM object's content_keys in get_tracks.
Source code in unshackle/core/service.py def get_clearkey_license(\n self, *, challenge: bytes, title: Title_T, track: AnyTrack\n) -> Optional[Union[bytes, str, dict]]:\n \"\"\"\n Get a W3C ClearKey License (JWK Set) by sending a License Request (challenge).\n\n Used for DASH `org.w3.clearkey` content. No CDM is involved: the challenge is\n the W3C EME JSON license request, e.g. ``{\"kids\": [\"<base64url>\"], \"type\": \"temporary\"}``,\n and the license is a JWK Set, e.g. ``{\"keys\": [{\"kty\": \"oct\", \"k\": \"...\", \"kid\": \"...\"}]}``.\n\n :param challenge: The JSON license request bytes to POST to the license server.\n :param title: The current `Title` from get_titles that is being executed. This is provided in\n case it has data needed to be used, e.g. for a HTTP request.\n :param track: The current `Track` needing decryption. Provided for same reason as `title`.\n :return: The JWK Set license as a dict, JSON str, or raw bytes. Return None (the default)\n to let the framework POST the challenge to the manifest-provided Laurl, if any.\n Services with no license server can instead pre-populate the DRM object's\n `content_keys` in get_tracks.\n \"\"\"\n return None\n
"},{"location":"reference/api/service/#unshackle.core.service.Service.get_titles","title":"get_titles abstractmethod","text":"get_titles()\n
Get Titles for the provided title ID.
Return a Movies, Series, or Album objects containing Movie, Episode, or Song title objects respectively. The returned data must be for the given title ID, or a spawn of the title ID.
At least one object is expected to be returned, or it will presume an invalid Title ID was provided.
You can use the data dictionary class instance attribute of each Title to store data you may need later on. This can be useful to store information on each title that will be required like any sub-asset IDs, or such.
Source code in unshackle/core/service.py @abstractmethod\ndef get_titles(self) -> Titles_T:\n \"\"\"\n Get Titles for the provided title ID.\n\n Return a Movies, Series, or Album objects containing Movie, Episode, or Song title objects respectively.\n The returned data must be for the given title ID, or a spawn of the title ID.\n\n At least one object is expected to be returned, or it will presume an invalid Title ID was\n provided.\n\n You can use the `data` dictionary class instance attribute of each Title to store data you may need later on.\n This can be useful to store information on each title that will be required like any sub-asset IDs, or such.\n \"\"\"\n
"},{"location":"reference/api/service/#unshackle.core.service.Service.get_titles_cached","title":"get_titles_cached","text":"get_titles_cached(title_id=None)\n
Cached wrapper around get_titles() to reduce redundant API calls.
This method checks the cache before calling get_titles() and handles fallback to cached data when API calls fail.
Parameters:
Name Type Description Default title_id str Optional title ID for cache key generation. If not provided, will try to extract from service instance.
None Returns:
Type Description Titles_T Titles object (Movies, Series, or Album)
Source code in unshackle/core/service.py def get_titles_cached(self, title_id: str = None) -> Titles_T:\n \"\"\"\n Cached wrapper around get_titles() to reduce redundant API calls.\n\n This method checks the cache before calling get_titles() and handles\n fallback to cached data when API calls fail.\n\n Args:\n title_id: Optional title ID for cache key generation.\n If not provided, will try to extract from service instance.\n\n Returns:\n Titles object (Movies, Series, or Album)\n \"\"\"\n # Try to get title_id from service instance if not provided\n if title_id is None:\n # Different services store the title ID in different attributes\n if hasattr(self, \"title\"):\n title_id = self.title\n elif hasattr(self, \"title_id\"):\n title_id = self.title_id\n else:\n # If we can't determine title_id, just call get_titles directly\n self.log.debug(\"Cannot determine title_id for caching, bypassing cache\")\n return self.apply_title_map(self.get_titles())\n\n # Get cache control flags from context\n no_cache = False\n reset_cache = False\n if self.ctx and self.ctx.parent:\n no_cache = self.ctx.parent.params.get(\"no_cache\", False)\n reset_cache = self.ctx.parent.params.get(\"reset_cache\", False)\n\n # Get account hash for cache key\n account_hash = get_account_hash(self.credential)\n\n # Use title cache to get titles with fallback support\n titles = self.title_cache.get_cached_titles(\n title_id=str(title_id),\n fetch_function=self.get_titles,\n region=self.current_region,\n account_hash=account_hash,\n no_cache=no_cache,\n reset_cache=reset_cache,\n )\n return self.apply_title_map(titles)\n
"},{"location":"reference/api/service/#unshackle.core.service.Service.apply_title_map","title":"apply_title_map","text":"apply_title_map(titles)\n
Rewrite service-provided titles using the per-service title_map config.
title_map lives under services.<TAG> in unshackle.yaml. Applied after the title cache so config edits take effect without a cache reset, and before any --enrich override so enrich wins. See remap_titles for the match rules.
Source code in unshackle/core/service.py def apply_title_map(self, titles: Titles_T) -> Titles_T:\n \"\"\"\n Rewrite service-provided titles using the per-service ``title_map`` config.\n\n ``title_map`` lives under ``services.<TAG>`` in unshackle.yaml. Applied after the\n title cache so config edits take effect without a cache reset, and before any\n ``--enrich`` override so enrich wins. See ``remap_titles`` for the match rules.\n \"\"\"\n return remap_titles(titles, (self.config or {}).get(\"title_map\") or {})\n
"},{"location":"reference/api/service/#unshackle.core.service.Service.get_tracks","title":"get_tracks abstractmethod","text":"get_tracks(title)\n
Get Track objects of the Title.
Return a Tracks object, which itself can contain Video, Audio, Subtitle or even Chapters. Tracks.videos, Tracks.audio, Tracks.subtitles, and Track.chapters should be a List of Track objects.
Each Track in the Tracks should represent a Video/Audio Stream/Representation/Adaptation or a Subtitle file.
While one Track should only hold information for one stream/downloadable, try to get as many unique Track objects per stream type so Stream selection by the root code can give you more options in terms of Resolution, Bitrate, Codecs, Language, e.t.c.
No decision making or filtering of which Tracks get returned should happen here. It can be considered an error to filter for e.g. resolution, codec, and such. All filtering based on arguments will be done by the root code automatically when needed.
Make sure you correctly mark which Tracks are encrypted or not, and by which DRM System via its drm property.
If you are able to obtain the Track's KID (Key ID) as a 32 char (16 bit) HEX string, provide it to the Track's kid variable as it will speed up the decryption process later on. It may or may not be needed, that depends on the service. Generally if you can provide it, without downloading any of the Track's stream data, then do.
:param title: The current Title from get_titles that is being executed. :return: Tracks object containing Video, Audio, Subtitles, and Chapters, if available.
Source code in unshackle/core/service.py @abstractmethod\ndef get_tracks(self, title: Title_T) -> Tracks:\n \"\"\"\n Get Track objects of the Title.\n\n Return a Tracks object, which itself can contain Video, Audio, Subtitle or even Chapters.\n Tracks.videos, Tracks.audio, Tracks.subtitles, and Track.chapters should be a List of Track objects.\n\n Each Track in the Tracks should represent a Video/Audio Stream/Representation/Adaptation or\n a Subtitle file.\n\n While one Track should only hold information for one stream/downloadable, try to get as many\n unique Track objects per stream type so Stream selection by the root code can give you more\n options in terms of Resolution, Bitrate, Codecs, Language, e.t.c.\n\n No decision making or filtering of which Tracks get returned should happen here. It can be\n considered an error to filter for e.g. resolution, codec, and such. All filtering based on\n arguments will be done by the root code automatically when needed.\n\n Make sure you correctly mark which Tracks are encrypted or not, and by which DRM System\n via its `drm` property.\n\n If you are able to obtain the Track's KID (Key ID) as a 32 char (16 bit) HEX string, provide\n it to the Track's `kid` variable as it will speed up the decryption process later on. It may\n or may not be needed, that depends on the service. Generally if you can provide it, without\n downloading any of the Track's stream data, then do.\n\n :param title: The current `Title` from get_titles that is being executed.\n :return: Tracks object containing Video, Audio, Subtitles, and Chapters, if available.\n \"\"\"\n
"},{"location":"reference/api/service/#unshackle.core.service.Service.get_chapters","title":"get_chapters abstractmethod","text":"get_chapters(title)\n
Get Chapters for the Title.
Parameters:
Name Type Description Default title Title_T The current Title from get_titles that is being processed.
required You must return a Chapters object containing 0 or more Chapter objects.
You do not need to set a Chapter number or sort/order the chapters in any way as the Chapters class automatically handles all of that for you. If there's no descriptive name for a Chapter then do not set a name at all.
You must not set Chapter names to \"Chapter {n}\" or such. If you (or the user) wants \"Chapter {n}\" style Chapter names (or similar) then they can use the config option chapter_fallback_name. For example, \"Chapter {i:02}\" for \"Chapter 01\".
Source code in unshackle/core/service.py @abstractmethod\ndef get_chapters(self, title: Title_T) -> Chapters:\n \"\"\"\n Get Chapters for the Title.\n\n Parameters:\n title: The current Title from `get_titles` that is being processed.\n\n You must return a Chapters object containing 0 or more Chapter objects.\n\n You do not need to set a Chapter number or sort/order the chapters in any way as\n the Chapters class automatically handles all of that for you. If there's no\n descriptive name for a Chapter then do not set a name at all.\n\n You must not set Chapter names to \"Chapter {n}\" or such. If you (or the user)\n wants \"Chapter {n}\" style Chapter names (or similar) then they can use the config\n option `chapter_fallback_name`. For example, `\"Chapter {i:02}\"` for \"Chapter 01\".\n \"\"\"\n
"},{"location":"reference/api/service/#unshackle.core.service.Service.on_segment_downloaded","title":"on_segment_downloaded","text":"on_segment_downloaded(track, segment)\n
Called when one of a Track's Segments has finished downloading.
Parameters:
Name Type Description Default track AnyTrack The Track object that had a Segment downloaded.
required segment Path The Path to the Segment that was downloaded.
required Source code in unshackle/core/service.py def on_segment_downloaded(self, track: AnyTrack, segment: Path) -> None:\n \"\"\"\n Called when one of a Track's Segments has finished downloading.\n\n Parameters:\n track: The Track object that had a Segment downloaded.\n segment: The Path to the Segment that was downloaded.\n \"\"\"\n
"},{"location":"reference/api/service/#unshackle.core.service.Service.on_track_downloaded","title":"on_track_downloaded","text":"on_track_downloaded(track)\n
Called when a Track has finished downloading.
Parameters:
Name Type Description Default track AnyTrack The Track object that was downloaded.
required Source code in unshackle/core/service.py def on_track_downloaded(self, track: AnyTrack) -> None:\n \"\"\"\n Called when a Track has finished downloading.\n\n Parameters:\n track: The Track object that was downloaded.\n \"\"\"\n
"},{"location":"reference/api/service/#unshackle.core.service.Service.on_track_decrypted","title":"on_track_decrypted","text":"on_track_decrypted(track, drm, segment=None)\n
Called when a Track has finished decrypting.
Parameters:
Name Type Description Default track AnyTrack The Track object that was decrypted.
required drm DRM_T The DRM object it decrypted with.
required segment Optional[Segment] The HLS segment information that was decrypted.
None Source code in unshackle/core/service.py def on_track_decrypted(self, track: AnyTrack, drm: DRM_T, segment: Optional[m3u8.Segment] = None) -> None:\n \"\"\"\n Called when a Track has finished decrypting.\n\n Parameters:\n track: The Track object that was decrypted.\n drm: The DRM object it decrypted with.\n segment: The HLS segment information that was decrypted.\n \"\"\"\n
"},{"location":"reference/api/service/#unshackle.core.service.Service.on_track_repacked","title":"on_track_repacked","text":"on_track_repacked(track)\n
Called when a Track has finished repacking.
Parameters:
Name Type Description Default track AnyTrack The Track object that was repacked.
required Source code in unshackle/core/service.py def on_track_repacked(self, track: AnyTrack) -> None:\n \"\"\"\n Called when a Track has finished repacking.\n\n Parameters:\n track: The Track object that was repacked.\n \"\"\"\n
"},{"location":"reference/api/service/#unshackle.core.service.Service.on_track_multiplex","title":"on_track_multiplex","text":"on_track_multiplex(track)\n
Called when a Track is about to be Multiplexed into a Container.
Note: Right now only MKV containers are multiplexed but in the future this may also be called when multiplexing to other containers like MP4 via ffmpeg/mp4box.
Parameters:
Name Type Description Default track AnyTrack The Track object that was repacked.
required Source code in unshackle/core/service.py def on_track_multiplex(self, track: AnyTrack) -> None:\n \"\"\"\n Called when a Track is about to be Multiplexed into a Container.\n\n Note: Right now only MKV containers are multiplexed but in the future\n this may also be called when multiplexing to other containers like\n MP4 via ffmpeg/mp4box.\n\n Parameters:\n track: The Track object that was repacked.\n \"\"\"\n
"},{"location":"reference/api/service/#unshackle.core.service.sanitize_proxy_for_log","title":"sanitize_proxy_for_log","text":"sanitize_proxy_for_log(uri)\n
Sanitize a proxy URI for logs by redacting any embedded userinfo (username/password).
Examples:
- http://user:pass@host:8080 -> http://REDACTED@host:8080
- socks5h://user@host:1080 -> socks5h://REDACTED@host:1080
Source code in unshackle/core/service.py def sanitize_proxy_for_log(uri: Optional[str]) -> Optional[str]:\n \"\"\"\n Sanitize a proxy URI for logs by redacting any embedded userinfo (username/password).\n\n Examples:\n - http://user:pass@host:8080 -> http://REDACTED@host:8080\n - socks5h://user@host:1080 -> socks5h://REDACTED@host:1080\n \"\"\"\n if uri is None:\n return None\n if not isinstance(uri, str):\n return str(uri)\n if not uri:\n return uri\n\n try:\n parsed = urlparse(uri)\n\n # Handle schemeless proxies like \"user:pass@host:port\"\n if not parsed.scheme and not parsed.netloc and \"@\" in uri and \"://\" not in uri:\n # Parse as netloc using a dummy scheme, then strip scheme back out.\n dummy = urlparse(f\"http://{uri}\")\n netloc = dummy.netloc\n if \"@\" in netloc:\n netloc = f\"REDACTED@{netloc.split('@', 1)[1]}\"\n # urlparse(\"http://...\") sets path to \"\" for typical netloc-only strings; keep it just in case.\n return f\"{netloc}{dummy.path}\"\n\n netloc = parsed.netloc\n if \"@\" in netloc:\n netloc = f\"REDACTED@{netloc.split('@', 1)[1]}\"\n\n return urlunparse(parsed._replace(netloc=netloc))\n except Exception:\n if \"@\" in uri:\n return f\"REDACTED@{uri.split('@', 1)[1]}\"\n return uri\n
"},{"location":"reference/api/titles/","title":"Titles API","text":"Auto-generated reference for the title model: Movies, Series/Episodes, and Music/Albums/Songs. A service returns one of these collections from its title lookup. See Creating a Service.
"},{"location":"reference/api/titles/#unshackle.core.titles.Episode","title":"Episode","text":"Episode(\n id_,\n service,\n title,\n season,\n number,\n name=None,\n year=None,\n language=None,\n data=None,\n description=None,\n air_date=None,\n part=None,\n absolute=None,\n)\n
Bases: Title
"},{"location":"reference/api/titles/#unshackle.core.titles.Episode.matches_wanted","title":"matches_wanted","text":"matches_wanted(wanted)\n
Whether a parsed -w key set selects this episode.
A part-ful episode answers to both its base key and its part key, so -w s1e1 takes every part. A dated episode also answers to its ISO air date. ! keys are part-qualified exclusions resolved here.
"},{"location":"reference/api/titles/#unshackle.core.titles.Movie","title":"Movie","text":"Movie(\n id_,\n service,\n name,\n year=None,\n language=None,\n data=None,\n description=None,\n)\n
Bases: Title
"},{"location":"reference/api/titles/#unshackle.core.titles.Album","title":"Album","text":"Album(\n iterable=None,\n kind=\"album\",\n title=None,\n artist=None,\n year=None,\n total_tracks=None,\n total_discs=None,\n artwork_url=None,\n total_duration=None,\n owner=None,\n description=None,\n)\n
Bases: Music
Backward-compatible collection name for album-style music releases.
"},{"location":"reference/api/titles/#unshackle.core.titles.Music","title":"Music","text":"Music(\n iterable=None,\n kind=\"album\",\n title=None,\n artist=None,\n year=None,\n total_tracks=None,\n total_discs=None,\n artwork_url=None,\n total_duration=None,\n owner=None,\n description=None,\n)\n
Bases: SortedKeyList, ABC
A grouped music release, such as an album, EP, single, compilation, or playlist.
"},{"location":"reference/api/titles/#unshackle.core.titles.Song","title":"Song","text":"Song(\n id_,\n service,\n name,\n artist,\n album,\n track,\n disc,\n year,\n language=None,\n data=None,\n album_artist=None,\n release_type=\"album\",\n total_tracks=None,\n total_discs=None,\n genre=None,\n explicit=None,\n isrc=None,\n upc=None,\n copyright=None,\n label=None,\n lyrics=None,\n artwork_url=None,\n)\n
Bases: Title
"},{"location":"reference/api/titles/#unshackle.core.titles.remap_titles","title":"remap_titles","text":"remap_titles(titles, title_map)\n
Rewrite titles in-place using an exact-match title_map.
Some services name a title differently from how the user wants it stored, which can break library matching. title_map maps a source title string to the desired output title. Episodes are matched on their title (the show name), Movies and Songs on their name. Returns the same collection for convenient chaining.
"},{"location":"reference/api/tracks/","title":"Tracks API","text":"Auto-generated reference for the track model: Tracks, Track, and the media-specific subclasses. See Service Architecture for how these fit together.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Attachment","title":"Attachment","text":"Attachment(\n path=None,\n url=None,\n name=None,\n mime_type=None,\n description=None,\n session=None,\n)\n
Create a new Attachment.
If providing a path, the file must already exist. If providing a URL, download() fetches the file during the download phase. Either path or url must be provided.
If name is not provided it will use the file name (without extension). If mime_type is not provided, it will try to guess it.
Parameters:
Name Type Description Default path Union[Path, str, None] Path to an existing file.
None url Optional[str] URL to download the attachment from.
None name Optional[str] Name of the attachment.
None mime_type Optional[str] MIME type of the attachment.
None description Optional[str] Description of the attachment.
None session Optional[AnySession] Optional requests session to use for downloading.
None"},{"location":"reference/api/tracks/#unshackle.core.tracks.Attachment.id","title":"id property","text":"id\n
Compute an ID from the attachment data.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Attachment.download","title":"download","text":"download(session=None, *, no_proxy_download=False)\n
Download a URL-backed attachment to the temp directory.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Attachment.to_dict","title":"to_dict","text":"to_dict()\n
Serialise a URL-backed attachment for export/import.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Attachment.from_url","title":"from_url classmethod","text":"from_url(\n url,\n name=None,\n mime_type=None,\n description=None,\n session=None,\n)\n
Create an attachment from a URL.
Parameters:
Name Type Description Default url str URL to download the attachment from.
required name Optional[str] Name of the attachment.
None mime_type Optional[str] MIME type of the attachment.
None description Optional[str] Description of the attachment.
None session Optional[AnySession] Optional requests session to use for downloading.
None Returns:
Name Type Description Attachment 'Attachment' A new attachment instance.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Audio","title":"Audio","text":"Audio(\n *args,\n codec=None,\n bitrate=None,\n channels=None,\n joc=None,\n descriptive=False,\n **kwargs,\n)\n
Bases: Track
Create a new Audio track object.
Parameters:
Name Type Description Default codec Optional[Codec] An Audio.Codec enum representing the audio codec. If not specified, MediaInfo will be used to retrieve the codec once the track has been downloaded.
None bitrate Optional[Union[str, int, float]] A number or float representing the average bandwidth in bytes/s. Float values are rounded up to the nearest integer.
None channels Optional[Union[str, int, float]] A number, float, or string representing the number of audio channels. Strings may represent numbers or floats. Expanded layouts like 7.1.1 is not supported. All numbers and strings will be cast to float.
None joc Optional[int] The number of Joint-Object-Coding Channels/Objects in the audio stream.
None descriptive Union[bool, int] Mark this audio as being descriptive audio for the blind.
False Note: If codec, bitrate, channels, or joc is not specified some checks may be skipped or assume a value. Specifying as much information as possible is highly recommended.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Audio.atmos","title":"atmos property","text":"atmos\n
Return True if the audio track contains Dolby Atmos.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Audio.parse_channels","title":"parse_channels staticmethod","text":"parse_channels(channels)\n
Converts a Channel string to a float representing audio channel count and layout. E.g. \"3\" -> \"3.0\", \"2.1\" -> \"2.1\", \".1\" -> \"0.1\".
This does not validate channel strings as genuine channel counts or valid layouts. It does not convert the value to assume a sub speaker channel layout, e.g. 5.1->6.0. It also does not support expanded surround sound channel layout strings like 7.1.2.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Audio.get_track_name","title":"get_track_name","text":"get_track_name()\n
Return the base Track Name.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Chapter","title":"Chapter","text":"Chapter(timestamp, name=None)\n
Create a new Chapter with a Timestamp and optional name.
The timestamp may be in the following formats: - \"HH:MM:SS\" string, e.g., 25:05:23. - \"HH:MM:SS.mss\" string, e.g., 25:05:23.120. - a timecode integer in milliseconds, e.g., 90323120 is 25:05:23.120. - a timecode float in seconds, e.g., 90323.12 is 25:05:23.120.
If you have a timecode integer in seconds, just multiply it by 1000. If you have a timecode float in milliseconds (no decimal value), just convert it to an integer.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Chapter.id","title":"id property","text":"id\n
Compute an ID from the Chapter data.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Chapter.named","title":"named property","text":"named\n
Check if Chapter is named.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Chapters","title":"Chapters","text":"Chapters(iterable=None)\n
Bases: SortedKeyList, ABC
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Chapters.id","title":"id property","text":"id\n
Compute an ID from the Chapter data.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Chapters.loads","title":"loads classmethod","text":"loads(data)\n
Load chapter data from a string.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Chapters.load","title":"load classmethod","text":"load(path)\n
Load chapter data from a file.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Chapters.dumps","title":"dumps","text":"dumps(fallback_name='')\n
Return chapter data in OGM-based Simple Chapter format. https://mkvtoolnix.download/doc/mkvmerge.html#mkvmerge.chapters.simple
Parameters:
Name Type Description Default fallback_name str Name used for Chapters without a Name set.
'' The fallback name can use the following variables in f-string style:
- {i}: The Chapter number starting at 1. E.g.,
\"Chapter {i}\": \"Chapter 1\", \"Intro\", \"Chapter 3\". - {j}: A number starting at 1 that increments any time a Chapter has no name. E.g.,
\"Chapter {j}\": \"Chapter 1\", \"Intro\", \"Chapter 2\".
These are formatted with f-strings, directives are supported. For example, \"Chapter {i:02}\" will result in \"Chapter 01\".
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Chapters.dump","title":"dump","text":"dump(path, *args, **kwargs)\n
Write chapter data in OGM-based Simple Chapter format to a file.
Parameters:
Name Type Description Default path Union[Path, str] The file path to write the Chapter data to, overwriting any existing data.
required See Chapters.dumps for more parameter documentation.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Hybrid","title":"Hybrid","text":"Hybrid(videos, source)\n
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Hybrid.log","title":"log instance-attribute","text":"log = logging.getLogger('hybrid')\n
Takes the Dolby Vision and HDR10(+) streams out of the VideoTracks. It will then attempt to inject the Dolby Vision metadata layer to the HDR10(+) stream. If no DV track is available but HDR10+ is present, it will convert HDR10+ to DV.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Hybrid.level_5","title":"level_5","text":"level_5(input_video)\n
Generate Level 5 active area metadata via crop detection on the HDR10 stream.
This resolves mismatches where DV has no black bars but HDR10 does (or vice versa) by telling the display the correct active area.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Hybrid.sanitize_l6","title":"sanitize_l6 staticmethod","text":"sanitize_l6(max_mdl, min_mdl, max_cll, max_fall)\n
Clamp static L6 values to a valid relationship.
MaxCLL must not exceed the mastering-display peak (some HDR10+ sources ship MaxCLL 10000 on a 1000-nit master), and MaxFALL must not exceed MaxCLL. A value of 0 means \"unknown\" and is preserved as-is.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Hybrid.level_6","title":"level_6","text":"level_6()\n
Edit RPU Level 6 values using the static L6 luminance data from the RPU.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Hybrid.extract_hdr10plus","title":"extract_hdr10plus","text":"extract_hdr10plus(_video)\n
Extract HDR10+ metadata from the video stream
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Hybrid.convert_hdr10plus_to_dv","title":"convert_hdr10plus_to_dv","text":"convert_hdr10plus_to_dv()\n
Convert HDR10+ metadata to Dolby Vision RPU
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle","title":"Subtitle","text":"Subtitle(\n *args,\n codec=None,\n cc=False,\n sdh=False,\n forced=False,\n **kwargs,\n)\n
Bases: Track
Create a new Subtitle track object.
Parameters:
Name Type Description Default codec Optional[Codec] A Subtitle.Codec enum representing the subtitle format. If not specified, MediaInfo will be used to retrieve the format once the track has been downloaded.
None cc bool Closed Caption. - Intended as if you couldn't hear the audio at all. - Can have Sound as well as Dialogue, but doesn't have to. - Original source would be from an EIA-CC encoded stream. Typically all upper-case characters. Indicators of it being CC without knowing original source: - Extracted with CCExtractor, or - >>> (or similar) being used at the start of some or all lines, or - All text is uppercase or at least the majority, or - Subtitles are Scrolling-text style (one line appears, oldest line then disappears). Just because you downloaded it as a SRT or VTT or such, doesn't mean it isn't from an EIA-CC stream. And I wouldn't take the streaming services (CC) as gospel either as they tend to get it wrong too.
False sdh bool Deaf or Hard-of-Hearing. Also known as HOH in the UK (EU?). - Intended as if you couldn't hear the audio at all. - MUST have Sound as well as Dialogue to be considered SDH. - It has no \"syntax\" or \"format\" but is not transmitted using archaic forms like EIA-CC streams, would be intended for transmission via SubRip (SRT), WebVTT (VTT), TTML, etc. If you can see important audio/sound transcriptions and not just dialogue and it doesn't have the indicators of CC, then it's most likely SDH. If it doesn't have important audio/sounds transcriptions it might just be regular subtitling (you wouldn't mark as CC or SDH). This would be the case for most translation subtitles. Like Anime for example.
False forced bool Typically used if there's important information at some point in time like watching Dubbed content and an important Sign or Letter is shown or someone talking in a different language. Forced tracks are recommended by the Matroska Spec to be played if the player's current playback audio language matches a subtitle marked as \"forced\". However, that doesn't mean every player works like this but there is no other way to reliably work with Forced subtitles where multiple forced subtitles may be in the output file. Just know what to expect with \"forced\" subtitles.
False Note: If codec is not specified some checks may be skipped or assume a value. Specifying as much information as possible is highly recommended.
Information on Subtitle Types https://bit.ly/2Oe4fLC (3PlayMedia Blog on SUB vs CC vs SDH). However, I wouldn't pay much attention to the claims about SDH needing to be in the original source language. It's logically not true.
CC == Closed Captions. Source: Basically every site. SDH = Subtitles for the Deaf or Hard-of-Hearing. Source: Basically every site. HOH = Exact same as SDH. Is a term used in the UK. Source: https://bit.ly/2PGJatz (ICO UK)
More in-depth information, examples, and stuff to look for can be found in the Parameter explanation list above.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.get_track_name","title":"get_track_name","text":"get_track_name()\n
Return the base Track Name.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.strip_webvtt_timestamp_map","title":"strip_webvtt_timestamp_map staticmethod","text":"strip_webvtt_timestamp_map(text)\n
Remove X-TIMESTAMP-MAP header lines (RFC 8216 \u00a73.5, HLS only).
Cue times are already absolute, but SubtitleEdit reads the header and offsets every cue by it during conversion.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.sanitize_webvtt_timestamps","title":"sanitize_webvtt_timestamps staticmethod","text":"sanitize_webvtt_timestamps(text)\n
Fix invalid timestamps in WebVTT files, particularly negative timestamps.
Parameters:
Name Type Description Default text str The WebVTT content as string
required Returns:
Type Description str Sanitized WebVTT content
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.has_webvtt_cue_identifiers","title":"has_webvtt_cue_identifiers staticmethod","text":"has_webvtt_cue_identifiers(text)\n
Check if WebVTT content has cue identifiers that need removal.
Parameters:
Name Type Description Default text str The WebVTT content as string
required Returns:
Type Description bool True if cue identifiers are detected, False otherwise
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.sanitize_webvtt_cue_identifiers","title":"sanitize_webvtt_cue_identifiers staticmethod","text":"sanitize_webvtt_cue_identifiers(text)\n
Remove WebVTT cue identifiers that can confuse subtitle parsers.
Some services use cue identifiers like \"Q0\", \"Q1\", etc. that appear on their own line before the timing line. These can be incorrectly parsed as part of the previous cue's text content by some parsers (like pysubs2).
Parameters:
Name Type Description Default text str The WebVTT content as string
required Returns:
Type Description str Sanitized WebVTT content with cue identifiers removed
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.has_overlapping_webvtt_cues","title":"has_overlapping_webvtt_cues staticmethod","text":"has_overlapping_webvtt_cues(text)\n
Check if WebVTT content has overlapping cues that need merging.
Detects cues with start times within 50ms of each other and the same end time, which indicates multi-line subtitles split into separate cues.
Parameters:
Name Type Description Default text str The WebVTT content as string
required Returns:
Type Description bool True if overlapping cues are detected, False otherwise
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.merge_overlapping_webvtt_cues","title":"merge_overlapping_webvtt_cues staticmethod","text":"merge_overlapping_webvtt_cues(text)\n
Merge WebVTT cues that have overlapping/near-identical times but different line positions.
Some services use separate cues for each line of a multi-line subtitle, with slightly different start times (1ms apart) and different line: positions. This merges them into single cues with proper line ordering based on the line: position (lower percentage = higher on screen = first line).
Parameters:
Name Type Description Default text str The WebVTT content as string
required Returns:
Type Description str WebVTT content with overlapping cues merged
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.sanitize_webvtt","title":"sanitize_webvtt staticmethod","text":"sanitize_webvtt(text)\n
More thorough sanitization of WebVTT files to handle multiple potential issues.
Parameters:
Name Type Description Default text str The WebVTT content as string
required Returns:
Type Description str Sanitized WebVTT content
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.convert","title":"convert","text":"convert(codec, *, forced=False)\n
Convert this Subtitle to another format.
Backend selection is data-driven (see tracks/subtitle_convert.py): the best available backend that supports source->target is used, falling back through the capability chain on failure. The backend can be pinned via the conversion_method config key (auto | subby | pysubs2 | subtitleedit | pycaption), or nudged per-service via preferred_conversion_method; an explicit config value always wins.
forced marks an explicit user request (--sub-format). Lossy downconverts of styled formats (SSA/ASS -> SRT) are skipped unless forced is True.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.extract_fonts","title":"extract_fonts staticmethod","text":"extract_fonts(text)\n
Font names referenced by an ASS/SSA subtitle.
Covers both sources that need attaching for correct rendering: - the Fontname column of every Style: line in [V4+ Styles]/[V4 Styles] (column located from the section's Format: line, not assumed by index), and - inline \\fn font overrides inside Dialogue override blocks.
Leading @ (vertical-writing prefix) is stripped and names are de-duplicated case-insensitively, preferring a mixed-case spelling over an all-lowercase one.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.sanitize_broken_webvtt","title":"sanitize_broken_webvtt staticmethod","text":"sanitize_broken_webvtt(text)\n
Remove or fix corrupted WebVTT lines, particularly those with invalid timestamps.
Parameters:
Name Type Description Default text str The WebVTT content as string
required Returns:
Type Description str Sanitized WebVTT content with corrupted lines removed
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.space_webvtt_headers","title":"space_webvtt_headers staticmethod","text":"space_webvtt_headers(data)\n
Space out the WEBVTT Headers from Captions.
Segmented VTT when merged may have the WEBVTT headers part of the next caption as they were not separated far enough from the previous caption and ended up being considered as caption text rather than the header for the next segment.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.merge_same_cues","title":"merge_same_cues staticmethod","text":"merge_same_cues(caption_set)\n
Merge captions with the same timecodes and text as one in-place.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.filter_unwanted_cues","title":"filter_unwanted_cues staticmethod","text":"filter_unwanted_cues(caption_set)\n
Filter out subtitle cues containing only \u00a0 or whitespace.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.merge_segmented_wvtt","title":"merge_segmented_wvtt staticmethod","text":"merge_segmented_wvtt(data, period_start=0.0)\n
Convert Segmented DASH WebVTT cues into a pycaption Caption List. Also returns an ISO 639-2 alpha-3 language code if available.
Code ported originally by xhlove to Python from shaka-player. Has since been improved upon by rlaphoenix using pymp4 and pycaption functions.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.strip_hearing_impaired","title":"strip_hearing_impaired","text":"strip_hearing_impaired()\n
Strip captions for hearing impaired (SDH).
The SDH stripping method is determined by the 'sdh_method' setting in config: - 'auto' (default): Tries subby first, then SubtitleEdit, then filter-subs - 'subby': Uses subby's SDHStripper - 'subtitleedit': Uses SubtitleEdit when available - 'filter-subs': Uses subtitle-filter library
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.reverse_rtl","title":"reverse_rtl","text":"reverse_rtl()\n
Reverse RTL (Right to Left) Start/End on Captions. This can be used to fix the positioning of sentence-ending characters.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.DownloadContext","title":"DownloadContext dataclass","text":"DownloadContext(\n save_path,\n save_dir,\n progress,\n session=None,\n proxy=None,\n max_workers=None,\n license_widevine=None,\n cdm=None,\n)\n
Shared arguments passed to each manifest's download_track.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.DownloadContext.ensure_session","title":"ensure_session","text":"ensure_session()\n
Return the session, or a new Session if none was set.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Track","title":"Track","text":"Track(\n url,\n language,\n is_original_lang=False,\n descriptor=URL,\n needs_repack=False,\n name=None,\n drm=None,\n edition=None,\n session=None,\n downloader=None,\n downloader_args=None,\n from_file=None,\n data=None,\n id_=None,\n extra=None,\n)\n
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Track.data","title":"data property writable","text":"data\n
Arbitrary track data dictionary.
A defaultdict is used with a dict as the factory for easier nested saving and safer exists-checks.
Reserved keys:
- \"hls\" used by the HLS class.
- playlist: m3u8.model.Playlist - The primary track information.
- media: m3u8.model.Media - The audio/subtitle track information.
- segment_durations: list[int] - A list of each segment's duration.
- \"dash\" used by the DASH class.
- manifest: lxml.ElementTree - DASH MPD manifest.
- period: lxml.Element - The period of this track.
- adaptation_set: lxml.Element - The adaptation set of this track.
- representation: lxml.Element - The representation of this track.
- timescale: int - The timescale of the track's segments.
- segment_durations: list[int] - A list of each segment's duration.
You should not add, change, or remove any data within reserved keys. You may use their data but do note that the values of them may change or be removed at any point.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Track.download","title":"download","text":"download(\n session,\n prepare_drm,\n max_workers=None,\n progress=None,\n *,\n cdm=None,\n no_proxy_download=False,\n)\n
Download and optionally Decrypt this Track.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Track.move","title":"move","text":"move(target)\n
Move the Track's file from current location, to target location. This will overwrite anything at the target path.
Raises:
Type Description TypeError If the target argument is not the expected type.
ValueError If track has no file to move, or the target does not exist.
OSError If the file somehow failed to move.
Returns the new location of the track.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Track.to_dict","title":"to_dict","text":"to_dict()\n
Serialise the track for export/import (identity/URL/descriptor/language).
DRM is not serialised here; the export writer attaches the licensed DRM + keys. Subclasses add their own codec/quality fields.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Track.base_kwargs_from_dict","title":"base_kwargs_from_dict staticmethod","text":"base_kwargs_from_dict(data)\n
Build the shared Track constructor kwargs from a to_dict() payload.
DRM is not reconstructed here \u2014 to_dict does not serialise it, and the import flow attaches the licensed DRM + content keys separately.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Track.from_dict","title":"from_dict classmethod","text":"from_dict(data)\n
Reconstruct the correct Track subclass from a to_dict() payload.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Track.get_track_name","title":"get_track_name","text":"get_track_name()\n
Get the Track Name.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Track.get_drm_for_cdm","title":"get_drm_for_cdm","text":"get_drm_for_cdm(cdm)\n
Return the DRM matching the provided CDM, if available.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Track.get_key_id","title":"get_key_id","text":"get_key_id(init_data=None, *args, **kwargs)\n
Probe the DRM encryption Key ID (KID) for this specific track.
It currently supports finding the Key ID by probing the track's stream with ffprobe for enc_key_id data, as well as for mp4 tenc (Track Encryption) boxes.
It explicitly ignores PSSH information like the PSSH box, as the box is likely to contain multiple Key IDs that may or may not be for this specific track.
To retrieve the initialization segment, this method calls :meth:get_init_segment with the positional and keyword arguments. The return value of get_init_segment is then used to determine the Key ID.
Returns:
Type Description Optional[UUID] The Key ID as a UUID object, or None if the Key ID could not be determined.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Track.load_drm_if_needed","title":"load_drm_if_needed","text":"load_drm_if_needed(service=None)\n
Load DRM information for this track if it was deferred during parsing.
Parameters:
Name Type Description Default service Service | None Service instance that can fetch track-specific DRM info
None Returns:
Type Description bool True if DRM was loaded or already present, False if failed
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Track.load_drm_from_playlist","title":"load_drm_from_playlist","text":"load_drm_from_playlist()\n
Fallback method to load DRM by fetching this track's individual playlist.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Track.get_init_segment","title":"get_init_segment","text":"get_init_segment(\n maximum_size=20000,\n url=None,\n byte_range=None,\n session=None,\n)\n
Get the Track's Initial Segment Data Stream.
HLS and DASH tracks must explicitly provide a URL to the init segment or file. Providing the byte-range for the init segment is recommended where possible.
If byte_range is not set, it will make a HEAD request and check the size of the file. If the size could not be determined, it will download up to the first 20KB only, which should contain the entirety of the init segment. You may override this by changing the maximum_size.
The default maximum_size of 20000 (20KB) is a tried-and-tested value that seems to work well across the board.
Parameters:
Name Type Description Default maximum_size int Size to assume as the content length if byte-range is not used, the content size could not be determined, or the content size is larger than it. A value of 20000 (20KB) or higher is recommended.
20000 url Optional[str] Explicit init map or file URL to probe from.
None byte_range Optional[str] Range of bytes to download from the explicit or implicit URL.
None session Optional[Session] Session context, e.g., authorization and headers.
None"},{"location":"reference/api/tracks/#unshackle.core.tracks.Track.repackage","title":"repackage","text":"repackage(bsf_v=None)\n
Remux the track with ffmpeg -c copy.
When bsf_v is given it is folded into the same pass as -bsf:v (used to normalize video VUI colour metadata without a second full-file remux). Repackaging is mandatory; the bitstream filter is best-effort. If the combined run fails (and it isn't the AAC-retry case) it is retried once without bsf_v so the remux still succeeds. Returns True if the requested bsf_v was applied (always False when bsf_v is None, since nothing was requested).
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Tracks","title":"Tracks","text":"Tracks(*args, manifest_url=None)\n
Video, Audio, Subtitle, Chapter, and Attachment Track Store. It provides convenience functions for listing, sorting, and selecting tracks.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Tracks.exists","title":"exists","text":"exists(by_id=None, by_url=None)\n
Check if a track already exists by various methods.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Tracks.add","title":"add","text":"add(tracks, warn_only=False)\n
Add a provided track to its appropriate array and ensuring it's not a duplicate.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Tracks.sort_videos","title":"sort_videos","text":"sort_videos(by_language=None, exact_match=False)\n
Sort video tracks by resolution then bitrate, and optionally language.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Tracks.sort_audio","title":"sort_audio","text":"sort_audio(\n by_language=None, codec_priority=None, exact_match=False\n)\n
Sort audio tracks by bitrate, codec priority, Atmos, descriptive, and optionally language.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Tracks.sort_subtitles","title":"sort_subtitles","text":"sort_subtitles(\n by_language=None,\n type_priority=None,\n group_by=None,\n exact_match=False,\n)\n
Sort subtitle tracks by various track attributes to a common P2P standard. You may optionally provide a sequence of languages to prioritize to the top.
Section Order - by_language groups prioritized to top, and ascending alphabetically
- then rest ascending alphabetically after the prioritized groups (Each section ascending alphabetically, but separated)
Type Order - Forced
- Normal
- Hard of Hearing (SDH/CC) (Least to most captions expected in the subtitle)
type_priority overrides the Type Order with an explicit ranking of \"forced\", \"normal\", and \"sdh\" (cc counts as sdh); unlisted types fall to the end.
group_by sets the major key. \"type\" (default) keeps every forced track together, then every normal, then every SDH, each block ascending by language. \"language\" groups by language instead, so Finnish sits next to Finnish SDH, with the Type Order applied inside each language.
exact_match makes a by_language entry sort only its own tag. By default \"en\" also sorts \"en-US\" and \"en-GB\".
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Tracks.filter","title":"filter","text":"filter(predicate)\n
Return a new Tracks with tracks filtered by predicate, preserving metadata.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Tracks.merge_video_selections","title":"merge_video_selections staticmethod","text":"merge_video_selections(*groups)\n
Concatenate video selections, dropping duplicates (by track id, order-preserving).
A DV track can be chosen as both the hybrid ingredient (lowest) and an explicit deliverable; without dedup the same track would be muxed/downloaded twice.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Tracks.partition_hybrid_videos","title":"partition_hybrid_videos staticmethod","text":"partition_hybrid_videos(videos, non_hybrid_ranges)\n
Split videos into hybrid-ingredient candidates and the standalone-deliverable pool.
HDR10/HDR10+/DV tracks are hybrid ingredients; they only enter the standalone pool when their range was explicitly requested alongside HYBRID, so e.g. -r HYBRID muxes only the hybrid while -r HYBRID,HDR10P also delivers HDR10+.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Tracks.flag_hybrid_ingredients","title":"flag_hybrid_ingredients staticmethod","text":"flag_hybrid_ingredients(\n hybrid_selected, non_hybrid_selected\n)\n
Mark tracks selected only as hybrid ingredients so the standalone mux loop skips them.
A track that was also picked as an explicit deliverable (same track in both selections) stays unflagged and is muxed standalone alongside the hybrid.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Tracks.mux","title":"mux","text":"mux(\n title,\n delete=True,\n progress=None,\n audio_expected=True,\n title_language=None,\n skip_subtitles=False,\n output_path=None,\n)\n
Multiplex all the Tracks into a Matroska Container file.
Parameters:
Name Type Description Default title str Set the Matroska Container file title. Usually displayed in players instead of the filename if set.
required delete bool Delete all track files after multiplexing.
True progress Optional[partial] Update a rich progress bar via completed=.... This must be the progress object's update() func, pre-set with task id via functools.partial.
None audio_expected bool Whether audio is expected in the output. Used to determine if embedded audio metadata should be added.
True title_language Optional[Language] The title's intended language. Used to select the best video track for audio metadata when multiple video tracks exist.
None skip_subtitles bool Skip muxing subtitle tracks into the container.
False output_path Optional[Path] Explicit destination for the muxed container. When None (default) the path is derived from the first track, so callers muxing several track groups that share a video list must pass distinct paths to avoid clobbering each other.
None"},{"location":"reference/api/tracks/#unshackle.core.tracks.Video","title":"Video","text":"Video(\n *args,\n codec=None,\n range_=None,\n bitrate=None,\n width=None,\n height=None,\n fps=None,\n scan_type=None,\n closed_captions=None,\n dv_compatible_bitstream=False,\n **kwargs,\n)\n
Bases: Track
Create a new Video track object.
Parameters:
Name Type Description Default codec Optional[Codec] A Video.Codec enum representing the video codec. If not specified, MediaInfo will be used to retrieve the codec once the track has been downloaded.
None range_ Optional[Range] A Video.Range enum representing the video color range. Defaults to SDR if not specified.
None bitrate Optional[Union[str, int, float]] A number or float representing the average bandwidth in bytes/s. Float values are rounded up to the nearest integer.
None width Optional[int] The horizontal resolution of the video.
None height Optional[int] The vertical resolution of the video.
None fps Optional[Union[str, int, float]] A number, float, or string representing the frames/s of the video. Strings may represent numbers, floats, or a fraction (num/den). All strings will be cast to either a number or float.
None Note: If codec, bitrate, width, height, or fps is not specified some checks may be skipped or assume a value. Specifying as much information as possible is highly recommended.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Video.Range","title":"Range","text":" Bases: str, Enum
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Video.Range.from_cicp","title":"from_cicp staticmethod","text":"from_cicp(primaries, transfer, matrix)\n
Convert CICP (Coding-Independent Code Points) values to Video Range.
CICP is defined in ITU-T H.273 and ISO/IEC 23091-2 for signaling video color properties independently of the compression codec. These values are used across AVC (H.264), HEVC (H.265), VVC, AV1, and other modern codecs.
The enum values (Primaries, Transfer, Matrix) match the official specifications: - ITU-T H.273: Coding-independent code points for video signal type identification - ISO/IEC 23091-2: Information technology \u2014 Coding-independent code points \u2014 Part 2: Video - H.264 Table E-3 (Colour Primaries) and Table E-4 (Transfer Characteristics) - H.265 Table E.3 and E.4 (identical to H.264)
Note: Value 0 = \"Reserved\" and Value 2 = \"Unspecified\" per specification. While both effectively mean \"unknown\" in practice, the distinction matters for spec compliance. Value 2 was added based on user feedback (GitHub issue) and verified against FFmpeg's AVColorPrimaries/AVColorTransferCharacteristic enums.
Sources: - https://www.itu.int/rec/T-REC-H.273 - https://www.itu.int/rec/T-REC-H.Sup19-202104-I - https://github.com/FFmpeg/FFmpeg/blob/master/libavutil/pixfmt.h
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Video.change_color_range","title":"change_color_range","text":"change_color_range(range_)\n
Change the Video's Color Range to Limited (0) or Full (1).
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Video.vui_bsf","title":"vui_bsf","text":"vui_bsf()\n
Return the -bsf:v value that rewrites SPS VUI colour metadata to match self.range.
Returns None when no rewrite is needed: SDR/DV/HYBRID ranges, non-AVC/HEVC codecs, a missing file, or when the bitstream already ships the correct colour metadata. Otherwise returns a {h264,hevc}_metadata=... string suitable for ffmpeg -bsf:v.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Video.normalize_vui","title":"normalize_vui","text":"normalize_vui()\n
Rewrite SPS VUI colour metadata to match self.range.
Some services ship HDR10/HLG bitstreams with stale BT.709 VUI, which makes downstream tools mis-classify the file. The manifest-derived range is the source of truth. Skips SDR, DV, and HYBRID. Returns True if the bitstream was rewritten.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Video.ccextractor","title":"ccextractor","text":"ccextractor(track_id, out_path, language, original=False)\n
Return a TextTrack object representing CC track extracted by CCExtractor.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Video.extract_c608","title":"extract_c608","text":"extract_c608()\n
Extract Apple-Style c608 box (CEA-608) subtitle using ccextractor.
This isn't much more than a wrapper to the track.ccextractor function. All this does, is actually check if a c608 box exists and only if so does it actually call ccextractor.
Even though there is a possibility of more than one c608 box, only one can actually be extracted. Not only that but it's very possible this needs to be done before any decryption as the decryption may destroy some of the metadata.
Need a test file with more than one c608 box to add support for more than one CEA-608 extraction.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Video.remove_eia_cc","title":"remove_eia_cc","text":"remove_eia_cc()\n
Remove EIA-CC data from Bitstream while keeping SEI data.
This works by removing all NAL Unit's with the Type of 6 from the bistream and then re-adding SEI data (effectively a new NAL Unit with just the SEI data). Only bitstreams with x264 encoding information is currently supported due to the obscurity on the MDAT mp4 box structure. Therefore, we need to use hacky regex.
"},{"location":"reference/configuration/","title":"Configuration Reference","text":"Every key you can set in unshackle.yaml, with its name, type, default value, and behaviour, split into one page per area. A worked config is on the annotated example page.
Where the file lives, how unshackle discovers it, and how to edit it: The Configuration File.
How defaults work
Every key is optional except output_template, which dl requires. Without it, dl aborts with an error. If you omit any other key, unshackle uses the built-in default listed here. There is no schema validation: unknown keys are silently ignored (they never become settings), and the file is parsed with yaml.safe_load. An empty or missing file means every other key falls back to its default.
Two keys behave specially on load
curl_impersonate is a deprecated alias of network and emits a DeprecationWarning. scene_naming has been removed. If it is set to any value, unshackle exits immediately with an error telling you to use output_template instead.
See Deprecated & removed keys.
"},{"location":"reference/configuration/#quick-index","title":"Quick index","text":"Area Keys Directories directories, filenames Services & authentication services, credentials, firefox_cookies, remote_services, serve Download & processing dl, subtitle, audio, muxing, language_tags, dual_multi_mode Output & naming output_template, tag, tag_rules, tag_group_name, tag_imdb_tmdb, chapter_fallback_name, unicode_filenames DRM & CDM cdm, remote_cdm, decryption Network & proxy network, headers, proxy_providers Key vaults key_vaults, vault_timeout External API keys omdb_api_key, tmdb_api_key, tvdb_api_key, tvdb_pin, tvdb_order, simkl_client_id, metadata_providers, anilist_title_language, disable_metadata, decrypt_labs_api_key, ipinfo_api_key Caching & updates title_cache_enabled, title_cache_time, title_cache_max_retention, update_checks, update_check_interval Logging, privacy & debug redact_paths, debug, debug_keys, debug_requests, set_terminal_bg Deprecated & removed curl_impersonate, downloader, scene_naming"},{"location":"reference/configuration/directories/","title":"Directories","text":"The directories key is a dict mapping directory names to filesystem paths. Only the names listed below are honoured; a handful are protected and cannot be moved (attempts to override them are silently skipped). All defaults are computed relative to the installed package, and every user-settable path is passed through Path(...).expanduser(), so ~ works.
unshackle.yamldirectories:\n downloads: ~/Media/unshackle\n temp: /mnt/fast-scratch/unshackle-temp\n wvds: ~/.unshackle/WVDs\n prds: ~/.unshackle/PRDs\n
Key Type Default Overridable Purpose downloads path <repo>/downloads \u2705 Default output directory for finished files. temp path <repo>/temp \u2705 Temporary working files during download/decrypt/mux. cache path <data>/cache \u2705 Generic cache, title cache, and the update-check store. cookies path <data>/cookies \u2705 Per-service cookie files (and VPN cookie files). logs path <data>/logs \u2705 Log files. exports path <data>/exports \u2705 Export JSON files. wvds path <data>/WVDs \u2705 Widevine device files (.wvd). prds path <data>/PRDs \u2705 PlayReady device files (.prd). dcsl path <data>/DCSL \u2705 DCSL data. commands path unshackle/commands \u2705 CLI command modules. services list | path [unshackle/services] \u2705 Service search paths and/or remote repo specs (see below). vaults path unshackle/vaults \u2705 Vault backend modules. fonts path unshackle/fonts \u2705 Bundled fonts. user_configs path unshackle/ \u274c protected Where unshackle.yaml lives. data path unshackle/ \u274c protected Base for the data subdirectories above. core_dir path unshackle/core \u274c protected Package core. namespace_dir path unshackle/ \u274c protected Package root. app_dirs - AppDirs(\"unshackle\", False) \u274c protected Internal AppDirs instance. The services directory is special
services may be a list, and each entry can be either a local directory or a repository spec: a git URL (https://..., ssh://..., git@..., or anything ending in .git) or owner/repo shorthand. Repo specs are cloned and updated automatically; plain paths are used as-is. List order is priority: the first source to define a service tag wins.
directories:\n services:\n - you/your-services # cloned from GitHub\n - https://example.com/private.git@stable\n - ~/my-services # local, lowest priority\n
How git-backed service repos are handled
unshackle's use of git is read-only on the remote: it only ever performs a shallow clone, fetch, pull, and a local reset; nothing is ever pushed. Private repos are authenticated through your existing git credential helper, and unshackle stores no tokens of its own. Clones live under <first-local-services-dir>/_repos/<host>__<owner>__<repo>/ (or the bundled unshackle/services), and nothing is written to the cache directory. After the first clone unshackle re-pulls at most once every 24 h, so it does not touch the network on every run.
Automatic and manual refresh behave differently
The automatic 24 h-TTL refresh that happens during a normal dl/search run refuses to refresh and exits (naming the offending clone) if that clone has uncommitted changes to tracked files or unpushed local commits. Untracked files (new service folders, __pycache__) never block it. The manual unshackle util refresh-services command instead hard-resets the clone to upstream, discarding any local edits.
Read-only installs and reinstalls
If the services directory lives inside the installed package, a reinstall can delete the _repos clones. They are re-cloned on next use. On read-only installs you must point services at a writable path, or cloning will fail.
"},{"location":"reference/configuration/directories/#filenames","title":"Filenames","text":"The filenames key is a dict of templated file/name patterns. Each value is used verbatim (no path processing). Braced fields like {time} and {service} are filled in at runtime.
Key Type Default Notes log str \"unshackle_{name}_{time}.log\" Written under directories.logs. debug_log str \"unshackle_debug_{service}_{time}.jsonl\" Structured debug log, under directories.logs. config str \"config.yaml\" Per-service config file, under that service's directory. root_config str \"unshackle.yaml\" The main config filename itself. chapters str \"Chapters_{title}_{random}.txt\" Under directories.temp. subtitle str \"Subtitle_{id}_{language}.srt\" Under directories.temp."},{"location":"reference/configuration/download/","title":"Download & processing","text":""},{"location":"reference/configuration/download/#dl","title":"dl","text":" - Type:
dict \u00a0\u00b7\u00a0 Default: {}
Persistent defaults for the dl command. The block is used as Click's default_map, so any dl flag can be given a default here. See Downloading and the CLI Reference for what each flag does.
Key naming. A key is the flag's long name with dashes replaced by underscores (--best-available \u2192 best_available, --sub-format \u2192 sub_format). A few keys are named after the flag's internal destination rather than its visible name; set these exact keys:
Flag Config key -r / --range range --list list --tmdb tmdb_id --imdb imdb_id --anilist anilist_id -naa / --noatmos no_atmos -o / --output output_dir --cdm-only / --vaults-only cdm_only Precedence (highest first): an explicit CLI flag \u2192 a per-service services.<TAG>.dl override \u2192 this global dl: block \u2192 the built-in default. In other words, config only fills in options you did not set on the command line.
Common keys (a useful subset; every dl flag works):
Key Type Default Sets lang list [\"orig\"] Video/audio language(s); orig = original language. A - prefix excludes, e.g. [all, -es]. a_lang / v_lang list [] Audio- / video-only language override. A - prefix excludes. s_lang list [\"all\"] Subtitle language(s). A - prefix excludes, e.g. [all, -es]. quality list [] (best) Resolution(s), e.g. [1080]. vcodec list [] (any) Video codec(s), e.g. [H265]. acodec list [] (any) Audio codec(s), e.g. [EC3]. range list [\"SDR\"] Colour range(s): SDR, HLG, HDR10, HDR10P, DV, HYBRID. channels float (unset) Audio channels, e.g. 6 for 5.1. sub_format str (unset) Convert subtitles to this format (srt, vtt, original, ...). forced_subs bool false Include forced subtitle tracks. forced_s_lang list [] Forced subtitle language(s); implies forced_subs. A - prefix excludes. no_subs / no_audio / no_chapters bool false Skip that track type. downloads int 1 Tracks downloaded concurrently. workers int (downloader default) Threads per track. speed_limit str (unlimited) Total download speed cap across all downloads combined, e.g. 500k, 5M, 1.5G or plain bytes/sec. Values are bytes, not bits (5M = 5.0 MB/s). slow str (unset) Inter-title delay, e.g. \"20-40\". best_available bool false Fall back to best quality if the request is unavailable. proxy str (unset) Default proxy URI or 2-letter country. no_folder bool false Do not create a per-title folder. Global download defaultsdl:\n lang: [en]\n quality: [1080]\n range: [HDR10]\n vcodec: [H265]\n sub_format: srt\n downloads: 2\n
Language exclusions in YAML
The language keys accept the same - exclusion tokens as the command line. Give them as a list or as one comma-separated string.
dl:\n s_lang: [all, -es]\n a_lang: \"orig,-es\"\n
See Excluding languages for the rules.
Per-service overrides
A dl block nested under a service in services takes the same keys and applies to that service only.
dl:\n lang: [en]\nservices:\n EXAMPLE1:\n dl:\n lang: [en, ja]\n range: [DV]\n
serve uses these same keys
The serve block accepts most of the same dl option keys at its top level to set server-wide download defaults for every request it handles. Keys the API does not know are ignored there (speed_limit, for example; serve has its own global_speed_limit).
"},{"location":"reference/configuration/download/#subtitle","title":"subtitle","text":" - Type:
dict \u00a0\u00b7\u00a0 Default: {}
Subtitle handling: SDH stripping, format conversion, and whether subtitles are muxed in or written as sidecar files. See Subtitles for the full guide.
Sub-key Type Default Description strip_sdh bool true Strip SDH/CC cues (hearing-impaired annotations) into a clean track. sdh_method str \"auto\" SDH-stripping backend to use. preserve_formatting bool true Keep styling/positioning when converting or stripping. convert_before_strip bool true Convert to the working format before stripping SDH. conversion_method str \"auto\" Subtitle conversion backend. output_mode str \"mux\" mux embeds subtitles in the MKV; sidecar writes separate files; both writes sidecars and still muxes. sidecar_format str \"srt\" Format for sidecar files when output_mode is sidecar or both. type_priority list (unset) Ordered ranking of subtitle types (forced, normal, sdh; CC counts as SDH). Types you leave out fall to the end. group_by str \"type\" Which key groups the subtitle tracks. type puts all forced tracks together, then all normal, then all SDH. language keeps each language next to its own variants. language_priority list (unset) Languages to sort to the top, in the order you give. The rest keep the default order: original language first, then alphabetical. Nothing is removed. subtitle:\n strip_sdh: true\n output_mode: sidecar\n sidecar_format: srt\n
group_by track order
group_by Result type Danish, Finnish, French, \u2026 Danish SDH, Finnish SDH, French SDH language Danish, Danish SDH, Finnish, Finnish SDH, French, French SDH type_priority still sets the order of the variants, so with group_by: language it controls the order inside each language.
language_priority sorts languages, it does not select them
subtitle:\n group_by: language\n language_priority: [en, es, fr]\n
Result for an English title Default English, Arabic, Bulgarian, Spanish, French, Japanese With the list above English, Spanish, French, Arabic, Bulgarian, Japanese Nothing is removed, unlike -sl, which drops every subtitle language it does not name.
Use orig in the list for the title's original language. A base tag also covers its regional forms, so en sorts en-US and en-GB, but zh does not cover zh-Hant, which you must name. The sort ignores entries that match no track, and it ignores entries that are not valid language tags.
--exact-lang applies here too: with it, en sorts only en, and en-US falls to the alphabetical tail with the rest. Name each tag you want when you use that flag.
type_priority picks the default subtitle only with muxing.default_language
At mux time the default flag goes to every forced subtitle whose language matches the first audio track, so a full dialogue track never becomes default on its own. type_priority alone does not change that. Name a subtitle language in muxing.default_language and the first track in that language gets the flag instead, which is where type_priority decides which track that is:
muxing:\n default_language:\n subtitle: en\nsubtitle:\n type_priority: [normal, sdh, forced]\n
"},{"location":"reference/configuration/download/#audio","title":"audio","text":" - Type:
dict \u00a0\u00b7\u00a0 Default: {}
Audio handling options. The most notable sub-key is codec_priority, an ordered list of audio codecs used to break ties in track selection. language_priority is an ordered list of languages that sort to the top of the audio tracks.
audio:\n codec_priority:\n - EAC3\n - AC3\n language_priority:\n - orig\n - en\n
codec_priority is a soft priority: nothing is dropped
codec_priority outranks bitrate: any listed codec sorts above every unlisted one, and among listed codecs the list order decides. Bitrate order survives only within a single codec rank. Nothing is ever removed. Two higher-level rules still override it: Atmos tracks always take precedence over codec_priority, and audio-description tracks are always forced to the end regardless of it. When codec_priority is unset, tracks sort by bitrate alone, with those same Atmos/descriptive rules still applied.
"},{"location":"reference/configuration/download/#muxing","title":"muxing","text":" - Type:
dict \u00a0\u00b7\u00a0 Default: {}
Matroska (MKV) muxing options.
Sub-key Type Default Description set_title bool true Write a human-readable title into the MKV container. default_language dict {} Force which language is flagged default per track type, e.g. {audio: en, subtitle: en}. merge_audio bool true Merge all audio into one file. --split-audio on the CLI flips this off. merge_video bool false Merge video tracks that share height, range, and codec into one file, so only language varies inside a file. --merge-video on the CLI flips this on. muxing:\n set_title: true\n default_language:\n audio: en\n subtitle: en\n
"},{"location":"reference/configuration/download/#language_tags","title":"language_tags","text":" - Type:
dict \u00a0\u00b7\u00a0 Default: {}
Language-tag remapping and the rule engine behind the {lang_tag} filename variable. When a rules list is present, each rule is evaluated in order and the first match's tag is used. A rule may test:
Condition Meaning audio Matches against the audio track languages. subs_contain At least one subtitle language matches. subs_contain_all Every listed language must be present (scalar or list). language_tags:\n rules:\n - audio: [ja]\n subs_contain: [en]\n tag: \"SUBBED\"\n
"},{"location":"reference/configuration/download/#dual_multi_mode","title":"dual_multi_mode","text":" - Type:
str \u00a0\u00b7\u00a0 Default: strict
Rule set for the {dual}, {multi}, and {dubbed} filename variables.
strict (default)count (legacy) Variable Set when {dual} Exactly two audio languages and one is the title's original language. {multi} Three or more audio languages, even without the original. {dubbed} A single audio language that is not the title's original language (requires a known original). Variable Set when {dual} Exactly two audio languages (original language ignored). {multi} Three or more audio languages. {dubbed} Never set. dual_multi_mode: strict\n
Dialects count as one language
Regional variants of the same base language (e.g. en-US and en-GB) collapse to a single language in both modes, so they never trigger {dual} on their own.
"},{"location":"reference/configuration/drm/","title":"DRM & CDM","text":"These are the config keys. For concepts, device provisioning, and the wvd/prd commands see the DRM & CDM guide. Device files themselves live in directories.wvds (.wvd) and directories.prds (.prd).
"},{"location":"reference/configuration/drm/#cdm","title":"cdm","text":" - Type:
dict \u00a0\u00b7\u00a0 Default: {}
Maps a service tag to the CDM (Widevine/PlayReady device) to use, with a default fallback. Resolution is case-insensitive: a per-request API override wins, then the per-service entry, then default.
cdm:\n default: chromecdm_l3\n EXAMPLE2: android_l1\n
A value may also be a nested dict for advanced selection by quality, DRM system, or profile:
cdm:\n EXAMPLE1:\n \">=1080\": android_l1 # by track height\n \"<1080\": chromecdm_l3\n widevine: android_l1 # by DRM system\n playready: sl3000\n default: chromecdm_l3\n
Quality keys support 1080, >=1080, >720, <=576, <480 style comparisons; DRM keys are widevine / playready. Quality keys are matched first, but only when a video height is known; if the value is still a dict after that, widevine/playready are used when present, otherwise the credential profile name, then default.
The resolved name is looked up in remote_cdm by name first. If no entry matches, it is loaded as a local device file, in this order: <name>.prd in directories.prds, <name>.prd in directories.wvds, then <name>.wvd in directories.wvds.
"},{"location":"reference/configuration/drm/#remote_cdm","title":"remote_cdm","text":" - Type:
list[dict] \u00a0\u00b7\u00a0 Default: []
A list of remote CDM server definitions. Each entry is matched by its name (referenced from cdm), and its type selects the backend:
type Backend Key fields decrypt_labs Decrypt Labs KeyXtractor host, device_name, secret (fields) custom_api Fully YAML-configurable remote API host (required), device, auth, endpoints, request_mapping, response_mapping, caching, timeout (fields) (none), Device Type: PLAYREADY pyplayready RemoteCdm host, secret, device_name, security_level (default 3000) (none), otherwise pywidevine RemoteCdm host, secret, device_name, device_type, system_id, security_level (default 3000) remote_cdm:\n - name: keyxtractor\n type: decrypt_labs\n device_name: L1\n - name: my_wv_server\n host: https://cdm.example.com\n secret: s3cr3t\n device_name: android_l1\n device_type: ANDROID\n system_id: 26830\n security_level: 1\n
For the two pywidevine/pyplayready backends, field names are read case-insensitively in both styles: Device Type/device_type, System ID/system_id, Security Level/security_level, Host/host, Secret/secret, Device Name/device_name.
PlayReady host needs a /playready suffix
pyplayready's RemoteCdm treats host as a base URL and appends its own endpoint paths, so a PlayReady entry's host must include the trailing /playready segment (for example https://cdm.example.com/playready). Without it the server returns 404 rather than a configuration error.
"},{"location":"reference/configuration/drm/#remote-cdm-decrypt-labs","title":"decrypt_labs fields","text":"Field Default Notes host https://keyxtractor.decryptlabs.com device_name ChromeCDM ChromeCDM, L1, L2 (Widevine) or SL2, SL3 (PlayReady) secret from decrypt_labs_api_key Sent as the decrypt-labs-api-key header. An error is raised if neither is set. system_id 26830 (Widevine), 0 (PlayReady) security_level Widevine 3; PlayReady 2000 for SL2, else 3000 PlayReady mode is used when device_type is PLAYREADY or device_name starts with SL.
"},{"location":"reference/configuration/drm/#remote-cdm-custom-api","title":"custom_api fields","text":"Field Type Notes host str Required. Base URL of the API. device dict name, type (CHROME, ANDROID, PLAYREADY), system_id, security_level auth dict type (default header): header uses header_name (default Authorization) and key; bearer uses bearer_token or key; basic uses username and password. custom_headers is merged in for any type. endpoints dict get_request and decrypt_response, each {path, method, timeout} request_mapping dict Per endpoint: param_names, static_params, conditional_params, transforms, nested_params, exclude_params response_mapping dict Per endpoint: fields, transforms, response_types, success_conditions, error_fields, key_fields caching dict enabled, use_vaults, check_cached_first legacy dict Legacy mode options. timeout int Default 30. Request timeout in seconds. Transforms cover base64/hex/JSON encoding and kid:key parsing. PlayReady mode is used when device.type is PLAYREADY or the device name is SL2/SL3.
remote_cdm:\n - name: my_custom\n type: custom_api\n host: https://cdm.example.com\n device:\n name: ChromeCDM\n type: CHROME\n system_id: 26830\n security_level: 3\n auth:\n type: bearer\n key: your-token\n endpoints:\n get_request:\n path: /get-challenge\n method: POST\n decrypt_response:\n path: /get-keys\n method: POST\n timeout: 30\n
"},{"location":"reference/configuration/drm/#decryption","title":"decryption","text":" - Type:
str or dict \u00a0\u00b7\u00a0 Default: \"shaka\"
Selects the tool used to physically decrypt CENC content. The value is compared case-insensitively; \"mp4decrypt\" selects Bento4's mp4decrypt, and anything else (including \"shaka\") selects shaka-packager. When the value is a dict, the service key is matched case-insensitively and the default entry is the fallback.
HLS AES-128 ClearKey is decrypted in-process and ignores this setting.
GlobalPer-service decryption: shaka\n
decryption:\n default: shaka\n EXAMPLE1: mp4decrypt\n
"},{"location":"reference/configuration/example/","title":"A complete annotated example","text":"The following config sets a handful of common keys. Every key not shown keeps its default.
unshackle.yamldirectories:\n downloads: ~/Media/unshackle\n temp: /mnt/scratch/unshackle\n\nnetwork:\n browser: Chrome131\n\nheaders:\n Accept-Language: en-US,en;q=0.9\n\ncdm:\n default: chromecdm_l3\n EXAMPLE2: android_l1\n\ndecryption: shaka\n\ncredentials:\n EXAMPLE2: user@example.com:hunter2\n\noutput_template:\n movies: \"{title}.{year}.{quality?}.{source}-{tag}\"\n series: \"{title}.{season_episode}.{quality?}.{source}-{tag}\"\n folder:\n movies: \"{title} ({year})\"\n series: \"{title} ({year})\"\n\ntag: MYGRP\n\nkey_vaults:\n - type: SQLite\n name: Local\n path: ~/.unshackle/keys.db\n\nproxy_providers:\n basic:\n us: http://user:pass@1.2.3.4:8080\n\nomdb_api_key: \"your-omdb-key\"\ntmdb_api_key: \"your-tmdb-key\"\ntvdb_api_key: \"your-tvdb-key\"\ntitle_cache_time: 3600\nredact_paths: true\n
For how these interact at runtime, continue to Downloading, DRM & CDM, and Output & naming.
"},{"location":"reference/configuration/misc/","title":"Other Settings","text":"Smaller configuration areas: external metadata API keys, caching and update checks, logging and debug flags, and deprecated keys.
"},{"location":"reference/configuration/misc/#external-api-keys","title":"External API keys","text":"All are unset by default and enable optional metadata/geolocation features. For which fields each metadata provider can actually answer with, see What each metadata provider supplies.
Key Type Default Description omdb_api_key str \"\" OMDb API key for IMDb metadata lookups; a fallback for when the keyless imdb provider has no answer. Free keys are available on the OMDb site. tmdb_api_key str \"\" TMDB API key for metadata enrichment and external-ID tags. simkl_client_id str \"\" SIMKL client ID for metadata lookups; an alternative/fallback source to TMDB. tvdb_api_key str \"\" TheTVDB v4 API key; a fallback source to TMDB, strongest on TV series. Also the source --enrich --tvdb reads. Free keys are available on the TVDB site. tvdb_pin str \"\" Subscriber PIN, only needed for a user-supported TVDB key. Leave empty for a normal project key. tvdb_order str \"\" Default for --tvdb-order: renumber episodes to a TVDB season order (official, dvd, absolute, alternate, regional). Empty keeps the numbering the service gives. metadata_providers list or map (see below) Metadata providers to use, in the order they are tried. anilist_title_language str english Which AniList title variant to use: english, romaji, or native. disable_metadata bool false Set to true to stop all automatic metadata lookups. An ID you give yourself is still looked up. decrypt_labs_api_key str \"\" Global Decrypt Labs API key (used by remote CDM / vault). ipinfo_api_key str \"\" ipinfo.io API key for IP/region lookups. How metadata_providers is applied
A lookup goes to each provider in turn and stops at the first good match, so the order decides which source wins. metadata_providers sets that order:
metadata_providers: [tvdb, tmdb, simkl]\n
Names not in the list are never used, so this both orders and filters. Unknown names are ignored with a warning. Leave it unset for the default order:
metadata_providers: [imdb, omdb, simkl, tmdb, tvdb, anilist]\n
Whatever the order, unshackle skips a provider that has no API key. imdb and anilist need no key, so they are always available. Available names are imdb, omdb, simkl, tmdb, tvdb, and anilist. The old name imdbapi still works and is read as imdb.
anilist is last in the default order. It only answers for anime and returns nothing for anything else, so it is harmless there. Move it earlier if you mostly download anime.
The order applies to a title search and to a direct lookup of an ID you give with --tmdb, --imdb, --tvdb, or --anilist. A supplied ID always goes to the providers that read that kind of ID, and it never falls back to a title search.
Which AniList title anilist_title_language picks
AniList holds up to three titles for a work: the English one, the romaji transliteration, and the native one. This option says which of them unshackle uses:
anilist_title_language: romaji\n
A work does not always have all three. When the one you asked for is unset on AniList's side, unshackle falls back in the order english \u2192 romaji \u2192 native. It takes the first one that is set, so you always get a title.
What disable_metadata turns off
With disable_metadata: true, unshackle never contacts a metadata provider on its own. The title search is dead, and that includes the keyless imdb provider. Titles, years and languages then come only from the service.
disable_metadata: true\n
An ID you give with --tmdb, --imdb, --tvdb, or --anilist is your permission to use that ID. That lookup still works. unshackle looks the ID up directly, cross references the other IDs from it, and --enrich still reads that source. Only the lookups you did not ask for are stopped.
The imdb provider needs no key
It replaces the earlier imdbapi provider, which read a third-party mirror (api.tiffara.com) and needed the imdb_api_enabled switch. That option is gone: delete it from your config, where it is now ignored.
A different order per title kind
Give a map instead of one list when the best source differs by kind. TVDB is strongest on series, TMDB on films. There are two kinds, tv and movie, and this example sets both of them and names every provider, so nothing is left to the default:
metadata_providers:\n tv: [tvdb, tmdb, simkl, imdb, omdb]\n movie: [tmdb, imdb, omdb, simkl, tvdb]\n
Each list is independent: it both orders and filters that kind on its own, so a provider you leave out of tv is still used for movie. Here TVDB is tried first for a series and last for a film.
You do not need to give both keys, or a full list in each. A kind you leave out uses the default order:
metadata_providers:\n tv: [tvdb, tmdb] # movies keep the default order\n
tv covers every episode and movie covers every film. unshackle only looks up metadata for those two kinds, so any other key, such as anime or music, is ignored.
ipinfo_api_key never touches your service sessions
The token is only ever sent to api.ipinfo.io as a per-request Authorization header; it is never attached to your service session, so it cannot leak to a streaming provider. Lookups degrade gracefully through a fallback chain: the authenticated Lite endpoint (higher rate limits, and ASN/continent data, but no city or region) \u2192 anonymous ipinfo \u2192 ip-api.in as a last resort.
"},{"location":"reference/configuration/misc/#caching-updates","title":"Caching & updates","text":""},{"location":"reference/configuration/misc/#title-cache","title":"Title cache","text":"unshackle caches fetched title metadata (region- and account-aware) to avoid repeat API calls.
Key Type Default Description title_cache_enabled bool true Master switch for the title cache. title_cache_time int (seconds) 1800 (30 min) Lifetime of fresh cached titles. title_cache_max_retention int (seconds) 86400 (24 h) Total retention window for a cached title, counted from when it was cached. Past title_cache_time the stale copy is only served as a fallback when a live fetch fails."},{"location":"reference/configuration/misc/#update-checks","title":"Update checks","text":"Key Type Default Description update_checks bool true Whether to check for new unshackle releases. update_check_interval int (hours) 24 Minimum hours between update checks. Note
Update checks query the GitHub releases API with a fixed 5-second timeout and cache the result in directories.cache/update_check.json.
"},{"location":"reference/configuration/misc/#logging-privacy-debug","title":"Logging, privacy & debug","text":"Key Type Default Description redact_paths bool true Mask install-root / venv / home prefixes in logged paths (<unshackle>, <venv>, ~). Set false to show full paths. debug bool false Global debug mode. debug_keys bool false Log decryption keys. debug_requests bool false Log HTTP requests. set_terminal_bg bool false Append the theme's background colour to output styling. Effect of set_terminal_bg
When on, the theme's background colour is appended to the foreground styles, which makes the full colour palette render correctly on terminals whose default background differs from unshackle's theme. When off, such terminals can render the ASCII-art banner and coloured output incorrectly.
Key exposure
With debug_keys on, decryption content keys are written to the structured debug log at INFO level during the DRM handshake. Treat debug, debug_keys, and the unshackle_debug_*.jsonl files as sensitive, and keep redact_paths enabled when sharing logs.
debug_keys affects only content-encryption keys (the content_key/key fields). Passwords, tokens, cookies, and session tokens are always redacted regardless of this setting, and KIDs, key counts, and other metadata are always logged either way.
"},{"location":"reference/configuration/misc/#deprecated-removed-keys","title":"Deprecated & removed keys","text":"Key Status Behaviour curl_impersonate Deprecated \u2192 use network Emits a DeprecationWarning; still honoured only if network is absent. downloader Deprecated Any value other than \"requests\" emits a DeprecationWarning; the value is otherwise ignored (the unified requests downloader is always used). scene_naming Removed If set to any non-null value, unshackle exits with an error directing you to configure output_template instead. imdb_api_enabled Removed Ignored. It gated the old imdbapi provider, which the keyless imdb provider replaces. Safe to delete from your config."},{"location":"reference/configuration/network/","title":"Network & proxy","text":""},{"location":"reference/configuration/network/#network","title":"network","text":" - Type:
dict \u00a0\u00b7\u00a0 Default: {}
TLS-fingerprinting and HTTP client settings for the rnet-based session.
Sub-key Type Default Description browser str \"Chrome131\" Impersonation preset. Must be an exact preset name (e.g. Chrome131, Firefox135, Edge101, Safari18, OkHttp4_12, OkHttp5, Opera118); unknown names raise an error. http1_only bool (unset) Force HTTP/1.1. http2_only bool (unset) Force HTTP/2. pool_max_idle_per_host int (unset) Connection-pool tuning. pool_max_size int (unset) Connection-pool tuning. tcp_nodelay bool (unset) Disable Nagle's algorithm. network:\n browser: Firefox135\n http2_only: true\n
Measured effect of http1_only
Both http1_only and http2_only are unset by default. In benchmarks, forcing HTTP/1.1 gained 30 to 50% on hosts that throttle per-connection or stall behind HTTP/2 flow control, and cost up to 27% on fast CDNs.
Renamed from curl_impersonate
The old curl_impersonate section is a deprecated alias. If you still use it, unshackle honours it (only when network is absent) but emits a DeprecationWarning. Rename it to network.
"},{"location":"reference/configuration/network/#headers","title":"headers","text":" - Type:
dict \u00a0\u00b7\u00a0 Default: {}
Default HTTP headers merged into every session unshackle creates.
headers:\n Accept-Language: en-US,en;q=0.9\n
Don't set Accept-Encoding (and similar) here
Compatibility headers such as Accept-Encoding are set by the rnet HTTP backend as part of its browser-impersonation profile; overriding them breaks the impersonation fingerprint. This block is for cross-service defaults only (for example Accept-Language, User-Agent); per-service headers belong in that service's own config.
"},{"location":"reference/configuration/network/#proxy_providers","title":"proxy_providers","text":" - Type:
dict \u00a0\u00b7\u00a0 Default: {}
Proxy/VPN provider configuration. Each sub-key names a provider, and its block is passed straight to that provider's constructor. See Proxies & VPN for the full provider guide. Recognised providers and their exit ports:
Provider Config key Credentials Proxy scheme/port Basic (static) basic country \u2192 URI(s) as specified NordVPN nordvpn service credentials (48 chars combined) https://...:89 Surfshark surfsharkvpn service credentials (48 chars combined) https://...:443 Windscribe windscribevpn service credentials https://...:443 ExpressVPN expressvpn device login (enable: true) / token cache https://cat:...@...:443 ProtonVPN protonvpn TV login or exported cookies https://...:4443 (or :443 Secure Core) Gluetun gluetun per-VPN keys/creds http://localhost:{port} (local Docker) Hola (none, auto) none http://...:{peer} proxy_providers:\n basic:\n us: http://user:pass@1.2.3.4:8080\n de:\n - http://a.example:8080\n - socks5://b.example:1080\n nordvpn:\n username: <service username>\n password: <service password>\n
Provider loading differs between CLI and REST server
The dl CLI loads all providers, including windscribevpn and gluetun. The REST API / remote-client path uses a separate resolver that does not load windscribevpn or gluetun. ExpressVPN and ProtonVPN also auto-load when their cached session exists, and Hola auto-loads whenever the hola-proxy binary is present.
"},{"location":"reference/configuration/output/","title":"Output & naming","text":"Keys that control the output filename, the per-title subfolder, release-group tagging, and filename character handling. For template cookbook material and worked naming examples, see the Output & naming guide.
"},{"location":"reference/configuration/output/#output_template","title":"output_template","text":" - Type:
dict \u00a0\u00b7\u00a0 Required (no usable default; dl exits with an error if this is missing)
Filename templates keyed by media kind, using {variable} placeholders. Recognised kinds are movies, series, and songs (and albums for folder templates). A trailing ? on a variable (e.g. {quality?}) marks it optional: if empty, it and one adjacent separator are dropped.
unshackle.yamloutput_template:\n movies: \"{title}.{year}.{quality?}.{source}-{tag}\"\n series: \"{title}.{season_episode}.{episode_name?}.{quality?}.{source}-{tag}\"\n songs: \"{track_number}. {title}\"\n folder: # special nested key, see below\n movies: \"{title} ({year})\"\n series: \"{title} ({year})\"\n
The nested folder sub-key controls output subfolders and is popped out of output_template:
- A dict sets per-kind folder templates (kinds:
movies, series, songs, albums). - A str sets a single folder template for everything.
unshackle validates templates on load and emits warnings for unknown variables, non-string values, filesystem-unsafe characters (< > : \" / \\ | ? *), empty templates, and unknown folder-kind keys.
All valid template variables title, year, season, episode, season_episode, episode_name, part, absolute, date, quality, resolution, source, tag, track_number, artist, album_artist, album, disc, track_total, disc_total, release_type, genre, explicit, isrc, upc, label, audio, audio_channels, audio_full, atmos, dual, multi, dubbed, video, hdr, hfr, edition, repack, lang_tag, title_type.
{part?} is already inside {season_episode}
part holds the bare part index of a split episode (2) and is empty on every other title. unshackle folds it into {episode} and {season_episode}, so the shipped series template names split episodes correctly with no edit. Putting {part?} next to either of them renders the part twice (S01E01.Part.2.2). Use the standalone variable only in a template that names neither {episode} nor {season_episode}, and note that it is always empty in a folder template, since folders do not carry the part.
{absolute} counts episodes across seasons
absolute holds the absolute episode number, zero-padded to 3 digits (007), and is empty on titles that have none. It is added to the name and never replaces {season} or {episode}. Any series with a TVDB absolute order can use it, anime most often. Services can supply it, and --enrich fills it in from TVDB. Use {absolute?} so the name stays clean when it is unknown.
"},{"location":"reference/configuration/output/#tagging-naming-keys","title":"Tagging & naming keys","text":"Key Type Default Description tag str \"\" Release-group tag. Fills the {tag} template variable and is written into MKV/audio tags. tag_group_name bool true Include the group name (tag) in the MKV Group tag. tag_imdb_tmdb bool true Write IMDb/TMDB/TVDB external-ID tags into the MKV (needs metadata providers). chapter_fallback_name str \"\" Fallback chapter-name template, e.g. \"Chapter {i:02}\". unicode_filenames bool false Allow Unicode in filenames; when false, names are ASCII-sanitised."},{"location":"reference/configuration/output/#tag_rules","title":"tag_rules","text":" - Type:
list \u00a0\u00b7\u00a0 Default: []
Conditionally replaces tag for a given download. Each rule pairs a when dict of conditions with the tag to use when they hold. Rules are evaluated in order and the first match wins; if no rule matches, the global tag is kept.
unshackle.yamltag_rules:\n - when: { title_type: movie, lang_tag: SUBBED }\n tag: SUBMOVIES\n - when: { quality: 2160p, hdr: [DV, DV.HDR, DV.HDR10P, HDR, HDR10P] }\n tag: UHDGROUP\n
Rule semantics:
- Every condition in
when must hold (they are AND-ed). - A value may be a scalar or a list; a list matches if any entry matches.
- Comparison is case-insensitive string equality.
- The keys of
when are the filename template variables that describe the release itself: title_type, quality, resolution, source, video, hdr, hfr, audio, audio_channels, audio_full, atmos, dual, multi, dubbed, edition, repack, and lang_tag. Only these are available. The title's own fields (title, year, season, episode, and so on) are added after the rules run and cannot be matched. - An unknown key in
when is a mistake: unshackle logs a warning that names the key and skips that rule. A rule without conditions or without a tag is also warned about and skipped.
Put the more specific rules first, because the first match ends the search.
Match hdr on the exact value
Matching is exact, and hdr is a composite label for Dolby Vision titles: a DV title with an HDR10 base layer gives DV.HDR, and with an HDR10+ base layer DV.HDR10P. Plain DV does not match those. List every variant you want, as the example above does. Note also that HDR10 gives HDR, not HDR10. The full set of values is HDR, HDR10P, DV, DV.HDR, DV.HDR10P, and HLG, as listed in the variable table.
"},{"location":"reference/configuration/output/#operators","title":"Operators","text":"A condition value may start with a comparison operator, which is applied to the rest of the value:
unshackle.yamltag_rules:\n - when: { resolution: \"<1080\" }\n tag: SDGROUP\n - when: { quality: \">=2160\" }\n tag: UHDGROUP\n - when: { video: \"!=H.265\" }\n tag: AVCGROUP\n
>, <, >=, <= compare numbers. unshackle reads the first number out of the actual value, so \">=2160\" matches a quality of 2160p. If the actual value has no number, the condition fails. =, ==, and != compare text, case-insensitively, when the operand is not a number. With a numeric operand they compare numbers like the operators above. A value with no number in it is never equal to a number, so \"!=2160\" holds for it and \"==2160\" does not. - An ordering operator with a text operand (for example
\">DV\") is a mistake: unshackle logs a warning and the condition fails. So does an operator with nothing after it (\">=\"). - A value with no operator keeps the plain equality behaviour described above.
Quote operator values in YAML
Two of these operators break an unquoted YAML value, and both stop the config from loading:
> opens a folded block scalar, so quality: >=2160 fails with a \"while scanning a block scalar\" error. ! starts a tag, so hdr: !=DV fails with a \"could not determine a constructor\" error.
Quote every operator value: quality: \">=2160\", resolution: \"<1080\", video: \"!=H.265\".
Keep a negation out of lists
A list matches if any entry matches, so two or more != entries in one list are always true. Give a negation as a single value, not as a list entry.
title_type is the media kind of the title: movie, series, or music. It is useful to give each kind its own group:
tag_rules:\n - when: { title_type: movie }\n tag: MOVIEGRP\n - when: { title_type: series }\n tag: TVGRP\n
The resolved tag applies to the {tag} filename variable only. The Group metadata tag written into MKV and music files always uses tag.
--tag wins
An explicit dl --tag on the command line disables the rules completely and uses the given tag.
"},{"location":"reference/configuration/services/","title":"Services & authentication","text":""},{"location":"reference/configuration/services/#services","title":"services","text":" - Type:
dict keyed by service tag \u00a0\u00b7\u00a0 Default: {}
Per-service configuration, keyed by the canonical service tag (for example EXAMPLE1, EXAMPLE2, EXAMPLE3). unshackle reads several well-known sub-keys out of each service's block:
proxy_map: remaps the region query you passed to -p/--proxy for this service. A key is that query, or \"provider:query\" when the flag also named a provider; the value is the query asked of the proxy provider instead. It has no effect when -p was given a full proxy URL. title_map: an exact-match rename map applied to fetched titles (source name \u2192 desired name), so a service that names a title differently from your library still matches. dl: per-service download defaults, using the same keys as the global dl block.
Individual services may read any additional keys they define. The merged result (the service's own config.yaml with this block layered on top) is handed to the service as self.config.
User- or device-specific values (API keys, account IDs, device attributes) belong in this block rather than in the service's own config.yaml, which holds shared defaults; see creating a service.
services:\n EXAMPLE1:\n proxy_map:\n us: us-nyc\n \"nordvpn:ca\": ca-toronto\n title_map:\n \"My Show: The Movie\": \"My Show Presents The Movie\"\n
"},{"location":"reference/configuration/services/#credentials","title":"credentials","text":" - Type:
dict keyed by service tag \u00a0\u00b7\u00a0 Default: {}
Per-service login credentials. Each value is a username:password[:extra] string, the same data as a [username, password] (or [username, password, extra]) list, or a dict of profile name to either of those. With the dict form, -p/--profile selects the entry, and the default key is used when no profile was given or the named profile is missing. These are parsed into Credential objects; the credential's SHA-1 is also used as an account hash for cache keys.
credentials:\n EXAMPLE2: user@example.com:hunter2\n EXAMPLE1:\n default: primary@example.com:pw1\n second: secondary@example.com:pw2\n
Cookies vs credentials
Cookies are stored as files under directories.cookies, not in this key. A service's authenticate() accepts cookies, credentials, or both.
"},{"location":"reference/configuration/services/#firefox_cookies","title":"firefox_cookies","text":" - Type:
dict keyed by service tag \u00a0\u00b7\u00a0 Default: {}
Settings for extracting cookies directly from a local Firefox profile. A service block is expected to provide hosts (a list of cookie hostnames; entries shorter than 3 characters are ignored to prevent dumping the whole cookie store) and an optional local_storage boolean to also pull matching entries from webappsstore.sqlite, which only services that keep auth tokens in localStorage rather than in HTTP cookies need. Extraction is read-only.
Firefox does not need to be closed
The extractor copies both cookies.sqlite and its WAL file into a 0700 temp directory, so writes Firefox has not yet flushed to the main DB are included. Extraction fails only if Firefox holds an exclusive write lock at the instant of the copy. The live profile is not modified.
Extraction falls back silently to file cookies
If extraction yields no cookies or fails for any reason, unshackle silently falls back to the normal file-based cookie path (cookies/<SERVICE>.txt or cookies/<SERVICE>/<profile>.txt), with no error reported.
"},{"location":"reference/configuration/services/#remote_services","title":"remote_services","text":" - Type:
dict \u00a0\u00b7\u00a0 Default: {}
Definitions of remote unshackle service servers, used by the --remote mode. Each entry is named by you (pick it with --server, or omit that flag when only one is configured) and gives the server's url (required), an optional api_key, an optional auth_headers list, an optional server_cdm boolean, and an optional services sub-dict of per-service local overrides such as title_map.
auth_headers lists extra header names to send the API key in, tried before the defaults X-Secret-Key and X-Api-Key, which are always appended as fallbacks. unshackle sends the first name; if the server answers 401, it retries the same request with the next name, and keeps the one that works for the rest of the session. Names you list keep your spelling and are not repeated in the fallbacks, so auth_headers: [\"Authorization\", \"x-secret-key\"] is tried as Authorization, x-secret-key, X-Api-Key.
In --remote mode unshackle turns the server's service list into synthetic CLI commands that run against it, falling back to the tags in that services sub-dict when the list cannot be fetched. See remote sessions for the full setup.
"},{"location":"reference/configuration/services/#serve","title":"serve","text":" - Type:
dict \u00a0\u00b7\u00a0 Default: {}
Configuration for the serve command (the built-in REST API server). The full server guide is the REST API section; these are the config keys.
Sub-key Type Default Description api_secret str (unset) Master secret accepted in the X-Secret-Key header. Required unless the server is started with --no-key. users dict {} Per-user API keys and their allowlists (see below). services list (unset) Global service allowlist. Omit to allow all. remote_only bool false Expose only the remote service session endpoints (health, services, search, session) and disable the rest of the REST API. session_ttl int (s) 300 Lifetime of an interactive auth session. max_sessions int 100 Maximum concurrent sessions. history_limit int 100 How many finished jobs to retain in history. compression_level int 1 gzip level for responses. global_speed_limit str (unlimited) Server-wide download speed cap, e.g. 10M, 1.5G or plain bytes/sec (same format as speed_limit). One shared budget across all concurrent jobs; per-job speed limits are ignored while it is set. cdm_overrides list or bool (unset) Allowed per-request CDM overrides: a list of permitted device names, or true for any. Unset rejects every override. allow_job_credentials bool false Permit clients to supply credentials per job. devices list (auto) Widevine devices offered; auto-filled from directories.wvds. playready_devices list (auto) PlayReady devices; auto-filled from directories.prds. Each entry under users is keyed by that user's API key and may set its own services, devices, and playready_devices allowlists, narrowing the global ones, plus an optional username used as the log label for that key (defaults to a truncated form of the key). A user with no playready_devices key gets no PlayReady access at all, not the global list.
server_cdm decides whether the server runs the CDM licensing for that key. It is false unless the entry sets it, so a remote client configured with server_cdm: true is told to license with its own local CDM instead, and a client that asks anyway gets a FORBIDDEN error. Because a download job always licenses with the server's CDM, a key without server_cdm also cannot submit or retry /api/download jobs. Keys that have no users entry, such as api_secret, keep server CDM access.
serve:\n api_secret: change-me\n remote_only: true\n services: [EXAMPLE1, EXAMPLE2]\n # server-wide download defaults (same keys as the `dl:` block)\n downloads: 3\n best_available: true\n users:\n a1b2c3d4: # this user's API key\n services: [EXAMPLE1] # may only use EXAMPLE1\n e5f6a7b8:\n server_cdm: true # this key may have the server do the licensing\n
dl keys inside serve
Most dl flag keys (downloads, workers, best_available, and so on) can be set directly inside serve, where they apply to every request the server handles. The server recognises a fixed subset of download parameters, so a few CLI-only flags are ignored here.
"},{"location":"reference/configuration/vaults/","title":"Key vaults","text":"The full guide is at Vaults. Two keys configure them.
"},{"location":"reference/configuration/vaults/#key_vaults","title":"key_vaults","text":" - Type:
list[dict] \u00a0\u00b7\u00a0 Default: []
An ordered list of key-vault backends. unshackle queries them in order and reuses content keys instead of re-licensing. Each entry needs a type (the backend module name) and a name, plus backend-specific keys.
type Purpose Required keys Notes SQLite Local SQLite database name, path Loaded critically; a failure aborts the run. MySQL Remote MySQL database name, host, database, username Extra keys (e.g. password, port) forwarded to pymysql. API RESTful JSON API name, uri, token Optional headers map is sent with every request. Honours vault_timeout. HTTP HTTP API with modes name, host, one of password/api_key, and username in query mode api_mode: query (default), json, decrypt_labs. Honours vault_timeout. key_vaults:\n - type: SQLite\n name: Local\n path: ~/.unshackle/keys.db\n - type: MySQL\n name: Team\n host: db.example.com\n database: keys\n username: unshackle\n password: hunter2\n no_push: false\n
Per-entry options
no_push: true makes a vault read-only (keys are fetched but never written to it). - A vault of
type: API whose name contains decrypt_labs auto-fills its token from decrypt_labs_api_key when not set inline. Vault type values are case-sensitive module names. - An all-zero content key (32 zeros) is treated as \"no key\" everywhere and is never stored.
"},{"location":"reference/configuration/vaults/#vault_timeout","title":"vault_timeout","text":" - Type:
float \u00a0\u00b7\u00a0 Default: 10.0
Timeout in seconds for vault operations. Injected automatically into any backend whose constructor accepts a timeout parameter (a per-vault timeout still wins).
"}]}
\ No newline at end of file
+{"config":{"lang":["en"],"separator":"[\\s\\-]+","pipeline":["stopWordFilter"],"fields":{"title":{"boost":1000.0},"text":{"boost":1.0},"tags":{"boost":1000000.0}}},"docs":[{"location":"","title":"unshackle","text":"unshackle is a modular archival tool for movies, TV, and music. It fetches media from streaming manifests, handles the DRM, and muxes everything into clean, well-tagged files, all driven from a single command-line tool, or served over a REST API for automation.
It is a fork of Devine with DASH, HLS, and Smooth Streaming (ISM) parsing, both Widevine and PlayReady DRM support, and a built-in HTTP server.
Use responsibly
unshackle is licensed under GPL-3.0. Do not use it for content you do not have the rights to. Keep the core free and open, keep service code private, and be kind.
"},{"location":"#what-it-does","title":"What it does","text":"unshackle does everything except talk to the streaming platform. You write that part as a service module (Creating a Service). Point unshackle at a title and it will:
- Fetch the title metadata (movies, full series, seasons, episodes, albums, tracks).
- Parse the streaming manifest: DASH, HLS, or Smooth Streaming (ISM).
- Select the tracks you asked for by resolution, codec, bitrate, range, language, and channel layout.
- Acquire decryption keys via a Widevine or PlayReady CDM, with optional key vaults so keys are reused instead of re-licensed.
- Decrypt, extract closed captions, repack, and mux the result into a Matroska (
.mkv) file with correct language, forced, and default flags. - Tag and name the output using configurable filename templates.
"},{"location":"#feature-highlights","title":"Feature highlights","text":"Area What you get Media types Movies, TV series/seasons/episodes, and music albums/tracks Manifests DASH, HLS, and Smooth Streaming (ISM) DRM Widevine and PlayReady, with ClearKey support Video AVC, HEVC, VC-1, VP8, VP9, AV1; SDR, HLG, HDR10, HDR10+, Dolby Vision, and Dolby Vision hybrid merging Audio AAC, AC-3, E-AC-3, AC-4, Opus, Vorbis, DTS, ALAC, FLAC; Dolby Atmos handling; per-channel-layout selection Subtitles SRT, WebVTT, ASS/SSA, TTML and more, with optional SDH stripping and format conversion Key vaults SQLite, MySQL, and HTTP/API vaults to store and share content keys Proxies Basic proxies plus NordVPN, ProtonVPN, Surfshark, Windscribe, ExpressVPN, Gluetun, and Hola providers Services A plugin API for the service modules you write. unshackle includes none Automation A REST API with a job queue, live progress, history, and remote-download sessions"},{"location":"#installation-at-a-glance","title":"Installation at a glance","text":"Install as a toolRun from a clone uv tool install git+https://github.com/unshackle-dl/unshackle.git\nunshackle --help\n
git clone https://github.com/unshackle-dl/unshackle.git\ncd unshackle\nuv run unshackle --help\n
External tools
unshackle shells out to FFmpeg, MKVToolNix, shaka-packager, Bento4, and dovi_tool for decryption, repacking, muxing, and Dolby Vision work. See Installation for the full list and recommended versions.
"},{"location":"#a-first-download","title":"A first download","text":"Every download is three parts: the dl command, a service tag, and the service's title argument (a URL, ID, or slug).
Download a 1080p title with English audiounshackle dl -q 1080 -l en EXAMPLE 81234567\n
The dl command carries all the quality, language, and output flags; the service tag (EXAMPLE above) selects which streaming service to talk to, and the value after it is passed to that service. See the Quickstart to go from a fresh install to your first file.
"},{"location":"#where-to-go-next","title":"Where to go next","text":" - Installation. Install unshackle and the external tools it depends on.
- Quickstart. Configure
unshackle.yaml and run your first download. - Downloading. The full
dl command guide: quality, codecs, languages, track selection, and output. - Configuration. Where
unshackle.yaml lives, the directory layout, and how to edit values. Full key list in the Configuration Reference. - REST API. Run the
serve HTTP server and drive downloads programmatically.
Getting help
Run unshackle --help or unshackle dl --help at any time. The community Discord is the fastest place to ask questions.
"},{"location":"dev/creating-a-service/","title":"Creating a Service","text":"A service is a plugin that teaches unshackle how to talk to one streaming provider: how to log in, how to look up a title, what video/audio/subtitle tracks exist, and how to license any DRM. Everything else (track selection, downloading, decryption, muxing, naming) is handled by the unshackle core.
Who this page is for
This is a developer guide. If you only want to use services, see downloading and configuration. Nothing here is required to run the CLI. Service code is deliberately kept separate from the core so it can live in its own private repository.
Unshackle ships one reference service, EXAMPLE, at unshackle/services/EXAMPLE/. It is a deliberately exhaustive, non-runnable showcase of every framework feature in a single file. Read it alongside this page; most snippets below are drawn from it.
"},{"location":"dev/creating-a-service/#how-a-service-is-discovered","title":"How a service is discovered","text":"At startup unshackle scans every path in the directories.services config key (a list). Each entry is either a local directory of services or a remote repo spec (a git URL or owner/repo shorthand, which is cloned automatically). The default is the bundled unshackle/services directory.
Within a services directory, every subfolder that contains an __init__.py is a service, and the folder name is the service tag:
services/\n\u2514\u2500\u2500 EXAMPLE/\n \u251c\u2500\u2500 __init__.py # required - defines the service class\n \u2514\u2500\u2500 config.yaml # optional - per-service configuration\n
Two rules the loader enforces:
- The class name must exactly match the folder/tag name. Folder
EXAMPLE must define class EXAMPLE. A mismatch raises a RuntimeError. - List order is priority. The first services path to define a given tag wins; later duplicates are shadowed. Put your own local overrides last to use them as fallbacks, or first to override a repo.
Once installed, a service is invoked as a subcommand of dl:
unshackle dl EXAMPLE 20914\nunshackle dl EX 20914 # via an ALIAS\n
Config lives next to the code
A service's own settings go in config.yaml inside the service folder (directories.services/<TAG>/config.yaml). This is separate from the global unshackle.yaml. Access it in code as self.config[...]. Never hardcode URLs, user agents, or certificates; put them here.
config.yaml is shared: keep secrets out of it
Treat config.yaml as a checked-in file of defaults: it belongs in the service repo and is shared by everyone who runs the service. Per-user secrets (API keys, device IDs, account tokens) must not live here. Those go in the user's own unshackle.yaml under services.<TAG>, which is merged into self.config at runtime. That checked-in-defaults / per-user-overrides split is exactly why every URL lives in config.yaml with {}/{name} placeholders while the secrets that fill them stay out of it. Both halves read back through the same self.config[...].
Split across files? Use relative imports
If your service grows beyond a single __init__.py, import your own modules relatively:
from .helpers import parse_manifest # correct\nfrom unshackle.services.TAG.helpers import parse_manifest # breaks from a repo\n
A service loaded from a remote repo lives outside the unshackle package, so the absolute unshackle.services.<TAG>.* path does not resolve and the import fails with no known parent package. Relative imports work both locally and from a cloned repo.
"},{"location":"dev/creating-a-service/#the-service-base-class","title":"The Service base class","text":"Every service subclasses unshackle.core.service.Service. The minimum is a Click cli entry point plus three abstract methods.
"},{"location":"dev/creating-a-service/#class-variables","title":"Class variables","text":"Declared at class level to configure framework behaviour:
Variable Type Purpose ALIASES tuple[str, ...] Extra tags that resolve to this service (e.g. (\"EX\", \"DOMAIN\")). Default (). GEOFENCE tuple[str, ...] IP region codes the service requires. Empty = no geofence. The first entry is treated as the main region for auto-proxy. VAULT_TAG Optional[str] Overrides the key-vault namespace so sibling services can share one vault. Default None (use the service's own tag). AUTH_METHODS Optional[tuple[str, ...]] Auth methods accepted (\"cookies\" / \"credentials\"). When None, the REST /services endpoint infers them from authenticate(). NO_SUBTITLES bool Set True on a service with no subtitle tracks to skip subtitle handling entirely. ANIME bool Set True when the catalog is anime, so metadata lookups prefer AniList. A title's own anime flag overrides it. DAILY bool Set True when the catalog is daily/date-based (talk shows, news, sports), so episodes are named by air date. A title's own daily flag overrides it. NO_SUBTITLES is a convention, not a base-class attribute
NO_SUBTITLES is checked by dl.py via hasattr and is deliberately not declared on the Service base class, so it only exists if your service defines it. Setting NO_SUBTITLES = True is what makes the pipeline skip subtitle work for services that never carry subtitles; leave it off otherwise.
GEOFENCE drives an automatic proxy: on startup, if you did not pass an explicit --proxy, the base class does a live IP check and, if your region is blocked, fetches a proxy to GEOFENCE[0] from your configured proxy providers.
class EXAMPLE(Service):\n ALIASES = (\"EX\", \"DOMAIN\")\n GEOFENCE = (\"US\", \"UK\")\n VAULT_TAG = \"DIFFERENT_NAME\"\n
"},{"location":"dev/creating-a-service/#what-service__init__-wires-up","title":"What Service.__init__ wires up","text":"When you call super().__init__(ctx), the base class sets up the attributes you will use throughout the service:
Attribute What it is self.config This service's config.yaml contents (a dict), or None if absent. self.log A logging.Logger named after your class. self.session A prepared requests.Session with config headers and a retry adapter (5 retries, backoff 0.2, retries on 429/500/502/503/504). self.cache A Cacher for arbitrary key/value data (tokens, etc.). self.title_cache A region/account-aware TitleCacher used by get_titles_cached. self.cache_dir config.directories.cache / <ClassName>. self.track_request A TrackRequest built from the CLI --vcodec / --range / --best-available flags. self.credential None until authenticate() runs. self.current_region The two-letter region resolved from proxy/IP. self.track_request is a small dataclass you can read and rewrite before fetching tracks:
@dataclass\nclass TrackRequest:\n codecs: list[Video.Codec] = [] # empty == accept any codec\n ranges: list[Video.Range] = [Video.Range.SDR]\n best_available: bool = False\n
Read the request, don't second-guess the user
Services may narrow track_request for hard technical constraints (e.g. \"HDR on this service is only delivered as HEVC\"), but must not filter the tracks they return by resolution, bitrate, or language. All selection is done by the core after get_tracks returns.
Override get_session() to defeat TLS-fingerprint bot detection
The base get_session() returns a plain requests.Session (config headers + retry adapter) and deliberately does not do TLS impersonation. When a provider blocks you on TLS fingerprint alone, override it to return session(\"Chrome131\") (any rnet.Impersonate preset). RnetSession is a drop-in requests.Session replacement (same .get/.post/cookie-jar interface), so nothing else in the service changes. If instead you need specific SSL cipher behaviour, mount unshackle.core.utils.sslciphers.SSLCiphers onto a plain requests.Session.
"},{"location":"dev/creating-a-service/#the-methods-you-implement","title":"The methods you implement","text":"Methods run in the order shown. Only the three abstract ones are mandatory.
graph LR\n A[cli] --> B[__init__] --> C[authenticate] --> D[get_titles] --> E[get_tracks] --> F[get_chapters]
"},{"location":"dev/creating-a-service/#cli-the-click-entry-point","title":"cli: the Click entry point","text":"A static method decorated as a Click command. It defines the CLI arguments and returns an instance of your service. Keep the command name= matching the tag.
name= is cosmetic: the directory name is what resolves the command
unshackle dl EXAMPLE is dispatched by the folder/class name, not by the name= in @click.command. A mismatched name= still loads and runs; it just produces confusing --help/usage output that names the wrong command. That confusing output is the reason to keep it in sync, not any dispatch requirement.
@staticmethod\n@click.command(name=\"EXAMPLE\", short_help=\"https://domain.com\", help=__doc__)\n@click.argument(\"title\", type=str)\n@click.option(\"-m\", \"--movie\", is_flag=True, default=False, help=\"Treat the title as a movie.\")\n@click.option(\"-d\", \"--device\", type=click.Choice([\"android_tv\", \"web\", \"ios\"]), default=\"android_tv\")\n@click.pass_context\ndef cli(ctx: click.Context, **kwargs: Any) -> EXAMPLE:\n return EXAMPLE(ctx, **kwargs)\n\ndef __init__(self, ctx: click.Context, title: str, movie: bool, device: str):\n self.title = title\n self.movie = movie\n self.device = device\n super().__init__(ctx) # wires up config, log, session, caches, track_request...\n self.cdm = ctx.obj.cdm\n
Your docstring becomes the CLI --help text (help=__doc__), so document the accepted URL/ID formats and options there.
CDM-aware behaviour
The resolved CDM is on ctx.obj.cdm (may be None for DRM-free runs). Use is_widevine_cdm() / is_playready_cdm() from unshackle.core.cdm.detect to classify it. These correctly handle local and remote CDMs, so never hand-roll an isinstance check. Services often pick a device profile or manifest endpoint based on which DRM the CDM speaks.
"},{"location":"dev/creating-a-service/#authenticate-optional-login","title":"authenticate: optional login","text":"Override to log in with cookies and/or credentials. Call super().authenticate() first: the base implementation loads the cookie jar into self.session.cookies and stores self.credential. Do all token fetching here; it runs before get_titles.
def authenticate(self, cookies=None, credential=None) -> None:\n super().authenticate(cookies, credential)\n self.session.headers.update({\"user-agent\": self.config[\"client\"][self.device][\"user_agent\"]})\n\n cache = self.cache.get(f\"tokens_{self.device}_{self.profile}\")\n if cache and cache.data.get(\"expires_in\", 0) > int(datetime.now().timestamp()):\n self.log.info(\" + Using cached tokens\")\n else:\n token = self.session.post(self.config[\"endpoints\"][\"login\"], data=body).json()\n cache.set(data=token, expiration=token.get(\"expires_in\"))\n self.token = cache.data[\"token\"]\n
Cookies come from files under directories.cookies; credentials from the credentials map in unshackle.yaml. For interactive prompts (OTP, captcha) use self.request_input(prompt), never a bare input(). Under serve mode there is no local terminal, so a bare input() would hang the server waiting on stdin that never arrives; request_input instead relays the prompt to the remote client through the attached InputBridge. Locally it routes through the shared Rich console (console.input) so the prompt renders correctly alongside progress and log output.
Key token caches by whatever varies per session
The cache key above is tokens_{device}_{profile} on purpose. A token cache must include every dimension that changes the token (device, profile, or credential.sha1), or two profiles/devices will share one cache entry and stomp each other's tokens. This isn't obvious from the caching API, which happily lets you use a single flat key.
Handle the credential carefully
Use the Credential only inside authenticate() to obtain tokens; don't stash it or the raw cookie jar elsewhere. The base class already caches identity for you.
"},{"location":"dev/creating-a-service/#get_titles-required","title":"get_titles: required","text":"Return a titles collection for the given ID. The return type depends on content:
Return Contains Use for Movies Movie objects Films Series Episode objects Shows (flatten seasons into episodes) Album Song objects Music Every title carries a language (its original recorded language) and an arbitrary data dict you can use to stash metadata for later methods. At least one title must be returned, or the ID is treated as invalid.
def get_titles(self) -> Titles_T:\n match = re.match(self.TITLE_RE, self.title)\n if not match:\n raise ValueError(\"Could not parse a title ID - is the URL/ID correct?\")\n metadata = self.session.get(\n self.config[\"endpoints\"][\"metadata\"].format(title_id=match.group(\"title_id\")),\n params={\"token\": self.token},\n ).json()\n original_lang = Language.find(metadata[\"languages\"][0])\n\n return Movies([\n Movie(\n id_=metadata[\"id\"],\n service=self.__class__,\n name=metadata[\"title\"],\n year=metadata.get(\"releaseYear\") or None,\n language=original_lang, # ORIGINAL audio language, not the -l preference\n data=metadata, # read later as title.data\n )\n ])\n
Common constructor fields:
MovieEpisodeSong Movie(id_, service, name, year=None, language=None, data=None, description=None)\n
Episode(id_, service, title, season, number, name=None, year=None,\n language=None, data=None, description=None, air_date=None,\n part=None)\n
Pass air_date for daily/sports content to name by date instead of SxxExx. Return a Series([...]) of episodes. Pass part only when the service splits one episode into several videos, see Split episodes.
Song(id_, service, name, artist, album, track, disc, year,\n language=None, data=None)\n
Return an Album([...]) of songs.
The ID must be unique and stable
id_ must be truthy and, if it has a length, at least 4 characters, to avoid clashes. Use the service's own stable IDs.
Title caching
You can call self.get_titles_cached() instead of get_titles() from the framework path to reuse cached results and gracefully fall back to stale data when the API errors. It also applies the per-service title_map config.
"},{"location":"dev/creating-a-service/#split-episodes","title":"Split episodes","text":"Some services split one logical episode into several separately playable videos, for example a variety show that ships episode 1 as Episode 1 (Part 1), (Part 2) and (Part 3). Episode.number stays an int. The optional part carries the index.
Episode(..., season=1, number=1, part=1, name=\"The Reckoning\")\nEpisode(..., season=1, number=1, part=2, name=\"The Reckoning\")\n
part counts from 1, contiguous. part=0 is rejected, and -w can only address parts up to 99. - Every part of one episode shares the same
season and number. - Each part is a separate
Episode with its own unique id_. - Unsplit episode: leave
part unset. Never pass part=1 for a whole episode. - Do not put the part in
name or number. Pass a clean name (or None) and set part. part is a split episode, not a split season. A service listing Season 1 Part 2 describes a group of episodes. Keep numbering those in sequence, the way the shipped services do.
part accepts an int or a digit string. A value that is not an integer raises TypeError. A value of 0 or less raises ValueError.
The framework never infers a part from the episode name
Rule 6 is the reason. Shipped services already use the word \"Part\" to mean a season part, so name-sniffing would misread every one of them. The part has to arrive structurally, as the part argument.
Clean the part out of the name
Episode discards any name that starts with Episode <number>, so a raw \"Episode 1 (Part 2): The Reckoning\" leaves you with no episode name at all. Strip the prefix before you pass it, or pass None.
Once part is set, the filename gains a part token (S01E01.Part.2, see Output & naming), -w S01E01.2 selects a single part while a bare -w S01E01 selects them all, and the --list-titles tree labels the episode 01.2. The REST API reports it as an extra part key on the serialized title, described in Endpoints.
Opt-in and additive
Leaving part unset costs you nothing: filenames, sort order, -w matching and the API JSON are identical to a plain episode, so a service that does not split episodes has nothing to adopt.
"},{"location":"dev/creating-a-service/#get_tracks-required","title":"get_tracks: required","text":"Given one title, return a Tracks object holding Video, Audio, and Subtitle tracks. In almost all cases you build these by parsing a manifest, not by hand.
The manifest parsers live in unshackle.core.manifests:
from unshackle.core.manifests import DASH, HLS, ISM\n
Each exposes from_url(url, session=...) (or from_text(text, url)) and then .to_tracks(language=...):
DASH (.mpd)HLS (.m3u8)ISM (Smooth Streaming) def get_tracks(self, title: Title_T) -> Tracks:\n return DASH.from_url(manifest_url, session=self.session) \\\n .to_tracks(language=title.language)\n
def get_tracks(self, title: Title_T) -> Tracks:\n return HLS.from_url(manifest_url, session=self.session) \\\n .to_tracks(language=title.language)\n
The URL must be a variant (master) playlist, not a media playlist.
def get_tracks(self, title: Title_T) -> Tracks:\n return ISM.from_url(manifest_url, session=self.session) \\\n .to_tracks(language=title.language)\n
Always pass language=
Pass the title's original language to to_tracks(language=...). For DASH and HLS this is required as a fallback. If a track's language can't be derived and no valid fallback was given, to_tracks raises ValueError. It's also what lets the parser flag is_original_lang on each track (via is_close_match), driving -l best/all selection and the filename language token.
Some services deliver a separate manifest per codec/range. The base class provides _get_tracks_for_variants(title, fetch_fn) to fan out over every codec\u00d7range in the TrackRequest, including HYBRID (fetch HDR10 + DV and merge) and --best-available skip-on-error handling:
def get_tracks(self, title: Title_T) -> Tracks:\n def _fetch_variant(title, codec, range_) -> Tracks:\n vcodec = \"H265\" if codec == Video.Codec.HEVC else \"H264\"\n return self._fetch_dash_manifest(title, vcodec=vcodec, range_=range_)\n return self._get_tracks_for_variants(title, _fetch_variant)\n
After parsing, you may correct track metadata the manifest gets wrong: stamp the real video.range, fix odd audio channel counts, mark descriptive audio, add hand-built subtitles or an Attachment (e.g. cover art). Do not filter for resolution/bitrate. See _fetch_dash_manifest in the EXAMPLE service for a thorough demonstration.
Flip HDR10 \u2192 HDR10+ by hand when you know the platform embeds it
HDR10+ is a bitstream (SEI) feature. The HLS parser intentionally does not sniff it from the stream, so a manifest that embeds HDR10+ SEI but labels the variant plain HDR10 will parse as HDR10. The label is the only signal the parser has, and it's wrong. Only the service author knows the platform embeds HDR10+, so it's on you to correct video.range to HDR10+ here for those services.
get_tracks is your only chance to capture license inputs
The license callbacks (get_widevine_license, etc.) fire much later, in the download/decrypt step, long after get_tracks has returned, and they receive only challenge, title, and track. Nothing else from the manifest response reaches them. So anything a license call needs (the license URL, dt-custom-data, a session token) has to be stashed now, during get_tracks, onto self.license_data or title.data. This is a lifecycle-timing consequence you can't infer from the callback signatures.
Provide the KID if you cheaply can
If you can obtain a track's Key ID (32-char hex) without downloading stream data, set track.kid; it speeds up the decryption/key-lookup path. And be sure encrypted tracks carry the correct drm so the core licenses them.
"},{"location":"dev/creating-a-service/#get_chapters-required","title":"get_chapters: required","text":"Return a Chapters object (0 or more Chapters). You don't need to number or sort them; Chapters does that automatically and inserts a 00:00:00.000 marker if missing. A Chapter timestamp accepts \"HH:MM:SS[.mmm]\", an int in milliseconds, or a float in seconds.
def get_chapters(self, title: Title_T) -> Chapters:\n chapters = Chapters()\n seen: set[int] = set()\n for ch in title.data.get(\"chapters\", []):\n chapter = Chapter(timestamp=ch[\"start\"], name=ch.get(\"name\"))\n if chapter.timestamp == 0 or chapter.timestamp in seen:\n continue # see the warning below\n seen.add(chapter.timestamp)\n chapters.add(chapter)\n return chapters\n
Skip markers at 0 and de-duplicate timestamps before add()
Chapters.add() auto-injects a Chapter(0) at 00:00:00.000 the first time you add any chapter, and it raises ValueError if a chapter already exists at an exact timestamp. So an API marker reported at 0 (an intro or recap that starts at the very beginning) collides with that auto-inserted opening chapter, and two markers sharing a timestamp collide with each other. Guard both cases: drop any marker at timestamp 0 and de-duplicate timestamps before adding, as above.
An unnamed chapter is the idiom for closing a named range
A Chapter with no name is perfectly valid. Inserting an unnamed marker is the standard way to close out a named range: e.g. add Chapter(name=\"Intro\") at the start and an unnamed Chapter where the intro ends, so the \"Intro\" label applies only to that span.
Don't invent chapter names
Never name chapters \"Chapter 1\" yourself. Leave unnamed markers unnamed; users who want generic names set chapter_fallback_name (e.g. \"Chapter {i:02}\") in their config.
"},{"location":"dev/creating-a-service/#handling-drm","title":"Handling DRM","text":"The core drives licensing. Your job is to answer the license challenges the CDM produces. Mark encrypted tracks with the right DRM in get_tracks (the manifest parsers do this automatically for PSSH/EXT-X-KEY found in the manifest), then implement the license callbacks you need. Each receives the challenge plus the current title and track:
Method For Returns get_widevine_service_certificate Widevine privacy mode The service certificate (bytes or base64 str), or None. get_widevine_license Widevine The raw license response (bytes or base64 str), unmodified. get_playready_license PlayReady The license response. Defaults to delegating to get_widevine_license. get_clearkey_license DASH org.w3.clearkey The JWK Set (dict/JSON/bytes), or None to let the framework POST the challenge to the manifest's Laurl. def get_widevine_license(self, *, challenge: bytes, title, track):\n response = self.session.post(\n self.license_data[\"url\"],\n data=challenge, # POST the challenge as-is\n headers={\"dt-custom-data\": self.license_data[\"data\"]},\n )\n response.raise_for_status()\n try:\n return response.json()[\"license\"] # some services wrap it in JSON\n except (ValueError, KeyError):\n return response.content # others return raw bytes\n
Return the license untouched
Do not base64-encode or decode the challenge or response. Pass them through verbatim. A malformed license request can get your CDM/device flagged, banned, or downgraded.
ClearKeyCENC (org.w3.clearkey) has three escalating integration levels
Pick the lowest level that works for the platform:
- The manifest carries a
<Laurl>: implement nothing. The framework POSTs the challenge to that URL itself. - A custom endpoint or extra headers are needed: override
get_clearkey_license to do the POST yourself. - Keys arrive obfuscated or through a bespoke channel: fetch and unwrap the key in
get_tracks and pre-populate drm.content_keys[kid] = key_hex on the track's ClearKeyCENC. The non-obvious payoff: when every KID is already keyed, the framework skips the license round-trip entirely.
Things you don't handle:
- HLS AES-128 (
ClearKey) and DRM-free content have no license callback. The key comes from the manifest and is applied directly. - Key vaults cache
KID:KEY pairs across runs, so repeat downloads skip the license round-trip entirely. - Decryption tooling (shaka-packager or mp4decrypt) and the choice of local vs remote CDM are user configuration, not service concerns.
See the DRM & CDM reference for the full picture.
"},{"location":"dev/creating-a-service/#optional-event-hooks","title":"Optional event hooks","text":"Override any of these to react to pipeline stages (all no-ops by default): on_segment_downloaded, on_track_downloaded, on_track_decrypted, on_track_repacked, on_track_multiplex. Also override search() to yield SearchResult objects for unshackle search.
def search(self) -> Generator[SearchResult, None, None]:\n results = self.session.get(self.config[\"endpoints\"][\"search\"],\n params={\"q\": self.title}).json()\n for r in results[\"entries\"]:\n yield SearchResult(id_=r[\"id\"], title=r[\"title\"],\n description=r.get(\"description\"), url=r.get(\"url\"))\n
"},{"location":"dev/creating-a-service/#minimal-service-skeleton","title":"Minimal service skeleton","text":"A complete, minimal single-manifest service. Save as services/MYSVC/__init__.py (class name MYSVC), with an optional services/MYSVC/config.yaml beside it.
services/MYSVC/__init__.pyfrom __future__ import annotations\n\nimport re\nfrom typing import Any, Optional\n\nimport click\n\nfrom unshackle.core.manifests import DASH\nfrom unshackle.core.service import Service\nfrom unshackle.core.titles import Movie, Movies, Title_T, Titles_T\nfrom unshackle.core.tracks import Chapters, Tracks\n\n\nclass MYSVC(Service):\n \"\"\"\n MyService - https://myservice.com\n\n \\b\n Usage: unshackle dl MYSVC <title-id-or-url>\n \"\"\"\n\n ALIASES = (\"MY\",)\n GEOFENCE = (\"US\",)\n TITLE_RE = r\"^(?:https?://myservice\\.com/watch/)?(?P<id>[\\w-]+)\"\n\n @staticmethod\n @click.command(name=\"MYSVC\", short_help=\"https://myservice.com\", help=__doc__)\n @click.argument(\"title\", type=str)\n @click.pass_context\n def cli(ctx: click.Context, **kwargs: Any) -> \"MYSVC\":\n return MYSVC(ctx, **kwargs)\n\n def __init__(self, ctx: click.Context, title: str):\n self.title = title\n super().__init__(ctx)\n\n def authenticate(self, cookies=None, credential=None) -> None:\n super().authenticate(cookies, credential)\n # obtain any tokens here, e.g. self.token = ...\n\n def get_titles(self) -> Titles_T:\n match = re.match(self.TITLE_RE, self.title)\n if not match:\n raise ValueError(\"Could not parse a title ID.\")\n meta = self.session.get(\n self.config[\"endpoints\"][\"metadata\"].format(id=match.group(\"id\"))\n ).json()\n return Movies([\n Movie(\n id_=meta[\"id\"],\n service=self.__class__,\n name=meta[\"title\"],\n year=meta.get(\"year\") or None,\n language=meta.get(\"originalLanguage\"),\n data=meta,\n )\n ])\n\n def get_tracks(self, title: Title_T) -> Tracks:\n playback = self.session.get(\n self.config[\"endpoints\"][\"playback\"].format(id=title.id)\n ).json()\n return DASH.from_url(\n url=playback[\"manifest_url\"], session=self.session\n ).to_tracks(language=title.language)\n\n def get_chapters(self, title: Title_T) -> Chapters:\n return Chapters()\n\n def get_widevine_license(self, *, challenge: bytes, title, track):\n res = self.session.post(self.config[\"endpoints\"][\"license\"], data=challenge)\n res.raise_for_status()\n return res.content\n
services/MYSVC/config.yamlendpoints:\n metadata: https://api.myservice.com/v1/metadata/{id}\n playback: https://api.myservice.com/v1/playback/{id}\n license: https://api.myservice.com/v1/license/widevine\n
Test it with:
unshackle dl MYSVC https://myservice.com/watch/abc123\n
"},{"location":"dev/creating-a-service/#checklist","title":"Checklist","text":" - Folder name, class name, and Click
name= all match the tag exactly. -
cli is a @staticmethod returning an instance; __init__ calls super().__init__(ctx). -
get_titles, get_tracks, get_chapters implemented; titles carry the original language. - Tracks come from a manifest parser with
to_tracks(language=title.language); no filtering by quality/language. - Encrypted tracks are marked with DRM; the license callbacks you need return responses unmodified.
- URLs, user agents, and certificates live in
config.yaml, read via self.config[...]. - Multi-file services import their own modules relatively (
from .helpers import x), so they work when loaded from a repo.
Read unshackle/services/EXAMPLE/ end to end; it annotates every feature touched above in one place.
"},{"location":"dev/service-architecture/","title":"Service Architecture","text":"This page is the conceptual map of the internal object model that a service author works against. It ties together the moving parts: the Service base class, the Title hierarchy a service returns, the Track model those titles carry, the manifest parsers that produce tracks, the DRM hooks that license and decrypt them, and the request/session objects wired up for you at construction time.
Who this page is for
This is a developer reference. If you only want to run downloads, you never touch these classes directly; see Downloading and the CLI reference instead. If you are writing or reading a service plugin, this page explains what each object is and how the pieces connect. For a guided, step-by-step build, see Creating a Service; for exact signatures, see the auto-generated Service API, Titles API, and Tracks API.
"},{"location":"dev/service-architecture/#the-shape-of-a-download","title":"The shape of a download","text":"A service is a translator between one streaming platform and unshackle's generic pipeline. It never downloads or decrypts anything itself; it just answers questions. The dl command drives the whole flow and only ever calls three required methods on your service, in order:
Service.get_titles \u2192 which movies / episodes / songs exist\n \u2502\nService.get_tracks \u2192 fetch a manifest for one title ...\n \u2502\nmanifest.to_tracks \u2192 ... parsed into Video / Audio / Subtitle tracks\n \u2502\nTrack.download \u2192 fetch + merge segments (core-driven)\n \u2502\nDRM license + decrypt \u2192 service license hooks \u2192 CDM \u2192 decrypt tool\n \u2502\nTracks.mux \u2192 mkvmerge \u2192 tagged .mkv\n
get_titles() returns a Titles collection: the movies, episodes, or songs the platform exposes for the requested ID. get_tracks(title) returns a Tracks object for a single chosen title. In practice this means fetching a manifest and handing it to a parser, which builds the Video/Audio/Subtitle objects. get_chapters(title) returns any chapter markers.
Everything after that (track selection, licensing, decryption, repacking, subtitle conversion, and muxing) is handled by the core using the objects your service returned. Your job is to produce accurate Title and Track objects; the framework does the rest.
"},{"location":"dev/service-architecture/#the-service-base-class","title":"The Service base class","text":"Every service subclasses unshackle.core.service.Service, an abstract base class. Two kinds of members matter: class variables that describe the service, and methods that the pipeline calls.
"},{"location":"dev/service-architecture/#class-variables","title":"Class variables","text":"Declared at the top of your service class, these are static descriptors:
Class variable Type Purpose ALIASES tuple[str, ...] Alternate tags the service answers to (resolved by Services.get_tag). GEOFENCE tuple[str, ...] ISO region codes the service requires; empty means no geofence. The first entry is treated as the \"main region\" for automatic proxy selection. VAULT_TAG Optional[str] Overrides the key-vault namespace; defaults to the service's own tag. AUTH_METHODS Optional[tuple[str, ...]] Accepted auth methods (\"cookies\" / \"credentials\"). When None, the REST /services endpoint infers them from authenticate(). ANIME bool The catalog is anime, so metadata lookups prefer AniList. A title's own anime flag overrides it. DAILY bool The catalog is daily/date-based, so episodes are named by air date. A title's own daily flag overrides it."},{"location":"dev/service-architecture/#required-methods","title":"Required methods","text":"These are abstract; a service will not load unless it implements all three:
get_titles() -> Titles_T: resolve the requested ID into a title collection. get_tracks(title) -> Tracks: build the track set for one title. get_chapters(title) -> Chapters: return chapter markers (may be empty).
"},{"location":"dev/service-architecture/#optional-methods-and-hooks","title":"Optional methods and hooks","text":" authenticate(cookies=None, credential=None): the base implementation loads a CookieJar into self.session.cookies and stores self.credential. Override it to perform a real login handshake. search(): raises NotImplementedError by default; implement it to support the search command. - DRM license hooks:
get_widevine_service_certificate, get_widevine_license, get_playready_license (delegates to Widevine by default), and get_clearkey_license (returns None by default). See DRM hooks below. request_input(prompt): prompts the user; routed through the serve-mode input bridge or the rich console. - Event callbacks:
on_segment_downloaded, on_track_downloaded, on_track_decrypted, on_track_repacked, on_track_multiplex let a service react to pipeline events (for example, MonaLisa decrypts each segment in on_segment_downloaded). resolve_server_keys(title): an escape hatch for services whose decryption keys are resolved server-side ahead of the normal per-track license flow. Unlike everything else here it is not declared on the Service base class at all: dl.py probes for it with hasattr and calls it (once, after track selection) only if your service defines it. Use it when keys must be fetched or injected in bulk before download, separate from the per-track license callbacks below.
"},{"location":"dev/service-architecture/#what-__init__-wires-up-for-you","title":"What __init__ wires up for you","text":"You rarely override __init__; the base constructor takes the Click context and sets up the objects your methods rely on:
Attribute What it is self.config The per-service config slice (see Request and session objects). self.log A logging.Logger named after your class. self.session A ready-to-use HTTP session (headers, retries, and proxy applied). self.cache A generic key/value Cacher namespaced to the service. self.title_cache A region- and account-aware TitleCacher. self.cache_dir config.directories.cache / <ServiceName>. self.credential The active Credential, or None until authenticated. self.track_request A TrackRequest describing the requested codecs, ranges, and best-available flag (see below). The constructor also resolves proxies: it reads --proxy / --proxy-query / --proxy-provider, consults the per-service proxy_map, and, if GEOFENCE is set and no explicit proxy was given, does a live IP check and auto-fetches a proxy to GEOFENCE[0] when it detects a geoblock. See Proxies and VPN.
TrackRequest
self.track_request is a small dataclass built from the CLI: codecs (from --vcodec, empty means \"accept any\"), ranges (from --range, default [Video.Range.SDR]), and best_available. Use it in get_tracks to decide which variants to fetch.
"},{"location":"dev/service-architecture/#titles-and-the-title-hierarchy","title":"Titles and the Title hierarchy","text":"get_titles() returns a title collection, one of the Titles_T types. Each collection holds one or more Title objects (Title_T). There are three parallel families, one per media kind:
Media kind Item (Title_T) Collection (Titles_T) Movies Movie Movies TV Episode Series Music Song Album, and Music (the base collection) All items subclass Title, which carries the shared identity: a unique id, the originating service class, an optional original language, arbitrary data, and a freshly-constructed self.tracks = Tracks() that get_tracks will fill. Collections are sorted lists, so titles stay in a stable order.
The unique ID is validated
Title.__init__ rejects a falsy id_, and rejects any ID shorter than 4 characters (\"The unique ID is not large enough, clash likely.\"). Pick a stable, sufficiently unique identifier from the platform.
"},{"location":"dev/service-architecture/#constructors-at-a-glance","title":"Constructors at a glance","text":"Each subclass adds its own required fields on top of id_ and service:
MovieEpisodeSong Movie(id_, service, name, year=None, language=None, data=None, description=None)\n
name is required; year is optional but must be a positive integer if given.
Episode(id_, service, title, season, number,\n name=None, year=None, language=None, data=None,\n description=None, air_date=None)\n
title is the show name; season and number are required (0 is allowed). An air_date is normalized to a date so the {date} naming token stays clean, and a placeholder episode name like \"Episode 4\" is discarded automatically.
Song(id_, service, name, artist, album, track, disc, year,\n language=None, data=None)\n
name, artist, album, track, disc, and year are all required and validated.
"},{"location":"dev/service-architecture/#title-remapping","title":"Title remapping","text":"After titles are fetched (and after any cache), the framework applies remap_titles(titles, title_map) using the per-service title_map config. This does an exact-string rename (Episodes are matched on their show title, Movies and Songs on their name), so a platform's odd naming can be normalized to what your library expects. Titles are also cache-wrapped via get_titles_cached(), which is region- and account-aware; see the TitleCacher notes.
The ordering here is deliberate on both sides. title_map is applied after the title cache so that editing the map takes effect immediately, without forcing a cache reset, and before metadata enrichment (--enrich) so that an explicit enrich override still wins over a mapped name.
"},{"location":"dev/service-architecture/#tracks-and-the-track-model","title":"Tracks and the Track model","text":"A Tracks object is the container get_tracks returns. It has typed buckets (videos, audio, subtitles, chapters, and attachments) plus a manifest_url. Iterating a Tracks yields only video, audio, and subtitle tracks (not chapters or attachments), and the canonical display/mux order is Video \u2192 Audio \u2192 Subtitle \u2192 Chapter \u2192 Attachment.
Track IDs must be deterministic
Tracks.add refuses duplicate IDs. Track IDs are CRC32-derived from the track's identity (URL, codec, language, bitrate) and must be stable across requests, never random. The manifest parsers already do this correctly; if you build tracks by hand, keep IDs deterministic or add will raise.
"},{"location":"dev/service-architecture/#the-base-track","title":"The base Track","text":"Track is the base for Video, Audio, and Subtitle. (Chapter, Chapters, and Attachment are standalone classes, not Track subclasses.) Every track knows how it is delivered, via Track.Descriptor:
Descriptor Delivery URL A single direct URL. HLS HTTP Live Streaming. DASH Dynamic Adaptive Streaming over HTTP. ISM Smooth Streaming. Key attributes include url (a string or list of strings), language (a langcodes.Language), drm (an optional iterable of DRM objects), needs_repack, and descriptor-specific state stashed in track.data[\"hls\"] / track.data[\"dash\"]. When name is omitted, a human-readable name is derived from the language automatically.
"},{"location":"dev/service-architecture/#video","title":"Video","text":"Video adds codec, range, bitrate, width, height, fps, scan_type, and closed-caption descriptors. The relevant enums:
Video.Codec: AVC (H.264), HEVC (H.265), VC1, VP8, VP9, AV1. Video.Range: SDR, HLG, HDR10, HDR10P (HDR10+), DV (Dolby Vision), and HYBRID. HYBRID is special: it tells the pipeline to select both an HDR10(+) base and a DV track and merge the DV RPU onto the HDR10 stream with dovi_tool (see Dolby Vision hybrid).
Bitrate units
Both Video and Audio store bitrate in bytes per second (rounded up), even though the string form displays kb/s.
"},{"location":"dev/service-architecture/#audio","title":"Audio","text":"Audio adds bitrate, channels, joc (Joint Object Coding, the Atmos indicator), and descriptive. Audio.Codec covers AAC, AC3 (DD), EC3 (DD+), AC4, OPUS, OGG (Vorbis), DTS, ALAC, and FLAC. The atmos property is True when joc is set (or when extra flags it).
"},{"location":"dev/service-architecture/#subtitle","title":"Subtitle","text":"Subtitle adds three mutually-constrained flags: cc (closed captions), sdh (subtitles for the deaf and hard-of-hearing), and forced. A track cannot be both cc and sdh, and forced cannot combine with either. Subtitle.Codec includes SubRip (SRT), WebVTT (VTT), SubStationAlpha/SubStationAlphav4 (SSA/ASS), TimedTextMarkupLang (TTML), SAMI, and the box-encapsulated DASH formats fTTML (STPP) and fVTT (WVTT). Subtitles receive post-download normalization (segment merging, WebVTT sanitization, format conversion, and optional SDH stripping), all documented for users in Subtitles.
"},{"location":"dev/service-architecture/#chapters-and-attachments","title":"Chapters and attachments","text":" Chapter(timestamp, name=None): a single marker; the timestamp accepts HH:MM:SS[.mmm], integer milliseconds, or float seconds. Chapters: a sorted collection that auto-inserts a 00:00:00.000 chapter and reads/writes OGM Simple format. Attachment: a font or image attached to the mux (e.g. fonts an ASS subtitle references), built from a local path or a url.
"},{"location":"dev/service-architecture/#selection-sorting-and-muxing","title":"Selection, sorting, and muxing","text":"Tracks is also the selection engine the dl command uses. It offers sort_videos / sort_audio / sort_subtitles (P2P-standard ordering), predicate-based select_* filters, by_resolutions, and by_language. When downloads finish, the same object multiplexes everything: Tracks.mux(title, ...) builds and runs an mkvmerge command, applying language, default-track, forced, and hearing-impaired flags, attaching chapters and fonts, and writing a .muxed.mkv (or .mka / .mks for audio- or subtitle-only sets). As a service author you never call mux yourself; you just return correct tracks.
"},{"location":"dev/service-architecture/#manifest-parsers","title":"Manifest parsers","text":"Most services do not build tracks by hand; they hand a manifest to one of three parsers in unshackle.core.manifests. Each parser has the same three-part contract:
Format Class Notes MPEG-DASH (.mpd) DASH Parses <MPD>; enumerates tracks from the first content period. HLS (.m3u8) HLS Requires a variant (master) playlist, not a media playlist. Smooth Streaming (.ism) ISM Synthesizes init segments from CodecPrivateData."},{"location":"dev/service-architecture/#the-parser-contract","title":"The parser contract","text":" - Construct:
DASH.from_url(url, session=...) or DASH.from_text(text, url) (and likewise for HLS / ISM). A url is always required, even for from_text, because relative segment paths are resolved against it. to_tracks(...): parses the manifest into a Tracks container of Video, Audio, and Subtitle objects, each carrying descriptor-specific state. download_track(...): a staticmethod the core calls to fetch, decrypt, and merge segments. You do not call it; you just return the tracks it will operate on.
A typical get_tracks therefore looks like fetching the manifest text with self.session and returning DASH.from_text(mpd, url).to_tracks(language=title.language).
Pass the original language
DASH and HLS to_tracks raise ValueError if a track's language cannot be derived and no valid fallback was supplied. Always pass the title's original recorded language. For HLS this fallback is used directly for the primary track, since HLS variants carry no language.
"},{"location":"dev/service-architecture/#segment-filtering-hooks","title":"Segment-filtering hooks","text":" - HLS: set
track.OnSegmentFilter(segment) to drop unwanted segments (ads/bumpers). - DASH: pass a
period_filter callable to to_tracks to drop whole periods. Caching-breadth periods some services emit and non-content segment types are dropped automatically.
"},{"location":"dev/service-architecture/#format-gotchas-worth-knowing","title":"Format gotchas worth knowing","text":" - DASH enumerates tracks from only the first content period, but downloads segments from all content periods matching the representation.
SegmentTemplate, SegmentList, and SegmentBase addressing and $Number%0Nd$ padding are handled. - HLS re-fetches the media playlist at download time, recovers
SUPPLEMENTAL-CODECS from raw text (python-m3u8 drops it) to detect Dolby Vision composite streams, and uses the media-sequence-number IV for AES-128 keys that omit an explicit IV. - ISM has no segment list and no init segments: segment URLs are synthesized from the
<c> fragment timeline, and a moov/ftyp init box is rebuilt from CodecPrivateData (supported for AVC, HEVC, DV-over-HEVC, AAC, EC-3, and TTML).
"},{"location":"dev/service-architecture/#drm-hooks","title":"DRM hooks","text":"If a track carries DRM, the framework licenses and decrypts it, but it needs your service to talk to the platform's license server. unshackle recognizes five DRM systems:
DRM class System License flow Widevine Widevine CDM challenge \u2192 license server \u2192 content keys (.wvd device) PlayReady PlayReady CDM challenge (WRM header) \u2192 license server \u2192 content keys (.prd device) ClearKeyCENC W3C org.w3.clearkey over CENC JSON KID request \u2192 JWK set; no CDM ClearKey HLS AES-128 key fetched directly from a URI; no CDM, no challenge MonaLisa Proprietary WASM DRM keys extracted locally; no license server ClearKey vs ClearKeyCENC
Despite the shared prefix these are entirely different systems. ClearKey is HLS AES-128, decrypted in-process. ClearKeyCENC is the W3C EME JWK flow over MPEG-CENC, decrypted with shaka-packager or mp4decrypt like Widevine.
"},{"location":"dev/service-architecture/#the-service-side-hooks","title":"The service-side hooks","text":"The framework supplies the CDM and the decryption tool; your service provides the network calls that the CDM's challenge must be sent through. Override the hooks that your platform needs:
Hook Return Purpose get_widevine_service_certificate(title, track, challenge) cert bytes Optional privacy-mode service certificate. get_widevine_license(title, track, challenge) license bytes POST the Widevine challenge to the license server, return the response. get_playready_license(title, track, challenge) license Same for PlayReady; defaults to delegating to Widevine. get_clearkey_license(title, track, challenge) dict / str / bytes / None For ClearKeyCENC; return None to let the framework POST to the manifest Laurl. You do not open CDM sessions or run shaka-packager yourself. The Widevine/PlayReady classes drive the CDM handshake and invoke the decryption tool. Your hook is just the \"send this challenge, give me back the license\" step.
"},{"location":"dev/service-architecture/#how-a-cdm-is-chosen-and-keys-are-found","title":"How a CDM is chosen and keys are found","text":"CDM selection is config-driven: the cdm map resolves a service tag (and optionally the track quality or DRM type) to a device name, which load_cdm instantiates as a local .wvd/.prd or a remote CDM. Before ever requesting a license, the framework tries an in-process key cache and the configured key vaults; only unresolved KIDs trigger an actual license request, and newly licensed keys are written back to every vault. This is why the same key is rarely re-licensed twice. Users configure all of this in DRM and CDM and Key vaults; the API surface is documented in the DRM API reference.
"},{"location":"dev/service-architecture/#dolby-vision-and-hybrid","title":"Dolby Vision and hybrid","text":"Two DV mechanisms sit on top of the track model. DV fixup re-exposes DV signaling on HLS streams that carry a valid DV RPU but advertise plain HEVC (dv_compatible_bitstream). Hybrid processing (Video.Range.HYBRID) selects an HDR10(+) base and a DV donor, extracts the RPU with dovi_tool, and injects it onto the base layer, producing a single stream with both HDR10 and DV metadata. Both are automatic once the right tracks are selected; a service only needs to expose accurate ranges.
"},{"location":"dev/service-architecture/#request-and-session-objects","title":"Request and session objects","text":"Everything a service does over the network flows through objects the base constructor prepared, all derived from the Click context (ctx) that dl passes in.
self.config: your service's slice of unshackle.yaml, i.e. config.services[<ServiceName>]. This is a plain dict; use it for per-service settings like base URLs, an API key, or a title_map. See Configuration. self.session: a requests.Session with config.headers applied, a retry policy (total=5, backoff, retry on 429/5xx), and any resolved proxy already mounted. Pass this session into the manifest parsers and DRM calls so headers, cookies, and proxies flow everywhere. (unshackle also ships an rnet-based TLS-fingerprinting session used by the newer session() factory and configured via the network config key.) self.credential: the active Credential (username:password[:extra]), populated by authenticate. Its sha1 is used as the account hash for cache keys. self.cache: a generic Cacher: JSON key/value storage under the service's cache dir, with CRC32 integrity checks, versioning, and optional expiry (auto-derived from a JWT exp claim when present). Good for auth tokens and API responses. self.title_cache: a TitleCacher that wraps get_titles results, keyed by title ID, region, and account hash. It serves fresh data within title_cache_time, falls back to stale data within title_cache_max_retention when an API call fails, and is bypassed by --no-cache. self.track_request: the TrackRequest describing what the user asked for (codecs, ranges, best-available), for use inside get_tracks.
A minimal service skeleton
services/EXMP/__init__.pyfrom unshackle.core.service import Service\nfrom unshackle.core.manifests import DASH\nfrom unshackle.core.titles import Movie, Movies\n\n\nclass EXMP(Service):\n ALIASES = (\"example\",)\n GEOFENCE = (\"us\",)\n\n def get_titles(self) -> Movies:\n data = self.session.get(f\"https://api.example.com/title/{self.title}\").json()\n return Movies([\n Movie(\n id_=data[\"id\"],\n service=self.__class__,\n name=data[\"name\"],\n year=data.get(\"year\"),\n language=data.get(\"language\"),\n )\n ])\n\n def get_tracks(self, title) -> \"Tracks\":\n mpd_url = self.session.get(...).json()[\"manifest\"]\n mpd = self.session.get(mpd_url).text\n return DASH.from_text(mpd, mpd_url).to_tracks(language=title.language)\n\n def get_chapters(self, title):\n return []\n\n def get_widevine_license(self, *, challenge, title, track):\n return self.session.post(\"https://lic.example.com/wv\", data=challenge).content\n
The directory name (EXMP) must exactly match the class name; that stem is the service tag. Put your service in one of the directories.services paths.
"},{"location":"dev/service-architecture/#where-to-go-next","title":"Where to go next","text":" - Creating a Service: a guided walkthrough that builds on this model.
- Service API: exact signatures for the
Service base class. - Titles API and Tracks API: the full object reference.
- DRM API: the DRM and CDM classes in detail.
- Downloading: how the flags a user passes map onto track selection.
"},{"location":"dev/rest-api/","title":"REST API Overview","text":"unshackle ships with a built-in HTTP server. Alongside the command-line tool, you can run unshackle serve and drive the same download engine over a REST API: searching services, listing titles and tracks, queueing downloads, watching live progress, and browsing history, all as JSON over HTTP.
This page explains what the API is for, how to start the server, where it listens, and how the endpoints are organised. Later pages cover each area in depth.
Who this section is for
The REST API is aimed at people building automation, dashboards, or remote clients on top of unshackle. If you only download the occasional title, the command-line tool does everything the API does and is simpler to use. Reach for the API when you want a long-running service, a job queue, or a web front-end.
"},{"location":"dev/rest-api/#what-the-api-is-for","title":"What the API is for","text":"The server exposes the same capabilities as the dl command, but as discrete HTTP calls you can script against:
- Discovery: list the services available on the server, run a service's search, and enumerate the titles and tracks behind a title ID.
- Downloading: submit a download as a background job, then follow its status and live progress (by polling, or by subscribing to its Server-Sent Events stream), cancel it, retry it, or bump it to the front of the queue.
- History and housekeeping: read the log of finished jobs, inspect the server's effective (redacted) configuration, check external tool versions, and clear caches or temp files.
- Remote-download sessions: a thin local client can authenticate against a service on the server, then pull titles, tracks, and segment info back, and proxy DRM licensing. This is how one machine with the credentials and CDMs can serve downloads to another. See Remote Sessions.
Because every download runs as a queued job in a separate worker process, the API is well suited to running unattended: submit work, come back later, and read the result.
"},{"location":"dev/rest-api/#running-the-server","title":"Running the server","text":"Start the server with the serve command:
Start the REST API on the default host and portunshackle serve\n
By default the server binds to 127.0.0.1:8786 and requires an API key on every request. It also serves the local Widevine and PlayReady CDM endpoints (for pywidevine / pyplayready clients) in addition to the REST API.
An API key is required by default
Unless you pass --no-key, the server needs an API secret configured under the serve section of your config. If none is set, unshackle serve refuses to start. See Authentication for how to set api_secret and how the X-Secret-Key header works.
"},{"location":"dev/rest-api/#common-options","title":"Common options","text":"The serve command accepts the following flags:
Option Default Effect -h, --host 127.0.0.1 Address to bind to. Use 0.0.0.0 to accept connections from other machines. -p, --port 8786 Port to bind to. --api-only off Serve only the REST API, without the Widevine/PlayReady CDM HTTP endpoints. --no-widevine off Disable the Widevine CDM endpoints. Cannot be combined with --api-only. --no-playready off Disable the PlayReady CDM endpoints. Cannot be combined with --api-only. --no-key off Disable API-key authentication entirely; every request is allowed. --remote-only off Expose only the remote-session subset of routes. Implies --api-only. --debug-api off Include tracebacks and worker stderr in error responses. --debug off Enable debug-level logging. --caddy off Also launch Caddy as a reverse proxy, using the Caddyfile in your user-config directory. Serve the REST API only, on all interfacesunshackle serve --api-only --host 0.0.0.0 --port 8786\n
Binding to a public interface
--no-key disables authentication completely. Anyone who can reach the port can search, download, and read your configuration. Only combine it with a host like 127.0.0.1, or keep the default key-based auth when binding to 0.0.0.0. Put a TLS-terminating reverse proxy (Caddy, nginx) in front for anything internet-facing.
"},{"location":"dev/rest-api/#server-modes","title":"Server modes","text":"The set of routes that is actually mounted depends on how you start the server:
Default (CDM + REST)--api-only--remote-only Both the pywidevine/pyplayready CDM endpoints and the full REST API are mounted. This is the mode used when you run unshackle serve with no mode flags.
Only the REST API is mounted; the CDM HTTP endpoints are omitted. Authentication uses the single configured API key (or none, with --no-key).
Only the remote-session subset of routes is exposed (health, services, search, and everything under /api/session/*). This mode implies --api-only, and the interactive Swagger UI is not mounted.
"},{"location":"dev/rest-api/#base-url","title":"Base URL","text":"All REST endpoints live under the /api prefix. With the default bind address the base URL is:
http://127.0.0.1:8786/api\n
Every path in this documentation is relative to the host and port. For example, the health check is GET http://127.0.0.1:8786/api/health.
Interactive API docs
Unless you started the server with --remote-only, an interactive Swagger UI is available at /api/docs/. It is generated from the same route table the server actually serves, so it stays in sync with the running build. Note that in --api-only mode the docs page is itself behind the API key; only /api/health is exempt from authentication.
A quick smoke test that needs no key:
Confirm the server is upcurl http://127.0.0.1:8786/api/health\n
Response{\n \"status\": \"ok\",\n \"version\": \"5.3.0\",\n \"update_check\": {\n \"update_available\": false,\n \"current_version\": \"5.3.0\",\n \"latest_version\": \"5.3.0\"\n }\n}\n
"},{"location":"dev/rest-api/#endpoint-map","title":"Endpoint map","text":"Every REST path uses JSON request and response bodies and lives under /api. The table below is a map of the surface; the Endpoints page documents each request and response in full.
"},{"location":"dev/rest-api/#discovery","title":"Discovery","text":"Method Path Purpose GET /api/health Liveness and version check. Never requires a key. GET /api/services List the services available on this server. POST /api/search Run a service's search for a query. POST /api/list-titles List the titles behind a service title ID. POST /api/list-tracks List the video, audio, and subtitle tracks for a title."},{"location":"dev/rest-api/#downloads-and-jobs","title":"Downloads and jobs","text":"Method Path Purpose POST /api/download Queue a download; returns a job_id. GET /api/download/jobs List jobs, with filtering and sorting. POST /api/download/jobs/clear-finished Remove all finished jobs. GET /api/download/jobs/{job_id} Full detail for one job. GET /api/download/jobs/{job_id}/events Stream one job's progress as Server-Sent Events. DELETE /api/download/jobs/{job_id} Cancel a running job, or remove a finished one. POST /api/download/jobs/{job_id}/retry Re-queue a finished job's parameters as a new job. POST /api/download/jobs/{job_id}/priority Move a queued job to the front."},{"location":"dev/rest-api/#history-and-server-info","title":"History and server info","text":"Method Path Purpose GET /api/history Read the persisted log of finished jobs. DELETE /api/history/{job_id} Delete a history entry. GET /api/profiles Named credential profiles per service. GET /api/config The server's effective, redacted configuration. GET /api/env/check Presence and versions of external tools."},{"location":"dev/rest-api/#maintenance","title":"Maintenance","text":"Method Path Purpose POST /api/maintenance/clear-cache Clear the cache directory. POST /api/maintenance/clear-temp Clear the temp directory. POST /api/maintenance/refresh-services git pull each configured service repository."},{"location":"dev/rest-api/#remote-download-sessions","title":"Remote-download sessions","text":"Method Path Purpose POST /api/session/create Start a server-side, authenticated service session. GET /api/session/{session_id} Session info and time-to-live. DELETE /api/session/{session_id} Tear down a session. GET /api/session/{session_id}/titles Titles for the session's title ID. POST /api/session/{session_id}/tracks Tracks (with URLs) for a chosen title. POST /api/session/{session_id}/segments Per-track segment/download details. POST /api/session/{session_id}/license Proxy or server-side DRM licensing. GET /api/session/{session_id}/prompt Poll for interactive auth status/prompt. POST /api/session/{session_id}/prompt Submit an answer to an auth prompt. Remote-only routes
The health, services, search, and /api/session/* routes are the ones exposed when the server runs with --remote-only. Everything else (downloads, jobs, history, config, maintenance) is only available in the full or --api-only modes.
"},{"location":"dev/rest-api/#authentication-in-one-line","title":"Authentication in one line","text":"With the default configuration, send your API secret in the X-Secret-Key header on every request except /api/health:
An authenticated requestcurl -X POST http://127.0.0.1:8786/api/search \\\n -H \"X-Secret-Key: your-api-secret\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"service\": \"EXAMPLE\", \"query\": \"example\"}'\n
A missing or unknown key returns 401. The full model, including per-key service allowlists, is covered in Authentication.
"},{"location":"dev/rest-api/#where-to-go-next","title":"Where to go next","text":" - Quick Start: start the server, set a key, and run your first end-to-end download over the API.
- Authentication: the
X-Secret-Key header, --no-key, per-key service allowlists, and CORS. - Endpoints: every request body, query parameter, and response shape, endpoint by endpoint.
- Job Lifecycle: how a download moves from
queued to a terminal state, live progress, cancellation, and retries. - Remote Sessions: the
/api/session/* flow for driving a service from a thin remote client. - Error Responses: the standard error envelope, error codes, and their HTTP statuses.
"},{"location":"dev/rest-api/authentication/","title":"REST API Authentication","text":"The unshackle REST API protects every endpoint (with one exception) behind a single API key. Clients present that key in a request header, and the server checks it against the keys defined in your configuration. This page explains how that check works, how to configure keys, how clients send them, and the extra controls that build on top of the key: per-key service allowlists, per-key CDM device access, and session IP binding.
The API is started with the unshackle serve command. Authentication is on by default: if you start the server without an API key configured, it refuses to launch.
Which server am I running?
unshackle serve can run in two shapes, and each uses a slightly different piece of code to check your key:
- The integrated server (the default) exposes the REST API and the pywidevine / pyplayready CDM HTTP endpoints.
- The API-only server (
--api-only, also implied by --remote-only) exposes only the REST API.
The header, the config, and the behaviour you see as a client are the same in both. The differences are internal and are called out in the developer notes below.
"},{"location":"dev/rest-api/authentication/#the-short-version","title":"The short version","text":" - Add an
api_secret to the serve section of your unshackle.yaml. - Start the server with
unshackle serve. - Send that secret in the
X-Secret-Key header on every request except GET /api/health.
A minimal authenticated requestcurl -H \"X-Secret-Key: your-secret-key-here\" \\\n http://127.0.0.1:8786/api/services\n
"},{"location":"dev/rest-api/authentication/#configuring-authentication","title":"Configuring authentication","text":"All authentication settings live under the serve section of your unshackle config file (unshackle.yaml).
"},{"location":"dev/rest-api/authentication/#a-single-api-key","title":"A single API key","text":"The simplest setup defines one shared secret with api_secret:
unshackle.yamlserve:\n api_secret: \"a-long-random-secret-string\"\n
This value becomes the one and only key that the server accepts. Requests that present it in the X-Secret-Key header are allowed; everything else is rejected.
The server will not start without a key
If you start unshackle serve with no api_secret configured, and you have not passed --no-key, the command fails immediately with:
API secret key is not configured. Please add 'api_secret' to the 'serve' section in your config.\n
Choose a long, unpredictable value. The key is the only thing standing between the internet and your CDM and download endpoints.
"},{"location":"dev/rest-api/authentication/#multiple-keys-with-users","title":"Multiple keys with users","text":"For finer control you can define named users, each with their own key. A user entry can also grant CDM device access and restrict which services that key may touch:
unshackle.yamlserve:\n api_secret: \"the-primary-admin-key\"\n users:\n a-secret-key-for-alice:\n username: alice\n devices: [\"my_widevine_device\"] # Widevine (.wvd) devices this key may use\n playready_devices: [\"my_playready_prd\"] # PlayReady (.prd) devices this key may use\n services: [\"EXAMPLE1\", \"EXAMPLE2\"] # optional per-key service allowlist\n a-secret-key-for-bob:\n username: bob\n devices: [\"my_widevine_device\"]\n
The key of each entry under users (for example a-secret-key-for-alice) is the secret that client sends in X-Secret-Key. The username is a human-readable label used in logs and in job/history metadata.
How api_secret and users combine (integrated server)
On the integrated server, the configured api_secret is automatically added as an extra accepted key alongside everyone in users. It is granted access to all loaded Widevine and PlayReady devices and is labelled api_user. So api_secret behaves as an admin-level key in addition to any named users you define.
On the API-only server, only api_secret is accepted: the single-key model. (See the developer notes for the exact difference.)
"},{"location":"dev/rest-api/authentication/#cdm-device-access-per-key","title":"CDM device access per key","text":"Under the integrated server, a key can only run DRM licensing through a CDM device that its user entry lists:
Config key Grants access to devices Widevine .wvd devices, by name playready_devices PlayReady .prd devices, by name PlayReady access is opt-in per user
If a user entry omits playready_devices, that key is given no PlayReady access, and the server logs a warning at startup. You must list PlayReady devices explicitly for any key that needs them. Widevine and PlayReady device files themselves are auto-loaded from your WVDs and PRDs directories.
Device lists are the server-side-decryption switch
There is no separate tier, capability flag, or permission toggle for whether the server will hand back keys (KID:KEY) for a session download. The presence of devices on the calling key decides it. With empty devices and playready_devices, the server can only proxy CDM challenges, so the client must run its own CDM. Once those lists are populated, the client may request mode:server_cdm and receive keys back. Don't go looking for a tier setting; configuring devices is what enables server-side decryption.
"},{"location":"dev/rest-api/authentication/#disabling-authentication","title":"Disabling authentication","text":"The --no-key flag turns authentication off entirely. Every request is allowed, X-Secret-Key is ignored, and no key needs to be configured.
unshackle serve --no-key\n
Only use --no-key on a trusted, isolated network
With --no-key there is nothing stopping any client that can reach the port from listing your services, starting downloads, or using your CDM devices. Never expose a --no-key server to an untrusted network.
"},{"location":"dev/rest-api/authentication/#how-clients-present-credentials","title":"How clients present credentials","text":"Clients authenticate by sending the secret in a single HTTP request header on every call:
X-Secret-Key: <your-secret>\n
The value must exactly match one of the configured keys (api_secret, or one of the keys under users). There is no login step, no session cookie, and no token exchange for API access. The same static key is sent on each request.
One route also accepts the key as a query parameter
The job event stream, GET /api/download/jobs/{job_id}/events, also accepts the key in a secret_key query parameter. This is for browsers: the EventSource API cannot set request headers. The exception applies to that one route only, and it works in both server modes.
new EventSource(`http://127.0.0.1:8786/api/download/jobs/${jobId}/events?secret_key=${key}`);\n
The header always wins. If the request has an X-Secret-Key header, the server checks that header and ignores the query parameter, so a wrong header is rejected even when the query parameter holds a valid key.
A key in a URL is easier to leak than a key in a header, because proxies and browser history can record it. Use the header wherever your client can set one.
curlPython (requests)JavaScript (fetch) curl -H \"X-Secret-Key: your-secret-key-here\" \\\n http://127.0.0.1:8786/api/services\n
import requests\n\nBASE = \"http://127.0.0.1:8786\"\nHEADERS = {\"X-Secret-Key\": \"your-secret-key-here\"}\n\nresp = requests.get(f\"{BASE}/api/services\", headers=HEADERS, timeout=30)\nresp.raise_for_status()\nprint(resp.json())\n
const resp = await fetch(\"http://127.0.0.1:8786/api/services\", {\n headers: { \"X-Secret-Key\": \"your-secret-key-here\" },\n});\nif (!resp.ok) throw new Error(`Server returned ${resp.status}`);\nconsole.log(await resp.json());\n
The reference client sets it for you
unshackle's own remote download client (RemoteService) sends X-Secret-Key automatically, reading the key from your remote_services config. It also sends a User-Agent of unshackle/<version>. You only deal with the header directly when writing your own client.
"},{"location":"dev/rest-api/authentication/#the-health-check-is-always-open","title":"The health check is always open","text":"One endpoint is exempt from authentication: GET /api/health. It never requires a key, so you can use it for liveness and version probes without embedding a secret in your monitoring:
No key neededcurl http://127.0.0.1:8786/api/health\n
Response{\n \"status\": \"ok\",\n \"version\": \"5.3.0\",\n \"update_check\": {\n \"update_available\": null,\n \"current_version\": \"5.3.0\",\n \"latest_version\": null\n }\n}\n
Every other endpoint (including the Swagger UI at /api/docs/ on the API-only server) requires a valid key.
"},{"location":"dev/rest-api/authentication/#authentication-failures","title":"Authentication failures","text":"When a request is rejected for authentication reasons, the response has an HTTP 401 Unauthorized status and a small JSON body.
Missing headerWrong key Sending a request with no X-Secret-Key header:
{\n \"status\": 401,\n \"message\": \"Secret Key is Empty.\"\n}\n
Sending a key that is not in your config:
{\n \"status\": 401,\n \"message\": \"Secret Key is Invalid.\"\n}\n
Auth errors use a different JSON shape than other errors (developer note)
The rest of the API reports errors with a structured body containing error_code, message, timestamp, and other fields (see Errors). The authentication middleware is different: it returns {\"status\": 401, \"message\": \"...\"}, where status is an integer, and there is no error_code or timestamp.
A robust client should treat any 401 as an authentication problem and be prepared to parse both shapes rather than assuming the standard error body is always present.
"},{"location":"dev/rest-api/authentication/#building-on-the-key-access-controls","title":"Building on the key: access controls","text":"Once a request is authenticated, several additional controls decide what that specific key is allowed to do.
"},{"location":"dev/rest-api/authentication/#per-key-service-allowlists","title":"Per-key service allowlists","text":"You can restrict which services a key may use. Two layers combine:
- Global allowlist:
serve.services, applied to every key. - Per-key allowlist:
services inside that key's users entry.
The effective allowlist for a request is:
Global set? Per-key set? Result Yes Yes The intersection of the two Yes No The global list No Yes The per-key list No No No restriction, all services allowed Restricting servicesserve:\n api_secret: \"admin-key\"\n services: [\"EXAMPLE1\", \"EXAMPLE2\", \"EXAMPLE3\"] # global ceiling for all keys\n users:\n limited-key:\n username: limited\n services: [\"EXAMPLE1\"] # this key ends up allowed only EXAMPLE1\n
Endpoints that name a service (listing services, searching, listing titles/tracks, downloading, profiles, history, and remote sessions) all filter against this allowlist. A request for a service outside the key's allowlist is rejected as an invalid service. Service tags are matched after normalization, so casing and aliases resolve consistently.
"},{"location":"dev/rest-api/authentication/#cdm-overrides-and-per-job-credentials","title":"CDM overrides and per-job credentials","text":"Two download-time capabilities are gated behind server config and, unless enabled, are refused with 403 Forbidden even for an authenticated key:
Config key Default Controls serve.cdm_overrides off Whether a request may pick a specific CDM device via the cdm field. Set to true to allow any, or to a list/set of device names to allow only those. serve.allow_job_credentials off Whether a request may pass its own credential / credentials login details for a download job. Opting in to per-request CDM and credentialsserve:\n api_secret: \"admin-key\"\n cdm_overrides: [\"my_widevine_device\"] # or true to allow any configured device\n allow_job_credentials: true\n
When to enable these gates
The two gates are fully independent and can be combined. The intended posture is that a locked-down or public-facing deployment leaves both unset (they default off and reject with 403) so client requests can never steer the server's CDM or feed it their own logins. Enabling them only makes sense for a trusted single-client deployment, where the caller and the operator are effectively the same party.
Client-supplied credentials get isolated token caches
When allow_job_credentials is enabled and a download job supplies its own credential / credentials, each distinct credential is given its own isolated token cache on the server. Client-supplied logins are never shared or merged with the server's configured-credential caches, so they cannot cross-contaminate or read the server's own token state, and they don't clobber each other.
See Downloads for how these fields are used on the download endpoint.
"},{"location":"dev/rest-api/authentication/#session-ip-binding-remote-sessions","title":"Session IP binding (remote sessions)","text":"The remote-download session endpoints (/api/session/*) add an extra check on top of the key. When a session is created, the server records the client's IP address. Any later request for that session must come from the same IP, or it is rejected with 403 Forbidden. This means a session is bound both to the key that created it and to the address it was created from.
"},{"location":"dev/rest-api/authentication/#transport-security-https","title":"Transport security (HTTPS)","text":"The API server speaks plain HTTP. Because your key is sent on every request, you should not expose it over an unencrypted connection on an untrusted network. To serve over HTTPS, run a reverse proxy in front of unshackle. The serve command can launch Caddy for you:
unshackle serve --caddy\n
With --caddy, unshackle also starts caddy run using the Caddyfile located next to your unshackle config, letting you terminate TLS and reverse-proxy to the API. Configuring Caddy itself (certificates, domains, upstream) is done in that Caddyfile.
"},{"location":"dev/rest-api/authentication/#quick-reference","title":"Quick reference","text":"Item Value Auth header X-Secret-Key Query-parameter alternative ?secret_key= on GET /api/download/jobs/{job_id}/events only Default bind address 127.0.0.1:8786 Exempt endpoint GET /api/health Failure status 401 Unauthorized Missing-header body {\"status\": 401, \"message\": \"Secret Key is Empty.\"} Invalid-key body {\"status\": 401, \"message\": \"Secret Key is Invalid.\"} Disable auth --no-key (all requests allowed) Config location serve.api_secret and serve.users in unshackle.yaml"},{"location":"dev/rest-api/authentication/#developer-notes-how-the-check-is-implemented","title":"Developer notes: how the check is implemented","text":"This section is for people writing or debugging the server, not for API consumers.
"},{"location":"dev/rest-api/authentication/#two-middlewares-one-header","title":"Two middlewares, one header","text":"The header and config are identical between modes, but the code that enforces them differs:
-
API-only server (--api-only, or --remote-only which forces API-only). An api_key_authentication middleware runs on every request. It lets GET /api/health through unconditionally, then reads X-Secret-Key. An empty header returns the \"Secret Key is Empty.\" body; a header that is not a configured user returns \"Secret Key is Invalid.\". In this mode the accepted-keys map holds your api_secret (labelled api_user, with no devices) plus every key under serve.users, so a per-user key authenticates here exactly as it does on the integrated server, and that user's services allowlist applies as usual. With --no-key, this middleware is simply not installed and the users map is empty.
-
Integrated server (default). A serve_authentication middleware wraps pywidevine's authentication (and pyplayready's for /playready* paths), and the REST routes live in the same application, guarded by the same check. Here the accepted-keys map is your full serve config: api_secret (auto-added with access to all loaded devices) plus every entry under users, each with its declared devices / playready_devices.
Because the API-only middleware guards all non-health paths, the Swagger UI at /api/docs/ is also behind X-Secret-Key there. In --remote-only mode the Swagger UI is not mounted at all.
Both middlewares read the caller's key through the shared request_secret_key() helper in unshackle/core/api/handlers.py, which returns the X-Secret-Key header and falls back to the secret_key query parameter only when the matched route is the job event stream. The integrated server needs one extra branch for this: pywidevine's authentication is header-only, so serve_authentication compares the query-parameter key itself when the header is absent, and delegates to pywidevine in every other case. That is why a present-but-wrong header is rejected in both modes.
"},{"location":"dev/rest-api/authentication/#cors-and-the-authorization-header","title":"CORS and the Authorization header","text":"A cors_middleware runs on every response in both modes (and answers OPTIONS preflight requests with an empty response). It emits:
Access-Control-Allow-Origin: *\nAccess-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS\nAccess-Control-Allow-Headers: Content-Type, X-Secret-Key, Authorization\nAccess-Control-Max-Age: 3600\n
The REST API authenticates with X-Secret-Key; Authorization is advertised because the co-hosted pywidevine / pyplayready CDM endpoints use it. PUT appears in the allowed methods list but no REST route uses it; only GET, POST, and DELETE are wired up.
"},{"location":"dev/rest-api/authentication/#where-device-access-is-read","title":"Where device access is read","text":"The map of accepted keys lives at request.app[\"config\"][\"users\"]. For DRM operations, a key's permitted Widevine devices come from its devices list and PlayReady devices from playready_devices; the first configured device is used when a request does not (or may not) override it. A server-wide config.cdm[<service>] mapping, if present, takes precedence over the key's device list when resolving which device to use.
"},{"location":"dev/rest-api/endpoints/","title":"REST API Endpoints","text":"This is the full endpoint reference for the unshackle REST API. It covers every route: its method and path, the request body it accepts, the response shape it returns, and the status codes it can produce. Routes are grouped into general, download jobs, server management, and remote-dl sessions.
Start the server with unshackle serve. By default it binds to 127.0.0.1:8786, and every route lives under the /api/ prefix. Authentication, server modes, and the X-Secret-Key header are covered in detail on the Authentication page. Read that first if you have not already. For an end-to-end walkthrough, see the Quickstart.
Base URL
All examples below assume a base URL of http://127.0.0.1:8786. Replace the host and port to match your own unshackle serve -h <host> -p <port> invocation.
Interactive docs
A live Swagger UI is mounted at /api/docs/ whenever the server is not running in --remote-only mode. Its title is \"Unshackle REST API\" and its version tracks the installed package version. In API-only mode with a key configured, the Swagger UI itself sits behind X-Secret-Key; only /api/health is exempt.
"},{"location":"dev/rest-api/endpoints/#conventions","title":"Conventions","text":" - Authentication. Every request except
GET /api/health requires the X-Secret-Key header when a key is configured. Starting the server with --no-key disables the check entirely. See Authentication. - Content type. Request bodies are JSON (
Content-Type: application/json); all success responses are JSON unless a 204 No Content is documented. - CORS. Every response carries permissive CORS headers, and
OPTIONS preflight requests are answered automatically. - Compression. JSON responses of 256 bytes or more are gzip-compressed when the client sends
Accept-Encoding: gzip. - Service allowlist. Service-facing endpoints are filtered by the effective allowlist for your API key (the intersection of the global
serve.services list and your per-key list). Services you are not allowed to use are treated as unknown.
Error responses have two shapes
Structured application errors (raised inside a handler) return {\"status\": \"error\", \"error_code\": \"...\", \"message\": \"...\", \"timestamp\": \"...\"}. The authentication middleware, however, returns a different shape: {\"status\": 401, \"message\": \"...\"} with an integer status and no error_code. Clients must handle both. The full error model is documented in the Error responses section at the bottom of this page.
"},{"location":"dev/rest-api/endpoints/#general-endpoints","title":"General endpoints","text":"These endpoints report server state and enumerate what is available. GET /api/health, GET /api/services, and POST /api/search are also exposed in --remote-only mode.
"},{"location":"dev/rest-api/endpoints/#get-apihealth","title":"GET /api/health","text":"Health check. This is the only route exempt from authentication: you can call it without an X-Secret-Key header. It reports the running version and a best-effort update check.
RequestResponse 200 curl http://127.0.0.1:8786/api/health\n
{\n \"status\": \"ok\",\n \"version\": \"5.3.0\",\n \"code_hash\": \"1d22a1e\",\n \"update_check\": {\n \"update_available\": false,\n \"current_version\": \"5.3.0\",\n \"latest_version\": \"5.3.0\"\n }\n}\n
code_hash fingerprints the framework source the server is running, so it identifies the code itself rather than the release. It is the same value for a git checkout and for an installed package built from that checkout, and it changes if anything under unshackle/core, unshackle/commands, unshackle/utils, or unshackle/vaults is edited. Service modules are not included. The value is null only when the source files cannot be read.
If the update check fails (for example, no network), update_available and latest_version are null while current_version still reports the installed version.
Status Meaning 200 Server is healthy."},{"location":"dev/rest-api/endpoints/#get-apiservices","title":"GET /api/services","text":"List the streaming services available on this server, filtered by your allowlist. Each entry describes the service's tag, matching rules, capability flags, and its CLI parameters (useful for building a UI that accepts service-specific options).
RequestResponse 200 curl -H \"X-Secret-Key: $KEY\" http://127.0.0.1:8786/api/services\n
{\n \"services\": [\n {\n \"tag\": \"EXAMPLE\",\n \"aliases\": [\"EX\"],\n \"geofence\": [\"US\"],\n \"title_regex\": \"^https?://example\\\\.com/.+\",\n \"url\": \"https://example.com\",\n \"help\": \"EXAMPLE\\nAuthorization: Cookies\\nSecurity: FHD@L3\",\n \"cli_params\": [\n {\"name\": \"title\", \"kind\": \"argument\", \"required\": true},\n {\n \"name\": \"movie\",\n \"kind\": \"option\",\n \"opts\": [\"-m\", \"--movie\"],\n \"is_flag\": true,\n \"default\": false,\n \"help\": \"Title is a movie.\",\n \"type\": \"boolean\",\n \"multiple\": false\n }\n ],\n \"needs_auth\": true,\n \"has_search\": true,\n \"has_drm\": true,\n \"auth_methods\": [\"cookies\"]\n }\n ]\n}\n
Field notes:
Field Description tag Canonical service tag; use this as the service value on other endpoints. aliases Alternate tags the service also answers to. geofence Region codes the service is geofenced to (may be empty). title_regex URL/ID pattern the service matches, as a string or list of strings (or null). url Service URL, taken from the service's CLI short help. help The service module's docstring. cli_params Array of parameter descriptors. Arguments have {name, kind: \"argument\", required}; options have {name, kind: \"option\", opts, is_flag, default, help, type, multiple} and, for choice options, a choices list. needs_auth Whether the service overrides authenticate(). has_search Whether the service supports POST /api/search. has_drm Whether the service overrides a Widevine or PlayReady license hook. auth_methods Declared or inferred auth methods, e.g. [\"cookies\"], [\"credentials\"]. Status Meaning 200 Service list returned. 500 Unexpected server error (INTERNAL_ERROR)."},{"location":"dev/rest-api/endpoints/#post-apisearch","title":"POST /api/search","text":"Search a service for titles by query string. The service must implement search() (see has_search above).
Request body
Field Type Required Default Description service string yes - Service tag. query string yes - Search query. profile string no null Credential/cookie profile to use. proxy string no null Proxy URI or country code. no_proxy boolean no false Force-disable all proxy use. RequestResponse 200 curl -X POST http://127.0.0.1:8786/api/search \\\n -H \"X-Secret-Key: $KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"service\": \"EXAMPLE\", \"query\": \"planet earth\"}'\n
{\n \"results\": [\n {\n \"id\": \"12345\",\n \"title\": \"Planet Earth\",\n \"description\": \"A nature documentary series.\",\n \"label\": \"Available\",\n \"url\": \"https://example.com/title/12345\"\n }\n ],\n \"count\": 1\n}\n
Status Error code Meaning 200 - Search completed. 400 INVALID_PARAMETERS Missing query. 400 INVALID_SERVICE Unknown or disallowed service. 400 INVALID_PROXY Proxy could not be resolved. 502 SERVICE_ERROR Service failed to initialize, or does not support search."},{"location":"dev/rest-api/endpoints/#post-apilist-titles","title":"POST /api/list-titles","text":"Resolve a title ID into its list of titles (episodes, movie, etc.) without downloading anything.
Request body
Field Type Required Description service string yes Service tag. title_id string yes Title identifier. profile string no Credential/cookie profile. proxy string no Proxy URI or country code. no_proxy boolean no Force-disable proxy. cdm_type string no Preferred CDM type. Service-specific CLI options may also be passed as additional keys.
RequestResponse 200 curl -X POST http://127.0.0.1:8786/api/list-titles \\\n -H \"X-Secret-Key: $KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"service\": \"EXAMPLE\", \"title_id\": \"12345\"}'\n
{\n \"titles\": [\n {\n \"type\": \"episode\",\n \"name\": \"Pilot\",\n \"id\": \"ep-1\",\n \"language\": \"en\",\n \"description\": \"The first episode.\",\n \"date\": \"2020-01-01\",\n \"cover_url\": \"https://example.com/cover.jpg\",\n \"year\": 2020,\n \"series_title\": \"Example Show\",\n \"season\": 1,\n \"number\": 1\n }\n ]\n}\n
Each serialized title carries type (\"episode\", \"movie\", or \"other\"), name, id, language, description, date, and cover_url. Episodes and movies add year; episodes additionally add series_title, season, and number.
These keys are sent only when the title carries them, so a title without them serializes exactly as before:
Key Type On Meaning part integer episode Part index of a split episode, counting from 1. air_date string episode ISO air date of dated content. unshackle names the episode by date instead of SxxExx. absolute integer episode Absolute episode number across all seasons. daily boolean episode The episode is daily/date-based. Set from the title, or from the service's DAILY class attribute. anime boolean episode, movie The title is anime, so metadata lookups prefer AniList. Set from the title, or from the service's ANIME class attribute. part on a split episode
A service that splits one episode into several separately playable videos reports each one as its own episode. Those titles share a season and number and add a part integer counting from 1. No other title carries the key, so a client that ignores it is unaffected. See Split episodes.
Status Error code Meaning 200 - Titles returned. 400 INVALID_INPUT Missing service or title_id. 400 INVALID_SERVICE Unknown or disallowed service. 401 AUTH_FAILED Authentication with the service failed. 404 NOT_FOUND Title not found. 500 INTERNAL_ERROR Unexpected error."},{"location":"dev/rest-api/endpoints/#post-apilist-tracks","title":"POST /api/list-tracks","text":"List the video, audio, and subtitle tracks for a title. For series, you can scope to specific episodes.
Spotting the original-language audio
Each audio track carries is_original, which marks the track the default lang of orig selects. Unshackle resolves it with the same language matcher the downloader uses, so it stays correct for regional variants a client cannot separate from tag text alone (pt marks pt-BR rather than pt-PT). Services need not report a title's language, so every track can legitimately come back false.
Request body
Field Type Required Description service string yes Service tag. title_id string yes Title identifier. wanted string no Episode/season range (e.g. \"S01E01-S01E03\"). season int/string no Season number (combined with episode). episode int/string no Episode number (combined with season). part int/string no Part index of a split episode (combined with season and episode). profile, proxy, no_proxy, cdm_type - no As on list-titles. When both season and episode are given, they are combined into a \"{season}x{episode}\" selector. Adding part narrows that to \"{season}x{episode}.{part}\", one part of a split episode; without it you get every part of the episode. part is read only when season and episode are both given, and wanted takes precedence over all three. To scope a part by range, put it in wanted instead (\"S01E01.2\").
Single title / movie: Response 200Multiple episodes: Response 200 {\n \"title\": { \"type\": \"movie\", \"name\": \"Example Movie\", \"id\": \"12345\", \"year\": 2021 },\n \"video\": [\n {\n \"id\": \"v-1\", \"codec\": \"H265\", \"codec_display\": \"HEVC\",\n \"bitrate\": 8000, \"width\": 3840, \"height\": 2160,\n \"resolution\": \"2160p\", \"fps\": \"24\", \"range\": \"HDR10\",\n \"range_display\": \"HDR10\", \"language\": \"en\", \"drm\": [], \"descriptor\": \"DASH\"\n }\n ],\n \"audio\": [\n {\n \"id\": \"a-1\", \"codec\": \"EC3\", \"codec_display\": \"DD+\",\n \"bitrate\": 640, \"channels\": \"5.1\", \"language\": \"en\", \"is_original\": true,\n \"atmos\": false, \"descriptive\": false, \"drm\": [], \"descriptor\": \"DASH\"\n }\n ],\n \"subtitles\": [\n { \"id\": \"s-1\", \"codec\": \"vtt\", \"language\": \"en\",\n \"forced\": false, \"sdh\": false, \"cc\": false, \"descriptor\": \"DASH\" }\n ]\n}\n
{\n \"episodes\": [\n {\n \"title\": { \"type\": \"episode\", \"name\": \"Pilot\", \"id\": \"ep-1\", \"season\": 1, \"number\": 1 },\n \"video\": [ ... ],\n \"audio\": [ ... ],\n \"subtitles\": [ ... ]\n }\n ],\n \"unavailable_episodes\": [\n { \"title\": { \"id\": \"ep-2\" }, \"error\": \"...\" }\n ]\n}\n
Tracks are sorted by bitrate, descending. Episodes that fail to resolve are collected into unavailable_episodes rather than failing the whole request. Track objects here do not include a download url; segment URLs are only exposed through the remote-dl session endpoints.
The drm array on a track is a list of {type, pssh?, kids?, content_keys?, license_url?} objects.
Status Error code Meaning 200 - Tracks returned. 400 INVALID_INPUT Missing service or title_id. 400 INVALID_PARAMETERS Invalid wanted range. 400 INVALID_SERVICE Unknown or disallowed service. 404 NO_CONTENT No matching episodes, or all matches failed."},{"location":"dev/rest-api/endpoints/#download-jobs","title":"Download jobs","text":"Downloads are asynchronous. POST /api/download enqueues a job and returns immediately with a job_id; the server runs each job in a background worker subprocess. You then poll the job endpoints for progress and results.
Job states
A job moves through queued \u2192 downloading \u2192 one of the terminal states completed, failed, or cancelled. Only queued jobs can be prioritized; only terminal jobs can be retried or removed.
"},{"location":"dev/rest-api/endpoints/#post-apidownload","title":"POST /api/download","text":"Create a download job. Requires service and title_id; every other field is an optional download parameter with a server-side default. On success this returns 202 Accepted, not 200.
RequestResponse 202 curl -X POST http://127.0.0.1:8786/api/download \\\n -H \"X-Secret-Key: $KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"service\": \"EXAMPLE\",\n \"title_id\": \"12345\",\n \"quality\": [2160],\n \"vcodec\": \"H265\",\n \"range\": [\"HDR10\"],\n \"wanted\": [\"S01E01\"]\n }'\n
{\n \"job_id\": \"b0f7c8e2-4a1d-4e9c-9b7e-2f3a1c0d5e6f\",\n \"status\": \"queued\",\n \"created_time\": \"2026-07-03T12:00:00+00:00\"\n}\n
Common parameters (all optional). These mirror the dl command flags:
Field Type Default Notes profile string null Credential/cookie profile. quality int[] [] Target resolution(s); best available if empty. vcodec string/string[] null Video codec filter. acodec string/string[] null Audio codec filter. vbitrate / abitrate int null Video / audio bitrate in kbps (positive). vbitrate_range / abitrate_range string null \"MIN-MAX\" bitrate window. range string[] [\"SDR\"] Dynamic range(s). channels number null Audio channel count. no_atmos boolean false Exclude Atmos tracks. wanted string[] [] Episode/season selectors. Accepts the part form, \"S01E01.2\", and the air-date form, \"2026-08-11\" or \"2026-08-01:2026-08-31\". latest_episode boolean false Only the newest episode. lang / v_lang / a_lang / s_lang string[] [\"orig\"] / [] / [] / [\"all\"] Language filters. require_subs string[] [] Required subtitle languages. forced_subs boolean false Include forced subtitles. forced_s_lang string[] [] Forced subtitle language(s); implies forced_subs. exact_lang boolean false Exact language matching. sub_format string null Output subtitle format. video_only / audio_only / subs_only / chapters_only boolean false Track-type restriction (at most one). no_subs / no_audio / no_chapters / no_video boolean false Skip a track type. audio_description boolean false Download audio description. slow boolean/string null Randomized delay between downloads. split_audio boolean null Separate files per audio codec. skip_dl boolean false Only fetch keys, do not download. export boolean false Export manifest/keys/subs to JSON. cdm_only boolean null Force CDM-only (true) or vault-only (false) key retrieval. proxy / no_proxy / no_proxy_download string / bool / bool null / false / false Proxy controls. no_folder / no_source / no_mux boolean false Output/mux controls. workers int null Threads per track. downloads int 1 Concurrent tracks. worst boolean false Lowest bitrate within quality. best_available boolean false Fall back to best available. repack boolean false Add REPACK tag. tag string null Release group tag. tmdb_id / imdb_id / tvdb_id / anilist_id - null External ID overrides. Each resolves its metadata directly instead of by a title search, and is used for tagging. Set enrich to also take the title, year and original language. Give at most one of tmdb_id, imdb_id and tvdb_id, since unshackle resolves the others from it. Sending two returns 400. anilist_id still combines with one of them. tmdb_id and tvdb_id must be positive integers, imdb_id must look like tt1375666, and anilist_id must be a positive integer or a string like mal:12345, or the request returns 400. An ID whose provider is unconfigured, such as tmdb_id with no tmdb_api_key, fails the job rather than returning 400. anilist_id needs no key. tvdb_order official, dvd, absolute, alternate, regional null Renumber episodes to a TVDB season order. Falls back to the tvdb_order config option. enrich boolean false Overwrite title, year and original language with the external source's. Needs one of tmdb_id, imdb_id, tvdb_id or anilist_id. Without one the job fails instead of returning 400. daily boolean false Treat the title as daily/date-based content and fill missing episode air dates from TVDB. The fill needs enrich and a TVDB ID. An air date the service already set is kept. output_dir string null Override output directory. no_cache / reset_cache boolean false Title cache controls. Validation. Invalid values return 400 INVALID_PARAMETERS. Notably: vcodec must be one of H264/H265/H.264/H.265/AVC/HEVC/VC1/VC-1/VP8/VP9/AV1; acodec one of AAC/AC3/EC3/EAC3/DD/DD+/AC4/OPUS/FLAC/ALAC/VORBIS/OGG/DTS; range one of SDR/HDR10/HDR10P/DV/HLG/HYBRID (HDR10+ is accepted); bitrate/worker/download counts must be positive integers; at most one of the *_only flags may be set; no_subs cannot be combined with subs_only, no_audio with audio_only, or s_lang with require_subs.
Gated parameters (developer)
A few keys are accepted but only permitted when the server config opts in, otherwise the request is rejected with 403 FORBIDDEN:
cdm (per-request CDM device name): allowed only if serve.cdm_overrides is true or a list containing that device name. credential (\"user:pass\") and credentials (a map): allowed only if serve.allow_job_credentials is truthy (off by default).
Server-side, effective parameters are merged as defaults \u2192 serve: config overrides \u2192 service-specific defaults \u2192 your request body.
Status Error code Meaning 202 - Job queued. 400 INVALID_INPUT Missing service/title_id or malformed JSON. 400 INVALID_PARAMETERS A parameter failed validation. 400 INVALID_SERVICE Unknown or disallowed service. 403 FORBIDDEN A gated parameter is not permitted."},{"location":"dev/rest-api/endpoints/#get-apidownloadjobs","title":"GET /api/download/jobs","text":"List download jobs, with optional filtering and sorting.
Query parameters
Param Values Default Description status queued, downloading, completed, failed, cancelled - Filter by status. service service tag - Filter by service. sort_by created_time, started_time, completed_time, progress, status, service created_time Sort field. sort_order asc, desc desc Sort direction. full true, false false Include full per-job details. RequestResponse 200 curl -H \"X-Secret-Key: $KEY\" \\\n \"http://127.0.0.1:8786/api/download/jobs?status=downloading&sort_by=progress&sort_order=desc\"\n
{\n \"jobs\": [\n {\n \"job_id\": \"b0f7c8e2-...\",\n \"status\": \"downloading\",\n \"created_time\": \"2026-07-03T12:00:00+00:00\",\n \"service\": \"EXAMPLE\",\n \"title_id\": \"12345\",\n \"title\": \"Example Show S01E01\",\n \"progress\": 42.5,\n \"phase\": \"downloading\",\n \"current_title\": \"Episode 1\",\n \"completed_tracks\": 1,\n \"total_tracks\": 3,\n \"active_tracks\": [\"v-1\"],\n \"track_progress\": [{\"label\": \"Video\", \"progress\": 42.5, \"speed\": \"5.2 MB/s\"}],\n \"segments_done\": 120,\n \"segments_total\": 300,\n \"speed\": \"5.2 MB/s\",\n \"skipped_subtitles\": []\n }\n ]\n}\n
With full=true, each job additionally includes parameters (with secrets redacted), started_time, completed_time, output_files, error_message, error_details, error_code, error_traceback, and worker_stderr.
Status Error code Meaning 200 - Jobs returned. 400 INVALID_PARAMETERS Invalid sort_by or sort_order."},{"location":"dev/rest-api/endpoints/#get-apidownloadjobsjob_id","title":"GET /api/download/jobs/{job_id}","text":"Fetch a single job with full details (equivalent to a full=true list entry).
Status Error code Meaning 200 - Job returned. 404 JOB_NOT_FOUND No such job."},{"location":"dev/rest-api/endpoints/#get-apidownloadjobsjob_idevents","title":"GET /api/download/jobs/{job_id}/events","text":"Stream the job's progress as Server-Sent Events. Use this instead of polling GET /api/download/jobs/{job_id}.
The response has the content type text/event-stream. Each event has this format:
event: <name>\ndata: <job JSON>\n
The data field of every event is the same job object that GET /api/download/jobs/{job_id} returns.
Event When it is sent snapshot Immediately, as the first event. Gives the job's current state. status The job leaves the queue and starts to download. progress The worker's progress record changed. The server reads that record every 0.5 seconds, so you get at most two of these events each second, and none while the record stays the same. completed The job finished. The server then closes the stream. failed The job failed. The server then closes the stream. cancelled The job was cancelled. The server then closes the stream. The server sends a : keep-alive comment each 15 seconds while the job is quiet, to keep proxies from closing an idle connection. Ignore these lines.
If the job is already in a terminal state, the server sends the snapshot event, then the terminal event, and closes the stream immediately.
Authentication from a browser
EventSource cannot set headers. For this endpoint only, the server also accepts the key in the secret_key query parameter. This works in both server modes, the default integrated server and --api-only:
const events = new EventSource(\n `http://127.0.0.1:8786/api/download/jobs/${jobId}/events?secret_key=${key}`\n);\nevents.addEventListener(\"progress\", (e) => console.log(JSON.parse(e.data).progress));\nevents.addEventListener(\"completed\", () => events.close());\n
The header always wins. Send the key in the query parameter or in the header, not both: if an X-Secret-Key header is present, the server checks that header and ignores the query parameter. See Authentication.
RequestResponse 200 curl -N -H \"X-Secret-Key: $KEY\" \\\n \"http://127.0.0.1:8786/api/download/jobs/b0f7c8e2-.../events\"\n
event: snapshot\ndata: {\"job_id\":\"b0f7c8e2-...\",\"status\":\"downloading\",\"progress\":42.5,...}\n\nevent: progress\ndata: {\"job_id\":\"b0f7c8e2-...\",\"status\":\"downloading\",\"progress\":48.0,...}\n\n: keep-alive\n\nevent: completed\ndata: {\"job_id\":\"b0f7c8e2-...\",\"status\":\"completed\",\"progress\":100.0,...}\n
Status Error code Meaning 200 - Event stream started. 404 JOB_NOT_FOUND No such job."},{"location":"dev/rest-api/endpoints/#delete-apidownloadjobsjob_id","title":"DELETE /api/download/jobs/{job_id}","text":"Cancel or remove a job. The behaviour depends on the job's current state:
- Terminal job (completed/failed/cancelled): removed from the manager, returning
204 No Content with an empty body. - Queued or downloading job: cancelled, returning
200 {\"status\": \"success\", \"message\": \"Job cancelled\"}.
Warning
A 204 response has no body. Do not attempt to parse JSON from it.
Status Error code Meaning 200 - Job cancelled. 204 - Terminal job removed (empty body). 400 INVALID_PARAMETERS Job is in a state that cannot be cancelled. 404 JOB_NOT_FOUND No such job."},{"location":"dev/rest-api/endpoints/#post-apidownloadjobsclear-finished","title":"POST /api/download/jobs/clear-finished","text":"Remove every terminal (completed/failed/cancelled) job from the manager. No request body.
Response 200 { \"removed\": 3 }\n
"},{"location":"dev/rest-api/endpoints/#post-apidownloadjobsjob_idretry","title":"POST /api/download/jobs/{job_id}/retry","text":"Enqueue a new job that reuses a terminal job's service, title, and parameters. The service allowlist and parameter gates are re-checked. Returns 202 with the new job's ID.
Retry reuses the original, unredacted parameters
Retry replays the finished job's stored parameters exactly as they were submitted, including any secrets such as credential/credentials, not the redacted parameters view that GET /api/download/jobs and GET /api/history expose. This is deliberate: it lets a retried job reproduce a credentialed download that the redacted job record alone never could. It also means anyone who can retry a job can re-run it with its original credentials, even though those credentials are masked everywhere else in the API.
Response 202 {\n \"job_id\": \"9c1e2f3a-...\",\n \"status\": \"queued\",\n \"created_time\": \"2026-07-03T12:05:00+00:00\"\n}\n
Status Error code Meaning 202 - New job queued. 404 JOB_NOT_FOUND No such job. 409 CONFLICT Job is not in a terminal state. 400 INVALID_SERVICE Service is no longer allowed. 403 FORBIDDEN A gated parameter is no longer permitted."},{"location":"dev/rest-api/endpoints/#post-apidownloadjobsjob_idpriority","title":"POST /api/download/jobs/{job_id}/priority","text":"Move a queued job to the front of the download queue.
Response 200 { \"job_id\": \"b0f7c8e2-...\", \"position\": \"front\" }\n
Status Error code Meaning 200 - Job moved to front. 404 JOB_NOT_FOUND No such job. 409 CONFLICT Job is not queued."},{"location":"dev/rest-api/endpoints/#server-management","title":"Server management","text":"Endpoints for profiles, effective configuration, download history, environment checks, and maintenance actions.
"},{"location":"dev/rest-api/endpoints/#get-apiprofiles","title":"GET /api/profiles","text":"List the named credential profiles configured per service (usable as the profile parameter elsewhere). Only services whose credentials are a mapping of profile name to credential are listed; services with a single unnamed credential are omitted. Filtered by your allowlist.
Response 200 {\n \"profiles\": {\n \"EXAMPLE\": [\"default\", \"premium\"],\n \"OTHER\": [\"us-account\"]\n }\n}\n
"},{"location":"dev/rest-api/endpoints/#get-apiconfig","title":"GET /api/config","text":"Return a read-only, redacted view of the effective server configuration, suitable for a settings page in a UI. Secrets are never emitted: keys matching secret, password, token, api_key, or credential, and any userinfo in proxy URLs, are masked.
Response 200 {\n \"config\": {\n \"dl\": { \"...\": \"redacted default dl parameters\" },\n \"serve\": {\n \"max_concurrent_downloads\": 2,\n \"job_retention_hours\": 24,\n \"history_limit\": 100,\n \"services\": null,\n \"remote_only\": false,\n \"cdm_overrides\": null,\n \"allow_job_credentials\": false\n },\n \"directories\": {\n \"downloads\": \"/home/user/Downloads/unshackle\",\n \"temp\": \"/home/user/.unshackle/temp\",\n \"cache\": \"/home/user/.unshackle/cache\"\n },\n \"services\": [\"EXAMPLE\", \"OTHER\"]\n }\n}\n
"},{"location":"dev/rest-api/endpoints/#get-apihistory","title":"GET /api/history","text":"Read the persisted history of jobs that reached a terminal state, newest first. History is stored in api_history.jsonl in the cache directory; corrupt lines are skipped and a missing file yields an empty list.
Query parameters
Param Type Default Description limit int (\u2265 1) 100 Maximum entries to return. service string - Case-insensitive service filter. Response 200 {\n \"history\": [\n {\n \"job_id\": \"b0f7c8e2-...\",\n \"service\": \"EXAMPLE\",\n \"title_id\": \"12345\",\n \"title\": \"Example Show S01E01\",\n \"status\": \"completed\",\n \"created_time\": \"2026-07-03T12:00:00+00:00\",\n \"completed_time\": \"2026-07-03T12:08:00+00:00\",\n \"output_files\": [\"/downloads/Example.S01E01.mkv\"],\n \"error_message\": null,\n \"parameters\": { \"...\": \"redacted\" }\n }\n ],\n \"count\": 1\n}\n
Status Error code Meaning 200 - History returned. 400 INVALID_PARAMETERS Invalid limit."},{"location":"dev/rest-api/endpoints/#delete-apihistoryjob_id","title":"DELETE /api/history/{job_id}","text":"Remove a single persisted history entry. Returns 204 No Content (empty body) on success.
Status Error code Meaning 204 - Entry removed (empty body). 404 NOT_FOUND No such entry (or outside your allowlist)."},{"location":"dev/rest-api/endpoints/#post-apimaintenanceclear-cache-and-post-apimaintenanceclear-temp","title":"POST /api/maintenance/clear-cache and POST /api/maintenance/clear-temp","text":"Delete and recreate the cache or temp directory. Neither takes a request body. Both are blocked with 409 CONFLICT if any job is currently downloading (the details include the offending active_jobs).
The 409 guard is about active file I/O, not an arbitrary lock: an in-flight job may still be reading cached title data under the cache directory, and active download workers are writing segment files under temp. Removing either directory mid-download would corrupt those running jobs, so the endpoints refuse until nothing is downloading.
clear-cache also wipes download history
The persistent history read by GET /api/history lives in api_history.jsonl inside the cache directory. Because clear-cache deletes and recreates that whole directory, calling it also erases your entire download history, not just cached title data. If you want to keep history, do not clear the cache; remove individual entries with DELETE /api/history/{job_id} instead.
Response 200 { \"cleared\": true, \"freed_bytes\": 1048576 }\n
Status Error code Meaning 200 - Directory cleared. 409 CONFLICT A download is currently active."},{"location":"dev/rest-api/endpoints/#post-apimaintenancerefresh-services","title":"POST /api/maintenance/refresh-services","text":"git-pull every service repository configured under directories.services. refreshed is true when all repos updated successfully (or none are configured).
Response 200 {\n \"refreshed\": true,\n \"repos\": [\n { \"spec\": \"github.com/example/services\", \"updated\": true, \"changes\": [\"a1b2c3d Update EXAMPLE\"] }\n ]\n}\n
"},{"location":"dev/rest-api/endpoints/#get-apienvcheck","title":"GET /api/env/check","text":"Report the install status of the external binaries unshackle depends on (the same set unshackle env check inspects), with best-effort version detection.
Response 200 {\n \"checks\": [\n { \"name\": \"ffmpeg\", \"installed\": true, \"version\": \"6.1.1\", \"required\": true },\n { \"name\": \"ccextractor\", \"installed\": false, \"version\": null, \"required\": false }\n ]\n}\n
"},{"location":"dev/rest-api/endpoints/#remote-dl-sessions","title":"Remote-dl sessions","text":"Developer feature
The /api/session/* endpoints power unshackle's remote download mode, where a thin local client authenticates once against a remote server, then fetches titles, tracks, and segments and proxies DRM licensing back through it. These endpoints are exposed in --remote-only mode, and they are what the built-in RemoteService client drives. Most end users never call them directly.
Sessions are held in memory. They expire after serve.session_ttl (default 300s), except while awaiting interactive authentication input, which uses a 600s window. A session is bound to the IP that created it, so a request from a different IP is rejected with 403 FORBIDDEN.
"},{"location":"dev/rest-api/endpoints/#session-lifecycle-overview","title":"Session lifecycle overview","text":"POST /api/session/create \u2192 returns session_id, status \"authenticating\"\nGET /api/session/{id}/prompt \u2192 poll until authenticated (submit answers via POST)\nGET /api/session/{id}/titles \u2192 list titles\nPOST /api/session/{id}/tracks \u2192 tracks + chapters (with URLs) for one title\nPOST /api/session/{id}/segments \u2192 resolve segment URLs / DRM / headers per track\nPOST /api/session/{id}/license \u2192 proxy or server-CDM DRM licensing\nDELETE /api/session/{id} \u2192 tear down, harvest updated cache\n
"},{"location":"dev/rest-api/endpoints/#post-apisessioncreate","title":"POST /api/session/create","text":"Create a session for a service and title. Authentication runs asynchronously in the background. This call returns immediately with status: \"authenticating\", and you then poll the prompt endpoint. The body accepts service and title_id (both required) plus a broad set of optional keys (additionalProperties is allowed): credentials ({username, password, extra?}), cookies (base64 of zlib-compressed Netscape cookie file), proxy, no_proxy, profile, cache (map of forwarded cache files), client_region, cdm_type, range_, vcodec, quality, best_available, and any service CLI options.
Response 200 {\n \"session_id\": \"d4e5f6a7-...\",\n \"service\": \"EXAMPLE\",\n \"status\": \"authenticating\"\n}\n
Status Error code Meaning 200 - Session created; authentication in progress. 400 INVALID_INPUT Missing service or title_id. 400 INVALID_SERVICE Unknown or disallowed service."},{"location":"dev/rest-api/endpoints/#get-apisessionsession_idprompt","title":"GET /api/session/{session_id}/prompt","text":"Poll for the authentication status and any pending interactive prompt (OTP, PIN, device code). Response variants:
AuthenticatingPending inputAuthenticatedFailed { \"status\": \"authenticating\" }\n
{ \"status\": \"pending_input\", \"prompt\": \"Enter the code sent to your email:\" }\n
{ \"status\": \"authenticated\" }\n
{ \"status\": \"failed\", \"error\": \"Invalid credentials\" }\n
Status Error code Meaning 200 - Status returned. 403 FORBIDDEN Request IP differs from the session creator. 404 SESSION_NOT_FOUND No such session."},{"location":"dev/rest-api/endpoints/#post-apisessionsession_idprompt","title":"POST /api/session/{session_id}/prompt","text":"Submit a response to a pending prompt. Body: { \"response\": \"...\" } (required).
Response 200 { \"status\": \"accepted\" }\n
Status Error code Meaning 200 - Response accepted. 400 INVALID_INPUT Missing response, or no prompt is pending. 404 SESSION_NOT_FOUND No such session."},{"location":"dev/rest-api/endpoints/#get-apisessionsession_idtitles","title":"GET /api/session/{session_id}/titles","text":"Fetch the titles for an authenticated session.
Response 200 {\n \"session_id\": \"d4e5f6a7-...\",\n \"titles\": [ { \"type\": \"episode\", \"name\": \"Pilot\", \"id\": \"ep-1\" } ]\n}\n
Requires an authenticated session; 404 SESSION_NOT_FOUND otherwise.
"},{"location":"dev/rest-api/endpoints/#post-apisessionsession_idtracks","title":"POST /api/session/{session_id}/tracks","text":"Get tracks and chapters for a specific title within the session. Body: { \"title_id\": \"...\" } (required). Unlike list-tracks, track objects here include download URLs, along with session headers/cookies and manifest data the client needs to download directly.
Response 200 {\n \"title\": { \"type\": \"episode\", \"name\": \"Pilot\", \"id\": \"ep-1\" },\n \"video\": [ { \"id\": \"v-1\", \"url\": \"https://...\", \"drm\": [ ... ] } ],\n \"audio\": [ { \"id\": \"a-1\", \"url\": \"https://...\" } ],\n \"subtitles\": [ { \"id\": \"s-1\", \"url\": \"https://...\" } ],\n \"chapters\": [ { \"timestamp\": \"00:00:00.000\", \"name\": \"Intro\" } ],\n \"attachments\": [ { \"url\": \"https://...\", \"name\": \"font.ttf\", \"mime_type\": \"font/ttf\", \"description\": \"\" } ],\n \"manifests\": [ { \"type\": \"dash\", \"url\": \"https://...\", \"data\": \"<base64 zlib xml>\" } ],\n \"session_headers\": { \"User-Agent\": \"...\" },\n \"session_cookies\": { \"sid\": \"...\" },\n \"server_cdm_type\": \"widevine\"\n}\n
Status Error code Meaning 200 - Tracks returned. 400 INVALID_INPUT Missing or unknown title_id. 404 SESSION_NOT_FOUND No such session."},{"location":"dev/rest-api/endpoints/#post-apisessionsession_idsegments","title":"POST /api/session/{session_id}/segments","text":"Resolve the download URL, DRM info, headers, cookies, and raw track data for selected tracks. Body: { \"track_ids\": [\"v-1\", \"a-1\"] } (required).
Response 200 {\n \"tracks\": {\n \"v-1\": { \"descriptor\": \"DASH\", \"url\": \"https://...\", \"drm\": [ ... ], \"headers\": { ... }, \"cookies\": { ... }, \"data\": { ... } }\n }\n}\n
Status Error code Meaning 200 - Segment info returned. 400 INVALID_INPUT Missing track_ids. 404 TRACK_NOT_FOUND An unknown track ID was requested."},{"location":"dev/rest-api/endpoints/#post-apisessionsession_idlicense","title":"POST /api/session/{session_id}/license","text":"Acquire DRM keys. Two modes are supported via the mode field.
Proxy mode (mode: \"proxy\", the default) forwards the client's CDM challenge to the service's license endpoint and returns the raw license bytes:
RequestResponse 200 { \"track_id\": \"v-1\", \"challenge\": \"<base64 CDM challenge>\", \"drm_type\": \"widevine\" }\n
{ \"license\": \"<base64 license bytes>\" }\n
Server-CDM mode (mode: \"server_cdm\") runs the full CDM flow on the server using the device configured for your API key, returning content keys directly. Use track_id for a single track or track_ids for a batch:
Single: Response 200Batch: Response 200 { \"keys\": { \"<kid_hex>\": \"<key_hex>\" } }\n
{ \"keys\": { \"v-1\": { \"<kid_hex>\": \"<key_hex>\" } }, \"drm_type\": \"widevine\" }\n
Field Description track_id / track_ids Single track, or a batch of tracks. challenge Base64 CDM challenge (proxy mode). pssh Base64 PSSH (server-CDM mode). drm_type widevine (default) or playready. mode proxy (default) or server_cdm. Status Error code Meaning 200 - License/keys returned. 400 INVALID_INPUT Missing challenge/PSSH, or no CDM device configured for the key. 400 INVALID_PARAMETERS Unsupported drm_type. 404 TRACK_NOT_FOUND Unknown track ID. 404 NO_CONTENT Server CDM produced no keys. 502 SERVICE_ERROR The service aborted during licensing."},{"location":"dev/rest-api/endpoints/#get-apisessionsession_id","title":"GET /api/session/{session_id}","text":"Return session metadata and validity.
Response 200 {\n \"session_id\": \"d4e5f6a7-...\",\n \"service\": \"EXAMPLE\",\n \"valid\": true,\n \"expires_in\": 300,\n \"track_count\": 4,\n \"title_count\": 10\n}\n
404 SESSION_NOT_FOUND if the session does not exist or has expired.
"},{"location":"dev/rest-api/endpoints/#delete-apisessionsession_id","title":"DELETE /api/session/{session_id}","text":"Tear down a session. Its input bridge is cancelled, any updated cache files are harvested, and the session plus its cache directory are deleted.
Response 200 {\n \"status\": \"ok\",\n \"cache\": { \"tokens.json\": \"<base64 zlib bytes>\" }\n}\n
The cache field is present only if the session produced cache files. 404 SESSION_NOT_FOUND if the session does not exist.
"},{"location":"dev/rest-api/endpoints/#error-responses","title":"Error responses","text":"Every structured error raised inside a handler is serialized to the same JSON envelope:
{\n \"status\": \"error\",\n \"error_code\": \"INVALID_PARAMETERS\",\n \"message\": \"Invalid video codec: XYZ\",\n \"timestamp\": \"2026-07-03T12:00:00+00:00\",\n \"details\": { \"field\": \"vcodec\" },\n \"retryable\": true,\n \"debug_info\": { \"exception_type\": \"APIError\", \"traceback\": \"...\" }\n}\n
details appears only when non-empty. retryable appears only when the error is retryable (e.g. network errors, rate limiting). debug_info appears only when the server was started with --debug-api.
Authentication errors differ
Errors raised by the API-key middleware use a distinct shape ({\"status\": 401, \"message\": \"Secret Key is Empty.\"} or {\"status\": 401, \"message\": \"Secret Key is Invalid.\"}) with an integer status and no error_code, timestamp, or details. A malformed JSON body on any POST, by contrast, returns the standard envelope with error_code: \"INVALID_INPUT\".
"},{"location":"dev/rest-api/endpoints/#error-codes-and-default-status","title":"Error codes and default status","text":"Error code HTTP status INVALID_INPUT 400 INVALID_SERVICE 400 INVALID_PROXY 400 INVALID_PARAMETERS 400 AUTH_FAILED 401 FORBIDDEN 403 GEOFENCE 403 NOT_FOUND 404 NO_CONTENT 404 JOB_NOT_FOUND 404 SESSION_NOT_FOUND 404 TRACK_NOT_FOUND 404 CONFLICT 409 RATE_LIMITED 429 INTERNAL_ERROR 500 DOWNLOAD_ERROR 500 WORKER_ERROR 500 SERVICE_ERROR 502 DRM_ERROR 502 NETWORK_ERROR 503 SERVICE_UNAVAILABLE 503 When a generic (uncategorized) exception reaches a handler, unshackle inspects its message and type to pick a code. For example, messages mentioning \"connection\" or \"timeout\" become NETWORK_ERROR (503, retryable), \"rate limit\" or \"429\" become RATE_LIMITED (429, retryable), and anything unrecognized collapses to INTERNAL_ERROR (500).
"},{"location":"dev/rest-api/endpoints/#see-also","title":"See also","text":" - REST API overview. What the server is and when to use it.
- Authentication. Keys, server modes, and the
X-Secret-Key header. - Quickstart. A first end-to-end request walkthrough.
- Downloading. The
dl flags mirrored by POST /api/download.
"},{"location":"dev/rest-api/errors/","title":"Error Responses","text":"Every endpoint of the unshackle REST API reports failures in a single, predictable shape. Whether a request was rejected because a field was missing, a service refused to authenticate, or the download worker crashed, the server answers with a JSON body that carries a machine-readable error code, a human-readable message, and a matching HTTP status. This page documents that format and catalogues every error code the server can emit.
If you are building a client against the API, read this page alongside the endpoint reference. Each endpoint lists the specific codes it raises, and the meanings of those codes are defined here.
Where this comes from
The error format is defined in unshackle/core/api/errors.py, and the wrapper that turns raised errors into responses lives in unshackle/core/api/routes.py. Nothing on this page is invented; it reflects the actual server behaviour.
"},{"location":"dev/rest-api/errors/#the-standard-error-shape","title":"The standard error shape","text":"A structured error is always a JSON object with status set to the literal string \"error\". The full set of fields:
Error response body{\n \"status\": \"error\",\n \"error_code\": \"INVALID_PARAMETERS\",\n \"message\": \"vcodec must be one of: H264, H265, ...\",\n \"timestamp\": \"2026-07-03T12:34:56.789012+00:00\",\n \"details\": { \"field\": \"vcodec\" },\n \"retryable\": true,\n \"debug_info\": {\n \"exception_type\": \"APIError\",\n \"traceback\": \"...\"\n }\n}\n
Field Type Always present? Meaning status string Yes Always the literal \"error\". Lets a client distinguish an error body from a success body at a glance. error_code string Yes A stable, machine-readable code from the catalogue below. Match on this, not on message. message string Yes A human-readable description of what went wrong. Wording may change between versions, so do not parse it. timestamp string Yes ISO 8601 timestamp (UTC) of when the error response was built. details object Only when non-empty Structured, error-specific context (for example the invalid field, a list of available titles, or the set of currently active jobs). retryable boolean Only when true Present and set to true when retrying the same request may succeed later (network blips, rate limiting, transient outages). Absent otherwise. debug_info object Only with --debug-api Technical diagnostics; see Debug information. Match on error_code, branch on HTTP status
Treat error_code as the primary signal your code reacts to, and the HTTP status as a coarse fallback for anything you do not recognise. The two are consistent: every code maps to a default status (see the catalogue), and the pairing is stable across releases.
"},{"location":"dev/rest-api/errors/#the-details-object","title":"The details object","text":"When present, details carries extra context specific to the error. It is only included when it is non-empty, so a client must treat a missing details as \"no extra information\". Some concrete examples you may encounter:
- A validation failure may include the offending field or the list of valid values.
- A \"title not found\" error on a remote-dl session includes
available_titles. - A maintenance conflict includes
active_jobs, the jobs blocking the operation. - Categorised exceptions add a
reason tag, such as \"network_connectivity\" or \"geofence_restriction\".
"},{"location":"dev/rest-api/errors/#the-retryable-hint","title":"The retryable hint","text":"The retryable field appears only when it is true. It is set for conditions the server believes are transient: network errors, rate limiting, and temporary service unavailability. When you see it, backing off and retrying is reasonable; when it is absent, assume the request will keep failing until you change something.
A retryable network error
{\n \"status\": \"error\",\n \"error_code\": \"NETWORK_ERROR\",\n \"message\": \"Network error occurred: Connection timed out\",\n \"timestamp\": \"2026-07-03T12:00:00+00:00\",\n \"details\": { \"reason\": \"network_connectivity\" },\n \"retryable\": true\n}\n
"},{"location":"dev/rest-api/errors/#error-code-catalogue","title":"Error code catalogue","text":"Every code below is a member of the APIErrorCode enumeration. The Status column is the default HTTP status the server returns for that code. A handler can override the status in special cases (for example, a network error is always forced to 503), but the defaults hold for the overwhelming majority of responses.
"},{"location":"dev/rest-api/errors/#client-errors-4xx","title":"Client errors (4xx)","text":"Code Status Meaning INVALID_INPUT 400 A required field is missing, or the request body could not be parsed. Also used as the generic \"your input was malformed\" code. INVALID_SERVICE 400 The named service is unknown, or is not permitted for your API key. INVALID_PROXY 400 The supplied proxy specification could not be resolved or is malformed. INVALID_PARAMETERS 400 One or more download or query parameters failed validation (bad codec, bitrate, sort field, and so on). AUTH_FAILED 401 Authentication with the streaming service failed (bad credentials or cookies). FORBIDDEN 403 The action is not allowed. Raised by server-side gates (per-key restrictions on CDM or credential overrides) and by session IP binding. GEOFENCE 403 The content is not available in the applicable region. NOT_FOUND 404 A requested resource (title, history entry, and so on) does not exist. NO_CONTENT 404 The request was valid but produced nothing: no matching titles, tracks, episodes, or keys. JOB_NOT_FOUND 404 The referenced download job does not exist. SESSION_NOT_FOUND 404 The remote-dl session does not exist or has expired. TRACK_NOT_FOUND 404 A referenced track ID is not present in the session. CONFLICT 409 The target resource is in a state that disallows the action (retrying a running job, prioritising a non-queued job, clearing cache during an active download). RATE_LIMITED 429 The streaming service is rate-limiting requests. Retryable."},{"location":"dev/rest-api/errors/#server-errors-5xx","title":"Server errors (5xx)","text":"Code Status Meaning INTERNAL_ERROR 500 An unexpected server-side error. This is the fallback for any exception the server could not categorise. DOWNLOAD_ERROR 500 The download process itself failed. WORKER_ERROR 500 The download worker subprocess errored out. SERVICE_ERROR 502 The streaming service's API returned an error, or the service does not support the requested operation. DRM_ERROR 502 DRM or license acquisition failed. Retryable. NETWORK_ERROR 503 A network connectivity problem (connection, timeout, DNS, TLS). Retryable. SERVICE_UNAVAILABLE 503 The service is temporarily unavailable or under maintenance. Retryable. Fallback status
If a code is ever emitted without a mapped status, the server falls back to 500. In practice every code above has an explicit mapping.
"},{"location":"dev/rest-api/errors/#how-errors-are-produced","title":"How errors are produced","text":"Understanding where an error comes from helps interpret it.
Explicitly raised errors. Most endpoints raise a structured APIError directly. For example, a missing service field yields INVALID_INPUT, and a disallowed service yields INVALID_SERVICE. These carry a precise code, message, and often a populated details object.
Categorised exceptions. When an endpoint calls into a streaming service and an uncategorised exception bubbles up, the server runs it through a classifier that inspects the exception type and message and maps it to the most appropriate code. The classification is keyword-based, checked in order:
If the error mentions... It becomes Retryable auth, login, credential, unauthorized, forbidden, token AUTH_FAILED (401) no connection, timeout, network, unreachable, socket, dns, resolve (or ConnectionError, TimeoutError, URLError, SSLError) NETWORK_ERROR (503) yes geofence, region, \"not available in\", territory GEOFENCE (403) no \"not found\", 404, \"does not exist\", \"invalid id\" NOT_FOUND (404) no rate limit, too many requests, 429, throttle RATE_LIMITED (429) yes drm, license, widevine, playready, decrypt DRM_ERROR (502) no \"service unavailable\", 503, maintenance, \"temporarily unavailable\" SERVICE_UNAVAILABLE (503) yes invalid, malformed, validation (or ValueError, ValidationError) INVALID_INPUT (400) no anything else INTERNAL_ERROR (500) no Categorised errors include a reason tag inside details (such as \"network_connectivity\" or \"drm_failure\") so a client can react without parsing the message text.
Invalid JSON bodies. If a POST endpoint receives a body that is not valid JSON, the server returns INVALID_INPUT (400) with the parse error in details.error:
Malformed request body{\n \"status\": \"error\",\n \"error_code\": \"INVALID_INPUT\",\n \"message\": \"Invalid JSON request body\",\n \"timestamp\": \"2026-07-03T12:00:00+00:00\",\n \"details\": { \"error\": \"Expecting value: line 1 column 1 (char 0)\" }\n}\n
"},{"location":"dev/rest-api/errors/#the-authentication-error-exception","title":"The authentication error exception","text":"There is one place where the response does not follow the standard shape: the API key authentication middleware. When you send a request to a protected endpoint without a valid X-Secret-Key header, the middleware short-circuits with a legacy body that uses an integer status and no error_code or timestamp.
Missing keyInvalid key { \"status\": 401, \"message\": \"Secret Key is Empty.\" }\n
{ \"status\": 401, \"message\": \"Secret Key is Invalid.\" }\n
Handle both shapes
A robust client must expect two error formats: the standard structured error (string status of \"error\", with error_code), and this authentication error (integer status, no error_code). The GET /api/health endpoint is exempt from authentication, so it never returns the key-error shape.
"},{"location":"dev/rest-api/errors/#responses-with-no-body","title":"Responses with no body","text":"Not every failure or completion has a JSON body. Some endpoints answer with 204 No Content and an empty body, notably deleting a terminal download job and deleting a history entry.
Do not parse 204 bodies
A 204 response has no body. Clients must check the status code before attempting to decode JSON, or the decode will fail on an empty string.
"},{"location":"dev/rest-api/errors/#debug-information","title":"Debug information","text":"By default, error responses never include tracebacks or internal diagnostics. If the server was started with the --debug-api flag, every structured error gains a debug_info object:
Error with --debug-api enabled{\n \"status\": \"error\",\n \"error_code\": \"INTERNAL_ERROR\",\n \"message\": \"An unexpected error occurred: ...\",\n \"timestamp\": \"2026-07-03T12:00:00+00:00\",\n \"debug_info\": {\n \"exception_type\": \"KeyError\",\n \"traceback\": \"Traceback (most recent call last): ...\"\n }\n}\n
The debug_info object always carries exception_type and, for exception-derived errors, a formatted traceback. Some endpoints add extra diagnostics such as captured worker stderr.
Developer use only
--debug-api exposes internal tracebacks in responses. Enable it only for local debugging, never on a server reachable by untrusted clients.
"},{"location":"dev/rest-api/errors/#client-handling-recommendations","title":"Client handling recommendations","text":"For anyone writing a consumer of the API, a durable strategy is:
- Read the HTTP status first. It tells you the broad class of outcome and lets you handle the
204/no-body and authentication-error cases before touching the body. - Branch on
error_code. For structured errors, this is the stable identifier to switch on. Do not match on message. - Respect
retryable. When it is true, back off and retry; the reference client retries 429, 500, 502, 503, and 504 with exponential backoff. When absent, fix the request rather than repeating it. - Fall back gracefully. Unrecognised codes should be surfaced using the
message plus the HTTP status.
Reference consumer
unshackle's own remote-download client treats any status of 400 or above as fatal: it logs Server error [<error_code>]: <message> and exits. It also wraps requests in a retry adapter (five attempts, backing off on 429, 500, 502, 503, and 504). This is a reasonable template for your own client's error handling.
"},{"location":"dev/rest-api/job-lifecycle/","title":"Download Job Lifecycle","text":"When you submit a download over the REST API, the server does not block the request until the file is written. Instead it creates a job, hands it back to you with an ID, and works through it in the background. Everything you do afterwards (watching progress, cancelling, retrying, reading the result) is done by referring to that job ID.
This page explains the states a job moves through, how live progress is reported, how to cancel or retry, and, in clearly marked developer sections, how the worker and manager behind the queue actually run the download.
Who this page is for
The first half is for anyone driving the API: the states, the fields you can read, and the calls that move a job around. The Under the hood sections near the end describe the internal worker/manager model and are aimed at contributors and people debugging the server. You do not need them to use the API.
For the full request and response shape of each endpoint, see Endpoints. For how errors are reported, see Error Responses.
"},{"location":"dev/rest-api/job-lifecycle/#the-five-states","title":"The five states","text":"A job is always in exactly one of five states, exposed as the status field:
Status Meaning queued Created and waiting for a free worker. No download has started yet. downloading A worker has picked the job up and the download is in progress. completed The download finished successfully; output_files lists the results. failed The download stopped with an error; error_message and error_code explain why. cancelled The job was cancelled before it could finish, from either the queued or downloading state. The last three (completed, failed, and cancelled) are terminal: once a job reaches one of them it never changes state again. Terminal jobs can be removed, retried, or read from history, but they will not resume.
"},{"location":"dev/rest-api/job-lifecycle/#how-a-job-moves-between-states","title":"How a job moves between states","text":"stateDiagram-v2\n [*] --> queued: POST /api/download\n queued --> downloading: a worker picks it up\n queued --> cancelled: DELETE (while queued)\n downloading --> completed: download succeeds\n downloading --> failed: download errors\n downloading --> cancelled: DELETE (while downloading)\n completed --> [*]\n failed --> [*]\n cancelled --> [*]\n\n completed --> queued: POST .../retry (as a NEW job)\n failed --> queued: POST .../retry (as a NEW job)\n cancelled --> queued: POST .../retry (as a NEW job)
The transitions in prose:
queued \u2192 downloading happens automatically. As soon as one of the server's workers is free, it takes the next job off the queue and starts it. You do not trigger this; you only observe it. downloading \u2192 completed is the happy path: the download engine wrote all the requested files and reported success. downloading \u2192 failed is any error during the download: a service failure, a network problem, a DRM error, and so on. The job records what went wrong. queued/downloading \u2192 cancelled happens when you send a DELETE for a job that has not finished (see Cancelling a job). - Retry does not move a terminal job back to
queued. Instead it creates a brand-new job that reuses the old job's service, title, and parameters. The original job stays in its terminal state (see Retrying a job).
Priority does not change status
Moving a job to the front of the queue with the priority endpoint reorders when it will start, but it stays queued until a worker actually picks it up. It is not a state change.
"},{"location":"dev/rest-api/job-lifecycle/#creating-a-job","title":"Creating a job","text":"A download is queued with POST /api/download. The request needs at least a service and a title_id, plus any download options you want to set. On success the server replies with 202 Accepted and the new job's identity:
Queue a downloadcurl -X POST http://127.0.0.1:8786/api/download \\\n -H \"X-Secret-Key: your-api-secret\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"service\": \"EXAMPLE\",\n \"title_id\": \"81234567\",\n \"quality\": [\"1080\"],\n \"range\": [\"HDR10\"]\n }'\n
202 Accepted{\n \"job_id\": \"b1e6c7de-6d0a-4f2f-9d1e-3a2b4c5d6e7f\",\n \"status\": \"queued\",\n \"created_time\": \"2026-07-03T12:00:00.000000\"\n}\n
The job starts in queued. Workers auto-start on the first job submitted, so there is nothing else to switch on. Keep the job_id; every later call is addressed to it.
The full parameter set
POST /api/download accepts the same download options as the dl command (quality, codecs, language selection, wanted episode ranges, and many more), with server-side defaults filling in anything you omit. The complete list, their defaults, and validation rules are documented on the Endpoints page.
"},{"location":"dev/rest-api/job-lifecycle/#reading-a-jobs-status-and-progress","title":"Reading a job's status and progress","text":"There are two ways to look at jobs.
One job in fullAll jobs (summaries) GET /api/download/jobs/{job_id} returns the complete detail for a single job, including parameters (with secrets redacted), timing, output files, and error information if it failed.
curl http://127.0.0.1:8786/api/download/jobs/b1e6c7de-6d0a-4f2f-9d1e-3a2b4c5d6e7f \\\n -H \"X-Secret-Key: your-api-secret\"\n
GET /api/download/jobs lists every job the manager currently holds. By default each entry is a summary. Pass full=true to get the same full detail as the single-job endpoint for every job.
curl \"http://127.0.0.1:8786/api/download/jobs?status=downloading&sort_by=progress&sort_order=desc\" \\\n -H \"X-Secret-Key: your-api-secret\"\n
The list can be filtered by status and service, and ordered with sort_by (created_time, started_time, completed_time, progress, status, or service) and sort_order (asc / desc). See Endpoints for the query-parameter reference.
"},{"location":"dev/rest-api/job-lifecycle/#the-progress-fields","title":"The progress fields","text":"While a job is downloading, the following fields update roughly twice a second so a client can poll and render live progress. They are present on both the summary and the full detail:
Field Type Meaning progress number 0 to 100 Overall percent complete. phase string Human-readable phase, e.g. downloading video 1080p HDR10, audio en 5.1. title string Title being processed. current_title string The specific episode/movie in progress (for a batch). completed_tracks int Number of tracks finished so far. total_tracks int Total tracks selected for the download. active_tracks list of strings Labels of the tracks downloading right now. track_progress list Per-active-track {label, progress, speed} entries. segments_done number Segments downloaded for the track in progress. segments_total number Total segments for the track in progress. speed string Transfer speed of the track in progress, e.g. 4.20 MB/s. skipped_subtitles list Subtitles that were skipped as non-fatal failures (when skip_subtitle_errors is set). The full detail adds started_time, completed_time, output_files, the redacted parameters, and, for failed jobs, error_message, error_details, error_code, error_traceback, and worker_stderr.
A typical polling loop
JOB=b1e6c7de-6d0a-4f2f-9d1e-3a2b4c5d6e7f\nwhile true; do\n curl -s \"http://127.0.0.1:8786/api/download/jobs/$JOB\" \\\n -H \"X-Secret-Key: your-api-secret\" \\\n | python -c 'import sys,json; d=json.load(sys.stdin); print(d[\"status\"], d[\"progress\"], d.get(\"phase\"))'\n sleep 1\ndone\n
Or subscribe instead of polling
GET /api/download/jobs/{job_id}/events pushes the same job object as Server-Sent Events, and closes the stream when the job ends. See Endpoints.
What the percentage means
progress climbs from 0 toward 90 while tracks download, then the final stretch to 100 covers post-download work such as decryption cleanup, repackaging, and muxing. A job that has finished downloading but is still muxing can sit near 90 for a moment before completing. On success the manager sets it to exactly 100.
For a batch, progress is per-episode, not whole-batch
A multi-episode request (a wanted range) is a single job that processes its episodes sequentially. As each new episode begins, progress and the track counts (completed_tracks / total_tracks) reset to zero and count up again for that episode alone. The top-level bar therefore tracks the current episode, not the batch as a whole. To derive true overall completion, compare the growing output_files array against parameters.wanted rather than trusting progress.
"},{"location":"dev/rest-api/job-lifecycle/#terminal-states-in-detail","title":"Terminal states in detail","text":""},{"location":"dev/rest-api/job-lifecycle/#completed","title":"Completed","text":"A completed job has its output_files populated with the paths of the muxed results, and progress is 100. This is the state you are waiting for.
"},{"location":"dev/rest-api/job-lifecycle/#failed","title":"Failed","text":"A failed job carries the error. error_message is the short human message, error_code is one of the API's structured error codes (for example NETWORK_ERROR, DRM_ERROR, AUTH_FAILED, or DOWNLOAD_ERROR), and error_details gives more context. When the server was started with --debug-api, error_traceback and worker_stderr are also filled in for diagnosis.
Secrets are scrubbed from error text
A service can echo a credential or a proxy URL into its output. The server masks any known secret values out of error_message, error_details, error_traceback, and worker_stderr before returning them, so it is safe to log these fields.
The error-code catalogue and how each maps to an HTTP status is on the Error Responses page.
"},{"location":"dev/rest-api/job-lifecycle/#cancelled","title":"Cancelled","text":"A cancelled job was stopped on request before finishing. It may have partial output, but it is not considered complete. See below.
"},{"location":"dev/rest-api/job-lifecycle/#cancelling-a-job","title":"Cancelling a job","text":"Send DELETE /api/download/jobs/{job_id}. The behaviour depends on the job's current state, and this is important because the response differs:
Job state when you DELETE What happens Response queued or downloading The job is cancelled. A running download's worker process is terminated. 200 with {\"status\": \"success\", \"message\": \"Job cancelled\"} completed, failed, or cancelled (terminal) The job is removed from the manager entirely. 204 No Content, empty body Not found - 404 with error code JOB_NOT_FOUND In other words, DELETE on a live job cancels it, while DELETE on an already finished job deletes it from the list. This dual meaning is deliberate: one verb both stops in-flight work and tidies up afterwards.
Do not parse the body of a 204
Removing a terminal job returns 204 No Content with an empty body. A client must not try to decode JSON from it; check the status code first.
Cancel a running jobcurl -X DELETE http://127.0.0.1:8786/api/download/jobs/b1e6c7de-6d0a-4f2f-9d1e-3a2b4c5d6e7f \\\n -H \"X-Secret-Key: your-api-secret\"\n
To remove all terminal jobs at once, use POST /api/download/jobs/clear-finished, which returns {\"removed\": <count>}.
"},{"location":"dev/rest-api/job-lifecycle/#retrying-a-job","title":"Retrying a job","text":"POST /api/download/jobs/{job_id}/retry re-runs a finished job by creating a new job with the same service, title, and parameters. The original job is untouched and stays in its terminal state; the response gives you the new job's ID with 202.
- Only terminal jobs can be retried. Retrying a
queued or downloading job returns 409 with error code CONFLICT. - A missing job returns
404 (JOB_NOT_FOUND). - The service allowlist and any download gates are re-checked for the new job, exactly as they were for the original submission, so a retry can be rejected with
INVALID_SERVICE or FORBIDDEN if permissions changed.
202 Accepted, a fresh job{\n \"job_id\": \"c2f7d8ef-7e1b-5a3a-ae2f-4b3c5d6e7f80\",\n \"status\": \"queued\",\n \"created_time\": \"2026-07-03T12:30:00.000000\"\n}\n
"},{"location":"dev/rest-api/job-lifecycle/#prioritising-a-job","title":"Prioritising a job","text":"POST /api/download/jobs/{job_id}/priority moves a queued job to the front of the line, so it will be the next one a free worker picks up.
- The job must be
queued. Prioritising a job in any other state returns 409 (CONFLICT); a missing job returns 404. - On success the response is
{\"job_id\": \"<id>\", \"position\": \"front\"}.
This only reorders the queue; the job's status stays queued until a worker starts it.
"},{"location":"dev/rest-api/job-lifecycle/#history-and-retention","title":"History and retention","text":"Every job that reaches a terminal state is also written once to a persistent history log, so it survives even after the job is removed from the live list.
- Read it with
GET /api/history (newest first; supports limit and service filters). - Delete a single entry with
DELETE /api/history/{job_id} (returns 204).
The live in-memory jobs are separate from this on-disk history. A background cleanup task periodically drops old terminal jobs from the live list based on a retention period; the history file keeps them for later reference. See Endpoints for the history response shape.
Why the history file lives under the cache directory
api_history.jsonl is placed under the cache directory on purpose, so it stays out of the config/data tree. It is disposable runtime state, not user configuration or curated data, which is exactly why clearing the cache is a safe operation against it.
"},{"location":"dev/rest-api/job-lifecycle/#under-the-hood-the-workermanager-model","title":"Under the hood: the worker/manager model","text":"Developer section
Everything below describes server internals. It is useful for contributors and for anyone debugging the server, but it is not needed to use the API.
"},{"location":"dev/rest-api/job-lifecycle/#the-manager-and-its-queue","title":"The manager and its queue","text":"A single process-wide download queue manager owns all jobs. It holds them in memory keyed by ID, backs the wait list with a FIFO queue, and keeps a separate priority deque for jobs that have been bumped to the front. On the first job, it starts a fixed pool of worker tasks plus one cleanup task; starting is idempotent, so later jobs reuse the same pool.
- Concurrency is capped by
max_concurrent_downloads (default 2). That many jobs can be in downloading at once; the rest wait in queued. - Retention is governed by
job_retention_hours (default 24). An hourly cleanup task removes terminal jobs whose completion time (or, if never started, creation time) is older than the retention window.
Two concurrency knobs that are easy to conflate
max_concurrent_downloads caps how many separate download jobs run at once. The separate serve.downloads setting controls how many tracks download in parallel within a single job. They are independent, and raising one does not affect the other.
Concurrency and retention config keys
The manager reads these as top-level config attributes (config.max_concurrent_downloads and config.download_job_retention_hours). Those keys are not part of the standard config schema, so in practice the built-in defaults of 2 and 24 apply. The live values are surfaced by GET /api/config. The related serve.history_limit (default 100) instead controls how many entries the on-disk history file keeps.
Each worker loop drains the priority deque first, then the FIFO queue. A job that was promoted has its stale FIFO copy skipped so it does not run twice. When a worker takes a job it flips it to downloading, stamps started_time, runs it, and on finish stamps completed_time and records history.
"},{"location":"dev/rest-api/job-lifecycle/#each-job-runs-in-its-own-subprocess","title":"Each job runs in its own subprocess","text":"The manager does not run the download in the server's event loop or even in the server process. For each job it spawns a separate Python subprocess:
python -m unshackle.core.api.download_worker <payload.json> <result.json> <progress.json>\n
Three temporary files bridge the parent and the worker:
payload.json: written by the manager before launch; contains the job ID, service, title ID, and merged parameters. progress.json: written continuously by the worker as the download proceeds. result.json: written by the worker at the end.
Running each download out-of-process isolates the server from crashes, memory growth, and blocking work inside the download engine, and makes hard cancellation (killing the process) reliable.
The worker's exit codes are 0 for success, 1 for a download error, and 2 for bad arguments. Its result.json is either {\"status\": \"success\", \"output_files\": [...]} or {\"status\": \"error\", \"message\", \"error_details\", \"error_code\", \"traceback\"}. If the process exits non-zero, or the result is missing or not a success, the parent marks the job failed and adopts the worker's error_code (or derives one by categorising the exception).
"},{"location":"dev/rest-api/job-lifecycle/#how-progress-reaches-the-api","title":"How progress reaches the API","text":"While the worker runs, the manager polls progress.json every 0.5 s and copies the fields it finds onto the live DownloadJob. That is what the GET endpoints return.
Inside the worker, progress is aggregated across all selected tracks. Each track's completion fraction is weighted by its bitrate, so video and audio dominate the bar and subtitles barely move it (subtitles get a small fixed weight). Track fractions are kept monotonic: the percentage only ever climbs. The download portion is scaled to fill 0 to 90; the remaining 90 to 100 is driven by the download engine as it repackages and muxes. A track counts as done when it reaches a terminal state such as Downloaded, Decrypted, or a skipped subtitle.
"},{"location":"dev/rest-api/job-lifecycle/#how-cancellation-actually-works","title":"How cancellation actually works","text":"Each job carries a cancellation event. When you DELETE a live job:
- If it is
queued, it flips straight to cancelled, its history is recorded immediately (it never reaches a worker), and the worker loop skips it if it later pops it off the queue. - If it is
downloading, the manager sets the cancellation event and flips the job to cancelled. The polling loop notices the event, terminate()s the worker subprocess (escalating to kill() if it does not exit within 5 seconds), and raises a cancellation so the task unwinds. The subprocess model is what makes this a clean, immediate stop rather than a cooperative one.
"},{"location":"dev/rest-api/job-lifecycle/#secret-handling-in-serialized-jobs","title":"Secret handling in serialized jobs","text":"Job parameters may carry secrets: a raw user:pass credential, or a proxy URL with embedded userinfo. Whenever the manager serializes a job (for an API response or the history file), it masks the sensitive keys (credential, credentials, password, token, api_key) and strips userinfo from proxy URLs. Free-text fields that a service might have leaked a secret into (error_message, error_details, error_traceback, worker_stderr, and worker stdout/stderr in the logs) are scrubbed of the job's known secret values, longest-match first, before they are ever returned or logged.
"},{"location":"dev/rest-api/job-lifecycle/#see-also","title":"See also","text":" - Endpoints: the full request/response reference for every job endpoint.
- Error Responses: the error envelope, codes, and HTTP statuses a failed job reports.
- Authentication: the
X-Secret-Key header and per-key service allowlists that gate job creation and retry. - Downloading: the equivalent options on the
dl command line.
"},{"location":"dev/rest-api/quickstart/","title":"REST API Quick Start","text":"Unshackle ships with a built-in REST API that exposes the same download engine you drive from the command line. It is served by the unshackle serve command and lets you queue downloads, poll their progress, browse services, and manage history over HTTP instead of the terminal.
The full loop runs end to end: start the server, authenticate, submit a download job, and poll it to completion, using real curl commands built directly from the API routes.
Who this page is for
This is a developer guide. It assumes you are comfortable with HTTP, JSON, and a tool like curl. If you just want to download something, the command line is simpler. The REST API is meant for building tools, UIs, and automation on top of unshackle.
"},{"location":"dev/rest-api/quickstart/#before-you-begin","title":"Before you begin","text":"You need a working unshackle install with services and CDM devices already configured, exactly as you would for a normal command-line download. If unshackle dl works for you, the API will too, since it runs the identical download pipeline.
Installed as a toolFrom a clone uv tool install git+https://github.com/unshackle-dl/unshackle.git\nunshackle --help\n
uv run unshackle --help\n
"},{"location":"dev/rest-api/quickstart/#step-1-configure-an-api-secret","title":"Step 1: Configure an API secret","text":"Every request (except the health check) is authenticated with a secret key sent in the X-Secret-Key header. The server reads this key from the serve section of your unshackle.yaml config.
At minimum you must set api_secret:
unshackle.yamlserve:\n api_secret: \"choose-a-long-random-secret\"\n
If you want the API to be able to license and download DRM-protected content, you also need to grant CDM devices to the key. Configure that under serve.users, keyed by the secret:
unshackle.yamlserve:\n api_secret: \"choose-a-long-random-secret\"\n users:\n choose-a-long-random-secret:\n devices: [\"your_widevine_device\"] # Widevine .wvd stems\n playready_devices: [\"your_playready_device\"] # PlayReady .prd stems\n username: api_user\n
The secret must exist
If api_secret is not set and you do not pass --no-key, unshackle serve refuses to start and raises an error. Treat the secret like a password. Anyone who has it can queue downloads on your machine.
Disabling auth for local testing
Passing --no-key to serve turns authentication off entirely: every request is allowed and the X-Secret-Key header is ignored. This is convenient for a throwaway local test, but never expose a --no-key server to a network you do not fully trust.
"},{"location":"dev/rest-api/quickstart/#step-2-start-the-server","title":"Step 2: Start the server","text":"Start the REST API with the serve command:
unshackle serve --api-only\n
By default the server binds to 127.0.0.1:8786. The --api-only flag serves just the REST API and omits the Widevine/PlayReady CDM HTTP endpoints; drop it if you also want to expose those.
Common options:
Option Default Effect -h, --host 127.0.0.1 Address to bind to -p, --port 8786 Port to bind to --api-only off Serve only the REST API, no CDM endpoints --no-key off Disable API-key authentication (allow all requests) --debug-api off Include tracebacks and stderr in error responses --debug off Enable DEBUG-level logging --remote-only off Expose only the remote-session subset of routes --caddy off Also launch Caddy as a reverse proxy (for HTTPS) Once it is up you will see the API mounted under /api/ and, unless you started in remote-only mode, an interactive Swagger UI:
REST API endpoints available at http://127.0.0.1:8786/api/\nSwagger UI available at http://127.0.0.1:8786/api/docs/\n
Swagger UI is also behind auth
In --api-only mode (without --no-key), the Swagger UI at /api/docs/ requires the X-Secret-Key header just like every other route. Only /api/health is exempt from authentication.
For the rest of this page we will assume the server is reachable at http://127.0.0.1:8786 and store the secret in a shell variable:
export SECRET=\"choose-a-long-random-secret\"\nexport BASE=\"http://127.0.0.1:8786\"\n
"},{"location":"dev/rest-api/quickstart/#step-3-confirm-the-server-is-alive","title":"Step 3: Confirm the server is alive","text":"The health endpoint is the one route that never requires a key. Use it to confirm the server is running and to read its version:
curl \"$BASE/api/health\"\n
200 OK{\n \"status\": \"ok\",\n \"version\": \"5.3.0\",\n \"update_check\": {\n \"update_available\": false,\n \"current_version\": \"5.3.0\",\n \"latest_version\": \"5.3.0\"\n }\n}\n
The update_check block is best effort. If unshackle cannot reach the update source, update_available and latest_version come back as null rather than failing the request.
"},{"location":"dev/rest-api/quickstart/#step-4-authenticate-a-real-request","title":"Step 4: Authenticate a real request","text":"Every other endpoint needs the X-Secret-Key header. A quick way to prove your key works is to list the services the server can see:
curl \"$BASE/api/services\" \\\n -H \"X-Secret-Key: $SECRET\"\n
200 OK (truncated){\n \"services\": [\n {\n \"tag\": \"EXAMPLE\",\n \"aliases\": [\"EX\"],\n \"geofence\": [],\n \"title_regex\": null,\n \"url\": \"https://example.com\",\n \"help\": \"EXAMPLE service documentation...\",\n \"needs_auth\": true,\n \"has_search\": true,\n \"has_drm\": true,\n \"auth_methods\": [\"cookies\", \"credentials\"],\n \"cli_params\": []\n }\n ]\n}\n
If the key is missing or wrong, you get a 401 with a plain body:
401 Unauthorized{ \"status\": 401, \"message\": \"Secret Key is Invalid.\" }\n
Two error shapes exist
The authentication middleware returns the short {\"status\": 401, \"message\": \"...\"} shape shown above. Every other error the API produces uses the richer structured shape (error_code, timestamp, details, ...) described in Error handling. A robust client should handle both.
The tag value for the service you want is the identifier you pass as service in the calls below.
"},{"location":"dev/rest-api/quickstart/#step-5-submit-a-download-job","title":"Step 5: Submit a download job","text":"Downloads are asynchronous. You POST a job to /api/download, the server immediately hands back a job_id, and the actual download runs in the background so your request never blocks.
The only required fields are service and title_id. Everything else mirrors the flags of the dl command and has a sensible default, so a minimal job looks like this:
curl -X POST \"$BASE/api/download\" \\\n -H \"X-Secret-Key: $SECRET\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"service\": \"EXAMPLE\",\n \"title_id\": \"12345\"\n }'\n
A more realistic job pins the quality and codecs and asks for specific episodes:
curl -X POST \"$BASE/api/download\" \\\n -H \"X-Secret-Key: $SECRET\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"service\": \"EXAMPLE\",\n \"title_id\": \"12345\",\n \"quality\": [1080],\n \"vcodec\": \"H265\",\n \"range\": [\"SDR\"],\n \"wanted\": [\"S01E01\", \"S01E02\"]\n }'\n
On success the server responds with 202 Accepted and the identifier you will poll:
202 Accepted{\n \"job_id\": \"3f9c1e0a-8d2b-4e6f-9a1c-77b0f2a1c4d5\",\n \"status\": \"queued\",\n \"created_time\": \"2026-07-03T12:00:00.000000+00:00\"\n}\n
Capture the job_id for the next step:
JOB=$(curl -s -X POST \"$BASE/api/download\" \\\n -H \"X-Secret-Key: $SECRET\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"service\":\"EXAMPLE\",\"title_id\":\"12345\",\"quality\":[1080]}' \\\n | python -c \"import sys, json; print(json.load(sys.stdin)['job_id'])\")\n
"},{"location":"dev/rest-api/quickstart/#frequently-used-download-fields","title":"Frequently used download fields","text":"The request body accepts the full download parameter set. A few of the most useful:
Field Default Meaning service - Service tag (required) title_id - Title identifier (required) profile null Named credential/cookie profile to use quality [] Resolution(s), e.g. [1080]; empty means best available vcodec null Video codec, e.g. \"H265\" acodec null Audio codec, e.g. \"EC3\" range [\"SDR\"] Dynamic range: SDR, HDR10, HDR10+, DV, HLG, HYBRID wanted [] Episodes to fetch, e.g. [\"S01E01\"]; empty means all lang [\"orig\"] Video/audio language(s) s_lang [\"all\"] Subtitle language(s) downloads 1 Number of tracks to download concurrently proxy null Proxy URI or country code Validation is strict: invalid codecs, subtitle formats, dynamic-range names, non-positive counts, or mutually exclusive flags (for example both video_only and audio_only) come back as 400 with error code INVALID_PARAMETERS. See the full reference on the download reference page for every accepted field.
Gated fields
Passing a per-request CDM (cdm) or inline credentials (credential / credentials) is refused with 403 FORBIDDEN unless the server operator has explicitly opted in via serve.cdm_overrides and serve.allow_job_credentials. Both are off by default.
"},{"location":"dev/rest-api/quickstart/#step-6-poll-the-job-to-completion","title":"Step 6: Poll the job to completion","text":"A job moves through these states:
queued \u2192 downloading \u2192 completed | failed | cancelled\n
The last three are terminal. Poll the job detail endpoint until you reach one of them. This endpoint always returns the full job record:
curl \"$BASE/api/download/jobs/$JOB\" \\\n -H \"X-Secret-Key: $SECRET\"\n
While the download is running you will see status: \"downloading\" and a progress value climbing from 0 toward 100:
200 OK (in progress){\n \"job_id\": \"3f9c1e0a-8d2b-4e6f-9a1c-77b0f2a1c4d5\",\n \"status\": \"downloading\",\n \"progress\": 42.7,\n \"phase\": \"downloading\",\n \"service\": \"EXAMPLE\",\n \"title_id\": \"12345\",\n \"title\": \"Example Show S01E01\",\n \"current_title\": \"Example Show S01E01\",\n \"completed_tracks\": 1,\n \"total_tracks\": 3,\n \"segments_done\": 512,\n \"segments_total\": 1200,\n \"speed\": \"8.4 MiB/s\",\n \"created_time\": \"2026-07-03T12:00:00.000000+00:00\",\n \"started_time\": \"2026-07-03T12:00:03.000000+00:00\"\n}\n
When it finishes you will see status: \"completed\" and the produced files under output_files:
200 OK (completed){\n \"job_id\": \"3f9c1e0a-8d2b-4e6f-9a1c-77b0f2a1c4d5\",\n \"status\": \"completed\",\n \"progress\": 100.0,\n \"output_files\": [\"/path/to/downloads/Example Show S01E01.mkv\"],\n \"completed_time\": \"2026-07-03T12:04:31.000000+00:00\"\n}\n
A simple polling loop in shell:
poll.shwhile true; do\n STATUS=$(curl -s \"$BASE/api/download/jobs/$JOB\" \\\n -H \"X-Secret-Key: $SECRET\" \\\n | python -c \"import sys, json; print(json.load(sys.stdin)['status'])\")\n echo \"job status: $STATUS\"\n case \"$STATUS\" in\n completed|failed|cancelled) break ;;\n esac\n sleep 3\ndone\n
Polling cadence
A few seconds between polls is plenty. The server updates a job's progress roughly twice a second internally, and each poll reads the current snapshot.
Or let the server push the updates
GET /api/download/jobs/{job_id}/events streams the same job record as Server-Sent Events, and closes the stream when the job ends. It removes the polling loop above. See Endpoints.
curl -N \"$BASE/api/download/jobs/$JOB/events\" \\\n -H \"X-Secret-Key: $SECRET\"\n
If a job ends in failed, the full record includes error_message, error_code, and (when the server was started with --debug-api) error_traceback and worker_stderr to help you diagnose it. Sensitive values in these fields are scrubbed before they are returned.
"},{"location":"dev/rest-api/quickstart/#step-7-clean-up","title":"Step 7: Clean up","text":"Once you have collected the output you can remove the finished job. DELETE on a job has two behaviors depending on its state:
curl -X DELETE \"$BASE/api/download/jobs/$JOB\" \\\n -H \"X-Secret-Key: $SECRET\"\n
- If the job is terminal (completed/failed/cancelled) it is removed from the manager and you get
204 No Content with an empty body. - If the job is still queued or downloading it is cancelled instead, and you get
200 with {\"status\": \"success\", \"message\": \"Job cancelled\"}.
Do not parse JSON on 204
A 204 response has no body. This applies to terminal-job removal here and to deleting history entries; check the status code before attempting to decode JSON.
To clear every finished job at once instead of one at a time:
curl -X POST \"$BASE/api/download/jobs/clear-finished\" \\\n -H \"X-Secret-Key: $SECRET\"\n
200 OK{ \"removed\": 3 }\n
"},{"location":"dev/rest-api/quickstart/#managing-the-queue","title":"Managing the queue","text":"A handful of endpoints round out job control. All require the X-Secret-Key header.
Method Path Purpose GET /api/download/jobs List all jobs, with optional filtering and sorting GET /api/download/jobs/{job_id} Full detail for one job DELETE /api/download/jobs/{job_id} Cancel a running job, or remove a terminal one POST /api/download/jobs/{job_id}/retry Re-queue a new job from a terminal one POST /api/download/jobs/{job_id}/priority Move a queued job to the front of the queue POST /api/download/jobs/clear-finished Remove all terminal jobs Listing supports query parameters for filtering by status or service, sorting via sort_by (created_time, started_time, completed_time, progress, status, service) and sort_order (asc / desc), and requesting full per-job detail with full=true:
curl \"$BASE/api/download/jobs?status=downloading&sort_by=progress&sort_order=desc\" \\\n -H \"X-Secret-Key: $SECRET\"\n
200 OK{\n \"jobs\": [\n {\n \"job_id\": \"3f9c1e0a-8d2b-4e6f-9a1c-77b0f2a1c4d5\",\n \"status\": \"downloading\",\n \"service\": \"EXAMPLE\",\n \"title_id\": \"12345\",\n \"title\": \"Example Show S01E01\",\n \"progress\": 42.7,\n \"phase\": \"downloading\",\n \"completed_tracks\": 1,\n \"total_tracks\": 3\n }\n ]\n}\n
Retrying reuses the original job's service, title, and parameters to enqueue a brand new job (returning 202 with a fresh job_id); it only works on terminal jobs and returns 409 CONFLICT otherwise. Prioritizing only works on jobs still in the queued state.
"},{"location":"dev/rest-api/quickstart/#beyond-a-single-download","title":"Beyond a single download","text":"The download loop above is the core of the API, but the same server exposes more:
- Discovery:
POST /api/search finds titles by query, and POST /api/list-titles / POST /api/list-tracks inspect what a title offers before you commit to downloading it. See list titles and tracks. - History:
GET /api/history reads a persisted log of jobs that reached a terminal state (newest first), and DELETE /api/history/{job_id} removes a single entry. See history. - Introspection:
GET /api/profiles lists configured credential profiles per service, GET /api/config returns a redacted view of the effective server config, and GET /api/env/check reports which external binaries are installed. - Maintenance:
POST /api/maintenance/clear-cache, .../clear-temp, and .../refresh-services perform housekeeping (the cache/temp clears are blocked with 409 while a download is active). - Remote sessions: the
/api/session/* endpoints drive the thin-client \"remote download\" flow, where the server authenticates and licenses on behalf of a local client. See remote sessions.
The interactive Swagger UI at /api/docs/ documents every route and lets you try them in the browser (send your X-Secret-Key there too).
"},{"location":"dev/rest-api/quickstart/#error-handling","title":"Error handling","text":"Apart from the auth middleware's short form, every error the API returns uses a single structured envelope:
{\n \"status\": \"error\",\n \"error_code\": \"INVALID_PARAMETERS\",\n \"message\": \"Human-readable description\",\n \"timestamp\": \"2026-07-03T12:00:00Z\",\n \"details\": { }\n}\n
details appears only when there is something to add, retryable: true appears only for transient failures, and a debug_info block (exception type and traceback) is included only when the server runs with --debug-api.
The error_code maps to an HTTP status. The ones you will meet most often:
Error code HTTP Typical cause INVALID_INPUT 400 Malformed JSON body or missing required field INVALID_SERVICE 400 Unknown or disallowed service tag INVALID_PARAMETERS 400 A download field failed validation AUTH_FAILED 401 Service login/credential failure FORBIDDEN 403 A gated field was used without server opt-in NOT_FOUND 404 Title or resource does not exist JOB_NOT_FOUND 404 No job with that job_id CONFLICT 409 Illegal state transition (e.g. retry a running job) RATE_LIMITED 429 Upstream throttling (retryable) INTERNAL_ERROR 500 Unexpected server-side failure SERVICE_ERROR 502 The service raised an error NETWORK_ERROR 503 Connection/timeout reaching the service (retryable) Retry politely
Codes marked retryable (NETWORK_ERROR, RATE_LIMITED, SERVICE_UNAVAILABLE) reflect transient conditions. Back off and retry rather than hammering the server.
"},{"location":"dev/rest-api/remote-sessions/","title":"Remote Service Sessions","text":"A remote service session lets one machine do the hard, service-specific work (authenticating to a streaming service, listing titles, resolving tracks and manifests, and, optionally, DRM licensing) while another machine runs the actual download, decryption, and muxing locally.
This is the mechanism behind unshackle's client/server \"remote download\" workflow. A thin local client (RemoteService) talks to an unshackle serve instance over HTTP. The server keeps an authenticated service instance alive between requests so the client can authenticate once and then make many follow-up calls against the same session.
Why you'd use this
- The server has service accounts, cookies, or a CDM you don't want to copy to every machine.
- You want to run downloads on a fast/local machine but keep credentials and licensing centralized.
- A service is region-locked to where the server lives, and the client isn't.
"},{"location":"dev/rest-api/remote-sessions/#the-big-picture","title":"The big picture","text":"\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 HTTP (X-Secret-Key) \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 Local machine (client) \u2502 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u25b6 \u2502 unshackle serve \u2502\n\u2502 \u2502 \u2502 \u2502\n\u2502 unshackle dl --server \u2502 POST /api/session/create \u2502 authenticated Service \u2502\n\u2502 \u2502 GET /api/session/{id}/titles \u2502 instance, kept alive in \u2502\n\u2502 \u2022 track selection \u2502 POST /api/session/{id}/tracks \u2502 an in-memory session \u2502\n\u2502 \u2022 download / decrypt \u2502 POST /api/session/{id}/segments \u2502 store (TTL-based) \u2502\n\u2502 \u2022 mux \u2502 POST /api/session/{id}/license \u2502 \u2502\n\u2502 \u2502 DELETE /api/session/{id} \u2502 \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n
The client implements the same interface dl expects from a normal service, but every service-facing method proxies to the server. Everything after track selection (downloading segments, decrypting, and muxing) runs locally.
Migration: remote_dl is gone
The RemoteService adapter lives inside the ordinary dl command and replaces the old standalone remote_dl command. If you have prior scripts or docs that invoke remote_dl, use dl with a configured remote server instead. The functionality now rides on dl via RemoteService.
For the auth-facing endpoints, header format, and error shapes referenced below, see Authentication and the endpoint reference in the REST API index.
"},{"location":"dev/rest-api/remote-sessions/#configuring-a-remote-server-client-side","title":"Configuring a remote server (client side)","text":"On the client, add a remote_services block to your unshackle.yaml. Each entry names a server and provides its URL and API key.
unshackle.yaml (client)remote_services:\n my_server:\n url: \"https://my-box.example:8786\"\n api_key: \"your-api-key\"\n # Optional: let the server's CDM do the licensing instead of a local CDM\n server_cdm: false\n # Optional: per-service config overrides applied locally (title_map, cdm, etc.)\n services:\n EXAMPLE:\n title_map:\n \"0ABCDEF\": \"The Show (Renamed Locally)\"\n
Key Required Meaning url yes Base URL of the unshackle serve instance (no trailing slash needed) api_key no Sent as the X-Secret-Key header on every request server_cdm no When true, DRM keys are resolved by the server's CDM rather than a local device (default false) services no Per-service local config overrides, keyed by service tag Port default
The unshackle serve default port is 8786 (not 8080). Use the port your server actually binds. Some older example snippets show 8080, which is not the default.
"},{"location":"dev/rest-api/remote-sessions/#selecting-a-server","title":"Selecting a server","text":"If exactly one server is configured, unshackle uses it implicitly:
unshackle dl EXAMPLE 0ABCDEF\n
If more than one server is configured, you must pick one with --server:
unshackle dl --server my_server EXAMPLE 0ABCDEF\n
With no remote_services configured at all, unshackle raises a clear error telling you to add the block shown above.
"},{"location":"dev/rest-api/remote-sessions/#what-happens-during-a-download","title":"What happens during a download","text":"From your point of view the command looks like an ordinary download. Under the hood the client walks a session through its lifecycle.
1. Create + authenticate2. Interactive prompts (if needed)3. Titles + tracks4. Licensing5. Download + close The client calls POST /api/session/create, forwarding whatever it can so the server doesn't have to prompt:
- Local credentials for the service/profile (
{username, password, extra?}) - Local cookies, compressed and base64-encoded
- A resolved proxy, or, if you didn't set one, your detected client region so the server can auto-proxy to match
- Track-selection hints (
range_, vcodec, quality, best_available) so the server fetches the right manifests - Your local per-service cache files (e.g. refreshed tokens)
The server responds immediately with a session ID and a status. Authentication runs in the background on the server.
Some services need a one-time code, PIN, or device confirmation. When the server's auth flow asks for input, the session enters pending_input. The client polls, displays the prompt to you locally, collects your answer, and sends it back. The server's auth thread resumes with your response.
Enter the 6-digit code sent to your email: 483920\n
Once authenticated, the client fetches the title list, then the tracks for the chosen title. The tracks come back with playback URLs, and DASH/ISM manifests are shipped as compressed XML so the client can re-parse them locally for downloading. Any session_headers / session_cookies the server used are merged into the client's local HTTP session.
For DRM content the client either proxies its CDM challenge through the server, or, if server_cdm: true, asks the server to run the full CDM flow and return the content keys directly. See Server-side vs. proxied CDM below.
The client downloads, decrypts, and muxes locally. On completion it deletes the session. If the server has updated cache files (e.g. a refreshed token), they're returned on delete and saved locally so the next session can skip interactive auth.
Renaming remote titles locally
You can rename titles for a remote service you don't have installed locally by adding a title_map under that service in your client remote_services.<name>.services config. The server sends raw titles; your local map wins.
This is deliberate: the server does no title_map remapping of its own and sends titles exactly as the service returns them. All remapping happens on the client, applied to the titles the server sends back. It keeps the final output name fully under the client's control and lets you rename titles even for services you have no local install of.
"},{"location":"dev/rest-api/remote-sessions/#server-side-vs-proxied-cdm","title":"Server-side vs. proxied CDM","text":"There are two ways DRM keys get resolved, chosen by the client's server_cdm flag.
Proxied CDM (default)Server-side CDM (server_cdm: true) Your local CDM builds the license challenge. The client sends that challenge to POST /api/session/{id}/license, the server forwards it to the service, and the raw license bytes come back for your local CDM to parse.
- Keeps your CDM local; the server just relays the license request.
- Used when
server_cdm is false (the default).
The server's CDM does everything. The client sends track IDs (or a PSSH), the server checks its key vaults, loads the device configured for your API key, runs the CDM flow, and returns KID:KEY pairs directly. No CDM is needed on the client.
- Enable with
server_cdm: true in the server entry. - The server tells the client which DRM type it actually used (
widevine or playready).
"},{"location":"dev/rest-api/remote-sessions/#session-lifecycle-and-expiry","title":"Session lifecycle and expiry","text":"Sessions live in an in-memory store on the server and expire on a timer. You generally never touch this directly, but it explains behavior you might observe.
Behavior Value Notes Idle session TTL 300s (5 min) default Refreshed on every request to the session Max concurrent sessions 100 default Oldest (least recently used) is evicted when full Auth/input timeout 600s (10 min) Sessions still authenticating or awaiting a prompt use this longer window instead of the TTL Cleanup sweep every 60s Expired sessions are removed and their input prompts cancelled Auth is never rushed
A session that is still authenticating or waiting on pending_input is not subject to the short 300s TTL; it gets the full 600s auth window. This gives you time to enter an interactive code without the session vanishing mid-prompt.
The TTL and max-session limits are read from server config:
unshackle.yaml (server)serve:\n session_ttl: 300 # seconds a session may sit idle\n max_sessions: 100 # cap on concurrent sessions\n
"},{"location":"dev/rest-api/remote-sessions/#security-properties-worth-knowing","title":"Security properties worth knowing","text":" - API key required. Every session request carries
X-Secret-Key; requests without a valid key are rejected (health check aside). See Authentication. - IP binding. A session records the creator's IP at create time. If a later request for the same session comes from a different IP, the server returns
403 FORBIDDEN. Sessions are not portable between hosts. - Namespaced, isolated cache. Each session gets its own cache directory, namespaced by a hash of the API key and the session ID, so sessions can't read each other's cached tokens. The directory is deleted when the session ends.
- Secrets are scrubbed from logs. Session IDs, service tags, and other user values are sanitized before logging.
"},{"location":"dev/rest-api/remote-sessions/#developer-reference","title":"Developer reference","text":"This section is for developers
The material below documents the server-side implementation and the HTTP contract. End users configuring a client can stop at the sections above.
"},{"location":"dev/rest-api/remote-sessions/#session-endpoints","title":"Session endpoints","text":"All routes are exposed even in --remote-only server mode. Paths use the session_id returned by create.
Why --remote-only exists
--remote-only narrows the server to just the health, services, search, and session subset (and emits CORS headers) precisely so it is safe to sit behind Cloudflare or serve cross-origin browser clients. That trimmed surface, rather than the full --api-only mode, is what makes a CORS/Cloudflare-fronted deployment practical: reach for it when the server is public-facing or accessed from a browser origin, not when you simply want the local HTTP API.
Method Path Purpose POST /api/session/create Build + authenticate a service instance; returns a session ID immediately GET /api/session/{id}/titles Fetch and cache the title list POST /api/session/{id}/tracks Resolve tracks, manifests, chapters, headers/cookies for a title POST /api/session/{id}/segments Resolve per-segment/track download descriptors POST /api/session/{id}/license DRM licensing (proxy or server CDM) GET /api/session/{id}/prompt Poll interactive auth status / pending prompt POST /api/session/{id}/prompt Submit an answer to a pending prompt GET /api/session/{id} Session info (validity, TTL, counts) DELETE /api/session/{id} Close the session, return updated cache, clean up"},{"location":"dev/rest-api/remote-sessions/#create-request-and-response","title":"Create: request and response","text":"POST /api/session/create requires service and title_id. It also accepts credentials, cookies (base64 of zlib-compressed Netscape cookie file), proxy, no_proxy, profile, cache (a map of filename \u2192 base64(zlib(bytes))), client_region, cdm_type, and the track-selection hints range_, vcodec, quality, best_available, plus arbitrary service CLI options (additionalProperties: true).
The response returns before authentication finishes:
{ \"session_id\": \"...uuid4...\", \"service\": \"EXAMPLE\", \"status\": \"authenticating\" }\n
Authentication runs on a background thread (asyncio.to_thread(authenticate, ...)). The session starts in AUTHENTICATING; the client must poll the prompt endpoint until it reaches authenticated (or failed).
"},{"location":"dev/rest-api/remote-sessions/#interactive-auth-the-inputbridge","title":"Interactive auth: the InputBridge","text":"When the service calls its input function during authenticate() on the server thread, an InputBridge pauses that thread and exposes the prompt to the HTTP layer.
AuthStatus values: authenticating, pending_input, authenticated, failed. InputBridge.request_input(prompt, timeout=600) blocks the sync auth thread on a threading.Event until submit_response() or cancel() fires. - A timeout raises
TimeoutError and marks the session FAILED. AUTH_INPUT_TIMEOUT = 600.0 seconds. This is also the TTL granted to AUTHENTICATING / PENDING_INPUT sessions in the store.
GET /api/session/{id}/prompt returns one of:
{ \"status\": \"authenticated\" }\n{ \"status\": \"authenticating\" }\n{ \"status\": \"pending_input\", \"prompt\": \"Enter code: \" }\n{ \"status\": \"failed\", \"error\": \"...message...\" }\n
A missing session returns 404 SESSION_NOT_FOUND; an IP mismatch returns 403 FORBIDDEN. POST /api/session/{id}/prompt takes { \"response\": \"...\" } and returns { \"status\": \"accepted\" }; posting with no pending prompt is an INVALID_INPUT error.
"},{"location":"dev/rest-api/remote-sessions/#sessionstore-internals","title":"SessionStore internals","text":"Source: unshackle/core/api/session_store.py. A singleton obtained via get_session_store().
- Config-driven limits.
serve.session_ttl (default 300) and serve.max_sessions (default 100) are read as properties, so config changes take effect without recreating the store. create() evicts the least-recently-accessed session when at capacity, then stores a new SessionEntry (defaulting auth_status to AUTHENTICATED; the create handler overrides it to AUTHENTICATING). get() refreshes last_accessed via touch(). It returns None (and deletes the entry) if an authenticated session has been idle longer than the TTL. Sessions in AUTHENTICATING / PENDING_INPUT are exempt from TTL expiry. cleanup_expired() runs every 60s: authenticated sessions expire at ttl, in-flight-auth sessions expire at AUTH_INPUT_TIMEOUT. Removing a session cancels its InputBridge and deletes its cache directory (pruning empty parent dirs up to, but not including, the cache root). cancel_all_bridges() is called on server shutdown to unblock any waiting auth threads.
SessionEntry fields:
Field Description session_id UUID4 string service_tag Normalized service tag service_instance The authenticated service object kept alive between calls titles / title_map Result of get_titles() and a title_id \u2192 Title map tracks / tracks_by_title / chapters_by_title Cached resolved tracks and chapters creator_ip IP recorded at create time for IP-binding checks cache_tag Per-session cache directory tag input_bridge InputBridge for interactive auth, if any auth_status / auth_error Current AuthStatus and last error message created_at / last_accessed Timestamps; last_accessed drives TTL and LRU eviction"},{"location":"dev/rest-api/remote-sessions/#per-session-cache-namespacing","title":"Per-session cache namespacing","text":"The create handler builds a Cacher namespaced as:
_sessions/<pbkdf2_hmac(sha256, X-Secret-Key, \"unshackle-session-ns\", 100000)[:12]>/<session_id>/<service>\n
Forwarded cache files are written into that directory before authentication. On DELETE, the handler harvests updated cache files (compressing each with zlib and base64-encoding, excluding titles_* files) and returns them under a cache key so the client can persist refreshed tokens:
{ \"status\": \"ok\", \"cache\": { \"tokens\": \"...base64(zlib(bytes))...\" } }\n
"},{"location":"dev/rest-api/remote-sessions/#session-info-response","title":"Session info response","text":"GET /api/session/{id}:
{\n \"session_id\": \"...\",\n \"service\": \"EXAMPLE\",\n \"valid\": true,\n \"expires_in\": 300,\n \"track_count\": 12,\n \"title_count\": 40\n}\n
expires_in is the configured TTL, not time remaining
The expires_in value reports the store's configured session_ttl, not the seconds left before this specific session expires.
"},{"location":"dev/rest-api/remote-sessions/#reference-client-remoteservice","title":"Reference client: RemoteService","text":"Source: unshackle/core/remote_service.py. This is the canonical consumer of the session API and a good template for any client.
RemoteClient._request sets X-Secret-Key and User-Agent: unshackle/<version>, uses a 120s timeout for POST and 30s for GET/DELETE, and treats any status_code >= 400 as fatal: it logs Server error [<error_code>]: <message> and raises SystemExit(1). - Retries. The download-side HTTP session mounts an adapter with
Retry(total=5, backoff_factor=0.2, status_forcelist=[429, 500, 502, 503, 504]). - Flow.
authenticate() \u2192 create (+ poll prompt every 2s up to a 600s deadline, answering pending_input prompts) \u2192 get_titles() \u2192 get_tracks() (merging returned session_headers/session_cookies, re-parsing manifests) \u2192 license via proxy or server_cdm \u2192 close() (DELETE, saving any returned cache). - Server resolution.
resolve_server() reads config.remote_services.<name> into {url, api_key, services, server_cdm}, injecting the server_cdm flag into the services map as _server_cdm.
"},{"location":"dev/rest-api/remote-sessions/#troubleshooting","title":"Troubleshooting","text":"\\\"Could not connect to remote server ... Is it running?\\\"
The client couldn't reach the URL. Confirm unshackle serve is running on the server, the url/port in remote_services are correct (default port 8786), and any firewall or reverse proxy allows the connection.
403 FORBIDDEN mid-download
The request came from a different IP than the one that created the session. Sessions are IP-bound. Don't move between networks (or NAT egress IPs) during a remote download.
Auth times out or the prompt never resolves
Interactive auth allows up to 600s. If you miss that window the session is marked failed and cleaned up; just re-run the download. If the server never prompts you, check that credentials/cookies forwarded from the client are valid for the service.
\\\"Multiple remote services configured. Use --server ...\\\"
You have more than one entry under remote_services. Pass --server <name> to pick one.
"},{"location":"getting-started/configuration-file/","title":"The Configuration File","text":"Almost everything about how unshackle behaves is controlled by a single YAML file named unshackle.yaml: where it saves downloads, which CDM it uses, your credentials, proxies, filename templates, and more. This page covers where that file lives, how unshackle finds it, the shape of its contents, the directory layout unshackle builds around it, and how to edit values from the command line.
Where the exhaustive key list lives
This page is an overview. For the complete, key-by-key breakdown of every setting, its type, and its default, see the Configuration Reference.
"},{"location":"getting-started/configuration-file/#the-config-filename","title":"The config filename","text":"The main configuration file is always named unshackle.yaml. It is a plain YAML file. You can open and edit it in any text editor, or manage individual values with the unshackle cfg command.
If you have never created one, that is fine: unshackle runs entirely on built-in defaults when no config file exists. The file only needs to contain the keys you want to change from their defaults.
"},{"location":"getting-started/configuration-file/#where-the-config-file-lives","title":"Where the config file lives","text":"When unshackle starts, it searches a fixed list of locations and uses the first one that exists. In order of priority:
# Location Typical path 1 The unshackle package folder .../site-packages/unshackle/unshackle.yaml 2 The parent of the package folder .../site-packages/unshackle.yaml 3 Your OS user-config directory see the table below The third location, your per-user config directory, is the recommended place for most installations, because it lives outside the package and survives reinstalls and upgrades. Its exact path depends on your operating system:
LinuxWindowsmacOS ~/.config/unshackle/unshackle.yaml\n
%LOCALAPPDATA%\\unshackle\\unshackle.yaml\n
~/Library/Application Support/unshackle/unshackle.yaml\n
Not sure which file is being used?
Run unshackle env info. It prints the path the config was loaded from (or tells you none was found), along with every directory unshackle is currently using.
The config is read exactly once, when unshackle starts. If you edit the file, the change takes effect on the next command you run.
"},{"location":"getting-started/configuration-file/#top-level-structure","title":"Top-level structure","text":"unshackle.yaml is a flat map of top-level keys. Each key configures one area of the program. You only include the keys you want to change; everything else falls back to its default.
Here is a small, realistic example that sets a download location, a release tag, a default CDM, and one service's credentials:
unshackle.yaml# Where finished downloads are written\ndirectories:\n downloads: ~/Videos/unshackle\n\n# Release group tag appended to filenames\ntag: MYGRP\n\n# Default Widevine/PlayReady device to use, with per-service overrides\ncdm:\n default: my_device_l3\n EXAMPLE: my_device_l1\n\n# Per-service login details (service tag -> \"username:password\")\ncredentials:\n EXAMPLE: my_email@example.com:hunter2\n\n# Default HTTP headers merged into every request\nheaders:\n Accept-Language: en-US,en;q=0.9\n
The top-level keys group loosely into these areas:
Area Example keys Downloading & DRM dl, cdm, remote_cdm, decryption Networking network, headers Credentials & cookies credentials, firefox_cookies Tracks & muxing subtitle, audio, muxing, language_tags Key vaults key_vaults, vault_timeout Proxies & remote proxy_providers, remote_services, serve, services Naming & tagging tag, output_template, chapter_fallback_name External API keys tmdb_api_key, tvdb_api_key, simkl_client_id, ipinfo_api_key, metadata_providers Behavior & logging update_checks, redact_paths, debug, unicode_filenames Paths directories, filenames For the full list with types and defaults, see the Configuration Reference.
Unknown keys are silently ignored
unshackle does not validate your config against a schema. If you misspell a key, it is simply skipped and the default is used. You will not get an error. Double-check key names (and their nesting) if a setting does not seem to apply.
"},{"location":"getting-started/configuration-file/#setting-download-defaults-dl","title":"Setting download defaults (dl:)","text":"If you always pass the same dl flags (a language, a resolution, a codec), put them under a dl: key once and unshackle applies them to every download. Any flag from Downloading works here; the key is the flag's long name with dashes turned into underscores (--best-available \u2192 best_available).
unshackle.yamldl:\n lang: [en] # -l en\n quality: [1080] # -q 1080\n vcodec: [H.265] # -v H.265\n sub_format: srt # convert subtitles to SRT\n downloads: 2 # two tracks at once\n
You can still override any of these on the command line for a one-off; an explicit flag always beats the config default. You can also scope defaults to a single service by nesting a dl: block under it:
Per-service defaultsdl:\n lang: [en] # default for everything\nservices:\n EXAMPLE:\n dl:\n lang: [en, ja] # Example downloads English + Japanese\n
A few keys are named after the flag's internal name
Most keys are obvious, but set these exact ones: range (-r), list (--list), tmdb_id (--tmdb), imdb_id (--imdb), tvdb_id (--tvdb), no_atmos (--noatmos), and output_dir (-o). The Configuration Reference has the full list and every available key.
"},{"location":"getting-started/configuration-file/#the-directory-layout","title":"The directory layout","text":"The directories key controls where unshackle reads and writes its various files. Each directory has a sensible default, and you can override most of them by giving a new path. Paths support ~ for your home directory.
unshackle.yamldirectories:\n downloads: ~/Videos/unshackle\n temp: /mnt/fast/unshackle-temp\n cache: ~/.cache/unshackle\n
The directories unshackle uses:
Name Purpose Overridable downloads Default output folder for finished downloads Yes temp Temporary working files during a download Yes cache Cache store (title cache, update checks, service caches) Yes cookies Per-service cookie files Yes logs Log files Yes exports Export JSONs Yes wvds Widevine devices (.wvd files) Yes prds PlayReady devices (.prd files) Yes dcsl DCSL data Yes services Search paths for service code (see below) Yes commands CLI command modules Yes vaults Vault modules Yes fonts Bundled fonts Yes Some directories cannot be moved
unshackle protects five internal entries: app_dirs, core_dir, namespace_dir, user_configs, and data. If you list any of them under directories, unshackle ignores the override. This is intentional; those paths are tied to where the package is installed.
"},{"location":"getting-started/configuration-file/#the-services-directory-is-special","title":"The services directory is special","text":"Unlike the other entries, services is a list, and each entry can be either a local folder or a remote repository. This lets you mix your own local service code with services pulled from Git:
unshackle.yamldirectories:\n services:\n - you/your-services # a GitHub owner/repo shorthand\n - https://example.com/private-services.git\n - ~/code/local-services # a local folder\n
unshackle searches entries in the order listed, and the first source to define a given service tag wins, so put local folders last if you want them to act as fallbacks rather than overrides. unshackle clones remote repositories on first use and refreshes them at most once a day. See Creating a Service for how service discovery and repositories work.
"},{"location":"getting-started/configuration-file/#the-filenames-key","title":"The filenames key","text":"Alongside directories, the filenames key lets you override the templates unshackle uses when naming its own working and log files (for example the log filename or the temporary chapters file). Most users never need to touch this. The available names and their default templates are listed in the Configuration Reference.
"},{"location":"getting-started/configuration-file/#editing-the-config-with-unshackle-cfg","title":"Editing the config with unshackle cfg","text":"You do not have to edit unshackle.yaml by hand. The unshackle cfg command reads and writes individual values for you, creating the config file (and its parent directory) if it does not exist yet.
Read a single value by passing its key. Nested keys use dot notation:
$ unshackle cfg tag\n$ unshackle cfg cdm.default\n
Set a value by passing a key and a value:
$ unshackle cfg tag MYGRP\n$ unshackle cfg cdm.default my_device_l3\n
unshackle parses the value as a Python literal, so write booleans as True/False and quote the strings inside a list (\"['en']\"). Anything that is not valid Python literal syntax is stored as a plain string:
$ unshackle cfg update_checks False\n$ unshackle cfg vault_timeout 30\n
Remove a value with --unset:
$ unshackle cfg cdm.default --unset\n
List everything currently set with --list:
$ unshackle cfg --list\n
When it writes, unshackle cfg targets the config file that was loaded. If none exists yet, it creates unshackle.yaml inside the unshackle package folder (search location 1), not your OS user-config directory. To keep the config outside the package, create the file at the user-config path yourself first, then unshackle cfg writes to it.
Editing with cfg strips comments
Because unshackle cfg rewrites the whole file when it saves, any comments in unshackle.yaml are removed by a write. If you keep important notes as comments, edit the file by hand instead, or keep those notes elsewhere.
"},{"location":"getting-started/configuration-file/#next-steps","title":"Next steps","text":" - Browse the Configuration Reference for every key and default.
- Set up your first download in Downloading.
- Learn how services are discovered and updated in Creating a Service.
"},{"location":"getting-started/installation/","title":"Installation","text":"unshackle is a modular archival tool for movies, TV, and music. It ships as a single Python package named unshackle that installs one console command, also called unshackle. This page covers installing that command, the Python version it needs, and the external command-line tools it expects to find on your PATH.
What you get
Installing the package registers a single entry point, the unshackle command. It covers everything: downloading, key-vault management, device provisioning, the REST server, and the helper utilities.
"},{"location":"getting-started/installation/#requirements-at-a-glance","title":"Requirements at a glance","text":"Requirement Recommended version Needed for Python 3.11 to 3.14 Running unshackle itself uv \u2265 0.5 Installing and running unshackle FFmpeg \u2265 6.0 Media processing and analysis MKVToolNix \u2265 80 MKV muxing and metadata editing shaka-packager 2.6.1 DRM decryption Bento4 \u2265 1.6.0-639 Alternate DRM decryption (mp4decrypt) dovi_tool \u2265 2.1 Dolby Vision handling SubtitleEdit \u2265 5.0 Subtitle conversion (optional) The sections below explain each of these and how to confirm your setup is complete.
"},{"location":"getting-started/installation/#python-version-support","title":"Python version support","text":"unshackle requires Python 3.11 or newer, up to and including 3.14 (requires-python = \">=3.11,<3.15\"). Anything older than 3.11 or 3.15 and later is unsupported and the installer will refuse to build the environment.
You do not usually need to install a matching Python interpreter by hand, since uv can download and manage one for you. If you are working inside an existing virtual environment, make sure it is on a supported version.
"},{"location":"getting-started/installation/#installing-with-uv","title":"Installing with uv","text":"The recommended way to install unshackle is as a uv tool. This creates an isolated environment for the package and puts the unshackle command on your PATH without touching your system Python.
Install as a uv tooluv tool install git+https://github.com/unshackle-dl/unshackle.git\n
Once it finishes, the unshackle command is available globally:
unshackle --help\n
Upgrading and removing
Because it is installed from a git URL, re-run the same uv tool install command (uv will update in place) to pull a newer build, and use uv tool uninstall unshackle to remove it.
"},{"location":"getting-started/installation/#running-from-a-clone","title":"Running from a clone","text":"If you would rather work from a checkout of the repository (for example to try an in-development branch or to keep local service code alongside unshackle), use uv run inside the clone. This keeps the project's virtual environment active for the duration of the command, so you do not have to activate it yourself.
Run from a git clonegit clone https://github.com/unshackle-dl/unshackle.git\ncd unshackle\nuv run unshackle --help\n
Every command shown elsewhere in these docs works the same way from a clone, just prefix it with uv run:
uv run unshackle env check\n
Developer note
Contributors will also want the development and test dependency groups. The project defines dev (linters, type checkers) and test (pytest and friends) groups in pyproject.toml; install them with uv sync --group dev --group test inside the clone. End users can ignore these entirely.
"},{"location":"getting-started/installation/#external-tools-on-your-path","title":"External tools on your PATH","text":"unshackle shells out to a number of external command-line programs for media processing and DRM work. It looks for each one first in the binaries/ folder inside the installed package, and then falls back to your system PATH, so installing these tools system-wide (or dropping them into that folder) both work.
"},{"location":"getting-started/installation/#required-tools","title":"Required tools","text":"These must be present for core downloading, decryption, and muxing to work:
Tool Binary looked up Why it is needed FFmpeg ffmpeg Media processing: remuxing, conversion, stream handling FFprobe ffprobe Media analysis: inspecting tracks and container details MKVToolNix mkvmerge Muxing downloaded tracks into a final MKV mkvpropedit mkvpropedit Editing MKV metadata after muxing shaka-packager shaka-packager / packager Decrypting DRM-protected content (the default decryptor) shaka-packager is the default decryptor
unshackle decrypts with shaka-packager unless you explicitly configure a different decryptor. If it is missing, protected downloads will fail at the decryption step. See the configuration reference for the decryption key if you want to switch to mp4decrypt.
"},{"location":"getting-started/installation/#recommended-and-optional-tools","title":"Recommended and optional tools","text":"These extend unshackle's capabilities. Install the ones relevant to what you download. For example, dovi_tool only matters if you handle Dolby Vision.
Tool Binary looked up Why it is needed Bento4 (mp4decrypt) mp4decrypt Alternative DRM decryptor to shaka-packager dovi_tool dovi_tool Dolby Vision metadata handling HDR10Plus_tool hdr10plus_tool HDR10+ metadata handling SubtitleEdit seconv, SubtitleEdit Subtitle conversion (the SeConv CLI from 5.x) CCExtractor ccextractor Extracting closed captions FFplay ffplay Simple preview player MPV mpv Advanced preview player (used by util crop/util range previews) HolaProxy hola-proxy Hola proxy provider support Caddy caddy Optional reverse proxy for unshackle serve --caddy Docker docker Gluetun VPN proxy support Git git Fetching and updating remote service repositories SubtitleEdit specifics
unshackle looks for seconv first, the batch CLI shipped with SubtitleEdit 5.x, before falling back to a SubtitleEdit (4.x) binary. The 5.0 GUI has no batch mode, so the SeConv CLI is what you want for automated subtitle conversion.
"},{"location":"getting-started/installation/#verifying-your-installation","title":"Verifying your installation","text":"After installing, confirm the command runs and that unshackle can see the tools it depends on.
"},{"location":"getting-started/installation/#confirm-the-command-is-available","title":"Confirm the command is available","text":"unshackle --help\n
This prints the list of available commands (dl, env, kv, serve, wvd, prd, search, util, cfg, import). If your shell reports that unshackle is not found, the uv tool bin directory is probably not on your PATH. Run uv tool update-shell and open a new terminal.
"},{"location":"getting-started/installation/#check-external-dependencies","title":"Check external dependencies","text":"The env check subcommand inspects your environment and reports which tools it found:
unshackle env check\n
OutputFrom a clone It prints a table grouped by category (Core, DRM, HDR, Subtitle, Player, Network) with a status column: a green check for tools that resolved and a red cross for those it could not find, plus a summary line such as All required tools installed \u2713 or Missing required: <names>.
uv run unshackle env check\n
If a required tool shows as missing, install it and put it on your PATH (or in the package's binaries/ folder), then re-run the check.
"},{"location":"getting-started/installation/#inspect-environment-paths","title":"Inspect environment paths","text":"To see where unshackle will read its configuration from and where it stores downloads, caches, cookies, and devices, use:
unshackle env info\n
If no configuration file exists yet, this command lists the locations unshackle searches for one, so you know where to create it. See the configuration guide for the details of that file.
"},{"location":"getting-started/installation/#next-steps","title":"Next steps","text":" - Set up your configuration file (
unshackle.yaml). - Start downloading content once your tools check out.
"},{"location":"getting-started/quickstart/","title":"Quick Start","text":"This page takes you from a fresh install to your first downloaded file. You will create the one piece of configuration unshackle insists on, supply the service module and CDM it needs to fetch and decrypt media, run a basic unshackle dl command, and learn where the finished file lands.
Before you start
This guide assumes unshackle is already installed and on your PATH. If unshackle --help does not print a help screen, work through Installation first.
unshackle includes no services
unshackle handles manifests, DRM, downloading, and muxing. It does not include a module for any streaming platform, so you write that part yourself (step 3). EXAMPLE in this guide stands in for the tag you give yours.
"},{"location":"getting-started/quickstart/#1-check-your-environment","title":"1. Check your environment","text":"unshackle drives several external tools (FFmpeg, MKVToolNix, shaka-packager, and others) to decrypt, repack, and mux media. Confirm they are visible before you download anything:
unshackle env check\n
This prints a dependency table. The required tools (FFmpeg, FFprobe, MKVToolNix, mkvpropedit, and shaka-packager) must show a green check. Optional tools such as dovi_tool (Dolby Vision) and CCExtractor (closed captions) only matter for the features that use them.
Tip
The summary line at the bottom reports installed/total and lists anything required that is still missing, so you know exactly what to install next.
"},{"location":"getting-started/quickstart/#2-create-a-minimal-config","title":"2. Create a minimal config","text":"unshackle reads a single YAML file named unshackle.yaml. To see where it looks for that file, and where it would accept one if you have not made it yet, run:
unshackle env info\n
If no config exists, this prints the candidate locations. In search order they are:
unshackle.yaml inside the unshackle package folder. unshackle.yaml in that folder's parent. unshackle.yaml in your OS user-config directory (~/.config/unshackle/ on Linux, %LOCALAPPDATA%\\unshackle\\ on Windows, ~/Library/Application Support/unshackle/ on macOS).
The first file that exists wins. Create unshackle.yaml in one of those locations.
"},{"location":"getting-started/quickstart/#the-one-key-you-must-set","title":"The one key you must set","text":"unshackle refuses to start a download unless it knows how to name the output file. That means output_template is the one setting a first run genuinely requires. Everything else has a sensible default. A minimal, working config looks like this:
unshackle.yamloutput_template:\n movies: \"{title} ({year}) {quality} {source}\"\n series: \"{title} {season_episode} {episode_name?} {quality} {source}\"\n
Each {variable} is filled in from the title and the tracks you downloaded. The full set of valid variables, including resolution, video, audio, hdr, edition, tag, and more, is documented in Output and Naming.
Spaces or dots?
unshackle auto-detects your naming style from the template: if the separators between variables are mostly spaces, it uses spaces; if they are mostly dots, it produces scene-style Title.S01E01.1080p names. Write the template in the style you want the filenames to look.
Editing config from the CLI strips comments
You can read and set keys with unshackle cfg (for example unshackle cfg tag MYGROUP or unshackle cfg --list), but writing a value rewrites the file and removes any comments it contained. If you keep notes in your config, edit the file by hand instead.
"},{"location":"getting-started/quickstart/#3-write-a-service","title":"3. Write a service","text":"The service tag you pass to dl (like EXAMPLE) maps to a service module, the plugin that talks to one streaming platform. unshackle includes none, so you write the one you need. Creating a Service covers how.
Put the finished module in your directories.services folder (unshackle env info shows the path). The tag then works with dl, search, and the other service commands.
"},{"location":"getting-started/quickstart/#4-add-a-cdm-for-drm","title":"4. Add a CDM for DRM","text":"Most streaming content is encrypted. To fetch decryption keys, unshackle needs a CDM, a Widevine device (.wvd) or a PlayReady device (.prd). Register a Widevine device you already have with:
unshackle wvd add /path/to/device.wvd\n
This validates the file and moves it into your WVDs directory. Then point services at it in your config. The cdm map is keyed by service tag, with a default that covers everything else:
unshackle.yamlcdm:\n default: my_device # the .wvd file's name, without the extension\n EXAMPLE: my_other_device # override for a specific service\n
PlayReady works the same way with .prd files created and managed by the prd command. If a title is DRM-free, no CDM is needed.
Tip
Run unshackle wvd parse my_device to inspect a device's security level and contents, and unshackle env info to confirm where WVDs and PRDs are stored.
"},{"location":"getting-started/quickstart/#5-provide-authentication","title":"5. Provide authentication","text":"Services that require a login read either cookies or credentials.
-
Cookies: export the service's cookies to a Netscape-format text file and place it in your cookies directory. unshackle looks for, in order: cookies/{SERVICE}.txt, then cookies/{SERVICE}/{profile}.txt, then cookies/{SERVICE}/default.txt. So a file at cookies/EXAMPLE.txt is picked up automatically for the EXAMPLE service.
-
Credentials: store a username and password per service in your config:
unshackle.yamlcredentials:\n EXAMPLE: \"email@example.com:your-password\"\n
Use the -p/--profile flag to switch between multiple accounts for the same service. Whether a given service needs cookies, credentials, or nothing at all depends on the service module.
"},{"location":"getting-started/quickstart/#6-find-a-title-optional","title":"6. Find a title (optional)","text":"If you have a URL or ID already, skip this. Otherwise, search the service for a title and note the id it prints, since that is what you feed to dl:
unshackle search EXAMPLE \"My Show\"\n
You can also list what a service exposes for a given title without downloading:
unshackle dl --list-titles EXAMPLE 81234567 # show seasons/episodes\nunshackle dl --list EXAMPLE 81234567 # show available tracks\n
"},{"location":"getting-started/quickstart/#7-run-your-first-download","title":"7. Run your first download","text":"A download always has three parts:
unshackle dl <FLAGS> <SERVICE-TAG> <TITLE>\n
dl carries every quality, language, track, and output flag. <SERVICE-TAG> picks which service to talk to (e.g. EXAMPLE). <TITLE> is the URL, ID, or slug the service understands.
A good first command asks for 1080p with English audio:
Your first downloadunshackle dl -q 1080 -l en EXAMPLE 81234567\n
unshackle will fetch the title, select the tracks matching your flags, acquire keys through your CDM (and any key vaults), then decrypt, mux, and tag the result.
"},{"location":"getting-started/quickstart/#handy-first-flags","title":"Handy first flags","text":"Flag Meaning -q, --quality Target resolution(s), e.g. -q 1080 or -q 1080,720. Defaults to best available. -l, --lang Language(s) for video and audio, e.g. -l en or -l orig,en. orig = the title's original language. Defaults to orig. -sl, --s-lang Subtitle language(s); defaults to all. -v, --vcodec Video codec, e.g. -v H.265. Defaults to any. -r, --range Dynamic range, e.g. -r HDR10 or -r DV. Defaults to SDR. -w, --wanted Which episodes, e.g. -w S01 or -w S01E01-S01E03. -o, --output Override the output directory for this run. --list List the tracks that would be downloaded, then stop. A few realistic variations
# A whole first season in the best available quality\nunshackle dl -w S01 EXAMPLE 81234567\n\n# 2160p HDR10 with the original-language audio plus English subtitles\nunshackle dl -q 2160 -r HDR10 -l orig -sl en EXAMPLE 81234567\n\n# Just the newest episode of an ongoing show\nunshackle dl --latest-episode EXAMPLE 81234567\n
See Downloading for the complete flag reference, including codec, bitrate, channel-layout, and track-type selection.
"},{"location":"getting-started/quickstart/#8-where-the-output-lands","title":"8. Where the output lands","text":"By default, unshackle writes finished files to the downloads directory (unshackle env info shows its exact path; the built-in default is a downloads folder one level above the installed unshackle package). Override it per run with -o:
unshackle dl -q 1080 -o /mnt/media/incoming EXAMPLE 81234567\n
- unshackle writes movies as a single
.mkv file named from your movies template. - TV episodes are grouped into a per-show / per-season folder (from your
series template) unless you pass --no-folder. - unshackle builds the filename from your
output_template and adds IMDb/TMDB/TVDB IDs to the file's metadata tags when available.
To change the default output location permanently, set it in your config:
unshackle.yamldirectories:\n downloads: /mnt/media/incoming\n
"},{"location":"getting-started/quickstart/#where-to-go-next","title":"Where to go next","text":" - Downloading. The full
dl command: quality, codecs, languages, track selection, hybrid Dolby Vision, and output control. - Configuration. Every
unshackle.yaml key, plus directories, key vaults, proxies, and naming templates. - REST API. Run the
serve server to drive downloads over HTTP.
You are never far from help
unshackle --help, unshackle dl --help, and unshackle <command> --help list every option. When in doubt, add --list to a dl command to preview what it would do before it downloads anything.
"},{"location":"guide/cli-reference/","title":"CLI Reference","text":"A complete reference for every unshackle command, subcommand, and flag. Commands are grouped by purpose:
dl: the download pipeline (the command you use most). search: find titles on a service. import: rebuild a download from an export file. cfg, env: manage configuration and the environment. kv: Key Vault operations. wvd, prd: Widevine / PlayReady device management. serve: run the local CDM + REST API server. util: helper media utilities.
Getting help on any command
Every command accepts -h, --help, or -?. For example unshackle dl --help or unshackle wvd new --help. Service-specific arguments for dl and search (such as a title ID or search query) are defined by each service, so check unshackle dl SERVICE --help for those.
"},{"location":"guide/cli-reference/#root-command","title":"Root command","text":"unshackle [OPTIONS] COMMAND [ARGS]...\n
unshackle: Modular Movie, TV, and Music Archival Software.
Every invocation prints a banner and, when update_checks is enabled in your config, checks for a newer release.
Option Description -v, --version Print version information and exit. -d, --debug Enable DEBUG-level logs and JSON debug logging. Silences noisy HTTP libraries unless debug_requests is set in config. -h, --help, -? Show help and exit. Commands are auto-discovered, so third-party command modules dropped into the commands directory appear automatically.
"},{"location":"guide/cli-reference/#dl","title":"dl","text":"The download command. It is itself a group whose subcommands are the installed streaming services, so an invocation always has three layers:
unshackle dl [OPTIONS] SERVICE [SERVICE ARGS...]\n
OPTIONS: every flag below, parsed at the dl level. 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.
Typical downloads
# Best available 1080p SDR of a title\nunshackle dl -q 1080 EXAMPLE 81234567\n\n# 4K HDR10, English audio + subs, from a URL\nunshackle dl -q 2160 -r HDR10 -l en EXAMPLE \"https://www.example.com/...\"\n\n# A season range, HEVC video, EC3 audio\nunshackle dl -w S01-S03 -v H.265 -a EC3 SERVICE TITLE-ID\n
Config-driven defaults
Any dl flag can be given a default under the dl: section of unshackle.yaml, and per-service under services.<TAG>.dl. Explicit command-line values (and environment values) always win over both; service defaults only fill in options you did not set. See the configuration file guide.
"},{"location":"guide/cli-reference/#quality-codec-bitrate-range","title":"Quality, codec, bitrate & range","text":"Flag Default Description -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. -r, --range SDR Colour range(s): SDR, HLG, HDR10, HDR10P (HDR10+), DV, HYBRID. -c, --channels - Audio channels; matches sub-layouts (5.1 \u2248 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. -rvb, --real-video-bitrate off Probe real media size for true video bitrates, overriding the manifest. -rab, --real-audio-bitrate off Same for audio (slower). HYBRID range
-r HYBRID fetches both HDR10/HDR10+ and Dolby Vision and merges them with dovi_tool. It requires the dovi_tool binary on your PATH.
"},{"location":"guide/cli-reference/#language-subtitles","title":"Language & subtitles","text":"Flag Default Description -l, --lang orig Language(s) for both video and audio. orig = the title's original language; e.g. orig,en. A - prefix excludes, e.g. all,-es. -vl, --v-lang - Video-only language (overrides -l for video). A - prefix excludes. -al, --a-lang - Audio-only language (overrides -l for audio). A - prefix excludes. -sl, --s-lang all Subtitle language(s). A - prefix excludes, e.g. all,-es. --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. -fsl, --forced-s-lang none Language(s) wanted for forced subtitles; implies -fs. A - prefix excludes. --exact-lang off Exact matching only: -l es-419 matches es-419, not es-ES. Applies to selection and to sort order. --sub-format - Output subtitle format (SRT/srt, VTT/webvtt, ASS/ssa, TTML, SMI, ...), or original to keep the source format. These flags select languages, they do not order them
Naming languages with -l or -sl removes the others. To keep every language but put your preferred ones first, use audio.language_priority and subtitle.language_priority in your configuration file.
The special language tokens orig, all, and best are honoured everywhere a language is expected.
"},{"location":"guide/cli-reference/#title-episode-selection","title":"Title & episode selection","text":"Flag Description -w, --wanted Wanted episodes, e.g. S01-S05,S07, S01E01-S02E03. Supports exclusions with a leading - (e.g. -S03). For a split episode, .N picks one part (S01E01.2), a range must stay inside the episode (S01E01.1-S01E01.3), and S01E01 on its own takes every part. For dated content, a token can also be an ISO air date (2026-08-11) or a date range with a colon (2026-08-01:2026-08-31). --select-titles Interactively select what to download: episodes of a series, or films when a title has more than one. Cannot combine with -w. --latest-episode Download only the single most recent episode. --list-titles List titles only; do not download."},{"location":"guide/cli-reference/#track-type-inclusion-exclusion","title":"Track-type inclusion / exclusion","text":"Keep only certain track types, or skip certain track types. Attachments are always kept.
Include-only Skip -V, --video-only -nv, --no-video -A, --audio-only -na, --no-audio -S, --subs-only -ns, --no-subs -C, --chapters-only -nc, --no-chapters Flag Description -ad, --audio-description Include descriptive (audio-description) tracks. --skip-subtitle-errors Skip a failed subtitle instead of aborting the title. Video/audio failures remain fatal."},{"location":"guide/cli-reference/#output-muxing-files","title":"Output, muxing & files","text":"Flag Description --split-audio Write a separate output file per audio codec instead of merging. Defaults to config muxing.merge_audio. --merge-video Mux all selected video tracks into one file. Defaults to config muxing.merge_video. -o, --output Override the output directory for this run. --no-folder Disable folder creation for TV shows. --no-source Remove the source tag from the filename/path. --no-mux Do not mux; keep individual track files. --tag Group tag override. --repack Add a REPACK tag to the filename."},{"location":"guide/cli-reference/#metadata-tagging","title":"Metadata & tagging","text":"Flag Description --tmdb TMDB ID (integer). Used for the tags. Skips the title search. --enrich reads it too. Needs tmdb_api_key. --imdb IMDb ID, e.g. tt1375666. Used for the tags. Skips the title search. --enrich reads it too. Needs no key. --tvdb TVDB ID (integer). Used for the tags. Skips the series lookup that --tvdb-order would otherwise do. --enrich reads it too. Needs tvdb_api_key. --anilist AniList ID (integer), e.g. --anilist 21. A MyAnimeList ID is accepted as mal:12345 and resolved to the AniList entry. Used for the tags. Skips the title search. --enrich reads it too. Needs no key. --enrich Overwrite show title, year and original language with the external source's. Requires one of --tmdb, --imdb, --tvdb, or --anilist. --daily Treat the title as daily/date-based content and fill missing episode air dates from TVDB. The fill needs --enrich and a TVDB ID. See Daily and date-based content. --tvdb-order Renumber episodes to a TVDB season order: official (aired), dvd, absolute, alternate, or regional. Needs tvdb_api_key. One ID at a time
--tmdb, --imdb and --tvdb cannot be combined. Give one and unshackle resolves the others from it, writing all three to the tags. --anilist is outside that rule and pairs with one of them, which is how you attach a western ID to an anime title. An ID with no provider that can resolve it, such as --tmdb with no tmdb_api_key, fails before the download starts. --anilist needs no key, so it never fails this check.
"},{"location":"guide/cli-reference/#episode-ordering","title":"Episode ordering","text":"A service does not always number a series the way TVDB's aired order does. Some services list Futurama in TVDB's alternate (Streaming) order, for example. --tvdb-order works out which order the service used, then renumbers the episodes into the order you asked for.
Orders that do not cover the whole series
An order can omit episodes the service carries; TVDB's dvd order for Futurama leaves out the four movies. Those episodes keep their original numbering. If that would give two episodes the same season/episode slot, and so the same filename, unshackle logs an error and keeps the service's numbering unchanged. Pick an order that covers the whole series.
"},{"location":"guide/cli-reference/#drm-keys-decryption","title":"DRM, keys & decryption","text":"Flag Description --cdm-only / --vaults-only Use only the CDM, or only Key Vaults, for key acquisition. --skip-dl Skip the download but still retrieve keys. --export Export track info and keys to a JSON file in the exports directory."},{"location":"guide/cli-reference/#network-proxy","title":"Network & proxy","text":"Flag Description --proxy Proxy URI, a 2-letter country code resolved from configured providers, or provider:region (e.g. nordvpn:ca, gluetun:us, protonvpn:de:berlin). --no-proxy Force-disable all proxy use. --no-proxy-download Bypass the proxy for all downloads (manifest, licence, and auth stay proxied). --remote Use a remote unshackle server. --server Name a remote server from the remote_services config."},{"location":"guide/cli-reference/#concurrency-caching-listing","title":"Concurrency, caching & listing","text":"Flag Default Description --workers downloader default Per-track download threads. --downloads 1 Number of tracks downloaded concurrently. --speed-limit unlimited Cap total download speed across all threads and tracks, e.g. 500k, 5M, 1.5G or plain bytes/sec. Values are bytes, not bits (5M = 5.0 MB/s). off disables a configured limit. --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). 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.
"},{"location":"guide/cli-reference/#search","title":"search","text":"Search a service for titles. Like dl, search is a group whose subcommands are the installed services, and it reuses dl's authentication, cookie, and proxy machinery.
unshackle search [OPTIONS] SERVICE [QUERY...]\n
The query syntax is defined per service. Results are printed as a tree of titles with their service IDs. Feed an ID straight into dl.
Option Description -p, --profile Profile for credentials and cookies. --proxy Proxy URI, 2-letter country code, or provider:region. --no-proxy Force-disable all proxy use. Example
unshackle search EXAMPLE \"My Show\"\nunshackle search -p myprofile EXAMPLE \"Another Show\"\nunshackle search --proxy nordvpn:ca SERVICE \"query\"\n
"},{"location":"guide/cli-reference/#import","title":"import","text":"Reconstruct a download (download \u2192 decrypt \u2192 mux) from an --export JSON file without re-contacting the service. It re-fetches the manifest, injects the stored keys, and then runs the normal dl pipeline. The service tag is read from the export file, not passed by you.
unshackle import EXPORT_FILE [DL_ARGS...]\n
Any dl options placed after the file are forwarded verbatim, so you can override quality, range, proxy, and so on.
Example
unshackle import export.json\nunshackle import export.json -r HDR10 --proxy US\n
The export file must be a valid v2 export (created by a current build of unshackle via dl --export) and must contain a service tag.
"},{"location":"guide/cli-reference/#cfg","title":"cfg","text":"Read, set, delete, or list configuration values in unshackle.yaml without hand-editing YAML.
unshackle cfg [KEY] [VALUE] [OPTIONS]\n
Argument / Option Description KEY Dotted path into the config, e.g. tag, serve.api_secret, directories.downloads. VALUE Value to set. Parsed as a Python literal when possible, so true, 123, ['a','b'], and {'k':1} become real types; bare words stay strings. --unset Remove the configuration value. --list List all set configuration values. Comments are stripped
Writing a value through cfg rewrites the YAML file and removes all comments from it. If your config relies on comments, edit it by hand instead. Setting a value and using --unset together is an error.
Example
unshackle cfg --list # print entire config\nunshackle cfg serve.api_secret # read a nested key\nunshackle cfg tag MYGROUP # set a string\nunshackle cfg update_checks false # literal \u2192 boolean False\nunshackle cfg directories.downloads /mnt/dl # set a nested path\nunshackle cfg tag --unset # remove a key\n
"},{"location":"guide/cli-reference/#env","title":"env","text":"Inspect and manage the project environment.
unshackle env COMMAND [ARGS]...\n
"},{"location":"guide/cli-reference/#env-check","title":"env check","text":"unshackle env check\n
Prints a dependency table (Category / Tool / Status / Required / Purpose) showing which external tools resolve on your PATH, and a summary of how many required tools are installed.
Category Tools Core FFmpeg*, FFprobe*, MKVToolNix*, mkvpropedit* DRM shaka-packager*, mp4decrypt HDR dovi_tool, HDR10Plus_tool Subtitle SubtitleEdit, CCExtractor Player FFplay, MPV Network HolaProxy, Caddy, Docker, git * required; all others are optional.
"},{"location":"guide/cli-reference/#env-info","title":"env info","text":"unshackle env info\n
Shows where the config was loaded from (or lists the candidate config locations if none was found) and prints a table of every configured directory (downloads, temp, cache, cookies, logs, exports, WVDs, PRDs, services, and more).
"},{"location":"guide/cli-reference/#env-theme","title":"env theme","text":"unshackle env theme\n
Prints a sample of every available CLI theme: colour swatches, help text with option rows, a track listing with video/audio/subtitle entries, log lines, a progress bar, and the gradient pulse bar. The active theme is marked and each theme's aliases are listed. Set your choice with the theme config key.
"},{"location":"guide/cli-reference/#env-clear","title":"env clear","text":"Clear an environment directory. The directory is emptied and recreated, and the number of files and bytes freed is reported.
Command Description unshackle env clear cache [SERVICE] Clear the cache directory, or just one service's cache subdirectory. unshackle env clear temp Clear the temp directory. Example
unshackle env check\nunshackle env info\nunshackle env theme\nunshackle env clear cache\nunshackle env clear cache EXAMPLE # one service only\nunshackle env clear temp\n
"},{"location":"guide/cli-reference/#kv","title":"kv","text":"Manage Key Vaults. Vaults are configured under key_vaults in unshackle.yaml, each with a name, a type (sqlite, mysql, http, api), and type-specific options. Service tags are normalised automatically.
unshackle kv COMMAND [ARGS]...\n
"},{"location":"guide/cli-reference/#kv-copy-to_vault-from_vault","title":"kv copy TO_VAULT FROM_VAULT...","text":"Copy keys from one or more source vaults into a destination vault. Rows with matching KIDs are skipped unless the existing row has no key; existing data is never altered or deleted.
Option Description -s, --service Only copy data for a specific service. -l, --local-only Only copy data for services installed locally. Mutually exclusive with --service."},{"location":"guide/cli-reference/#kv-sync-vault","title":"kv sync VAULT...","text":"Ensure two or more vaults hold the same set of keys, essentially a chained bidirectional copy. Requires more than one vault. Accepts the same --service / --local-only options as copy.
"},{"location":"guide/cli-reference/#kv-add-file-service-vault","title":"kv add FILE SERVICE VAULT...","text":"Add content keys to one or more vaults for a service. FILE contains one KID:KEY pair per line (32 hex : 32 hex, UTF-8). Lines that don't match are skipped.
"},{"location":"guide/cli-reference/#kv-search-kid","title":"kv search KID","text":"Search configured vaults for a KID (32 hex characters, no dashes) and report any matching key.
Option Description -s, --service Limit the search to a specific service tag. -v, --vault Limit the search to a specific configured vault by name. Note
Remote vaults cannot be enumerated without a service, so pass --service when searching them.
"},{"location":"guide/cli-reference/#kv-prepare-vault","title":"kv prepare VAULT...","text":"Create service tables on vaults that use tables, for every installed service, where they don't already exist.
Example
unshackle kv copy main backup1 backup2\nunshackle kv copy main backup -s EXAMPLE\nunshackle kv sync main mysql_vault\nunshackle kv add keys.txt EXAMPLE main backup\nunshackle kv search 0123456789abcdef0123456789abcdef -s EXAMPLE -v main\nunshackle kv prepare main mysql_vault\n
"},{"location":"guide/cli-reference/#wvd","title":"wvd","text":"Manage Widevine Device (.wvd) files. Devices live in the configured WVDs directory.
unshackle wvd COMMAND [ARGS]...\n
Command Description wvd add PATHS... Validate and move one or more .wvd files into the WVDs directory. wvd delete NAMES... Delete .wvd files by name (without extension). Prompts for confirmation. wvd parse PATH Parse a .wvd and print its System ID, security level, type, flags, and client info. Relative paths resolve against the WVDs directory. wvd dump WVD_PATHS... OUT_DIR Extract a device's contents (metadata, private key, client ID, VMP) into OUT_DIR/<name>/. With no paths, dumps every WVD in the WVDs directory. wvd new NAME PRIVATE_KEY CLIENT_ID [FILE_HASHES] Create a new .wvd from a PEM private key and a ClientIdentification blob, optionally with a VMP (FileHashes) blob. wvd new options:
Option Default Description -t, --type Android Device type. -l, --level 1 Security level (1-3). -o, --output WVDs dir Output directory. Example
unshackle wvd add device.wvd\nunshackle wvd parse nexus_6p_1234_l1\nunshackle wvd dump device.wvd ./out_dir\nunshackle wvd new \"Nexus 6P\" private_key.pem client_id.bin vmp.bin -t ANDROID -l 3\n
"},{"location":"guide/cli-reference/#prd","title":"prd","text":"Manage PlayReady Device (.prd) files. Devices live in the configured PRDs directory. Built on pyplayready.
unshackle prd COMMAND [ARGS]...\n
"},{"location":"guide/cli-reference/#prd-new-paths","title":"prd new PATHS...","text":"Create a new .prd. Provide either a single folder containing zgpriv.dat (group key) and bgroupcert.dat (group certificate), or two file paths (group key, then group certificate).
Option Description -e, --encryption_key Optional device ECC private encryption key (generated if omitted). -s, --signing_key Optional device ECC private signing key (generated if omitted). -o, --output Output directory or .prd file path. prd new will not overwrite an existing file.
"},{"location":"guide/cli-reference/#prd-reprovision-prd_path","title":"prd reprovision PRD_PATH","text":"Reprovision an existing device by replacing its leaf certificate and keys. The device must support reprovisioning (version 3 or higher). Accepts the same -e / -s / -o options; defaults to overwriting the device in place.
"},{"location":"guide/cli-reference/#prd-test-device","title":"prd test DEVICE","text":"Test a device against the Microsoft PlayReady demo server and print the returned keys.
Option Default Description -c, --ckt aesctr Content key encryption type: aesctr or aescbc. -sl, --security-level 2000 Minimum security level: 150, 2000, or 3000. Example
unshackle prd new ./device_folder/\nunshackle prd new zgpriv.dat bgroupcert.dat -o /out/dir\nunshackle prd reprovision mydevice.prd\nunshackle prd test mydevice.prd -sl 3000 -c aescbc\n
"},{"location":"guide/cli-reference/#serve","title":"serve","text":"Serve your local Widevine/PlayReady devices and the REST API for remote access. Built on aiohttp.
unshackle serve [OPTIONS]\n
Option Default Description -h, --host 127.0.0.1 Host to serve from. -p, --port 8786 Port to serve from. --caddy off Also serve through Caddy (requires the caddy binary and a Caddyfile). --api-only off Serve only the REST API, not the CDM endpoints. Incompatible with --no-widevine/--no-playready. --no-widevine off Disable the Widevine CDM endpoints. --no-playready off Disable the PlayReady CDM endpoints. --no-key off Disable API-key authentication (allows all requests). --debug-api off Include tracebacks/stderr in API error responses. --debug off Enable debug logging for API operations. --remote-only off Expose only the remote service session endpoints (health, services, search, session). Implies --api-only. WVD files in the WVDs directory and PRD files in the PRDs directory are auto-loaded. The REST API lives under http://<host>:<port>/api/, with Swagger UI at /api/docs/ and a health check at /api/health (exempt from auth).
Configure api_secret first
Unless you pass --no-key, serve.api_secret must be set in your config. Requests authenticate with the X-Secret-Key header. Running with --no-key disables authentication entirely and should only be used on a trusted, private network.
unshackle.yamlserve:\n api_secret: \"your-api-secret\"\n users:\n your-secret-key:\n devices: [\"device_name\"] # Widevine devices\n playready_devices: [\"device_name\"] # PlayReady devices\n username: user\n
Example
unshackle serve\nunshackle serve -h 0.0.0.0 -p 8080\nunshackle serve --api-only\nunshackle serve --remote-only\nunshackle serve --caddy\n
"},{"location":"guide/cli-reference/#util","title":"util","text":"Various helper media utilities. When a command is given a directory, it processes every .mkv/.mp4 inside it in natural (S01E01 before S01E10) order.
unshackle util COMMAND [ARGS]...\n
"},{"location":"guide/cli-reference/#util-refresh-services","title":"util refresh-services","text":"unshackle util refresh-services\n
Force a refresh (git pull / hard reset) of all service repos configured under directories.services.
"},{"location":"guide/cli-reference/#util-crop-path-aspect","title":"util crop PATH ASPECT","text":"Losslessly crop H.264/H.265 video at the bitstream level. ASPECT is a W:H target such as 2.39:1.
Option Default Description --letter / --pillar --letter Crop top/bottom (--letter) or the sides (--pillar). -o, --offset 0 Fine-tune the computed crop area if not perfectly centred. -p, --preview off Preview the crop in MPV (or FFplay) instead of writing a file."},{"location":"guide/cli-reference/#util-range-path","title":"util range PATH","text":"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. -p, --preview off Preview instead of writing a file."},{"location":"guide/cli-reference/#util-test-path","title":"util test PATH","text":"Decode an entire video with FFmpeg and report any corruptions or errors. All streams are tested by default; subtitles cannot be tested.
Option Default Description -m, --map 0 Test specific streams via FFmpeg's -map, e.g. 0:v:0 or 0:a. Example
unshackle util refresh-services\nunshackle util crop video.mkv 2.39:1\nunshackle util crop ./season/ 16:9 --pillar -o 10\nunshackle util range video.mkv --full\nunshackle util test video.mkv -m 0:v:0\n
"},{"location":"guide/cli-reference/#see-also","title":"See also","text":" - Installation: install unshackle and its dependencies.
- Quickstart: your first download.
- Configuration file: set defaults for any
dl flag and configure vaults, proxies, and directories.
"},{"location":"guide/downloading/","title":"Downloading","text":"unshackle dl is the main download command: it fetches a title's manifest from a streaming service, selects exactly the tracks you asked for, acquires and applies decryption keys, and muxes everything into a finished Matroska file. The flags below cover quality and codec selection, language and subtitle handling, episode ranges, and the download flow itself.
Where the full flag list lives
This page covers the flags you will use most and explains how they interact. It is not an exhaustive enumeration. For the complete, always-current list of every option and its exact wording, run unshackle dl --help. Every flag shown here can also be given a default in your configuration file. See Configuration file.
"},{"location":"guide/downloading/#command-shape","title":"Command shape","text":"A download is always three layers: the root CLI, the dl command (which is where all the flags below are parsed), and a service tag subcommand that carries the title argument.
unshackle dl [OPTIONS] SERVICE [SERVICE ARGUMENTS]\n
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 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.
A minimal downloadunshackle dl EXAMPLE 81234567\n
Flags belong to dl, the title belongs to the serviceunshackle dl -q 1080 -v H.265 -r HDR10 --lang en EXAMPLE 'https://www.example.com/...'\n
See what a service accepts
Because the positional argument is defined by each service, run unshackle dl SERVICE --help (e.g. unshackle dl EXAMPLE --help) to see that service's own argument and any service-specific options.
"},{"location":"guide/downloading/#the-download-flow","title":"The download flow","text":"When you run a download, dl moves through the pipeline below. Understanding the order helps explain why, for example, --list stops early and why keys are fetched even with --skip-dl.
- Setup: load the DRM CDM, key vaults, proxy providers, cookies, and credentials.
- Authenticate: sign in to the service with your profile's cookies/credentials.
- Fetch titles: retrieve the movie, episode list, or album (cached unless
--no-cache/--reset-cache). - Filter titles: apply
--wanted, --latest-episode, or --select-titles. - Get tracks: parse the manifest into video, audio, subtitle, and chapter tracks.
- Select tracks: narrow each type down using your quality, codec, range, language, and bitrate flags.
- Download: pull segments for the selected tracks (concurrently per
--downloads). - License & decrypt: acquire content keys (vault \u2192 CDM), decrypt each track.
- Post-process: extract closed captions, convert subtitles, repack, mux with
mkvmerge, and move the finished file to your downloads directory.
--list prints the tracks a title exposes and stops before selection. --list-titles prints every title the service returned and stops before --wanted/--latest-episode filtering. --skip-dl runs the license step but skips the actual segment download.
"},{"location":"guide/downloading/#choosing-quality","title":"Choosing quality","text":""},{"location":"guide/downloading/#resolution","title":"Resolution","text":"-q / --quality takes one or more target resolutions (heights) as a comma-separated list. Without it, unshackle picks the best available resolution.
Single resolutionunshackle dl -q 1080 EXAMPLE 81234567\n
Multiple resolutions in one rununshackle dl -q 2160,1080,720 EXAMPLE 81234567\n
16:9 canvas matching
Resolution matching is by track height first. If no track has an exact matching height, unshackle falls back to a 16:9-canvas match, so -q 1080 will also match an anamorphic 1920\u00d7804 track (computed as int(width \u00d7 9 / 16)).
"},{"location":"guide/downloading/#best-available-and-worst","title":"Best-available and worst","text":"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 video/audio/subtitle languages and hybrid fallbacks. --worst Within the specified quality, pick the lowest bitrate rendition. Requires -q/--quality. Never fail on a missing resolutionunshackle dl -q 2160 --best-available EXAMPLE 0ABC123\n
"},{"location":"guide/downloading/#video-codec-and-color-range","title":"Video codec and color range","text":""},{"location":"guide/downloading/#codec","title":"Codec","text":"-v / --vcodec selects one or more video codecs; the default is any codec. It accepts either enum names or their values, comma-separated.
Name Value AVC H.264 HEVC H.265 VC1 VC-1 VP8 VP8 VP9 VP9 AV1 AV1 Either spelling worksunshackle dl -v HEVC EXAMPLE 81234567\nunshackle dl -v H.265 EXAMPLE 81234567\nunshackle dl -v hevc,avc EXAMPLE 81234567\n
"},{"location":"guide/downloading/#color-range","title":"Color range","text":"-r / --range selects one or more color ranges; the default is SDR.
Range Meaning SDR Standard dynamic range (default) HLG Hybrid Log-Gamma HDR10 HDR10 HDR10P HDR10+ DV Dolby Vision HYBRID Fetch both an HDR10/HDR10+ base and a DV track, then merge them Grab HDR10 and Dolby Vision in one rununshackle dl -q 2160 -r HDR10,DV EXAMPLE '...'\n
HYBRID requires dovi_tool
-r HYBRID produces a single hybrid stream by injecting the Dolby Vision RPU metadata onto an HDR10/HDR10+ base layer using dovi_tool. It requires the dovi_tool binary, resolved from unshackle's binaries/ folder or your PATH. The normal case is a DV track plus an HDR10 or HDR10+ base. With HDR10+ and no DV, unshackle converts the HDR10+ metadata to DV instead, which also needs hdr10plus_tool. With no DV and no HDR10+, the title fails. When HDR10+ is present it is preferred over HDR10 as the base layer.
To keep an HDR10+ deliverable alongside the hybrid, request both ranges: -r HYBRID,HDR10P. -r HYBRID on its own muxes only the merged hybrid.
"},{"location":"guide/downloading/#bitrate-selection","title":"Bitrate selection","text":"By default unshackle keeps the highest-bitrate rendition for each selected resolution/codec/range/language combination. You can constrain this.
Flag Purpose -vb / --vbitrate Exact video bitrate in kbps. -ab / --abitrate Exact audio bitrate in kbps. -vb-range / --vbitrate-range Video bitrate range in kbps, e.g. 6000-7000; picks the highest within it. -ab-range / --abitrate-range Audio bitrate range in kbps, e.g. 128-256. Exact vs range are mutually exclusive
--vbitrate cannot be combined with --vbitrate-range, and likewise for the audio pair. Pick one form.
"},{"location":"guide/downloading/#real-bitrate-probing","title":"Real bitrate probing","text":"Manifest-declared bitrates are sometimes rounded or wrong. Services often advertise a peak or nominal bandwidth that is far from the stream's real average. This matters because a track's bitrate drives the track listing, the sort order, and the --vbitrate/--vbitrate-range selection above. A bogus declared value therefore makes unshackle pick the wrong track. These flags probe the actual media size to compute a true bitrate for the top renditions, overriding the manifest value:
-rvb / --real-video-bitrate: probe real video bitrates (per codec/range). -rab / --real-audio-bitrate: probe real audio bitrates (per codec/channels/ language). Slower than the video variant because there are more renditions to probe.
Reading the probed numbers
A single-file track (DASH SegmentBase/BaseURL) is measured exactly. A multi-segment track (most HLS) is a sampled estimate, normally within a few percent of the true value. For MPEG-TS HLS the probed figure also reads a few percent above the demuxed elementary stream, because the segment bytes include container overhead. That is the real delivered size, not an over-report or a bug.
Why not every rendition is probed
Probing does not touch every rendition. Only the five highest declared-bitrate renditions of each quality tier are probed in parallel (video grouped by codec and range, audio by codec, channels, language, and descriptive flag), and unshackle extends a group downward while a lower unprobed rendition could still outrank a probed one. This keeps probing fast even when a service exposes dozens of renditions. Tracks whose duration can't be determined fall back to ffprobe, and a probe failure is non-fatal: the manifest value is simply left in place.
"},{"location":"guide/downloading/#audio-codec-channels-and-atmos","title":"Audio codec, channels, and Atmos","text":""},{"location":"guide/downloading/#codec_1","title":"Codec","text":"-a / --acodec selects one or more audio codecs (comma-separated); the default is any.
Name Value Codec AAC AAC Advanced Audio Coding AC3 DD Dolby Digital EC3 DD+ Dolby Digital Plus AC4 AC-4 Dolby AC-4 OPUS OPUS Opus OGG VORB Vorbis DTS DTS DTS ALAC ALAC Apple Lossless FLAC FLAC FLAC Names, values, and a few aliases are accepted: eac3 and ddp both resolve to EC3, and vorbis resolves to OGG.
Prefer Dolby Digital Plus, fall back to AACunshackle dl -a EC3,AAC EXAMPLE 81234567\n
"},{"location":"guide/downloading/#channels-and-atmos","title":"Channels and Atmos","text":" -c / --channels: desired channel layout, e.g. 5.1 or 2.0. Matching is by ceiling, so 5.1 implicitly matches a 6.0-reported layout. -naa / --noatmos: exclude Dolby Atmos audio tracks from selection.
5.1 audio, no Atmosunshackle dl -c 5.1 --noatmos EXAMPLE 0ABC123\n
"},{"location":"guide/downloading/#languages","title":"Languages","text":""},{"location":"guide/downloading/#video-and-audio-language","title":"Video and audio language","text":"-l / --lang sets the wanted language(s) for both video and audio. The default is orig, the title's original language.
Original language plus Englishunshackle dl -l orig,en EXAMPLE 81234567\n
The special token orig is resolved to the title's actual original language everywhere it is used. You can override each stream type independently:
-vl / --v-lang: language for video only (overrides -l for video). Useful when the burned-in video language differs from the audio you want. -al / --a-lang: language for audio only (overrides -l for audio).
English audio over the original videounshackle dl -al en EXAMPLE 81234567\n
"},{"location":"guide/downloading/#excluding-languages","title":"Excluding languages","text":"Put a - in front of a language to remove it from the selection. This works on -l, -vl, -al, -sl, and -fsl, in the same way as it works on -w / --wanted.
Every subtitle language except Spanishunshackle dl -sl all,-es EXAMPLE 81234567\n
The exclusions are subtracted from the languages the flag would otherwise select, and the order of the values does not matter. If you give only exclusions, the flag keeps its own default: -sl -es is the same as -sl all,-es, and -l -es is the same as -l orig,-es.
Only exclusions# every subtitle language except Spanish and French\nunshackle dl -sl -es,-fr EXAMPLE 81234567\n\n# the original audio language, unless it is Spanish\nunshackle dl -l -es EXAMPLE 81234567\n
-vl and -al have no default of their own: they cascade to -l. An override that names a language replaces -l for that stream type, exclusions included. An override that gives only exclusions adds them to the ones from -l.
Cascade# video keeps the Japanese track; only the audio drops Spanish\nunshackle dl -l en,-es -vl ja EXAMPLE 81234567\n
Rules for exclusion tokens
orig is resolved per title, so -sl -orig drops the subtitles in the title's original language. --exact-lang applies to exclusions with the same matching rules as selection: -es removes exactly the tracks that es would select. Without it, -es also removes es-419 and es-ES. - Tracks with no language tag are never excluded.
-all is rejected. Name the languages you do not want instead. - The comma form is the documented style.
-sl -es also works, but a shell can read a lone -es as another option.
"},{"location":"guide/downloading/#sort-order","title":"Sort order","text":"-l and -sl select languages: naming some removes the rest. To keep every language but decide which ones come first, set a priority list in your configuration file:
unshackle.yamlaudio:\n language_priority: [orig, en]\nsubtitle:\n group_by: language\n language_priority: [en, es, fr]\n
Subtitles then start with English, Spanish, and French. The languages you leave out follow alphabetically, after the title's original language. Nothing is removed. Audio works the same way, but the languages you leave out keep their bitrate and codec order instead of an alphabetical one.
See subtitle.language_priority for the details.
"},{"location":"guide/downloading/#exact-vs-fuzzy-matching","title":"Exact vs fuzzy matching","text":"By default language matching is fuzzy: -l en also accepts en-US, en-GB, etc. (up to a small distance). Pass --exact-lang to require an exact match: with it, -l es-419 matches only es-419, not es-ES.
The all and best tokens
For audio, the tokens all and best bypass the usual one-track-per-language pick and instead select the best track for each language present. For subtitles, all keeps every subtitle language.
"},{"location":"guide/downloading/#subtitles","title":"Subtitles","text":""},{"location":"guide/downloading/#selecting-subtitle-languages","title":"Selecting subtitle languages","text":"-sl / --s-lang sets the wanted subtitle language(s). The default is all, meaning every available subtitle language is downloaded.
Only English and Spanish subtitlesunshackle dl -sl en,es EXAMPLE 81234567\n
To keep the default and drop one language, use the - prefix described in Excluding languages. -sl all,-es keeps every subtitle language except Spanish, forced Spanish subtitles included.
"},{"location":"guide/downloading/#requiring-subtitles","title":"Requiring subtitles","text":"--require-subs takes a list of languages that must exist. If they all exist, all subtitles are downloaded; if any is missing, the title fails. This is useful for gating a download on the presence of a specific subtitle track.
Cannot combine with --s-lang
--require-subs and --s-lang are mutually exclusive. Use one or the other.
"},{"location":"guide/downloading/#forced-subtitles-and-output-format","title":"Forced subtitles and output format","text":" -fs / --forced-subs: include forced subtitle tracks (signs/foreign dialogue). Without this flag, forced subs are dropped from selection. -fsl / --forced-s-lang: keep forced subtitles only in these languages (implies -fs). Works independently of --s-lang, so -sl all -fsl en grabs every full subtitle but only the English forced track. It accepts exclusions too: -fsl all,-es keeps every forced subtitle except the Spanish one, and -fsl -es means the same. --sub-format: set the output subtitle format, converting only when necessary. Accepts codec names/values and common aliases (srt, vtt, ass, ssa, ttml, etc.), or the literal original to keep the source format.
Value Format SRT / srt SubRip VTT / vtt WebVTT ASS / ass Advanced SubStation Alpha SSA / ssa SubStation Alpha TTML / ttml Timed Text Markup original Keep the source format, no conversion Convert subtitles to SRTunshackle dl --sub-format srt EXAMPLE 81234567\n
SDH stripping happens by default
When a subtitle track is marked SDH (for the deaf/hard-of-hearing) and there is no plain same-language subtitle, unshackle produces a stripped, non-SDH variant automatically. This behaviour is governed by the subtitle.strip_sdh config option (default on).
"},{"location":"guide/downloading/#selecting-episodes","title":"Selecting episodes","text":"For series, several flags control which episodes are downloaded. By default, all episodes are downloaded.
"},{"location":"guide/downloading/#wanted-ranges","title":"Wanted ranges","text":"-w / --wanted accepts flexible season/episode ranges, comma-separated. Prefix a token with - to exclude it.
Whole seasonsSpecific episodesExclusionsSplit episodesAir dates unshackle dl -w S01 EXAMPLE 81234567\nunshackle dl -w S01-S03 EXAMPLE 81234567\nunshackle dl -w S01,S03,S05 EXAMPLE 81234567\n
unshackle dl -w S01E01 EXAMPLE 81234567\nunshackle dl -w S01E01-S01E05 EXAMPLE 81234567\nunshackle dl -w S01E01-S02E03 EXAMPLE 81234567\n
# Seasons 1 through 5, but not season 3\nunshackle dl -w S01-S05,-S03 EXAMPLE 81234567\n
# Part 2 of episode 1 only\nunshackle dl -w S01E01.2 EXAMPLE 81234567\n\n# Parts 1 through 3 of episode 1\nunshackle dl -w S01E01.1-S01E01.3 EXAMPLE 81234567\n\n# All of season 1 except episode 1 part 2\nunshackle dl -w S01,-S01E01.2 EXAMPLE 81234567\n
# One day\nunshackle dl -w 2026-08-11 EXAMPLE 81234567\n\n# A range of days, colon separated\nunshackle dl -w 2026-08-01:2026-08-31 EXAMPLE 81234567\n\n# August, but not the 15th\nunshackle dl -w 2026-08-01:2026-08-31,-2026-08-15 EXAMPLE 81234567\n
"},{"location":"guide/downloading/#daily-and-date-based-content","title":"Daily and date-based content","text":"Talk shows, news and sports have no official episode numbering, so unshackle names them by air date. An episode that carries an air date is written as Show.YYYY.MM.DD instead of SxxExx, the {date} token holds the ISO date, and the season folder becomes the year.
A service that only carries this kind of content sets DAILY = True on its class, and a service can set air_date on each episode itself. Add --daily to mark any other title as date-based:
unshackle dl --daily --tvdb 73871 --enrich EXAMPLE 81234567\n
With --enrich and a TVDB ID, --daily fills in the air date of every episode that has none. An air date the service already set is kept. Dates before 1970 and dates in the future are skipped, because TVDB carries placeholder schedule dates for episodes that have not aired. Without --enrich unshackle has no source to fill from, and says so.
A dated episode answers to its air date in -w, as well as to its SxxExx key. A date token is a plain ISO date (2026-08-11). A date range uses a colon (2026-08-01:2026-08-31), because the dashes in a date are part of the date. A range cannot span more than 1000 days. Date tokens and SxxExx tokens can be mixed in one -w.
"},{"location":"guide/downloading/#split-episodes","title":"Split episodes","text":"A few services split one episode into several separately playable videos. Where a service reports that, add .N after the episode to pick one part.
Token Selects S01E01 All parts of episode 1. S01E01.2 Part 2 of episode 1, and nothing else. S01E01.1-S01E01.3 Parts 1 to 3 of episode 1. S01,-S01E01.2 All of season 1 except episode 1 part 2. Parts 1 and 3 are kept. A season token such as S01 covers every episode and every part, so you only need .N when you want a part on its own.
A part range stays inside one episode
S01E01.1-S01E01.3 is valid. S01E01.1-S01E02.3 is rejected, because the parser cannot know how many parts episode 1 has and so cannot work out where the range ends. To span episodes, list the parts you want as separate tokens: -w S01E01.2,S01E02.1.
A part of an unsplit episode selects nothing
-w S01E02.2 on an episode that was never split is deliberately empty: it tells you the episode has no parts, rather than quietly handing you the whole episode. Run --list-titles to see which episodes carry parts.
"},{"location":"guide/downloading/#other-selection-flags","title":"Other selection flags","text":"Flag Behaviour --select-titles Interactively pick episodes of a series, or films when a title has more than one. Cannot combine with -w. --latest-episode Download only the single most recent episode. --list-titles List every title the service returned, then stop. -w/--latest-episode are not applied to this listing. Grab just the newest episodeunshackle dl --latest-episode EXAMPLE 81234567\n
"},{"location":"guide/downloading/#including-and-excluding-track-types","title":"Including and excluding track types","text":"You can restrict a download to certain track categories, either positively (only these) or negatively (everything but these).
Only these types (*-only flags):
-V / --video-only -A / --audio-only -S / --subs-only -C / --chapters-only
Skip these types (no-* flags):
-nv / --no-video -na / --no-audio -ns / --no-subs -nc / --no-chapters
Additional track-type flags:
-ad / --audio-description: include descriptive (audio-description) tracks, which are dropped by default. --skip-subtitle-errors: if a subtitle fails to download, skip it and continue rather than aborting the whole title. Video and audio failures remain fatal.
Subtitles onlyunshackle dl -S -sl en EXAMPLE 81234567\n
Everything except chaptersunshackle dl -nc EXAMPLE 81234567\n
Note
You can combine an *-only flag with no-* flags to fine-tune: *-only chooses the starting set of categories, then no-* subtracts from it. Attachments (e.g. fonts) are always kept.
"},{"location":"guide/downloading/#listing-and-dry-runs","title":"Listing and dry runs","text":"Before committing to a long download, inspect what unshackle would do:
Flag Effect --list List the tracks the service exposes for each title, then stop. No selection, no download. --list-titles List every title the service returned, then stop. -w/--latest-episode are not applied to this listing. --skip-dl Skip downloading but still acquire the decryption keys. See the track selection without downloadingunshackle dl -q 1080 -v H.265 -r HDR10 --list EXAMPLE 81234567\n
"},{"location":"guide/downloading/#output-and-muxing","title":"Output and muxing","text":""},{"location":"guide/downloading/#output-location-and-folders","title":"Output location and folders","text":" -o / --output: override the output directory for this run (otherwise the configured downloads directory is used). --no-folder: do not create a per-show folder for TV downloads. --no-source: remove the service source tag from the filename and path.
Send this download somewhere specificunshackle dl -o ~/Videos/incoming EXAMPLE 81234567\n
"},{"location":"guide/downloading/#muxing-behaviour","title":"Muxing behaviour","text":"By default, tracks are muxed into a single Matroska (.mkv) file with mkvmerge. These flags change how the output is assembled:
Flag Behaviour Default source --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 video tracks that share a height, range, and codec into one file, so only language varies inside a file. config muxing.merge_video (off)"},{"location":"guide/downloading/#naming-tags","title":"Naming tags","text":" --tag: set the release group tag (overrides the configured tag). --repack: add a REPACK tag to the output filename.
Custom group tag and a REPACK labelunshackle dl --tag MYGRP --repack EXAMPLE 81234567\n
"},{"location":"guide/downloading/#proxies","title":"Proxies","text":"--proxy accepts a full proxy URI, a 2-letter country code (resolved through your configured proxy providers), or a provider:region form.
Proxy formsunshackle dl --proxy us EXAMPLE 81234567\nunshackle dl --proxy nordvpn:ca EXAMPLE 81234567\nunshackle dl --proxy 'http://user:pass@host:8080' EXAMPLE 81234567\n
Two related flags:
--no-proxy: force-disable all proxy use for this run. --no-proxy-download: bypass the proxy for all downloads. The manifest, license, and authentication requests still go through the proxy. This is useful when the proxy is only needed to satisfy geo-checks, not to move the bulk of the data.
"},{"location":"guide/downloading/#performance-and-caching","title":"Performance and caching","text":"Flag Purpose --workers N Threads used per track for segment downloads. Default depends on the downloader. --downloads N Number of tracks downloaded concurrently. Default 1. --slow [MIN-MAX] Add a delay between titles to look more like a real device. --slow alone means 60-120s; --slow 20-40 sets a custom range. Minimum 20s. --no-cache Bypass the title cache for this download. --reset-cache Clear the title cache before fetching. Two tracks at a time, eight threads eachunshackle dl --downloads 2 --workers 8 EXAMPLE 81234567\n
Space out a season downloadunshackle dl -w S01 --slow 30-60 EXAMPLE 81234567\n
"},{"location":"guide/downloading/#keys-vaults-and-export","title":"Keys, vaults, and export","text":"By default, unshackle checks your key vaults first and only asks a CDM to license a key when the vault misses. You can force one side or the other:
--cdm-only: only use the CDM (skip vaults). --vaults-only: only use key vaults (never license via the CDM); a missing key fails.
Fetch keys only, no downloadunshackle dl --skip-dl EXAMPLE 81234567\n
--export writes a JSON file, into the configured exports directory, containing track info and the acquired content keys for each title. This is the format consumed by unshackle import to reconstruct a download later.
Export track info and keysunshackle dl --skip-dl --export EXAMPLE 81234567\n
Region is recorded only with a proxy
When --proxy is used, the export records the region so an import can reproduce the correct geofence. Without a proxy, no region is stored.
Some DASH and Smooth exports need a title language
An import re-fetches the DASH or ISM manifest and parses it again. Most manifests label their own streams, and those import fine. When a manifest labels nothing, the parse falls back to the title's original language, which comes from Title.language on the exporting service. If a service never sets it, importing that export fails with a message naming the service. Neither end guesses a language for you, so the fix belongs in the service.
"},{"location":"guide/downloading/#metadata-and-tagging","title":"Metadata and tagging","text":"unshackle looks up metadata automatically, but you can override the identifiers used for tagging and naming. An ID you give is authoritative: unshackle looks it up directly instead of searching by title, and the ID stays in the tags. An ID only settles which title this is. To also take that source's title, year and original language, add --enrich. Give at most one of --tmdb, --imdb and --tvdb; unshackle works the rest out from it. To stop the automatic lookups but keep the IDs you give, set disable_metadata in your config.
Flag Example Purpose --tmdb --tmdb 27205 Use this TMDB ID instead of an automatic search. Needs tmdb_api_key. --imdb --imdb tt1375666 Use this IMDb ID instead of an automatic search. Needs no key. --tvdb --tvdb 73871 Use this TVDB ID instead of looking the series up. Needs tvdb_api_key. --anilist --anilist 21 Use this AniList ID instead of an automatic search. mal:12345 is also accepted. Needs no key. --enrich - Overwrite the show title, year and original language with the external source's. Requires one of --tmdb, --imdb, --tvdb, or --anilist. --tvdb-order --tvdb-order dvd Renumber episodes to a TVDB season order. Needs tvdb_api_key. Force the right IMDb match, and keep the service's own namingunshackle dl --imdb tt1375666 EXAMPLE 81234567\n
Force the match and take IMDb's title, year and language toounshackle dl --imdb tt1375666 --enrich EXAMPLE 81234567\n
"},{"location":"guide/downloading/#what-each-metadata-provider-supplies","title":"What each metadata provider supplies","text":"Providers differ in what they answer with, so the fields --enrich can replace depend on which ID you gave. Every provider except imdb only runs when its key is configured:
Provider Config key Title and year Original language External IDs it returns TMDB tmdb_api_key yes yes, alpha-2 such as ko IMDb, TMDB, TVDB TVDB tvdb_api_key yes yes, alpha-3 such as kor TMDB, TVDB, sometimes IMDb OMDb omdb_api_key yes yes, as an English name such as Korean IMDb IMDb (no key needed) yes yes, alpha-2 such as ko IMDb SIMKL simkl_client_id yes no, it publishes a country and no language IMDb, TMDB, TVDB AniList (no key needed) yes yes, worked out from the country of origin, such as ja AniList Whatever the tag looks like, unshackle normalises it before use, so ko, kor and Korean all end up as the same language.
AniList only answers for anime
It is last in the default provider order and returns nothing for a title that is not anime. The only cost is one search that misses. Put anilist earlier in metadata_providers if you mostly download anime. Which of the three AniList title variants is used is set by anilist_title_language.
A service can also mark its titles as anime. For those titles unshackle tries AniList first. When AniList has no match, it falls through to the normal order.
Each ID goes to the providers that read that kind of ID, in metadata_providers order:
--tmdb reads TMDB. --imdb reads IMDb, then falls back to OMDb. IMDb needs no key, so this path works out of the box, and an OMDb key only matters when IMDb has no answer. --tvdb reads TVDB. It needs tvdb_api_key. --anilist reads AniList, which needs no key. It supplies the title, the year and the original language, and writes an AniList tag. It knows no TMDB, IMDB or TVDB ID, so pair it with --tmdb, --imdb or --tvdb when you want those tags as well.
Give one of --tmdb, --imdb and --tvdb. They cannot be combined, because one ID does the job on its own: unshackle resolves the other two from it and writes all three to the tags. Passing two is an error rather than a silent choice between them. --anilist is the exception and still pairs with one of the three, which is how you tag an anime title with a western ID.
The ID you give must have a provider that can actually resolve it, so unshackle checks that before it downloads anything. --tmdb without tmdb_api_key, or an ID whose providers your metadata_providers list leaves out, fails immediately with a message naming what to set. --imdb needs no key, since the imdb provider is keyless and in the default order.
With --enrich, the title, year and original language are all replaced with the external source's values, whether or not the service already filled them in. A field the source does not answer with is left alone, and unshackle logs which fields those were, so a provider with a thin record cannot blank out what the service told you.
--enrich also fills in the absolute episode number of each episode from TVDB's absolute order, for any series that has one and where the service did not supply it. Anime is the usual beneficiary, but no part of this is limited to anime. This only adds the {absolute} naming variable. The season and episode numbers are never changed.
With --daily, --enrich also fills in the air date of each episode from TVDB. See Daily and date-based content.
--enrich replaces the original language, which affects more than the filename
Track selection reads the original language, so replacing it changes which audio is treated as the original. That is the point when a service mislabels it, but it means a wrong ID can pick the wrong audio track and not only write a wrong name. Without --enrich the service's own value is kept and track selection is untouched.
"},{"location":"guide/downloading/#episode-ordering","title":"Episode ordering","text":"A service does not always number a series the way TVDB's aired order does. Some services list Futurama in TVDB's alternate (Streaming) order, for example, while TVDB's official order holds back four season-one episodes to the start of season two. --tvdb-order works out which order the service used, then renumbers the episodes into the order you asked for:
Download Futurama in DVD orderunshackle dl --tvdb-order dvd EXAMPLE 81234567\n
Available orders are official (aired), dvd, absolute, alternate, and regional. Set tvdb_order in your config to apply one by default.
Orders that do not cover the whole series
An order can leave out episodes the service carries. TVDB's dvd order does not list Futurama's four movies. Those episodes keep their original numbering. If that would give two episodes the same season/episode slot, and so the same filename, unshackle logs an error and leaves the numbering untouched. Pick an order that covers the whole series.
"},{"location":"guide/downloading/#configuration-defaults","title":"Configuration defaults","text":"Every flag on this page can be set as a default in your configuration file so you don't have to type it each time, and defaults can be scoped per service. See Configuration file for the dl: block and per-service overrides.
Explicit flags always win
A value you pass on the command line (or via an environment variable) always takes precedence over both the global dl: defaults and any per-service dl: overrides. Config only fills in options you didn't set explicitly.
"},{"location":"guide/downloading/#quick-reference","title":"Quick reference","text":"A condensed lookup of the most-used options. Run unshackle dl --help for the complete, authoritative list.
Flag Short Meaning --quality -q Target resolution(s), e.g. 1080,720. --vcodec -v Video codec(s). --acodec -a Audio codec(s). --range -r Color range(s); default SDR. --channels -c Audio channel layout. --noatmos -naa Exclude Atmos audio. --lang -l Video + audio language(s); default orig. - excludes, e.g. all,-es. --a-lang -al Audio-only language override. - excludes. --v-lang -vl Video-only language override. - excludes. --s-lang -sl Subtitle language(s); default all. - excludes, e.g. all,-es. --forced-subs -fs Include forced subtitles. --forced-s-lang -fsl Forced subtitle language(s); implies -fs. - excludes. --sub-format Output subtitle format. --wanted -w Episode/season range, or an air date. --daily Fill missing air dates from TVDB during --enrich. --select-titles Interactively pick episodes or films. --latest-episode Only the newest episode. --video-only / --audio-only / --subs-only -V / -A / -S Restrict track types. --no-video / --no-audio / --no-subs / --no-chapters -nv / -na / -ns / -nc Skip track types. --worst Lowest bitrate within -q. --best-available Degrade gracefully instead of failing. --output -o Output directory for this run. --split-audio / --merge-video / --no-mux Muxing behaviour. --proxy / --no-proxy / --no-proxy-download Proxy control. --workers / --downloads / --slow Concurrency and pacing. --list / --list-titles / --skip-dl Dry runs. --cdm-only / --vaults-only Key source control. --export Export track info and keys to JSON. --tmdb / --imdb / --tvdb / --anilist / --enrich Metadata overrides. --tvdb-order Renumber episodes to a TVDB season order."},{"location":"guide/drm-and-cdm/","title":"DRM & CDM Setup","text":"Most commercial streaming content is protected by a Digital Rights Management (DRM) system. To download and play it back, unshackle needs to obtain the content keys for the encrypted tracks and then decrypt them. This page explains the DRM systems unshackle supports, how to provide a device so it can talk to a license server, the difference between local and remote CDMs, and how to choose a decryption backend.
Who this page is for
This is a practical configuration guide for end users of the unshackle CLI. A few sections are marked Developer. They describe internal behaviour that is useful when writing a service, but you do not need them to download protected content.
"},{"location":"guide/drm-and-cdm/#key-concepts","title":"Key concepts","text":"Before configuring anything, it helps to know the vocabulary. unshackle draws a firm line between two things:
- A CDM (Content Decryption Module) is the trusted \"black box\" that turns a license challenge into content keys. It is backed by a device, a file containing the cryptographic identity of a real client (a phone, a browser, etc.).
- A DRM system is the wrapper around a CDM that parses the protection metadata, drives the challenge \u2192 license \u2192 keys handshake, and finally invokes an external tool to physically decrypt the downloaded file.
A handful of other terms show up throughout:
Term Meaning PSSH Protection System Specific Header. The per-system init data extracted from the file's init segment or the manifest. KID Key ID: a 16-byte UUID identifying which key a track needs. CEK Content Encryption Key: the actual AES key, mapped from its KID. Challenge The license request the CDM produces, sent to the service's license server. License The server's response, which the CDM parses to recover the content keys. For a Widevine or PlayReady title, the flow is: unshackle reads the PSSH and KIDs from the track, asks the CDM for a license challenge, sends that challenge to the service's license server, hands the response back to the CDM to extract the keys, and then decrypts the file with those keys.
"},{"location":"guide/drm-and-cdm/#supported-drm-systems","title":"Supported DRM systems","text":"unshackle implements five DRM systems. Only the first two need a device file and a license-server handshake; the rest are special cases.
System Device file License flow Decryption Widevine (Google) .wvd CDM challenge \u2192 license server \u2192 CDM parses \u2192 keys shaka-packager / mp4decrypt PlayReady (Microsoft) .prd CDM challenge (WRM header) \u2192 license server \u2192 CDM parses \u2192 keys shaka-packager / mp4decrypt ClearKey (CENC), W3C EME org.w3.clearkey none JSON {\"kids\":[...],\"type\":\"temporary\"} \u2192 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 Two different ClearKeys
\"ClearKey (HLS)\" and \"ClearKey (CENC)\" are entirely different systems that happen to share a name. The HLS variant is the AES-128 key referenced by an EXT-X-KEY tag and is decrypted in-process. The CENC variant is the W3C EME org.w3.clearkey scheme, delivers keys as a JWK Set, and is decrypted through shaka-packager or mp4decrypt just like Widevine. Neither involves a device file.
"},{"location":"guide/drm-and-cdm/#widevine-vs-playready","title":"Widevine vs PlayReady","text":"These are the two \"real\" DRMs, and in unshackle they behave almost identically: the same PSSH/KID handling, the same decryption backends, and the same key-caching flow. The differences that matter to you are:
- Device file: Widevine uses a
.wvd; PlayReady uses a .prd. - Availability: some services offer only one system, some offer both. When a manifest carries both, unshackle picks the appropriate CDM for each track (see 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 unshackle automatically selects the appropriate one for each track.
Obtaining device files
A .wvd or .prd encodes the private keys of a real client device. unshackle does not ship with one and cannot generate the underlying secret material for you. You must supply your own. The wvd and prd commands below manage and provision device files once you have the raw key material.
"},{"location":"guide/drm-and-cdm/#providing-a-device-with-the-wvd-and-prd-commands","title":"Providing a device with the wvd and prd commands","text":"Device files live in dedicated directories, by default:
Kind Default directory Widevine .wvd data/WVDs PlayReady .prd data/PRDs You can move these with the directories section of your unshackle.yaml:
unshackle.yamldirectories:\n wvds: /home/george/unshackle-data/WVDs\n prds: /home/george/unshackle-data/PRDs\n
"},{"location":"guide/drm-and-cdm/#widevine-devices-the-wvd-command","title":"Widevine devices: the wvd command","text":"The wvd command group manages .wvd files.
Add an existing deviceInspect a deviceCreate from raw materialDelete / dump $ unshackle wvd add ./nexus_6p.wvd\n
The file is validated and moved into the WVDs directory. You can add several at once by passing multiple paths.
$ unshackle wvd parse nexus_6p\n
Prints the System ID, Security Level, Type, Flags, and whether a Private Key, Client ID, and VMP are present. A bare name (no extension) is resolved inside the WVDs directory; a path with an extension is used as-is.
$ unshackle wvd new \"Nexus 6P\" ./private_key.pem ./client_id.bin ./vmp.bin -t ANDROID -l 3\n
Builds a .wvd from a PEM private key, a Client ID blob, and an optional FileHashes (VMP) blob. -t/--type is the device type (default Android) and -l/--level is the security level 1 to 3 (default 1). The VMP argument is optional.
$ unshackle wvd delete nexus_6p\n$ unshackle wvd dump nexus_6p ./out\n
delete removes a device (with a confirmation prompt). dump extracts a device's contents (metadata, private key, Client ID, VMP) into a folder.
v1 WVD files must be migrated
If loading a device fails with a message about a \"v1 WVD file\", it was made with an older format. Migrate it with pywidevine before use:
$ pywidevine migrate --help\n
"},{"location":"guide/drm-and-cdm/#playready-devices-the-prd-command","title":"PlayReady devices: the prd command","text":"The prd command group creates and maintains .prd files.
Create a new deviceReprovisionTest against Microsoft's demo server $ unshackle prd new ./zgpriv.dat ./bgroupcert.dat\n
Provision a new device from a group key and group certificate. You can instead point at a single folder that contains zgpriv.dat and bgroupcert.dat:
$ unshackle prd new ./device_folder/\n
A fresh leaf certificate is generated and the device is written to the PRDs directory (or to -o/--output). Use -e/--encryption_key and -s/--signing_key to supply your own ECC keys instead of generating them.
$ unshackle prd reprovision ./data/PRDs/mydevice.prd\n
Regenerates the leaf certificate and encryption/signing keys of an existing device in place. The device must support reprovisioning (a group key must be present, i.e. a version 3 or higher device).
$ unshackle prd test ./data/PRDs/mydevice.prd -sl 2000 -c aesctr\n
Runs a full challenge/license/keys cycle against Microsoft's public PlayReady test server and prints the returned keys, confirming the device works. -sl/--security-level accepts 150, 2000, or 3000 (default 2000); -c/--ckt accepts aesctr or aescbc.
"},{"location":"guide/drm-and-cdm/#selecting-which-device-to-use-the-cdm-config","title":"Selecting which device to use: the cdm config","text":"Having a device on disk is not enough; you must tell unshackle which device to use for which service. That is the job of the cdm section, which maps a service tag to a device name (the file's name without extension). A default entry acts as the fallback:
unshackle.yamlcdm:\n default: nexus_6p_26830_l3\n EXAMPLE1: chromecdm_l3\n EXAMPLE2: sm_g935f_l1\n
Lookups are case-insensitive.
The device name is the file name
A device named chromecdm_l3 in the config resolves to chromecdm_l3.wvd in the WVDs directory (or chromecdm_l3.prd in the PRDs directory). It may also be the name of a remote CDM defined in remote_cdm. See Remote CDMs below.
"},{"location":"guide/drm-and-cdm/#advanced-selection-by-quality-drm-or-profile","title":"Advanced selection: by quality, DRM, or profile","text":"Instead of a plain name, a cdm entry can be a nested mapping, letting unshackle pick a different device based on the track quality, the DRM system, or the active service profile.
By qualityBy DRM systemBy profile Keys are matched against the video height. You can use exact heights or comparisons (>=1080, >720, <=576, <480). More specific matches win.
cdm:\n SOMESERVICE:\n \">=1080\": sm_g935f_l1\n default: chromecdm_l3\n
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.
cdm:\n SOMESERVICE:\n widevine: sm_g935f_l1\n playready: mydevice_sl3\n
Keys are matched against the active profile, with a default fallback.
cdm:\n SOMESERVICE:\n premium: sm_g935f_l1\n default: chromecdm_l3\n
"},{"location":"guide/drm-and-cdm/#local-vs-remote-cdms","title":"Local vs remote CDMs","text":"A CDM can run in two places:
- A local CDM loads a
.wvd or .prd device from your machine and performs the challenge/license/keys handshake locally. This is what the cdm mapping above resolves to by default. - A remote CDM delegates the CDM operations to an HTTP API. Your device (and its secrets) live on the remote server; unshackle sends it PSSH/challenge data and receives keys back. This is useful for sharing a high-security-level device, using a hosted key service, or keeping device secrets off your download machine.
The device name in your cdm mapping resolves to a remote CDM whenever it matches the name of an entry in the remote_cdm list. Otherwise it falls back to a local .prd/.wvd file of that name.
"},{"location":"guide/drm-and-cdm/#remote-cdms","title":"Remote CDMs","text":"Remote CDMs are declared as a list under remote_cdm. Each entry has a name (which you then reference from the cdm mapping) and a shape that depends on its type. There are four kinds.
"},{"location":"guide/drm-and-cdm/#widevine-remote-cdm-pywidevine-serve","title":"Widevine remote CDM (pywidevine serve)","text":"With no type field and a non-PlayReady device type, the entry is treated as a standard pywidevine \"serve\" endpoint:
unshackle.yamlremote_cdm:\n - name: my_wv_remote\n device_type: ANDROID\n system_id: 26830\n security_level: 3\n host: https://cdm.example.com\n secret: your-api-secret\n device_name: sm_g935f_l1\n\ncdm:\n default: my_wv_remote\n
security_level defaults to 3000 if omitted. Field names are read case-insensitively, so Device Type/device_type, System ID/system_id, etc. are all accepted.
"},{"location":"guide/drm-and-cdm/#playready-remote-cdm","title":"PlayReady remote CDM","text":"If the device type is PLAYREADY, the entry is routed to the pyplayready remote CDM:
unshackle.yamlremote_cdm:\n - name: my_pr_remote\n device_type: PLAYREADY\n security_level: 3000\n host: https://cdm.example.com\n secret: your-api-secret\n device_name: mydevice_sl3\n
The host must end in /playready
For a PlayReady remote CDM the host URL has to include the trailing /playready path segment (e.g. https://cdm.example.com/playready). pyplayready's RemoteCdm treats host as a base and appends its own endpoint paths on top of it, so a host without the suffix points the requests at the wrong location. The symptom is 404 responses from the server rather than an obvious configuration error.
"},{"location":"guide/drm-and-cdm/#decrypt-labs-hosted-keyxtractor-api","title":"Decrypt Labs (hosted KeyXtractor API)","text":"Set type: decrypt_labs to use Decrypt Labs' hosted KeyXtractor service, which includes an intelligent key-caching layer:
unshackle.yamlremote_cdm:\n - name: decryptlabs\n type: decrypt_labs\n device_name: L1 # ChromeCDM, L1, L2 (Widevine) or SL2, SL3 (PlayReady)\n # host: https://keyxtractor.decryptlabs.com # optional, this is the default\n # secret: ... # optional if decrypt_labs_api_key is set globally\n\ndecrypt_labs_api_key: your-decrypt-labs-key\n
The API key is sent as the decrypt-labs-api-key header. If you omit secret on the entry, unshackle falls back to the global decrypt_labs_api_key value; if neither is present it raises an error. The host defaults to https://keyxtractor.decryptlabs.com. PlayReady devices (SL2/SL3) default to security level 2000/3000 respectively; Widevine devices default to system_id: 26830, security_level: 3.
"},{"location":"guide/drm-and-cdm/#custom-api-custom_api","title":"Custom API (custom_api)","text":"For an arbitrary CDM HTTP API, type: custom_api provides a fully configurable client that adapts to almost any request/response shape through YAML alone. A minimal example:
unshackle.yamlremote_cdm:\n - name: my_custom\n type: custom_api\n device:\n name: ChromeCDM\n type: CHROME # CHROME, ANDROID, or PLAYREADY\n system_id: 26830\n security_level: 3\n auth:\n type: bearer # header, bearer, or basic\n key: your-token\n endpoints:\n get_request:\n path: /get-challenge\n method: POST\n decrypt_response:\n path: /get-keys\n method: POST\n timeout: 30\n
The custom_api type supports much more: request/response field remapping, transforms (base64/hex/JSON encoding, kid:key parsing), conditional parameters, success/error detection, and vault-backed caching. Its full schema is aimed at developers integrating a bespoke key server; see the developer note below.
Developer: full custom_api schema
A custom_api entry accepts these sub-objects: device (name, type, system_id, security_level); auth (type of header/bearer/basic with the matching credential fields, plus custom_headers); endpoints (get_request and decrypt_response, each {path, method, timeout}); per-endpoint request_mapping (param_names, static_params, conditional_params, transforms, nested_params, exclude_params) and response_mapping (fields, transforms, response_types, success_conditions, error_fields, key_fields); caching (enabled, use_vaults, check_cached_first); and legacy/timeout. PlayReady mode is inferred when device.type == PLAYREADY or the device name is SL2/SL3.
"},{"location":"guide/drm-and-cdm/#decryption-backends","title":"Decryption backends","text":"Once the content keys are known, the encrypted file (for Widevine, PlayReady, and ClearKey-CENC) is decrypted by one of two external tools:
- shaka-packager is the default.
- mp4decrypt is part of the Bento4 tools.
Both must be discoverable in unshackle's bundled binaries/ directory or on your PATH. If the selected tool's binary is missing at decrypt time, unshackle raises an \"executable not found but is required\" error. See dependencies for installation.
You choose the backend with the decryption key. It can be a single value applied globally, or a per-service mapping:
Global defaultPer service unshackle.yamldecryption: shaka\n
unshackle.yamldecryption:\n default: shaka\n SOMESERVICE: mp4decrypt\n
How the value is interpreted
The default is shaka. Only the exact value mp4decrypt selects mp4decrypt; anything else (including shaka) selects shaka-packager. Per-service lookups are case-insensitive, with the default/DEFAULT entry used as the fallback.
The two backends produce equivalent output for standard CENC content. shaka-packager is generally preferred; switch a specific service to mp4decrypt if you hit a track that shaka-packager cannot handle.
ClearKey (HLS) does not use these tools
HLS AES-128 ClearKey content is decrypted in-process using AES-CBC and ignores the decryption setting entirely. There is nothing to configure.
"},{"location":"guide/drm-and-cdm/#clearkey","title":"ClearKey","text":""},{"location":"guide/drm-and-cdm/#clearkey-over-hls-aes-128","title":"ClearKey over HLS (AES-128)","text":"When an HLS playlist references a key with an EXT-X-KEY tag, unshackle fetches the key directly (from the given URI or an inline data: URL) and decrypts each segment in-process with AES-CBC. There is no device, no CDM, and no license challenge, so there is nothing to set up. Such tracks are handled automatically during download.
"},{"location":"guide/drm-and-cdm/#clearkey-over-cenc-w3c-eme-orgw3clearkey","title":"ClearKey over CENC (W3C EME org.w3.clearkey)","text":"The CENC ClearKey scheme is a proper license flow, but the \"license server\" simply returns the keys as a JWK Set rather than a DRM-wrapped blob. unshackle builds a W3C EME request of the form:
{\"kids\": [\"<base64url-kid>\", ...], \"type\": \"temporary\"}\n
It sends this to the manifest's license URL (or to whatever a service's ClearKey license hook returns) and reads the JSON Web Key set back. No device file is needed. The decrypted content still goes through shaka-packager or mp4decrypt like any other CENC track, so the decryption setting applies.
"},{"location":"guide/drm-and-cdm/#widevine-and-playready-on-the-same-title","title":"Widevine and PlayReady on the same title","text":"A single manifest can advertise both Widevine and PlayReady. unshackle picks the CDM per track: if a track needs a system that your loaded CDM does not provide, it transparently switches to the appropriate device for that track using your cdm mapping. If it cannot find a matching device, it fails with a clear message such as:
Title needs a Widevine CDM but SOMESERVICE is configured with PlayReady.\n
Use the cdm mapping's DRM-based selection to configure a device for each system; unshackle selects the right one per track automatically.
"},{"location":"guide/drm-and-cdm/#keys-vaults-and-caching","title":"Keys, vaults, and caching","text":"Fetching a license is comparatively slow and, for some services, rate-limited. unshackle therefore resolves each key ID in this order before ever contacting a license server:
- Keys already known for the current DRM object.
- An in-process license-key cache (for the running session).
- Your configured key vaults.
- Only if still missing, a live license request.
After a successful license request, the recovered keys are written back to all configured vaults so future downloads can skip the license server. Two flags let you control this trade-off on the dl command:
Flag Effect --vaults-only Never send a license request; use only cached/vault keys. Fails if a key is missing. --cdm-only Ignore vaults for sourcing keys and always perform a fresh license request. See key vaults for how to configure vault backends.
"},{"location":"guide/drm-and-cdm/#debugging-drm-issues","title":"Debugging DRM issues","text":"A few configuration toggles help when a title will not decrypt:
Config key Default Effect debug false Global debug output. debug_keys false Log decryption keys. debug_requests false Log HTTP requests. unshackle.yamldebug_keys: true\ndebug_requests: true\n
Keys and paths in logs
When key logging is enabled, content keys (KID:key pairs) are written to the logs. Treat those logs as sensitive. The unrelated redact_paths option (default true) masks local directory paths in log output.
"},{"location":"guide/drm-and-cdm/#see-also","title":"See also","text":" - Downloading: the
dl command and its DRM-related flags. - Key vaults: caching content keys across downloads.
- Configuration: the full
unshackle.yaml reference. - Dependencies: installing shaka-packager and mp4decrypt.
"},{"location":"guide/output-and-naming/","title":"Output & Naming","text":"This page explains where unshackle writes finished downloads and how it names them. You control naming with output templates (the filename) and folder templates (the directory the file lands in), using a set of {variable} placeholders that unshackle fills in from the media it just downloaded. It also covers muxing options, release-group tagging, Unicode filenames, and how to relocate unshackle's working directories.
If you have not configured unshackle.yaml yet, read Configuration File first. Everything below lives in that file.
"},{"location":"guide/output-and-naming/#how-an-output-path-is-assembled","title":"How an output path is assembled","text":"When a download finishes muxing, unshackle builds the final path from three pieces:
<output directory> / <folder template> / <filename template><extension>\n
- 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, or a single-string folder template that applies to all title kinds (see 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.
A typical episode path
/home/user/Downloads/\n \u2514\u2500 Show Name S01 1080p EXAMPLE WEB-DL DDP5.1 H.264/\n \u2514\u2500 Show.Name.S01E01.Pilot.1080p.EXAMPLE.WEB-DL.DDP5.1.H.264-TAG.mkv\n
The folder name comes from the series folder template and the file from the series output template. Two command-line flags change this layout for a single run:
Flag Effect -o, --output Use this directory instead of directories.downloads. --no-folder Skip the per-title subfolder; write the file directly into the output directory. --no-source Omit the service source tag from both the filename and folder (the {source} variable resolves to empty)."},{"location":"guide/output-and-naming/#output-templates","title":"Output templates","text":"Filename templates live under the output_template key, keyed by title kind. Each value is a format string built from {variable} placeholders.
unshackle.yamloutput_template:\n movies: \"{title}.{year}.{quality}.{source}.WEB-DL.{audio_full}.{video}-{tag}\"\n series: \"{title}.{season_episode}.{episode_name?}.{quality}.{source}.WEB-DL.{audio_full}.{video}-{tag}\"\n songs: \"{track_number}. {title}\"\n
The recognized kinds are:
Kind Applies to Default when unset 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) - 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.
"},{"location":"guide/output-and-naming/#required-vs-optional-variables","title":"Required vs. optional variables","text":" {variable}: required. If it is missing from the download's context, formatting raises an error. (Note that most variables are always present in context but resolve to an empty string when not applicable, so being \"present but empty\" is different from \"missing\".) {variable?}: optional. A trailing ? marks the variable conditional. When it resolves to empty, unshackle removes the placeholder and one adjacent separator (., -, or space), so the surrounding text collapses cleanly.
Use ? for anything that isn't always there
Episode names, editions, tags, HDR labels and audio extras aren't present on every title. Mark them optional so you never get doubled dots or dangling separators:
series: \"{title}.{season_episode}.{episode_name?}.{quality}.{hdr?}.{source}-{tag?}\"\n
For an episode with no on-screen name, {episode_name?} and its adjacent dot simply disappear."},{"location":"guide/output-and-naming/#separator-style-dots-vs-spaces","title":"Separator style (dots vs. spaces)","text":"unshackle looks at the characters you place between variables to decide the filename's separator style. If spaces outnumber dots in your template, it sanitizes with spaces; otherwise it uses dots (the scene-style default). This is also applied to the auto-generated date separators for daily/dated content, and to every segment of a nested folder template.
Scene style (dots)Readable style (spaces) output_template:\n movies: \"{title}.{year}.{quality}.{source}.WEB-DL.{audio_full}.{video}-{tag}\"\n
The.Movie.2024.1080p.EXAMPLE.WEB-DL.DDP5.1.H.264-TAG.mkv\n
output_template:\n movies: \"{title} ({year}) {quality} {source} WEB-DL {audio_full} {video}\"\n
The Movie (2024) 1080p EXAMPLE WEB-DL DDP5.1 H.264.mkv\n
"},{"location":"guide/output-and-naming/#template-variables","title":"Template variables","text":"Every variable below is valid in both output and folder templates. Values are derived from the downloaded media (via MediaInfo), from the title metadata, or from your config. Variables that don't apply to a given title resolve to an empty string.
"},{"location":"guide/output-and-naming/#common-video","title":"Common / video","text":"Variable Meaning Example title Title name (movie/show/song name; $ is rendered as S) The Show title_type Media kind of the title movie, series, music year Release year 2024 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 hdr Dynamic-range label HDR, HDR10P, DV, DV.HDR, DV.HDR10P, HLG hfr High frame rate marker (frame rate > 30) HFR edition Edition label from the track, if any Directors Cut tag Release-group tag (from config.tag or --tag) TAG repack REPACK when the --repack flag is used REPACK lang_tag Result of your language_tags rules, if configured MULTi How language_tags rules match
Rules are tried in order, but language comparison is fuzzy: a rule condition of en matches en-US, en-GB, and any other en-* variant. Within a single rule, all conditions must match (AND logic) for it to apply. The first rule that fully matches wins; if none match, {lang_tag?} is removed from the filename cleanly.
"},{"location":"guide/output-and-naming/#episode-specific","title":"Episode-specific","text":"Variable Meaning Example season Zero-padded season (S%02d) S01 episode Zero-padded episode (E%02d) E05 season_episode Combined S01E05 episode_name Episode title Pilot part Part index of a split episode, empty otherwise 2 absolute Absolute episode number, zero-padded to 3 digits, empty when unknown 007 date ISO air date for daily/dated content 2024-06-01 Daily & sports content
When an episode has an air date, unshackle switches to date-based naming automatically: season and season_episode become the formatted air date, episode and year are cleared, and {date} holds the ISO date. The date's internal separator (dots or spaces) follows your series template style.
"},{"location":"guide/output-and-naming/#split-episodes","title":"Split episodes","text":"A few services split one episode into several separately playable videos. Where a service reports that, the part index is folded into {episode} and {season_episode}, so the stock series template names it with no change to your config.
Show.Name.S01E01.Part.2.1080p.EXAMPLE.WEB-DL.DDP5.1.H.264-TAG.mkv\n
The part token sits immediately after the episode token, before the quality tags, and the group tag stays last. Its separator follows your template's own style, the same way dated content does, so a spaced template gives S01E01 Part 2 instead.
The part is left out of the folder name, so every part of an episode lands in the same season folder:
The.Show.S01.1080p.EXAMPLE.WEB-DL.DDP5.1.H.264-TAG/\n \u251c\u2500 The.Show.S01E01.Part.1.1080p.EXAMPLE.WEB-DL.DDP5.1.H.264-TAG.mkv\n \u251c\u2500 The.Show.S01E01.Part.2.1080p.EXAMPLE.WEB-DL.DDP5.1.H.264-TAG.mkv\n \u2514\u2500 The.Show.S01E02.1080p.EXAMPLE.WEB-DL.DDP5.1.H.264-TAG.mkv\n
The same holds for a folder template of your own: {season} never picks up the part, and {season_episode} drops it inside a folder template. An episode with no part is named like any other episode.
What media servers do with the part token
The form above follows scene practice for genuinely split episodes. Kodi stacks the parts back into one playable episode automatically. Jellyfin lists two versions of S01E01, still correct and playable, because its stacking pattern expects the part marker at the end of the name. Plex's scanner is closed-source, so its handling of text after the part token is unverified.
For custom templates there is also a standalone {part} variable. You rarely want it, see the caveat on that page.
"},{"location":"guide/output-and-naming/#audio","title":"Audio","text":"Variable Meaning Example audio Audio codec DDP, DD, AAC audio_channels Channel layout 5.1, 2.0 audio_full Codec + channels combined DDP5.1 atmos Atmos when any track carries JOC Atmos dual DUAL for two audio languages (see dual_multi_mode) DUAL multi MULTi for more than two audio languages MULTi dubbed DUBBED for a single audio language that is not the original (strict mode only) DUBBED"},{"location":"guide/output-and-naming/#music-specific","title":"Music-specific","text":"Variable Meaning track_number Zero-padded track number artist / album_artist Artist / album artist (falls back to artist) album Album name disc Disc number (only shown when greater than 1) track_total / disc_total Totals release_type album, single, ep, etc. genre Genre explicit Explicit when flagged isrc / upc / label ISRC, UPC and record label Unknown variables are ignored, not errors
If you use a variable that isn't in the list above, unshackle emits a warning at startup but continues. Double-check spelling. A typo'd {quailty} will simply be left in the filename literally or dropped, not filled in.
"},{"location":"guide/output-and-naming/#folder-templates","title":"Folder templates","text":"Folder templates are nested under a special folder key inside output_template. This key is handled separately from the filename kinds. You can give it a single string (used for all title kinds) or a per-kind map.
Single folder templatePer-kind folder templates unshackle.yamloutput_template:\n movies: \"{title}.{year}.{quality}.{source}-{tag}\"\n series: \"{title}.{season_episode}.{quality}.{source}-{tag}\"\n folder: \"{title}.{year}\"\n
unshackle.yamloutput_template:\n movies: \"{title}.{year}.{quality}.{source}-{tag}\"\n series: \"{title}.{season_episode}.{quality}.{source}-{tag}\"\n folder:\n movies: \"{title} ({year})\"\n series: \"{title} ({year})/Season {season}\"\n albums: \"{artist} - {album} ({year})\"\n
The per-kind folder keys are movies, series, songs, and albums. Any other key produces a startup warning. Path separators (/ or \\) are allowed in folder templates. Each segment is formatted independently, so you can build nested directory structures like Show/Season 01. unshackle reads the separator style from the whole template, so every segment is spaced alike.
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 (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}).
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 (or a single-string folder template, which folders every kind). Set one if you want each movie in its own directory.
"},{"location":"guide/output-and-naming/#muxing-options","title":"Muxing options","text":"Muxing (combining video, audio, subtitle, chapter and attachment tracks into a single Matroska file with mkvmerge) is configured under the muxing key.
unshackle.yamlmuxing:\n set_title: true\n merge_video: false\n merge_audio: true\n default_language:\n audio: en\n subtitle: en\n
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 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. When no preferred language is matched, unshackle applies sensible defaults: the video default falls back to the title language / original-language track / first track; the audio default is the original-language track; and the subtitle default is a forced track matching the first audio's language.
default_language only sets the default-track flag
default_language controls which track carries the MKV --default-track flag. Nothing else. It does not change track selection (that stays with -l/--alang and friends), and it does not touch which track is marked as original: --original-flag still tags the true original-audio track. That is the point: you can make your player default to, say, Polish audio on an English-original title without altering the original marker. When the configured language isn't present in the manifest, each track type falls back to its normal default rule described above.
merge_video collapses only the language dimension
merge_video groups video tracks by (resolution, range, codec) and merges them so that only language differs within a single file, with no re-encode and no concatenation. Different resolutions, ranges (SDR / HDR10 / HDR10+ / DV / HYBRID), and codecs (H.264 / H.265) always land in separate files. So -r HYBRID,DV,HDR10,SDR --merge-video produces one file per range (never a single fused file), whereas English + French video of identical resolution, range, and codec yields one file containing both video tracks. merge_audio works the same way for audio languages.
A few muxing behaviors are automatic and not configurable:
--no-date is always passed for privacy (no timestamps embedded). - Descriptive audio gets the visually-impaired flag; SDH subtitles get the hearing-impaired flag; forced subtitles get the forced flag.
- Chapters are written from the title's chapter list (see Chapters below).
The --no-mux flag skips muxing entirely and writes the individual track files, each with a track-type suffix, into the same output/folder structure.
"},{"location":"guide/output-and-naming/#tags-group-naming","title":"Tags & group naming","text":"The release-group tag is the -TAG portion at the end of scene-style names, produced by the {tag} variable. It can be swapped per release with tag_rules: ordered rules that match on the release-attribute variables only (quality, resolution, hdr, source, lang_tag, title_type, and so on), first match wins. The title's own fields, such as title and season_episode, cannot be matched. See the reference page for the exact list.
unshackle.yamltag: \"MYGROUP\"\ntag_group_name: true\ntag_imdb_tmdb: true\n
Key Type Default Effect tag str \"\" The release-group tag used for {tag}. tag_group_name bool true Write the group name (config.tag) into the MKV Group metadata tag. tag_imdb_tmdb bool true Look up and embed IMDb / TMDB / TVDB external-ID tags in the MKV metadata (uses tmdb_api_key / tvdb_api_key / simkl_client_id when available). The IDs are written as the Matroska IMDB, TMDB, and TVDB2 tags. TVDB2 values carry the entity prefix the Matroska tagging spec requires: series/73871 for a show, movies/113 for a film.
You can override the tag per run without editing config:
unshackle dl --tag OTHERGROUP EXAMPLE \"...\"\n
The --repack flag adds the REPACK marker (surfaced through the {repack} variable) to the filename for that run:
unshackle dl --repack EXAMPLE \"...\"\n
Empty tags collapse cleanly
If config.tag is empty and your template ends with -{tag?}, the trailing - and the placeholder are both removed. Use the optional form {tag?} if you sometimes download without a tag set.
"},{"location":"guide/output-and-naming/#unicode-filenames","title":"Unicode filenames","text":"By default unshackle transliterates non-ASCII characters (Korean, Japanese, Chinese, accented Latin, etc.) into ASCII equivalents, which is safest for older tools, DDL, and P2P sharing. Set unicode_filenames to keep the native characters.
unshackle.yamlunicode_filenames: true\n
Value Result false (default) \uae30\uc0dd\ucda9 \u2192 transliterated ASCII; accents stripped. true Native characters preserved in filenames and folders. Regardless of this setting, a set of filesystem-unsafe and structural characters is always removed or replaced during sanitization (for example / and ; become &, and characters like \\ * ! ? , ' \" < > | $ # ~ are stripped). This is why you should never put path-unsafe characters directly in a template. unshackle warns about templates containing < > : \" / \\ | ? * at startup (folder templates are checked per path segment, so / is allowed there as a directory separator).
"},{"location":"guide/output-and-naming/#directory-filename-configuration","title":"Directory & filename configuration","text":"The directories key relocates unshackle's working folders. Only the folders listed here can be moved; core package paths are protected and silently ignored if you try to override them.
unshackle.yamldirectories:\n downloads: \"~/Media/unshackle\"\n temp: \"/mnt/fast/unshackle-temp\"\n cache: \"~/.cache/unshackle\"\n cookies: \"~/.config/unshackle/cookies\"\n logs: \"~/.config/unshackle/logs\"\n
Name Purpose downloads Default output directory for finished files. temp Working directory for in-progress downloads, muxing, and intermediate files. cache Title/HTTP cache and update-check state. cookies Per-service cookie files. logs Log files. exports Export JSONs. services, vaults, fonts, commands Search paths for services, key-vault backends, bundled fonts, and CLI commands. wvds, prds, dcsl Widevine devices, PlayReady devices, and DCSL data. Paths support ~ expansion. The names app_dirs, core_dir, namespace_dir, user_configs, and data are protected and cannot be changed.
"},{"location":"guide/output-and-naming/#filename-patterns","title":"Filename patterns","text":"The filenames key overrides the naming patterns for a handful of internal files. These are not your media output names. They are logs, temp files, and per-service configs.
unshackle.yamlfilenames:\n log: \"unshackle_{name}_{time}.log\"\n chapters: \"Chapters_{title}_{random}.txt\"\n subtitle: \"Subtitle_{id}_{language}.srt\"\n
Name Default Where log unshackle_{name}_{time}.log directories.logs debug_log unshackle_debug_{service}_{time}.jsonl directories.logs config config.yaml per-service directory root_config unshackle.yaml main config filename chapters Chapters_{title}_{random}.txt directories.temp subtitle Subtitle_{id}_{language}.srt directories.temp"},{"location":"guide/output-and-naming/#chapters-naming","title":"Chapters naming","text":"When a title has chapters, they are written to an OGM chapters file (filenames.chapters) and passed to the muxer. Unnamed chapters use the chapter_fallback_name template:
unshackle.yamlchapter_fallback_name: \"Chapter {i:02}\"\n
The fallback supports two placeholders: {i} (chapter number, starting at 1) and {j} (which increments only for unnamed chapters). Standard format directives like {i:02} are allowed.
"},{"location":"guide/output-and-naming/#removed-migrated-options","title":"Removed & migrated options","text":"scene_naming was removed
The old scene_naming option no longer exists. If it is present in your config, unshackle exits immediately with an error asking you to configure output_template instead. Migrate any scene-naming setup to the templates described on this page.
"},{"location":"guide/output-and-naming/#worked-examples","title":"Worked examples","text":"Each tab shows a template and what it produces for a few different downloads, so you can see how the optional (?) variables appear and disappear.
MoviesTV seriesMulti-languageDaily showsMusic output_template:\n movies: \"{title}.{year}.{edition?}.{quality}.{source}.WEB-DL.{audio_full}.{atmos?}.{video}.{hdr?}-{tag?}\"\n folder:\n movies: \"{title} ({year})\"\n
A 4K Atmos HDR download fills every placeholder:
The Movie (2024)/\n \u2514\u2500 The.Movie.2024.2160p.EXAMPLE.WEB-DL.DDP5.1.Atmos.H.265.HDR-TAG.mkv\n
The same template on a plain 1080p SDR stereo download, where {edition?}, {atmos?} and {hdr?} collapse along with their dots:
The Movie (2024)/\n \u2514\u2500 The.Movie.2024.1080p.EXAMPLE.WEB-DL.AAC2.0.H.264-TAG.mkv\n
A director's cut fills {edition?}:
The Movie (2024)/\n \u2514\u2500 The.Movie.2024.Directors.Cut.2160p.EXAMPLE.WEB-DL.DDP5.1.Atmos.H.265.DV.HDR-TAG.mkv\n
output_template:\n series: \"{title}.{season_episode}.{episode_name?}.{quality}.{source}.WEB-DL.{audio_full}.{video}-{tag}\"\n folder:\n series: \"{title} ({year})/Season {season}\"\n
The Show (2023)/Season S02/\n \u2514\u2500 The.Show.S02E04.The.Reckoning.2160p.EXAMPLE.WEB-DL.DDP5.1.H.265-TAG.mkv\n
unshackle decides the separator style once for the whole folder template, and every segment then follows it. Spaces outnumber dots in {title} ({year})/Season {season}, so Season {season} keeps its space even though it holds only one variable.
When an episode has no on-screen name, {episode_name?} disappears cleanly:
The Show (2023)/Season S02/\n \u2514\u2500 The.Show.S02E05.2160p.EXAMPLE.WEB-DL.DDP5.1.H.265-TAG.mkv\n
Place {dual?}, {multi?} and {dubbed?} next to each other. At most one of them is ever set (see dual_multi_mode), and the empty ones collapse:
output_template:\n movies: \"{title}.{year}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{dubbed?}.{audio_full}.{video}-{tag}\"\n
Original English audio plus a French dub (-l en,fr):
The.Movie.2024.1080p.EXAMPLE.WEB-DL.DUAL.DDP5.1.H.264-TAG.mkv\n
Original plus several dubs (-l en,fr,de,es):
The.Movie.2024.1080p.EXAMPLE.WEB-DL.MULTi.DDP5.1.H.264-TAG.mkv\n
Only a dub, original audio left out (-l de on an English-original title):
The.Movie.2024.1080p.EXAMPLE.WEB-DL.DUBBED.DDP5.1.H.264-TAG.mkv\n
Two dialects of one language (en-US + en-GB) count as a single language, so none of the three variables is set:
The.Movie.2024.1080p.EXAMPLE.WEB-DL.DDP5.1.H.264-TAG.mkv\n
For finer control (e.g. a SUBBED tag driven by subtitle languages), use {lang_tag?} with language_tags rules instead.
Dated content needs no special template. When an episode carries an air date, {season_episode} becomes the date automatically and {episode} and {year} clear:
output_template:\n series: \"{title}.{season_episode}.{episode_name?}.{quality}.{source}.WEB-DL.{audio_full}.{video}-{tag}\"\n
A normal episode and a dated one, same template:
The.Daily.Show.S28E101.1080p.EXAMPLE.WEB-DL.DDP5.1.H.264-TAG.mkv\nThe.Daily.Show.2024.06.01.1080p.EXAMPLE.WEB-DL.DDP5.1.H.264-TAG.mkv\n
The date separator follows your template style: dots here, spaces if your series template uses spaces.
output_template:\n songs: \"{track_number}. {title}\"\n folder:\n albums: \"{album_artist} - {album} ({year})\"\n
The Artist - The Album (2024)/\n \u2514\u2500 01.Opening.Track.mka\n
In \"{track_number}. {title}\" dots and spaces are tied, and ties go to dot style; add more spaces between variables (as below) if you want space-separated names.
A multi-disc album with explicit tracks, using more of the music variables. {disc} is empty on disc 1, so {disc?} and its separator only appear from disc 2 onward:
output_template:\n songs: \"{disc?}-{track_number}. {title} {explicit?}\"\n folder:\n albums: \"{album_artist} - {album} ({year}) [{label?}]\"\n
The Artist - The Album (2024) [The Label]/\n \u251c\u2500 01. Opening Track.mka\n \u2514\u2500 02-03. Closing Track Explicit.mka\n
"},{"location":"guide/output-and-naming/#see-also","title":"See also","text":" - Configuration File: the full
unshackle.yaml structure. - Downloading: the
dl command and per-run flags like -o, --no-folder, --tag, and --repack.
"},{"location":"guide/proxies-and-vpn/","title":"Proxies & VPN","text":"Most streaming services are geo-restricted, region-priced, or simply refuse to hand over a manifest from the \"wrong\" country. unshackle routes its traffic through a proxy so the service sees the location you choose instead of your own. You can point it at a single proxy URL, or configure one of several proxy providers (commercial VPNs and self-hosted tunnels), then ask for a country by its two-letter code and let unshackle pick a working server for you.
The --proxy flag controls proxying, and a query is resolved to a real proxy URL by the providers unshackle supports: basic static proxies, Gluetun (documented in the most detail, since it is the most flexible), ExpressVPN, NordVPN, Proton VPN, Hola, Surfshark, and Windscribe.
Where proxies are configured
All provider configuration lives under the top-level proxy_providers: key in your unshackle.yaml. Each provider has its own sub-key, and the keys inside it map directly onto that provider's settings. See the Configuration file page for where the file lives and how it is loaded.
"},{"location":"guide/proxies-and-vpn/#the-proxy-flag","title":"The --proxy flag","text":"Proxying is controlled at download time by three options on the dl command:
Option Effect --proxy The proxy to use. Either an explicit URI, or a query that unshackle resolves against your configured providers. --no-proxy Force all proxy use off. No providers are initialised and no proxy query is resolved. --no-proxy-download Use the proxy for the manifest, licence, and authentication, but bypass it for the downloads themselves. Explicit proxy URIunshackle dl --proxy http://user:pass@1.2.3.4:8080 EXAMPLE 81234567\n
Resolve a US server from your configured providersunshackle dl --proxy us EXAMPLE 81234567\n
Target a specific providerunshackle dl --proxy nordvpn:us EXAMPLE 81234567\n
Set a default proxy in config
Like every dl flag, --proxy can be given a default in your configuration so you do not have to pass it on every command. See Downloading for how dl options and config defaults interact.
"},{"location":"guide/proxies-and-vpn/#-no-proxy-download","title":"--no-proxy-download","text":"Segment downloads are usually the largest share of traffic, and a residential-grade VPN proxy is often the slowest link in the chain. --no-proxy-download lets you keep the proxy where it matters (passing the geo-check for the manifest and the licence) while pulling the files themselves over your normal connection at full speed.
Proxy the manifest and licence, download segments directunshackle dl --proxy gb --no-proxy-download EXAMPLE 10a1234\n
When this is unsafe
Some services tie segment delivery (CDN tokens, per-segment geo-checks) to the same region as the manifest. On those services, bypassing the proxy for downloads will cause segment fetches to fail or return the wrong region. If downloads break with --no-proxy-download, remove it.
"},{"location":"guide/proxies-and-vpn/#how-proxy-resolution-works","title":"How proxy resolution works","text":"The value you pass to --proxy is interpreted in one of three ways. unshackle checks them in order.
flowchart TD\n A[\"--proxy value\"] --> B{\"Looks like a URI?<br/>http:// https:// socks\"}\n B -- yes --> C[\"Use it verbatim\"]\n B -- no --> D{\"Has a provider prefix?<br/>provider:query\"}\n D -- yes --> E[\"Ask that one provider<br/>for the query\"]\n D -- no --> F[\"Ask every provider in order,<br/>first match wins\"]
- 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. - A provider-prefixed query:
provider:query, for example nordvpn:us or gluetun:windscribe:us. unshackle finds the provider whose name matches the prefix (case-insensitive) and asks only that provider to resolve the remainder. If no such provider is loaded, or it returns no proxy, the download errors. - A bare query: a region like
us, gb, us:seattle, or us1234. unshackle asks each loaded provider in order and uses the first proxy any of them returns.
The query is matched against a region grammar (a two-letter country code, optionally followed by a server number and/or a :city or -city part), lowercased, and then handed to the provider. The exact forms each provider accepts are documented in its section below.
"},{"location":"guide/proxies-and-vpn/#provider-load-order","title":"Provider load order","text":"When you run a download without --no-proxy, unshackle instantiates every configured provider once, in this fixed order, and that same order is used for bare-query resolution:
Basic \u2192 ExpressVPN \u2192 NordVPN \u2192 Proton VPN \u2192 Surfshark \u2192 Windscribe \u2192 Gluetun \u2192 Hola
So if you have both Basic and NordVPN configured and run --proxy us, a us entry in your Basic config wins because Basic is tried first. To skip ahead to a specific provider, prefix the query (--proxy nordvpn:us).
Auto-loading providers
Most providers only load when you configure them under proxy_providers:. Three are 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.
"},{"location":"guide/proxies-and-vpn/#connection-feedback","title":"Connection feedback","text":"After a provider resolves a query, unshackle prints a line so you can confirm where you came out:
- Gluetun reports the verified exit IP, country, and city of the container.
- ExpressVPN and Proton VPN print a location summary such as
(USA - New York, #3 of 5): .214. - Other providers log the resolved proxy URL.
"},{"location":"guide/proxies-and-vpn/#basic-static-proxies","title":"Basic (static proxies)","text":"The Basic provider is pure static configuration, with no accounts and no network calls. You list proxy URLs per country code, and unshackle serves them back when you query that country. This is the right choice when you already have proxy endpoints (from a proxy seller, your own servers, or a corporate gateway).
Under each two-letter country code you can put either a single proxy string or a list of them:
unshackle.yamlproxy_providers:\n basic:\n us: http://user:pass@1.2.3.4:8080\n de:\n - http://a.example:8080\n - socks5://b.example:1080\n
Query forms:
Query Meaning us A proxy for the US. If several are listed, one is chosen at random. us2 The 2nd entry in the US list (1-based). - If a country has a single proxy string, that proxy is always returned.
- If it has a list and you give a plain country code, one is picked at random; append an index (
de1, de2) to pin a specific entry. - Each URL is normalised (a missing scheme defaults to
http) and validated, so a malformed URI is rejected up front rather than failing mid-download.
unshackle dl --proxy de2 EXAMPLE 81234567\n
"},{"location":"guide/proxies-and-vpn/#gluetun","title":"Gluetun","text":"Gluetun is unshackle's most flexible proxy backend, and the one to reach for if you want one configuration to cover many VPN providers. Instead of returning a remote proxy URL, Gluetun launches a local Docker container running the qmcgaw/gluetun image. That container establishes a WireGuard or OpenVPN tunnel to your chosen VPN provider and exposes a plain HTTP proxy on localhost, which unshackle then routes traffic through. When the download finishes, the container is torn down automatically.
This means you get access to the 50+ VPN providers that Gluetun supports, using your own VPN subscription, through a single uniform interface, with no per-provider integration in unshackle itself.
Docker is required
Gluetun needs a working Docker installation on your PATH. If Docker is not found, the provider fails to load with an error linking to the install docs. The container also needs the NET_ADMIN capability and access to /dev/net/tun to bring up the tunnel. unshackle requests both automatically.
"},{"location":"guide/proxies-and-vpn/#query-format","title":"Query format","text":"Gluetun uses a nested selector: the provider prefix picks Gluetun, and the rest names the VPN provider and the region.
gluetun:<vpn-provider>:<region>\n
A Windscribe US exit via Gluetununshackle dl --proxy gluetun:windscribe:us EXAMPLE 81234567\n
A NordVPN Germany exit via Gluetununshackle dl --proxy gluetun:nordvpn:de EXAMPLE 81234567\n
After unshackle strips the leading gluetun:, the provider receives exactly provider:region (two colon-separated parts; anything else is an error). The <vpn-provider> must be one you have configured under gluetun.providers, and friendly names are normalised to Gluetun's identifiers (for example pia and privateinternetaccess both map to private internet access).
"},{"location":"guide/proxies-and-vpn/#configuration","title":"Configuration","text":"Each VPN provider you want to use gets an entry under providers:, carrying its VPN type, credentials, and how your region aliases map onto Gluetun's server selectors.
unshackle.yamlproxy_providers:\n gluetun:\n providers:\n windscribe:\n vpn_type: wireguard\n credentials:\n private_key: YOUR_WIREGUARD_PRIVATE_KEY\n addresses: YOUR_WIREGUARD_ADDRESS\n preshared_key: YOUR_PRESHARED_KEY\n server_countries:\n us: US\n uk: GB\n server_cities: {}\n server_hostnames: {}\n extra_env: {}\n nordvpn:\n vpn_type: wireguard\n credentials:\n private_key: YOUR_WIREGUARD_PRIVATE_KEY\n server_countries:\n us: US\n de: DE\n\n # Global settings (all optional)\n base_port: 8888\n auto_cleanup: true\n container_prefix: unshackle-gluetun\n auth_user: null\n auth_password: null\n verify_ip: true\n
Per-provider keys:
Key Purpose vpn_type wireguard (default) or openvpn. credentials The secrets for the tunnel; see the credential rules below. server_countries Maps your region alias (e.g. us) to Gluetun's country value (e.g. US). server_cities Optional. Maps an alias to a Gluetun city. server_hostnames Optional. Maps an alias directly to a specific server hostname. extra_env Optional. Raw environment variables merged into the container last, for any Gluetun setting not covered above. Credential rules by VPN type:
WireGuardOpenVPN private_key is always required. surfshark, mullvad, and ivpn also require addresses. windscribe additionally requires both addresses and preshared_key (preshared_key may be an empty string, but the key must be present). - NordVPN and Proton VPN need only
private_key.
- Requires
username and password.
Which vpn_type should I pick?
WireGuard is the default because it can be faster, but it needs the fiddlier credential setup above (private keys, and for some providers addresses and a preshared key). OpenVPN is the simplest to configure (just a username and password) at the cost of some speed. If you are getting started or hitting credential trouble, OpenVPN is the path of least resistance; switch to WireGuard when you want the extra throughput.
Global keys:
Key Default Purpose base_port 8888 The first local port to expose proxies on. Additional containers take the next free ports. auto_cleanup true Remove containers on exit. If false, containers are only stopped, not deleted. container_prefix unshackle-gluetun Prefix for Docker container names. auth_user / auth_password null Optional HTTP-proxy credentials. If set, the local proxy requires auth and the credentials are embedded in the proxy URL. verify_ip true After connecting, verify the container's real exit IP and region."},{"location":"guide/proxies-and-vpn/#region-selection","title":"Region selection","text":"For a given query region, Gluetun resolves the server like this:
- An explicit entry in
server_hostnames, server_cities, or server_countries for that alias always wins. - A
us1239-style query (country code + number) with no explicit mapping is turned into a provider-specific server hostname (for example us1239.nordvpn.com for NordVPN, us-1239.prod.surfshark.com for Surfshark). - A bare two-letter code is expanded to the full country name Gluetun expects.
A few providers (Windscribe, VyprVPN, VPN Secure) select by region rather than country, and Windscribe region names are translated automatically. For instance us becomes US East and uk/gb become United Kingdom.
"},{"location":"guide/proxies-and-vpn/#container-lifecycle","title":"Container lifecycle","text":"Understanding what Gluetun does behind the scenes helps when something goes wrong:
- Image check.
qmcgaw/gluetun:latest is pulled if it is not already present. - Reuse. If a container for this exact
provider:region is already running (from a concurrent unshackle session), it is adopted instead of starting a new one. Stopped or dead containers with the same name are pruned first.
Expect a slow first request, then instant reuse
Bringing up a fresh tunnel takes roughly 10 to 30 seconds for the first request to a given provider:region. This is the VPN handshake and readiness wait, not a hang. Every subsequent request that reuses that same container is effectively instant. A slow first run is normal.
- Port allocation. A free local port is chosen starting from
base_port. - Start. The container is launched bound to
127.0.0.1:{port}. Credentials are passed through a temporary, 0600-permission env-file (never as -e flags), which is overwritten and deleted immediately after. - Readiness wait. unshackle polls the container logs (up to 60 seconds) until both the HTTP proxy is listening and the VPN reports a completed connection. Fatal errors like
authentication failed or invalid credentials abort early, and recent logs are attached to the error to help you diagnose it. - IP verification. When
verify_ip is on, unshackle queries the container's exit IP and checks the country matches what you asked for, raising a region mismatch error if it does not. - Proxy handed back. You get
http://localhost:{port} (or with auth credentials if configured).
Cleanup and Ctrl+C
Containers are cleaned up via an atexit handler on normal exit. Gluetun deliberately does not install its own signal handlers, so Ctrl+C continues to work as you expect. If a run is killed hard (e.g. kill -9) a container can be left behind; with auto_cleanup: true the next run adopting that name will still work, or you can remove it manually with docker rm -f.
"},{"location":"guide/proxies-and-vpn/#troubleshooting","title":"Troubleshooting","text":"Common Gluetun issues
RuntimeError about Docker not found. Install Docker and confirm docker is on your PATH. invalid credentials / authentication failed in the readiness wait. Your WireGuard/OpenVPN credentials are wrong or in the wrong fields. Double-check private_key, addresses, and (for Windscribe) preshared_key. - Region mismatch error. The VPN connected but exited in a different country than requested. Check your
server_countries mapping, or set verify_ip: false if you are intentionally using a nearby region. - Timeout after 60 seconds. The tunnel never came up. Inspect the container logs (
docker logs <container>) for the underlying VPN error.
"},{"location":"guide/proxies-and-vpn/#expressvpn","title":"ExpressVPN","text":"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.
Enable it and run a download interactively once:
unshackle.yamlproxy_providers:\n expressvpn:\n enable: true\n
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:
unshackle.yaml (optional)proxy_providers:\n expressvpn:\n region_map:\n us: ny-02 # prefer New York, server 02 for the US\n gb: # empty = smart random location\n server_map:\n myserver: usny-newyork-2\n
Default file location:
File Default path Token cache {cache}/vpn/expressvpn_tokens.json Where {cache} is your configured cache directory.
Query forms:
Query Meaning us A smart, random US location. us-ny US, New York. us-ny-2 / us-ny2 US, New York, the 2nd server. usny-newyork-2 A full location slug. hostname.expressprovider.com A direct endpoint hostname. The resolved proxy is an HTTPS proxy on port 443. On success, unshackle prints a location summary such as (USA - New York, #3 of 5): .214.
Cache safety
ExpressVPN's token cache is written with 0600 permissions so the refresh token is never briefly world-readable.
"},{"location":"guide/proxies-and-vpn/#nordvpn","title":"NordVPN","text":"NordVPN uses your Service Credentials, not your normal NordVPN login. Generate them from the NordVPN dashboard under manual setup; they are a long random username/password pair, not your email and password.
unshackle.yamlproxy_providers:\n nordvpn:\n username: YOUR_SERVICE_USERNAME\n password: YOUR_SERVICE_PASSWORD\n server_map: # optional: pin specific server IDs\n us: 1234\n us:seattle: 5678\n
Required keys: username, password. unshackle validates them up front: the 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.
server_map is optional and maps a region (or code:city) to a pinned NordVPN server ID. Its main use is tuning: for a given streaming service one specific server often performs noticeably better than NordVPN's recommended pick, so hard-coding that server for a day or two is a handy workaround until it degrades and you swap in another.
Use gb, not uk, for the United Kingdom
Query the UK as gb. unshackle uses the Alpha-2 country code gb deliberately to stay consistent with its other regional/country-code systems and avoid the language-vs- country-code confusion that uk invites.
Query forms:
Query Meaning us A recommended US server. us1234 The specific server us1234. us:seattle A recommended server in Seattle. 228 A NordVPN numeric country ID. The returned proxy is HTTPS on port 89 (NordVPN disabled its plain-HTTP proxies on port 80 in 2021).
unshackle dl --proxy nordvpn:us:seattle EXAMPLE 81234567\n
"},{"location":"guide/proxies-and-vpn/#proton-vpn","title":"Proton VPN","text":"Proton VPN supports two authentication methods, and unshackle can work with either. Note that free Proton accounts work but are limited to Proton's free-tier exit countries.
TV login (recommended)Exported cookies A self-sustaining, refreshable session obtained through Proton's TV device-login flow. This is the only method that can refresh itself headlessly, so it keeps working without re-exporting anything. Enable it and run a download interactively once:
unshackle.yamlproxy_providers:\n protonvpn:\n enable: true\n
With enable: true and an interactive terminal, unshackle prints a code and asks you to enter it at account.proton.me/vpn/tv/code. Once approved, the session is cached and reused automatically.
Export your account.proton.me session cookies (the AUTH-<UID> cookie) to the Proton cookie file. This method is access-only: it cannot refresh, so you must re-export when it expires.
The AUTH-<UID> cookie is HttpOnly
This is the single most common cause of a failed Proton cookie import: the required AUTH-<UID> cookie is HttpOnly, and many one-click cookie-export extensions silently skip HttpOnly cookies by default. If your import \"doesn't work,\" use an exporter that explicitly includes HttpOnly cookies.
Why this session can't refresh, and lapses after ~24h
Proton rotates browser refresh tokens and ties them to the live browser, so a headless refresh is impossible. unshackle therefore deliberately uses only the cookie's short-lived access token and never attempts a refresh, which is why the session lapses at Proton's ~24h TTL and must be re-exported. This is exactly why TV login is preferred for set-and-forget use.
Default file locations:
File Default path Cookies {cookies}/vpn/protonvpn.txt Session cache {cache}/vpn/protonvpn.json Proton VPN auto-loads if its cookie file exists. Like ExpressVPN, its cached session is written with 0600 permissions.
Cache vs cookie precedence
If both a TV-login cache and a cookie file exist, the cache is preferred unless the cookie file is newer. Cookies are read fresh every run and never written back to the cache. So to deliberately force use of the cookie session, just re-save (re-export) the cookie file so its modification time is newer than the cache.
Query forms (after the protonvpn: prefix):
Query Meaning us A random US server (paid tiers preferred over free). us12 Proton US server #12. us:ny A server in New York. The returned proxy is HTTPS on port 4443 (or port 443 for Secure Core servers). Secure Core and Tor servers are excluded from selection because they are too slow for a plain proxy. On success, unshackle prints a summary such as (Name - City): server-host.
Proton server numbers aren't sequential
When pinning a server with protonvpn:deNN, don't guess a low number. A country may expose non-contiguous IDs like #203, #813, and so on. Copy the number from a previous run's log line (e.g. DE#203) instead.
"},{"location":"guide/proxies-and-vpn/#hola","title":"Hola","text":"Hola requires no configuration at all. It uses the hola-proxy binary, and auto-loads whenever that binary is found on your PATH. If you query Hola but the binary is missing, the provider raises an error telling you to install it.
Install hola-proxy, then just query a countryunshackle dl --proxy hola:us EXAMPLE 81234567\n
Query forms: a two-letter country code (e.g. us, gb). unshackle asks hola-proxy for available proxies in that country and picks one at random, returning an HTTP proxy.
Temporary bans
Hola's free tier can rate-limit or temporarily ban you if queried too aggressively. If you see a temporary ban detected error, wait before retrying. Hola currently uses only datacenter proxies.
"},{"location":"guide/proxies-and-vpn/#surfshark","title":"Surfshark","text":"Surfshark, like NordVPN, uses Service Credentials rather than your login. Generate them from the Surfshark manual-setup page.
unshackle.yamlproxy_providers:\n surfsharkvpn:\n username: YOUR_SERVICE_USERNAME\n password: YOUR_SERVICE_PASSWORD\n server_map: # optional\n us: 1234\n
Required keys: username, password, validated with the same 48-character rule as NordVPN (combined username+password must be 48 alphanumeric characters, case-insensitive, no @). server_map optionally pins server IDs per region.
Query forms:
Query Meaning us A random US server. us-bos A specific server. us:seattle A random server in Seattle. The returned proxy is HTTPS on port 443.
"},{"location":"guide/proxies-and-vpn/#windscribe","title":"Windscribe","text":"Windscribe uses Service Credentials from its OpenVPN config page. Only the presence of a username and password is checked (there is no 48-character rule).
unshackle.yamlproxy_providers:\n windscribevpn:\n username: YOUR_SERVICE_USERNAME\n password: YOUR_SERVICE_PASSWORD\n server_map: # optional: alias -> hostname\n us: us-east.totallyacdn.com\n us:seattle: us-seattle.totallyacdn.com\n
Required keys: username, password. server_map optionally maps a region (or region:city) directly to a hostname.
Query forms:
Query Meaning us A random US server. us150 / sg007 A specific numbered server. us:seattle A random server in Seattle. The returned proxy is HTTPS on port 443.
Windscribe is not loaded by the REST API resolver
The standalone Windscribe provider (and Gluetun) are loaded by the dl command, but not by the proxy resolver used by the REST API and remote-download client. If you drive downloads through the API and need Windscribe or Gluetun exits, resolve the proxy on the CLI side or use a different provider for API jobs. See the developer note below.
"},{"location":"guide/proxies-and-vpn/#developer-notes","title":"Developer notes","text":"Two resolution paths (developers)
unshackle has two proxy-resolution code paths that are intentionally not identical:
unshackle/commands/dl.py inlines its own logic and loads all eight providers, including Windscribe and Gluetun. unshackle/core/proxies/resolve.py::initialize_proxy_providers() is the shared resolver used by the REST API handlers and the remote-service client. It omits Windscribe and Gluetun.
Both paths accept the same query grammar (a direct URI, a provider:query, or a bare country query tried against providers in order), but the set of available providers differs. Keep this in mind when adding a provider: wiring it into dl.py does not make it available over the API, and vice versa.
Implementing a new provider (developers)
Every provider subclasses unshackle.core.proxies.proxy.Proxy and implements three methods:
__init__(self, **kwargs) receives its proxy_providers sub-dict splatted as keyword arguments, so YAML keys map 1:1 to constructor parameters. Do authentication and catalog pre-fetching here. __repr__(self) is a human summary, conventionally \"{n} Countries ({m} Servers)\". get_proxy(self, query) returns a Requests-compatible proxy URI, or None only when the query was valid but no proxy exists. Raise on genuine errors or bad queries.
Optionally expose get_connection_info(query) (as Gluetun does) or last_connection_display() (as ExpressVPN and Proton VPN do) to have dl print a friendly connection line instead of the raw proxy URL.
"},{"location":"guide/proxies-and-vpn/#see-also","title":"See also","text":" - Downloading: the full
dl command guide, including where proxy flags sit in the download flow. - Configuration file: the
unshackle.yaml reference, including directories for cookies and cache used by the cookie-based providers. - Configuration reference: the developer-facing configuration API, including the
proxy_providers structure.
"},{"location":"guide/subtitles/","title":"Subtitles","text":"Subtitles are first-class tracks in unshackle. When you download a title, subtitle tracks are parsed from the manifest alongside video and audio, downloaded, cleaned up, optionally converted to the format you want, and then either muxed into the final Matroska file or written out as sidecar files next to it. The sections below cover the supported formats, how tracks are normalized after download, how to select and filter subtitles, and every subtitle-related configuration option.
Most of this is controlled with a handful of dl flags and a small subtitle block in your configuration file. See Downloading for how subtitle flags fit into a full download command.
"},{"location":"guide/subtitles/#supported-formats","title":"Supported formats","text":"unshackle understands the following subtitle codecs. The Format column is the value shown in track listings; the Extension column is the file extension used on disk.
Codec (internal name) Format Extension Notes SubRip SRT srt SubRip, the most widely compatible plain-text format SubStationAlpha SSA ssa SubStation Alpha (styled) SubStationAlphav4 ASS ass Advanced SubStation Alpha (styled) TimedTextMarkupLang TTML ttml Timed Text Markup Language (IMSC/DFXP) WebVTT VTT vtt WebVTT SAMI SMI smi Synchronized Accessible Media Interchange MicroDVD SUB sub MicroDVD MPL2 MPL2 mpl2 MPL2 TMP TMP tmp TMP fTTML STPP stpp MPEG-DASH box-encapsulated TTML (IMSC1) fVTT WVTT wvtt MPEG-DASH box-encapsulated WebVTT The two boxed formats convert automatically
fTTML (STPP) and fVTT (WVTT) are the fragmented, MP4-box-encapsulated forms that DASH and HLS deliver. You never keep these as-is: right after download they are unwrapped into their plain counterparts (fTTML becomes TTML and fVTT becomes WebVTT), so you always end up with an editable text subtitle. Note that this is a one-way, source-only relationship: you can convert from these boxed formats, but no backend can produce them as an output target, so they can never be a conversion destination.
"},{"location":"guide/subtitles/#what-happens-to-a-subtitle-after-download","title":"What happens to a subtitle after download","text":"A subtitle download does more than save a file. Once the bytes are on disk, unshackle runs format-appropriate clean-up to produce a single, valid file:
- Encoding: non-boxed subtitles are forced to UTF-8, and stray
‎ / ‏ bidirectional entities are unescaped. - Segment merging: when a WebVTT or boxed subtitle arrives as many DASH/HLS segments, the segments are stitched back into one continuous document with correct timing.
- WebVTT sanitization: merged WebVTT runs through a repair pipeline that clamps negative timestamps to
00:00:00.000, removes stray cue identifiers (like Q0, Q1) that confuse downstream parsers, and merges multi-line cues that a service split into separate overlapping cues. By default the original styling and layout are preserved (see preserve_formatting). - TTML for muxing: Matroska cannot carry TTML. If you do not request a specific output format, any plain TTML track is automatically converted to WebVTT before muxing (the closest lossless equivalent). Boxed
fTTML is likewise unwrapped to TTML first.
You normally do not need to think about any of this. It just produces a clean file.
"},{"location":"guide/subtitles/#selecting-and-filtering-subtitles","title":"Selecting and filtering subtitles","text":"Subtitle selection happens through dl flags. The most important is language selection.
Flag Purpose -sl, --s-lang Language(s) wanted for subtitles. Defaults to all. A - prefix excludes, e.g. all,-es. --require-subs Require these languages to exist; if present, download all subtitles. Cannot be combined with --s-lang. -fs, --forced-subs Include forced subtitle tracks (excluded by default). -fsl, --forced-s-lang Language(s) wanted for forced subtitles; implies -fs. A - prefix excludes. --exact-lang Exact language matching, with no regional variants. -S, --subs-only Download only subtitle tracks. -ns, --no-subs Do not download subtitle tracks at all. --sub-format Output subtitle format / conversion target (see conversion). --skip-subtitle-errors Skip a subtitle that fails to download instead of aborting the whole title."},{"location":"guide/subtitles/#choosing-languages","title":"Choosing languages","text":"By default (--s-lang all) unshackle keeps every subtitle language the service offers. To narrow it down, pass a comma-separated list of language tags:
English and Spanish subtitles onlyunshackle dl --s-lang en,es EXAMPLE 81234567\n
Language matching is fuzzy by default: en will match en-US, en-GB, and similar variants. If a requested language is missing but others were found, unshackle continues with whatever remains and tells you which languages it dropped.
For strict matching, where es-419 matches only es-419 and never es-ES, add --exact-lang:
Only Latin-American Spanish, no other Spanish variantsunshackle dl --s-lang es-419 --exact-lang EXAMPLE 'https://www.example.com/...'\n
"},{"location":"guide/subtitles/#requiring-languages-before-downloading","title":"Requiring languages before downloading","text":"--require-subs is a gate rather than a filter. It downloads all available subtitles, but only if every language you list is present; if any is missing the title errors out. This is useful for batch jobs where a title without your must-have language should fail loudly rather than silently produce a partial result.
Fail unless both English and French subs exist, then grab everythingunshackle dl --require-subs en,fr EXAMPLE B0ABCDEFGH\n
--require-subs and --s-lang are mutually exclusive
Because --require-subs forces downloading of all subtitle languages, it cannot be combined with --s-lang. Choose one or the other.
"},{"location":"guide/subtitles/#forced-subtitles","title":"Forced subtitles","text":"Forced tracks carry only the important on-screen signs and foreign-dialogue lines meant to display even when you are watching in your own language. They are excluded by default. Add -fs / --forced-subs to include them:
Include forced tracks alongside full subtitlesunshackle dl --s-lang en --forced-subs EXAMPLE 81234567\n
-fs keeps forced tracks in every selected language. If you only want forced tracks in certain languages, use -fsl / --forced-s-lang instead. It implies -fs, so you do not need both:
All full subtitles, but only the English forced trackunshackle dl --s-lang all --forced-s-lang en EXAMPLE 81234567\n
If you pass both flags, -fsl wins and keeps only forced tracks in its languages. It also understands orig (the title's original language) and all (every forced track, same as plain -fs).
"},{"location":"guide/subtitles/#track-types-forced-sdh-and-cc","title":"Track types: forced, SDH, and CC","text":"Every subtitle track carries flags describing what kind of captions it holds. These flags drive selection, sort order, output filenames, and the Matroska track flags.
- Forced: only signs and foreign dialogue; meant to play when the audio language matches. Off by default; enable with
--forced-subs. - SDH (Subtitles for the Deaf and Hard-of-Hearing; also called HOH): a full transcript that includes both dialogue and non-speech sound cues.
- CC (Closed Captions): captions originating from an EIA-608/708 broadcast stream, often uppercase with
>>> speaker markers and sound cues.
SDH and CC are treated together for sorting and stripping
unshackle orders subtitles from fewest to most captions: Forced \u2192 Normal \u2192 SDH/CC. For the purposes of hearing-impaired handling, SDH and CC tracks are grouped as the \"most captions\" variant.
By default this type order is the primary sort, so all forced tracks come first, then all normal tracks, then all SDH/CC tracks, each block sorted by language. To keep each language next to its own variants instead, set subtitle.group_by to language. Use subtitle.type_priority to change the Forced \u2192 Normal \u2192 SDH/CC order itself.
A single track cannot be both CC and SDH, and a forced track cannot also be flagged CC or SDH. These combinations are rejected as invalid.
"},{"location":"guide/subtitles/#closed-captions-embedded-in-video","title":"Closed captions embedded in video","text":"Some services do not ship subtitles as separate tracks but instead embed EIA-608/708 captions inside the video stream. After a video track is downloaded and decrypted, unshackle probes it for these captions and, when found, extracts them into a SubRip (SRT) subtitle flagged as CC. This runs automatically unless you disabled subtitles (--no-subs) or video (--no-video / --video-only semantics), and it requires the CCExtractor tool on your PATH.
"},{"location":"guide/subtitles/#automatic-sdh-stripping","title":"Automatic SDH stripping","text":"When a language has an SDH track but no plain (non-SDH, non-forced) track, unshackle can generate a clean non-SDH version for you by stripping the hearing-impaired cues: sound effects, speaker labels, music notes, and similar. This is on by default and controlled by the strip_sdh config key.
The check is deliberately loose: if a close-enough plain track already exists (for example an en-GB non-SDH track when the SDH track is en-US), no stripped copy is created. When a stripped copy is created, the original SDH track is kept as well, so you end up with both.
The stripping engine itself is chosen by the sdh_method config key. The default (auto) prefers the subby cleaner for SRT, falls back to SubtitleEdit when it is installed, and otherwise uses the built-in filter-subs approach.
Why convert_before_strip exists
subby's SDH stripper only operates on SRT. Handed any other codec it returns silently without stripping anything, not an error but a no-op that leaves the SDH cues in place. This is the whole reason for convert_before_strip: non-SubtitleEdit engines need the subtitle converted to SRT first, or the hearing-impaired cues survive the strip untouched.
"},{"location":"guide/subtitles/#converting-subtitle-formats","title":"Converting subtitle formats","text":"By default unshackle keeps each subtitle in its downloaded format (only auto-converting TTML to WebVTT for muxing, as noted above). To force a specific output format, use --sub-format:
Convert every subtitle to SRTunshackle dl --s-lang en,es --sub-format srt EXAMPLE 81234567\n
--sub-format accepts the common format names and aliases, including srt, vtt, ssa, ass, and ttml. Pass original to explicitly keep the source format and skip all conversion:
Keep whatever the service deliveredunshackle dl --sub-format original EXAMPLE 'https://www.example.com/...'\n
Conversion only runs when the source format differs from the target, so --sub-format srt on a track that is already SRT is a no-op.
Down-converting styled subtitles is lossy
Converting styled SSA/ASS subtitles to SRT throws away positioning, colours, and italics. unshackle never does this automatically. It only happens when you explicitly ask for it via --sub-format. If you want to preserve styling, convert to a format that supports it (or keep original).
Note that this automatic protection guards only the default muxed track: there the conversion is skipped and the styled original is kept. It does not cover sidecars. A sidecar_format: srt will still lossily flatten styled subtitles, dropping positioning, colours, and styling, and a per-download --sub-format srt forces the muxed track to convert as well. To keep raw styled sidecars, set sidecar_format: original.
"},{"location":"guide/subtitles/#how-the-conversion-backend-is-chosen","title":"How the conversion backend is chosen","text":"Under the hood unshackle picks the highest-fidelity converter that supports the source\u2192target pair. Which converter runs depends on the conversion_method config key (default auto) and the 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 - 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.
"},{"location":"guide/subtitles/#what-each-backend-actually-preserves","title":"What each backend actually preserves","text":"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; \u2014 means the backend cannot handle that pair.
Conversion subtitleedit pysubs2 subby pycaption WebVTT \u2192 SRT italic, bold, underline, position italic, underline italic, position (strips all) WebVTT \u2192 ASS italic, bold, underline, position italic, bold, underline \u2014 \u2014 WebVTT \u2192 TTML italic, bold, underline, position italic, bold, underline position (strips all) ASS/SSA \u2192 SRT all (+ colour) italic, underline \u2014 \u2014 ASS/SSA \u2192 TTML position only italic, bold, underline \u2014 \u2014 ASS/SSA \u2192 VTT position only italic, underline \u2014 \u2014 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 \u2192 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\u2194ASS. subby: reads only WebVTT/fVTT/SAMI (never ASS) and is tuned for \u2192 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.
What auto picks
auto prefers subtitleedit first when it is installed, then falls back to subby for \u2192 SRT and pysubs2 otherwise. The one exception comes from the table above: for ASS/SSA \u2192 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 (\u2192 SRT) or pysubs2. Install SubtitleEdit / seconv if you need colour or the highest \u2192 SRT fidelity.
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 conversion_method in unshackle.yaml always overrides that per-track preference, so your config takes precedence. This only matters when a service ships a non-default preference; leave conversion_method at auto to let the service's hint stand.
"},{"location":"guide/subtitles/#muxed-vs-sidecar-output","title":"Muxed vs. sidecar output","text":"By default subtitles are muxed into the final .mkv. You can instead (or additionally) write them as separate sidecar files next to the video, controlled by the output_mode config key:
mux (default)sidecarboth unshackle.yamlsubtitle:\n output_mode: mux\n
Subtitles are embedded in the Matroska file. Nothing is written separately.
unshackle.yamlsubtitle:\n output_mode: sidecar\n sidecar_format: srt\n
Subtitles are written as standalone files and not muxed into the video (when there is a video or audio track to sit beside).
unshackle.yamlsubtitle:\n output_mode: both\n sidecar_format: srt\n
Subtitles are muxed into the video and written as sidecar files.
Sidecar files are named after the output file with the language and flags encoded in the name, so players and media managers can match them automatically:
Show.S01E01.1080p.WEB.en.srt\nShow.S01E01.1080p.WEB.en.forced.srt\nShow.S01E01.1080p.WEB.en.sdh.srt\n
The pattern is {base}.{language}[.forced][.sdh].{extension}. The sidecar file format is set by sidecar_format; use a format name like srt or vtt, or original to keep each subtitle in its downloaded format.
"},{"location":"guide/subtitles/#fonts-for-styled-subtitles","title":"Fonts for styled subtitles","text":"Styled SSA/ASS subtitles reference specific fonts by name. When unshackle muxes such a subtitle it scans it for the fonts it uses and attaches the matching font files to the Matroska output so the styling renders correctly on playback. If a referenced font is not available locally, unshackle logs the missing font names and suggests installing the relevant font packages.
"},{"location":"guide/subtitles/#subtitle-configuration","title":"Subtitle configuration","text":"All of the behaviour above can be defaulted in the subtitle block of your configuration file:
unshackle.yamlsubtitle:\n strip_sdh: true\n preserve_formatting: true\n conversion_method: auto\n sdh_method: auto\n convert_before_strip: true\n output_mode: mux\n sidecar_format: srt\n
Key Default Values Purpose strip_sdh true true / false Auto-generate a stripped non-SDH copy when a language has only an SDH track. preserve_formatting true true / false Keep original WebVTT styling and layout during sanitization. When false, WebVTT is re-encoded more aggressively (merging identical cues, dropping empty ones). conversion_method auto auto, subby, pysubs2, subtitleedit, pycaption Which conversion backend to prefer. auto ranks them per conversion. sdh_method auto auto, subby, subtitleedit, filter-subs Engine used to strip hearing-impaired cues. convert_before_strip true true / false Convert a subtitle to SRT before stripping SDH (for non-SubtitleEdit engines). output_mode mux mux, sidecar, both Whether subtitles are embedded, written as sidecars, or both. sidecar_format srt a format name (e.g. srt, vtt) or original Format used for sidecar files. group_by type type, language Whether all tracks of one type stay together, or each language stays next to its own variants. language_priority (unset) a list of languages Languages to sort to the top. The rest keep the default order. Nothing is removed. SubtitleEdit is optional but recommended
Several high-fidelity paths (the best-quality conversions and the SubtitleEdit SDH stripper) only activate when the SubtitleEdit CLI is installed and on your PATH. See Installation for how to add it. Without it, unshackle falls back to its built-in Python backends.
"},{"location":"guide/subtitles/#developer-reference","title":"Developer reference","text":"Audience
This section is for people extending unshackle or writing services. Everyday users can stop here.
The subtitle model lives in unshackle.core.tracks.Subtitle. A few internals worth knowing when working with subtitle tracks in code:
- Flags and validation:
Subtitle(codec=..., cc=..., sdh=..., forced=...). A track cannot be both cc and sdh, and forced cannot be combined with either; both raise ValueError at construction. convert(codec, *, forced=False): converts the downloaded file in place through the backend chain in subtitle_convert.run_conversion. forced=True (what --sub-format sets) is the only way lossy styled down-converts such as ASS\u2192SRT are permitted. Raises NotImplementedError if no backend supports the pair and RuntimeError if all attempts fail. strip_hearing_impaired(): removes SDH cues using the engine selected by config.subtitle[\"sdh_method\"]. reverse_rtl(): fixes right-to-left sentence-ending positioning via SubtitleEdit (requires the binary). parse(data, codec): the central parser, returning a pycaption.CaptionSet; it handles the boxed DASH formats, SAMI, and the WebVTT repair helpers. extract_fonts(text): returns the set of font names referenced by an ASS/SSA subtitle, used to decide which fonts to attach at mux time.
The conversion backends (SubtitleEditBackend, SubbyBackend, Pysubs2Backend, PycaptionBackend) each implement a small protocol (is_available(), can_convert(), rank(), convert()) and are tried in rank order, lowest rank first. See the Tracks API reference for the full model.
"},{"location":"guide/troubleshooting/","title":"Troubleshooting & Debugging","text":"When a download fails, a service misbehaves, or a key won't resolve, unshackle gives you three tools to find out why:
- Debug logging: verbose
DEBUG-level console output plus a structured, machine-readable log file. unshackle env: verify that the external tools unshackle depends on are installed, and see exactly which directories and config file are in use. - Targeted toggles:
debug_keys and debug_requests for the two areas (DRM keys and raw HTTP traffic) that are deliberately quiet by default.
The sections below cover how to enable each of them, how to read what they produce, and which diagnostic fits which failure mode.
"},{"location":"guide/troubleshooting/#turning-on-debug-logging","title":"Turning on debug logging","text":"There are two independent ways to enable debug logging, and they do slightly different things.
The -d / --debug flagThe debug config key The root-level flag turns on verbose logging for a single invocation:
unshackle -d dl SERVICE TITLE_ID\n
It must come before the subcommand, because it is an option on the root unshackle command, not on dl.
-d does three things:
- Sets the console log level to
DEBUG, so every module logs its full detail. - Enables the structured JSON debug log file (see below).
- Adds source-path annotations to console log lines (which file and line each message came from).
To make debug logging the default for every run without typing -d each time, set it in your unshackle.yaml:
debug: true\n
or via the cfg command:
unshackle cfg debug true\n
debug: true is not identical to -d
The debug config key enables the JSON debug log file, but it does not raise the console to DEBUG level and does not add source-path annotations. Those only happen when you pass the -d flag. Use the flag when you want to watch the verbose output live; use the config key when you just want the log file written every time. Passing -d is the most complete option, and you can combine both.
Debug logging can be noisy and revealing
Debug output is verbose and includes request metadata, service internals, and stack traces. Secrets, URLs, and local paths are automatically redacted (see Redaction), but you should still glance over a log before sharing it publicly.
"},{"location":"guide/troubleshooting/#the-structured-json-debug-log","title":"The structured JSON debug log","text":"Beyond the console output, unshackle writes a structured log in JSON Lines format: one complete JSON object per line. This file is designed to be filtered, searched, and analysed with standard tooling (jq, grep, a text editor), and it is safe to attach to a bug report because it is redacted.
"},{"location":"guide/troubleshooting/#where-it-is-written","title":"Where it is written","text":"The debug log is created during a dl run (and by import, which wraps dl). The path is:
<logs directory>/unshackle_debug_<service>_<timestamp>.jsonl\n
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.
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 and read the Directories table.
"},{"location":"guide/troubleshooting/#what-a-log-entry-looks-like","title":"What a log entry looks like","text":"Every line is a self-contained JSON object. The first line of a session records the environment:
{\"timestamp\":\"2026-07-03T14:25:30.123456+00:00\",\"session_id\":\"a1b2c3d4\",\"level\":\"INFO\",\"operation\":\"session_start\",\"message\":\"Debug logging session started\",\"context\":{\"unshackle_version\":\"5.3.0\",\"unshackle_code_hash\":\"1d22a1e\",\"python_version\":\"3.12.4 ...\",\"platform\":\"Linux-6.18.5-x86_64\",\"platform_system\":\"Linux\",\"platform_release\":\"6.18.5\"}}\n
Quote unshackle_code_hash in a bug report. It fingerprints the framework source you are running, so it tells us the exact code state even when you did not install from git. A hash that matches no release means the source was edited locally.
Subsequent lines describe operations: service calls, DRM licence requests, vault lookups, downloader progress, and errors. Common fields include:
Field Meaning timestamp UTC ISO-8601 time of the entry. session_id Short random ID shared by every entry in one run, handy for correlating lines. level DEBUG, INFO, WARNING, or ERROR. operation The action being logged, e.g. service_call, download_init, drm_get_widevine_license, vault_get_key, session_end. message Human-readable description (redacted). service Service tag the entry belongs to, when applicable. context Arbitrary structured detail for the operation. request / response HTTP method/URL and response metadata for network operations. duration_ms How long a timed operation took. success Whether the operation succeeded. error On failures: the exception type, message, and a full traceback array (all redacted)."},{"location":"guide/troubleshooting/#reading-and-filtering-the-log","title":"Reading and filtering the log","text":"Because each line is independent JSON, jq is the natural tool:
# Show only error-level entries\njq 'select(.level == \"ERROR\")' unshackle_debug_EXAMPLE_20260703-142530.jsonl\n\n# List every operation and whether it succeeded\njq -c '{operation, success}' unshackle_debug_EXAMPLE_20260703-142530.jsonl\n\n# Pull the traceback out of the first error\njq -r 'select(.error) | .error.traceback[]' unshackle_debug_EXAMPLE_20260703-142530.jsonl\n\n# Show only DRM / licensing operations\njq 'select(.operation | startswith(\"drm_\"))' unshackle_debug_EXAMPLE_20260703-142530.jsonl\n
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.
"},{"location":"guide/troubleshooting/#debugging-drm-keys-with-debug_keys","title":"Debugging DRM keys with debug_keys","text":"By default, content-decryption keys are redacted in the JSON debug log. Any field that looks like a key is written as [REDACTED]. This keeps logs shareable. When you are specifically debugging a key-retrieval or decryption problem and need to see the actual KID/KEY values that were fetched, enable debug_keys:
debug_keys: true\n
unshackle cfg debug_keys true\n
With debug_keys: true, the debug log stops masking key fields, so you can confirm which keys came back from a licence server or vault and compare them against what decryption expected.
Keys are sensitive, turn this back off
debug_keys writes real decryption keys into the log file in cleartext. Only enable it while actively diagnosing a key issue, never share a log produced with it on, and set it back to false when you're done. Note that debug_keys only affects the JSON debug log file. It does not change what the console prints.
Fields that are always redacted regardless of debug_keys (anything whose name contains password, token, secret, auth, or cookie) stay masked, so credentials never leak even in a keys-on log.
"},{"location":"guide/troubleshooting/#debugging-http-traffic-with-debug_requests","title":"Debugging HTTP traffic with debug_requests","text":"When you pass -d, unshackle raises its own modules to DEBUG but deliberately silences the noisy low-level HTTP libraries (urllib3, requests, rnet, httpx, httpcore, hpack, h2) back down to WARNING. Without this, a single download would bury the useful output under thousands of connection-pool and HTTP/2 frame messages.
If you need that low-level traffic (for example when a request is failing at the TLS or connection layer and you need to see the underlying library's own logs), enable debug_requests:
debug_requests: true\n
unshackle cfg debug_requests true\n
With debug_requests: true, those libraries are left at DEBUG level when you run with -d, so their request/response chatter appears in the console.
debug_requests needs -d to have any effect
The debug_requests toggle only decides whether the HTTP libraries are silenced when the -d flag is active. If you are not running with -d (verbose console logging), it changes nothing. Combine them: unshackle -d dl ... with debug_requests: true in config.
"},{"location":"guide/troubleshooting/#what-gets-redacted","title":"What gets redacted","text":"unshackle redacts logged strings so that debug output is safe to share. Three passes are applied to every logged message, error, and structured value:
- Secrets: passwords, tokens, API secrets, auth values, and cookies are replaced with
[REDACTED]. - URLs: every
http(s) URL is collapsed to redacted (keeping only the file extension, so a manifest still shows as redacted.mpd and a segment as redacted.m4s). This hides content, CDN, manifest, and licence-server locations while preserving the type of resource. - Local paths: install-root, virtualenv, and home-directory prefixes are replaced with tokens, so your username and machine layout don't appear in logs.
Path redaction is controlled by the redact_paths config key, which is on by default:
redact_paths: true # default - mask local base directories in logged paths\n
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.
"},{"location":"guide/troubleshooting/#checking-your-environment","title":"Checking your environment","text":"Many \"download failed\" problems are really missing external tools or a config file that isn't where you think it is. The unshackle env command group answers both questions before you dig into logs.
"},{"location":"guide/troubleshooting/#env-check","title":"env check","text":"unshackle env check verifies every external binary unshackle can use and prints a table of what's installed:
unshackle env check\n
It reports each tool's status (\u2713 installed / \u2717 missing), whether it is required or optional, and what it's for. A summary line tells you the installed-vs-total count and, if anything mandatory is absent, exactly which required tools are missing.
The required tools (unshackle will not get far without them) are:
Tool Purpose FFmpeg Media processing FFprobe Media analysis MKVToolNix (mkvmerge) MKV muxing mkvpropedit MKV metadata Shaka Packager DRM decryption Optional tools unlock extra capabilities. For example, mp4decrypt (alternative decryptor), dovi_tool / HDR10Plus_tool (Dolby Vision / HDR10+), SubtitleEdit / CCExtractor (subtitle conversion and CC extraction), MPV / FFplay (playback preview), and git (service repositories) or docker (Gluetun VPN, see Proxies & VPN).
First thing to run when something breaks
If a decryption step errors, a mux fails, or a Dolby Vision title won't process, run unshackle env check first. A red \u2717 next to the relevant tool is the answer far more often than a bug in unshackle.
"},{"location":"guide/troubleshooting/#env-info","title":"env info","text":"unshackle env info shows where unshackle is reading configuration from and where every working directory lives:
unshackle env info\n
- If a config file was found, it prints the path it was loaded from. If none was found, it lists every location it would accept one (see Config file discovery below), so you can create the file in the right place.
- It then prints a Directories table for every configured path:
downloads, temp, cache, cookies, logs, exports, wvds, prds, the services search paths, and more.
This is the authoritative way to answer \"where are my logs / downloads / cookies actually going?\" The defaults are relative to the installed package, and any directories: overrides in your config are reflected here.
"},{"location":"guide/troubleshooting/#env-clear","title":"env clear","text":"Stale cache or leftover temp files can cause confusing behaviour (an old cached title list, a half-written temp file). env clear wipes those directories safely:
unshackle env clear cache # clear the whole cache directory\nunshackle env clear cache EXAMPLE # clear just one service's cache subdirectory\nunshackle env clear temp # clear the temp working directory\n
Each command reports how many files it removed and how much space it freed, then recreates the empty directory.
Cache-related weirdness
If a service keeps returning stale titles or metadata even after the source changed, clear that service's cache with unshackle env clear cache <SERVICE>. See Vaults for the separate, persistent key-vault store, which env clear does not touch.
"},{"location":"guide/troubleshooting/#common-failure-modes-and-how-to-inspect-them","title":"Common failure modes and how to inspect them","text":"A required tool is missing
Symptom: decryption, muxing, or analysis fails early, sometimes with a \"binary not found\" style error.
Inspect: run unshackle env check and look for a red \u2717 on a required row. Install the missing tool and re-run.
Decryption produces garbage or fails
Symptom: the download completes but the file won't play, or the decrypt step errors.
Inspect: enable debug_keys and run with -d, then check the JSON debug log's drm_* and vault_* operations to confirm which keys were fetched and from where. Also confirm Shaka Packager (or mp4decrypt) shows a sane version in the download_init binary-versions entry. See DRM & CDM.
A network request fails at the connection or TLS layer
Symptom: timeouts, connection resets, or handshake errors that unshackle's own logs don't fully explain.
Inspect: set debug_requests: true and run with -d to surface the underlying urllib3 / rnet / httpx logs. In the JSON debug log, filter for service_call operations to see which request failed. If a proxy is involved, cross-check Proxies & VPN.
The wrong titles/metadata keep coming back
Symptom: a service returns outdated titles even after the source changed.
Inspect: the title cache is likely serving stale data. Clear it with unshackle env clear cache <SERVICE>, or bypass it for one run using the dl no-cache / reset-cache options (see Downloading and the CLI Reference).
A config value doesn't seem to apply
Symptom: a setting you edited has no effect.
Inspect: run unshackle env info to confirm which config file is actually loaded, and unshackle cfg --list to dump the effective values. It's common to edit a config file in a location unshackle isn't reading from. See below.
"},{"location":"guide/troubleshooting/#why-is-my-config-not-being-picked-up","title":"Why is my config not being picked up?","text":"unshackle loads the first unshackle.yaml it finds, in this order:
unshackle.yaml inside the package's namespace directory. unshackle.yaml in the parent of that directory. unshackle.yaml in your OS user-config directory: on Linux ~/.config/unshackle/unshackle.yaml, on Windows %LOCALAPPDATA%\\unshackle\\unshackle.yaml, on macOS ~/Library/Application Support/unshackle/unshackle.yaml.
If you have config in more than one of these, an earlier one wins and your edits to a later file are silently ignored. unshackle env info prints the path that was actually loaded (or, if none exists, lists all three candidate locations so you can create the file correctly). See Configuration for editing values with the cfg command.
Developer note: structured logging in code
Service and command code emits entries through the shared log_event(...) helper (and the timed_operation(...) context manager for timing a block) from unshackle.core.utilities. These are no-ops when debug logging is disabled, so they are always safe to leave in place. When adding a new feature, call log_event(\"my_feature_event\", message=\"...\", context={...}) rather than reimplementing the \"is the logger enabled?\" guard. The redaction and JSON-serialisation are handled for you, and any field whose name looks like a key/secret/token is redacted automatically (keys only appear when the run has debug_keys enabled).
"},{"location":"guide/troubleshooting/#logging-conventions-when-adding-entries","title":"Logging conventions when adding entries","text":"The debug log is tuned for developers troubleshooting pipeline flow (maximum signal, minimum noise), not for end users. A few conventions keep it that way. They are not enforced by the log_event signature, so they are easy to miss and worth stating explicitly.
Log levels are a flow skeleton, not just severity
Treat level as structure, not decoration. Emit exactly one INFO milestone per pipeline stage so that jq 'select(.level==\"INFO\")' reads back the whole end-to-end flow of a run. Everything internal goes to DEBUG; failures go to ERROR. Keeping INFO sparse is the whole point. It is the line a developer skims first to see where a run got to before it broke.
# Read the pipeline's high-level flow, one line per stage\njq 'select(.level == \"INFO\")' unshackle_debug_EXAMPLE_20260703-142530.jsonl\n
No raw dumps
Never log a full Tracks object, an MPD/manifest body, or a response payload. Log only counts, ids, sizes, and safe_display_url(url). This keeps the log low-noise and prevents leaking content or manifest data into the file before redaction even runs. Redaction is a backstop, not a licence to dump raw objects.
Operation names: <area>_<event>, no registry
Operation names are plain lowercase strings following an <area>_<event> convention: manifest_dash_parse, drm_decrypt, vault_get_key, tool_run. They are written inline at each call site on purpose: there is no central registry or enum, and you should not go hunting for one or create one. The absence is a deliberate design choice, not an omission.
Message reads alone; data lives in fields
Give every entry a single-sentence message that stands on its own, and put all structured data (context, counts, ids, duration_ms) in fields rather than baking it into the prose. This separation is what lets the log be both human-scannable and machine-filterable; a message like \"Parsed DASH manifest\" with context={\"video\": 4, \"audio\": 2} beats \"Parsed manifest with 4 video and 2 audio tracks\".
Let tool runs log themselves
For external-tool calls (FFmpeg, mkvmerge, dovi_tool, and friends), route through run_step / ffprobe, which auto-emit a tool_run entry capturing the binary's version, return code, and duration consistently. Only fall back to calling log_tool_run manually for a direct subprocess.run you genuinely can't route through run_step. Auto-logging is the preferred path precisely because it captures those fields the same way every time.
"},{"location":"guide/vaults/","title":"Key Vaults","text":"A key vault is a store of DRM content keys that unshackle remembers between downloads. Once a title's keys have been recovered, unshackle saves them to your configured vaults. The next time you download something that reuses those same keys, unshackle finds them in a vault instead of performing another license exchange. The download starts faster and puts less load on the service.
Vaults are entirely optional, but if you download regularly they are one of the biggest quality-of-life improvements you can configure.
Vaults save license calls even on the very first download
The benefit is not limited to repeat downloads. Within a single run, a service often issues the same Key ID and content key for both the video and audio tracks, and for several resolutions or bitrates of the same title. Once unshackle has recovered a key it writes it to your vaults immediately, so the moment the next track presents a KID it has already seen, the key is served from the vault instead of triggering another license exchange. A single fetched key can therefore satisfy many tracks in one run.
"},{"location":"guide/vaults/#what-a-vault-stores","title":"What a vault stores","text":"Every key is stored as a KID \u2192 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
EXAMPLE, not ExampleService) that namespaces the key. Each service gets its own logical table.
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.
Null keys are ignored everywhere
A key made entirely of zeroes (00000000000000000000000000000000) is treated as \"no key\". Vault lookups skip it, and every backend raises an error if you try to add one. This prevents a placeholder key from masking a real one.
"},{"location":"guide/vaults/#how-lookups-and-caching-work","title":"How lookups and caching work","text":"When unshackle needs a key during a download it asks your vaults in the order they are listed in your config. It returns the first non-null key it finds and stops searching. If a vault raises a permission error or is unreachable, that vault is skipped and the search continues with the next one.
When new keys are recovered from a license, unshackle pushes them to all configured vaults, so every vault gradually converges on the same set of keys. Two rules apply to pushing:
- Keys that already exist in a vault are skipped (existing data is never overwritten or deleted).
- A vault marked
no_push: true receives lookups but is never written to. This is useful for read-only or shared upstream vaults you do not own.
A good two-vault setup
A common arrangement is a fast local SQLite vault plus a shared remote vault (MySQL, HTTP, or API). The local vault answers instantly and works offline; the remote vault lets you share keys across machines or with a group.
"},{"location":"guide/vaults/#configuring-vaults","title":"Configuring vaults","text":"Vaults are declared under the key_vaults list in your unshackle config file. Each entry is a mapping with two required fields, type (which backend to use) and name (a label you choose), plus whatever settings that backend needs.
unshackle.yamlkey_vaults:\n - type: SQLite\n name: local\n path: ~/.local/share/unshackle/vaults/local.db\n - type: MySQL\n name: shared\n host: vault.example.com\n database: unshackle\n username: reader\n password: \"s3cret\"\n
The type value is matched to a vault backend by name. The four backends that ship with unshackle are SQLite, MySQL, HTTP, and API, described below.
SQLite vaults are loaded critically
During a download, a SQLite vault that fails to load will abort the run. The assumption is that a local database you configured should always be available. MySQL, HTTP, and API vaults are loaded leniently: if one fails to load it is logged and skipped, and the download continues with the remaining vaults.
"},{"location":"guide/vaults/#vault_timeout","title":"vault_timeout","text":"Network-backed vaults (HTTP and API) accept an optional per-vault timeout in seconds. If you omit it, unshackle injects the global default:
unshackle.yamlvault_timeout: 10.0 # default, in seconds\n
A timeout set inline on an individual vault always wins over this global value. The SQLite and MySQL backends do not take a timeout field.
"},{"location":"guide/vaults/#sqlite-backend","title":"SQLite backend","text":"A locally-accessed SQLite database file. This is the simplest and fastest option and needs no server.
unshackle.yamlkey_vaults:\n - type: SQLite\n name: local\n path: ~/.local/share/unshackle/vaults/local.db\n
Field Required Description type yes Must be SQLite. name yes A label for this vault. path yes Path to the database file. ~ is expanded. The file (and its tables) are created automatically on first use. no_push no If true, keys are read but never written. Defaults to false. Each service gets its own table (named after the service tag), created on demand with kid and key_ columns. Connections use WAL journaling with a 30-second busy timeout, so the same database file can be used safely from multiple threads.
Moving the .db file silently forks your vault
Because the file is created on demand, if you relocate an existing .db without updating path to match, unshackle will simply create a fresh empty database at the old location, leaving you with two divergent vaults and no error to warn you. Always update the config path whenever you move the file.
Keep a local SQLite vault even when you share a MySQL one
The SQLite vault is best thought of as an offline-only backup should anything ever happen to a shared MySQL vault. Each member of a group should keep their own local SQLite vault alongside the shared database, so no one loses their keys if the shared vault goes down or away.
"},{"location":"guide/vaults/#mysql-backend","title":"MySQL backend","text":"A remotely-accessed MySQL/MariaDB database, ideal for sharing keys across several machines. It connects via pymysql. MariaDB is recommended over MySQL where you have the choice.
The vault connects directly to the database server
A MySQL vault (like SQLite) speaks the database wire protocol directly to the host or IP you configure. It cannot sit behind a PHP API or any other application layer. A common real-world snag: many hosting providers firewall their MySQL server to their own internal network, so a database on shared hosting is frequently unreachable from the outside world even when your credentials are perfectly correct. If connections time out, confirm the server accepts remote connections from your address before suspecting the config.
unshackle.yamlkey_vaults:\n - type: MySQL\n name: shared\n host: vault.example.com\n database: unshackle\n username: unshackle\n password: \"your-password\"\n port: 3306\n
Field Required Description type yes Must be MySQL. name yes A label for this vault. host yes Database server hostname or IP. database yes Database (schema) name. username yes Login user. no_push no If true, keys are read but never written. Any additional fields you provide (such as password, port, or ssl) are passed straight through to pymysql.connect, so you can use the full range of its connection options.
Account security is on you, not on unshackle
unshackle enforces none of this. It is operational policy for a database that may be shared with a group. Never connect with the root account, not even for yourself. Give every account a password, and never let two users share a username/password. Restrict each account's access to only the unshackle database, and grant normal users just SELECT and INSERT. Reserve the CREATE grant for a single trusted user who bootstraps the service tables (see kv prepare); everyone else can insert new keys but should not be creating tables.
Grants determine what the vault can do
On connection the backend reads its own SHOW GRANTS. It requires at least SELECT to load at all (a missing SELECT grant makes the vault fail to load). Writing keys additionally needs INSERT, and creating a new service table needs CREATE. A read-only account still works fine as a lookup source. It simply cannot push new keys. Because MySQL is loaded leniently during downloads, a permission problem will not abort the run.
"},{"location":"guide/vaults/#http-backend","title":"HTTP backend","text":"A flexible HTTP vault that speaks one of three wire protocols, selected with api_mode. Use this to integrate with a community or self-hosted vault service.
api_mode Transport Authentication Notes query (default) GET with query parameters username + password Full read and write. json POST with a JSON payload password used as a token Read and write per key; cannot bulk-enumerate keys. decrypt_labs POST in DecryptLabs format api_key sent as a header Read-only. no_push is forced on. query modejson modedecrypt_labs mode unshackle.yamlkey_vaults:\n - type: HTTP\n name: community\n host: https://vault.example.com/api\n api_mode: query\n username: myuser\n password: \"your-password\"\n
username is required in query mode. Both the username and password are sent as query parameters on each request.
unshackle.yamlkey_vaults:\n - type: HTTP\n name: jsonvault\n host: https://vault.example.com/rpc\n api_mode: json\n password: \"your-api-token\"\n
Requests are POSTed as {\"method\": ..., \"params\": ..., \"token\": ...} using methods like GetKey, InsertKey, and GetServices. This mode cannot list all keys in bulk, so a kv copy from a json vault relies on the remote server's own duplicate handling.
unshackle.yamlkey_vaults:\n - type: HTTP\n name: decrypt_labs\n host: https://api.decryptlabs.example/\n api_mode: decrypt_labs\n api_key: \"your-decrypt-labs-key\"\n
A read-only lookup source. The key is sent in a decrypt-labs-api-key header and the vault refuses all writes.
Field Required Description type yes Must be HTTP. name yes A label for this vault. host yes Base URL of the vault API. api_mode no query (default), json, or decrypt_labs. username for query Required in query mode; ignored otherwise. password one of these Password (query mode) or token (json mode). api_key one of these Alternative to password; used for decrypt_labs. If both are given, api_key wins. no_push no Read-only when true (always true for decrypt_labs). timeout no Request timeout in seconds; defaults to vault_timeout. You must supply either password or api_key, or the vault will fail to load.
"},{"location":"guide/vaults/#api-backend","title":"API backend","text":"A simple RESTful HTTP vault with a fixed, well-defined protocol. Authentication is a bearer token.
unshackle.yamlkey_vaults:\n - type: API\n name: myapi\n uri: https://vault.example.com/api\n token: \"your-bearer-token\"\n headers:\n CF-Access-Client-Id: \"CLIENT_ID_HERE\"\n CF-Access-Client-Secret: \"CLIENT_SECRET_HERE\"\n
Field Required Description type yes Must be API. name yes A label for this vault. uri yes Base URI of the vault API (a trailing slash is trimmed). token yes Bearer token, sent as Authorization: Bearer <token>. no_push no If true, keys are read but never written. timeout no Request timeout in seconds; defaults to vault_timeout. headers no Extra HTTP headers sent with every request, for example the Cloudflare Access service-token headers CF-Access-Client-Id and CF-Access-Client-Secret. The backend talks to endpoints under uri (for example GET {uri}/{service}/{kid} to look up a single key) and interprets a numeric code field in each JSON response to detect errors such as an invalid token, rate limiting, or an invalid service tag.
This is unshackle's own protocol, not a generic vault client
The API backend expects unshackle's specific HTTP request and response shape. An API or HTTP key-vault endpoint built for a different project or service will generally not be compatible. You cannot simply point this at an arbitrary third-party vault API and expect it to work. To bridge another service, put an adapter in front of it that speaks this format, or use the HTTP backend if one of its wire protocols matches. When pushing many keys at once it batches requests and automatically shrinks the batch size if the server rejects an over-large request.
DecryptLabs shortcut
An API vault whose name contains decrypt_labs will have its token filled in automatically from a global decrypt_labs_api_key in your config if you leave token unset. This lets you keep the key in one place.
"},{"location":"guide/vaults/#managing-vaults-with-unshackle-kv","title":"Managing vaults with unshackle kv","text":"The kv command group manages the contents of your configured vaults. Every subcommand refers to vaults by the name you gave them in key_vaults; an unknown name is an error.
$ unshackle kv --help\n
"},{"location":"guide/vaults/#kv-add-import-keys-from-a-file","title":"kv add: import keys from a file","text":"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.
$ unshackle kv add keys.txt EXAMPLE local shared\n
keys.txt9a04f07998404286ab92e65be0885f95:b1c2d3e4f5a6978012345678deadbeef\n0e7cc1a4e2f9411db0f24a3fbb8f8b21:00112233445566778899aabbccddeeff\n
The service argument (EXAMPLE above) is normalized to its canonical tag automatically. Keys that already exist in a vault are reported as skipped.
"},{"location":"guide/vaults/#kv-search-find-a-key-by-kid","title":"kv search: find a key by KID","text":"Search your vaults for a KID and print any matching key.
$ unshackle kv search 9a04f07998404286ab92e65be0885f95\n
The KID must be 32 hex characters (dashes are stripped for you). By default every configured vault and every service table is scanned; narrow the search with options:
Option Description -s, --service Limit the search to a single service tag. -v, --vault Limit the search to one configured vault by name. Note
Remote vaults that cannot enumerate their service tables (for example an HTTP vault in json or decrypt_labs mode) can only be searched when you also pass --service. Without it, they are skipped.
"},{"location":"guide/vaults/#kv-copy-copy-keys-between-vaults","title":"kv copy: copy keys between vaults","text":"Copy every key from one or more source vaults into a single destination vault. Existing rows are never altered; only genuinely new KIDs are added, and null keys are skipped.
$ unshackle kv copy shared local\n
The first argument is the destination; the rest are sources. You can supply several sources at once:
$ unshackle kv copy local shared community\n
Option Description -s, --service Only copy the given service. -l, --local-only Only copy services you have installed locally, skipping tables for unknown services. --service and --local-only are mutually exclusive.
"},{"location":"guide/vaults/#kv-sync-mirror-vaults-both-ways","title":"kv sync: mirror vaults both ways","text":"Ensure two or more vaults each end up with every key the others have. This is effectively a two-way copy between each pair, so afterwards all listed vaults hold the same set of keys.
$ unshackle kv sync local shared\n
It accepts the same --service and --local-only options as copy, and requires at least two vaults.
"},{"location":"guide/vaults/#kv-prepare-pre-create-service-tables","title":"kv prepare: pre-create service tables","text":"Create the per-service tables on table-based vaults (SQLite, MySQL) ahead of time, for every service you have installed. Vaults that do not use tables (the network-backed HTTP and API backends) are skipped.
$ unshackle kv prepare shared\n
This is mainly useful with a MySQL vault where you want the tables created once by an account that has the CREATE grant.
"},{"location":"guide/vaults/#see-also","title":"See also","text":" - Downloading: how keys are recovered and used during a download.
"},{"location":"reference/api/config/","title":"Config API","text":"Auto-generated reference for the Config object that models the unshackle configuration file. For a human-friendly walkthrough of every key, see the Configuration Reference.
"},{"location":"reference/api/config/#unshackle.core.config.Config","title":"Config","text":"Config(**kwargs)\n
"},{"location":"reference/api/config/#unshackle.core.config.Config.get_folder_template","title":"get_folder_template","text":"get_folder_template(kind)\n
Resolve the folder template for the given title kind.
kind: one of \"movies\", \"series\", \"songs\", \"albums\". Falls back to the legacy single-string folder template, then \"\".
"},{"location":"reference/api/config/#unshackle.core.config.Config.get_template_separator","title":"get_template_separator","text":"get_template_separator(template_type='movies')\n
Get the filename separator for the given template type.
Analyzes the active template to determine whether it uses dots or spaces between variables. Falls back to dot separator (scene-style) by default.
Parameters:
Name Type Description Default template_type str One of \"movies\", \"series\", or \"songs\".
'movies'"},{"location":"reference/api/config/#unshackle.core.config.resolve_decryption","title":"resolve_decryption","text":"resolve_decryption(decryption_map, default, service)\n
Pick the decryption tool for a service (case-insensitive), falling back to default.
"},{"location":"reference/api/config/#unshackle.core.config.resolve_cdm_name","title":"resolve_cdm_name","text":"resolve_cdm_name(cdm, service, override=None)\n
Resolve a service's top-level CDM entry (case-insensitive), with default fallback.
"},{"location":"reference/api/config/#unshackle.core.config.get_config_path","title":"get_config_path","text":"get_config_path()\n
Get Path to Config from any one of the possible locations.
Returns None if no config file could be found.
"},{"location":"reference/api/drm/","title":"DRM API","text":"Auto-generated reference for the DRM systems: Widevine, PlayReady, ClearKey, and related helpers. For configuring DRM as a user, see DRM & CDM Setup.
"},{"location":"reference/api/drm/#unshackle.core.drm.ClearKey","title":"ClearKey","text":"ClearKey(key, iv=None)\n
AES Clear Key DRM System.
Generally IV should be provided where possible. If not provided, it will be set to \u0000 of the same bit-size of the key.
"},{"location":"reference/api/drm/#unshackle.core.drm.ClearKey.decrypt","title":"decrypt","text":"decrypt(path)\n
Decrypt a Track with AES Clear Key DRM.
"},{"location":"reference/api/drm/#unshackle.core.drm.ClearKey.from_m3u_key","title":"from_m3u_key classmethod","text":"from_m3u_key(m3u_key, session=None)\n
Load a ClearKey from an M3U(8) Playlist's EXT-X-KEY.
Parameters:
Name Type Description Default m3u_key Key A Key object parsed from a m3u(8) playlist using the m3u8 library.
required session Optional[Session] Optional session used to request external URIs with. Useful to set headers, proxies, cookies, and so forth.
None"},{"location":"reference/api/drm/#unshackle.core.drm.ClearKeyCENC","title":"ClearKeyCENC","text":"ClearKeyCENC(kids, laurl=None, content_keys=None, **kwargs)\n
W3C EME ClearKey (org.w3.clearkey) DRM System over MPEG-CENC content.
Distinct from the HLS AES-128 ClearKey class: keys here are delivered by a license server as a JWK Set keyed by KID, and content is standard CENC (decrypted with shaka-packager/mp4decrypt KID:KEY pairs, same as Widevine).
"},{"location":"reference/api/drm/#unshackle.core.drm.ClearKeyCENC.kid","title":"kid property","text":"kid\n
Get first Key ID, if any.
"},{"location":"reference/api/drm/#unshackle.core.drm.ClearKeyCENC.Exceptions","title":"Exceptions","text":""},{"location":"reference/api/drm/#unshackle.core.drm.ClearKeyCENC.Exceptions.KIDNotFound","title":"KIDNotFound","text":" Bases: Exception
KID (Encryption Key ID) was not found.
"},{"location":"reference/api/drm/#unshackle.core.drm.ClearKeyCENC.Exceptions.CEKNotFound","title":"CEKNotFound","text":" Bases: Exception
CEK (Content Encryption Key) for KID was not found in License.
"},{"location":"reference/api/drm/#unshackle.core.drm.ClearKeyCENC.Exceptions.EmptyLicense","title":"EmptyLicense","text":" Bases: Exception
License returned no Content Encryption Keys.
"},{"location":"reference/api/drm/#unshackle.core.drm.ClearKeyCENC.to_dict","title":"to_dict","text":"to_dict()\n
Serialise this DRM instance for export/import (KIDs + license URL).
Content keys are stored once at the export's track level, not duplicated here.
"},{"location":"reference/api/drm/#unshackle.core.drm.ClearKeyCENC.get_license_challenge","title":"get_license_challenge","text":"get_license_challenge()\n
Build the W3C EME ClearKey JSON license request for the unkeyed KIDs.
"},{"location":"reference/api/drm/#unshackle.core.drm.ClearKeyCENC.get_content_keys","title":"get_content_keys","text":"get_content_keys(*, licence, session=None)\n
Obtain Content Keys for this DRM Instance from a ClearKey license server.
The licence param is expected to be a function and will be provided with the W3C JSON license request as challenge. It may return the JWK Set license as a dict, JSON str, or bytes. If it returns None and the manifest provided a Laurl, the challenge is POSTed there directly instead.
"},{"location":"reference/api/drm/#unshackle.core.drm.ClearKeyCENC.decrypt","title":"decrypt","text":"decrypt(path)\n
Decrypt a Track with ClearKey DRM (standard CENC). Args: path: Path to the encrypted file to decrypt Raises: EnvironmentError if the required decryption executable could not be found. ValueError if the track has not yet been downloaded. SubprocessError if the decryption process returned a non-zero exit code.
"},{"location":"reference/api/drm/#unshackle.core.drm.ClearKeyCENC.decrypt_with_mp4decrypt","title":"decrypt_with_mp4decrypt","text":"decrypt_with_mp4decrypt(path)\n
Decrypt using mp4decrypt
"},{"location":"reference/api/drm/#unshackle.core.drm.ClearKeyCENC.decrypt_with_shaka_packager","title":"decrypt_with_shaka_packager","text":"decrypt_with_shaka_packager(path)\n
Decrypt using Shaka Packager
"},{"location":"reference/api/drm/#unshackle.core.drm.MonaLisa","title":"MonaLisa","text":"MonaLisa(ticket, aes_key, device_path, **kwargs)\n
MonaLisa DRM System.
Unlike Widevine/PlayReady, MonaLisa does not use a challenge/response flow with a license server. Instead, the PSSH value (ticket) is provided directly by the service API, and keys are extracted locally via a WASM module.
Decryption is performed in two stages: 1. ML-Worker binary: Removes MonaLisa encryption layer (bbts -> ents) 2. AES-ECB decryption: Final decryption with service-provided key
Initialize MonaLisa DRM.
Parameters:
Name Type Description Default ticket Union[str, bytes] PSSH value from service API (base64 string or raw bytes).
required aes_key Union[str, bytes] AES-ECB key for second-stage decryption (hex string or bytes).
required device_path Path Path to the CDM device file (.mld).
required **kwargs Any Additional metadata stored in self.data.
{} Raises:
Type Description TicketNotFound If ticket/PSSH is empty.
KeyExtractionFailed If key extraction fails.
"},{"location":"reference/api/drm/#unshackle.core.drm.MonaLisa.kid","title":"kid property","text":"kid\n
Get the Key ID.
"},{"location":"reference/api/drm/#unshackle.core.drm.MonaLisa.key","title":"key property","text":"key\n
Get the content key as hex string.
"},{"location":"reference/api/drm/#unshackle.core.drm.MonaLisa.pssh","title":"pssh property","text":"pssh\n
Get the raw PSSH/ticket value as a string.
Returns:
Type Description str The raw PSSH value as a base64 string.
"},{"location":"reference/api/drm/#unshackle.core.drm.MonaLisa.content_id","title":"content_id property","text":"content_id\n
Extract the Content ID from the PSSH for display.
The PSSH contains an embedded Content ID at bytes 21-75 with format: H5DCID-V3-P1-YYYYMMDD-HHMMSS-MEDIAID-TIMESTAMP-SUFFIX
Returns:
Type Description Optional[str] The Content ID string if extractable, None otherwise.
"},{"location":"reference/api/drm/#unshackle.core.drm.MonaLisa.content_keys","title":"content_keys property","text":"content_keys\n
Get content keys in the same format as Widevine/PlayReady.
Returns:
Type Description dict[UUID, str] Dictionary mapping KID to key hex string.
"},{"location":"reference/api/drm/#unshackle.core.drm.MonaLisa.Exceptions","title":"Exceptions","text":""},{"location":"reference/api/drm/#unshackle.core.drm.MonaLisa.Exceptions.TicketNotFound","title":"TicketNotFound","text":" Bases: Exception
Raised when no PSSH/ticket data is provided.
"},{"location":"reference/api/drm/#unshackle.core.drm.MonaLisa.Exceptions.KeyExtractionFailed","title":"KeyExtractionFailed","text":" Bases: Exception
Raised when key extraction from the ticket fails.
"},{"location":"reference/api/drm/#unshackle.core.drm.MonaLisa.Exceptions.WorkerNotFound","title":"WorkerNotFound","text":" Bases: Exception
Raised when the ML-Worker binary is not found.
"},{"location":"reference/api/drm/#unshackle.core.drm.MonaLisa.Exceptions.DecryptionFailed","title":"DecryptionFailed","text":" Bases: Exception
Raised when segment decryption fails.
"},{"location":"reference/api/drm/#unshackle.core.drm.MonaLisa.from_ticket","title":"from_ticket classmethod","text":"from_ticket(ticket, aes_key, device_path)\n
Create a MonaLisa DRM instance from a PSSH/ticket.
Parameters:
Name Type Description Default ticket Union[str, bytes] PSSH value from service API.
required aes_key Union[str, bytes] AES-ECB key for second-stage decryption.
required device_path Path Path to the CDM device file (.mld).
required Returns:
Type Description MonaLisa MonaLisa DRM instance with extracted keys.
"},{"location":"reference/api/drm/#unshackle.core.drm.MonaLisa.decrypt_segment","title":"decrypt_segment","text":"decrypt_segment(segment_path)\n
Decrypt a single segment using two-stage decryption.
Stage 1: ML-Worker binary (bbts -> ents) Stage 2: AES-ECB decryption (ents -> ts)
Parameters:
Name Type Description Default segment_path Path Path to the encrypted segment file.
required Raises:
Type Description WorkerNotFound If ML-Worker binary is not available.
DecryptionFailed If decryption fails at any stage.
"},{"location":"reference/api/drm/#unshackle.core.drm.MonaLisa.decrypt","title":"decrypt","text":"decrypt(_path)\n
MonaLisa uses per-segment decryption during download via the on_segment_downloaded callback. By the time this method is called, the content has already been decrypted and muxed into a container.
Parameters:
Name Type Description Default _path Path Path to the file (ignored).
required"},{"location":"reference/api/drm/#unshackle.core.drm.PlayReady","title":"PlayReady","text":"PlayReady(pssh, kid=None, pssh_b64=None, **kwargs)\n
PlayReady DRM System.
"},{"location":"reference/api/drm/#unshackle.core.drm.PlayReady.to_dict","title":"to_dict","text":"to_dict()\n
Serialise this DRM instance for export/import (PSSH + KIDs).
Content keys are stored once at the export's track level, not duplicated here.
"},{"location":"reference/api/drm/#unshackle.core.drm.PlayReady.decrypt","title":"decrypt","text":"decrypt(path)\n
Decrypt a Track with PlayReady DRM. Args: path: Path to the encrypted file to decrypt Raises: EnvironmentError if the required decryption executable could not be found. ValueError if the track has not yet been downloaded. SubprocessError if the decryption process returned a non-zero exit code.
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine","title":"Widevine","text":"Widevine(pssh, kid=None, **kwargs)\n
Widevine DRM System.
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine.pssh","title":"pssh property","text":"pssh\n
Get Protection System Specific Header Box.
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine.kid","title":"kid property","text":"kid\n
Get first Key ID, if any.
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine.kids","title":"kids property","text":"kids\n
Get all Key IDs from PSSH, falling back to the externally provided KID.
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine.Exceptions","title":"Exceptions","text":""},{"location":"reference/api/drm/#unshackle.core.drm.Widevine.Exceptions.PSSHNotFound","title":"PSSHNotFound","text":" Bases: Exception
PSSH (Protection System Specific Header) was not found.
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine.Exceptions.KIDNotFound","title":"KIDNotFound","text":" Bases: Exception
KID (Encryption Key ID) was not found.
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine.Exceptions.CEKNotFound","title":"CEKNotFound","text":" Bases: Exception
CEK (Content Encryption Key) for KID was not found in License.
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine.Exceptions.EmptyLicense","title":"EmptyLicense","text":" Bases: Exception
License returned no Content Encryption Keys.
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine.from_track","title":"from_track classmethod","text":"from_track(track, session=None)\n
Get PSSH and KID from within the Initiation Segment of the Track Data. It also tries to get PSSH and KID from other track data like M3U8 data as well as through ffprobe.
Create a Widevine DRM System object from a track's information. This should only be used if a PSSH could not be provided directly. It is rare to need to use this.
You may provide your own requests session to be able to use custom headers and more.
Raises:
Type Description PSSHNotFound If the PSSH was not found within the data.
KIDNotFound If the KID was not found within the data or PSSH.
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine.from_init_data","title":"from_init_data classmethod","text":"from_init_data(init_data)\n
Get PSSH and KID from within Initialization Segment Data.
This should only be used if a PSSH could not be provided directly. It is rare to need to use this.
Raises:
Type Description PSSHNotFound If the PSSH was not found within the data.
KIDNotFound If the KID was not found within the data or PSSH.
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine.to_dict","title":"to_dict","text":"to_dict()\n
Serialise this DRM instance for export/import (PSSH + KIDs).
Content keys are stored once at the export's track level, not duplicated here.
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine.get_content_keys","title":"get_content_keys","text":"get_content_keys(cdm, certificate, licence)\n
Create a CDM Session and obtain Content Keys for this DRM Instance. The certificate and license params are expected to be a function and will be provided with the challenge and session ID.
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine.get_NF_content_keys","title":"get_NF_content_keys","text":"get_NF_content_keys(cdm, certificate, licence)\n
Create a CDM Session and obtain Content Keys for this DRM Instance. The certificate and license params are expected to be a function and will be provided with the challenge and session ID.
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine.decrypt","title":"decrypt","text":"decrypt(path)\n
Decrypt a Track with Widevine DRM. Args: path: Path to the encrypted file to decrypt Raises: EnvironmentError if the required decryption executable could not be found. ValueError if the track has not yet been downloaded. SubprocessError if the decryption process returned a non-zero exit code.
"},{"location":"reference/api/drm/#unshackle.core.drm.drm_from_dict","title":"drm_from_dict","text":"drm_from_dict(data)\n
Reconstruct a Widevine/PlayReady/ClearKeyCENC DRM instance from its to_dict() form.
Rebuilds the PSSH from the stored base64 (KIDs for ClearKey, which has no PSSH) and re-injects any saved content keys so the resulting object can decrypt without contacting a license server.
"},{"location":"reference/api/service/","title":"Service API","text":"Auto-generated reference for the Service base class that every service plugin subclasses. See Creating a Service for a guided walkthrough and Service Architecture for the wider object model.
"},{"location":"reference/api/service/#unshackle.core.service.TrackRequest","title":"TrackRequest dataclass","text":"TrackRequest(\n codecs=list(),\n ranges=(lambda: [SDR])(),\n best_available=False,\n)\n
Holds what the user requested for video codec and range selection.
Services read from this instead of ctx.parent.params for vcodec/range.
Attributes:
Name Type Description codecs list[Codec] Requested codecs from CLI. Empty list means no filter (accept any).
ranges list[Range] Requested ranges from CLI. Defaults to [SDR].
"},{"location":"reference/api/service/#unshackle.core.service.Service","title":"Service","text":"Service(ctx)\n
The Service Base Class.
Source code in unshackle/core/service.py def __init__(self, ctx: click.Context):\n console.print(Padding(Rule(f\"[rule.text]Service: {self.__class__.__name__}\"), (1, 2)))\n\n self.config = ctx.obj.config\n\n self.log = logging.getLogger(self.__class__.__name__)\n\n self.session = self.get_session()\n self.cache = Cacher(self.__class__.__name__)\n self.title_cache = TitleCacher(self.__class__.__name__)\n self.cache_dir = config.directories.cache / self.__class__.__name__\n\n # Store context for cache control flags and credential\n self.ctx = ctx\n self.credential = None # Will be set in authenticate()\n self.current_region = None # Will be set based on proxy/geolocation\n self._input_bridge: Optional[InputBridge] = None\n\n # Set track request from CLI params - services can read/override in their __init__\n vcodec = ctx.parent.params.get(\"vcodec\") if ctx.parent else None\n range_ = ctx.parent.params.get(\"range_\") if ctx.parent else None\n best_available = ctx.parent.params.get(\"best_available\", False) if ctx.parent else False\n self.track_request = TrackRequest(\n codecs=list(vcodec) if vcodec else [],\n ranges=list(range_) if range_ else [Video.Range.SDR],\n best_available=bool(best_available),\n )\n\n if not ctx.parent or not ctx.parent.params.get(\"no_proxy\"):\n if ctx.parent:\n proxy = ctx.parent.params[\"proxy\"]\n proxy_query = ctx.parent.params.get(\"proxy_query\")\n proxy_provider_name = ctx.parent.params.get(\"proxy_provider\")\n else:\n proxy = None\n proxy_query = None\n proxy_provider_name = None\n\n # Check for service-specific proxy mapping\n service_name = self.__class__.__name__\n service_config_dict = config.services.get(service_name, {})\n proxy_map = service_config_dict.get(\"proxy_map\", {})\n\n if proxy_map and proxy_query:\n # Build the full proxy query key (e.g., \"nordvpn:ca\" or \"us\")\n if proxy_provider_name:\n full_proxy_key = f\"{proxy_provider_name}:{proxy_query}\"\n else:\n full_proxy_key = proxy_query\n\n # Check if there's a mapping for this query\n mapped_value = proxy_map.get(full_proxy_key)\n if mapped_value:\n self.log.info(\n f\"Found service-specific proxy mapping: {full_proxy_key} -> {sanitize_proxy_for_log(mapped_value)}\"\n )\n # Query the proxy provider with the mapped value\n if proxy_provider_name:\n # Specific provider requested\n proxy_provider = next(\n (x for x in ctx.obj.proxy_providers if x.__class__.__name__.lower() == proxy_provider_name),\n None,\n )\n if proxy_provider:\n mapped_proxy_uri = proxy_provider.get_proxy(mapped_value)\n if mapped_proxy_uri:\n proxy = mapped_proxy_uri\n self.log.info(\n f\"Using mapped proxy from {proxy_provider.__class__.__name__}: {sanitize_proxy_for_log(proxy)}\"\n )\n else:\n self.log.warning(\n f\"Failed to get proxy for mapped value '{sanitize_proxy_for_log(mapped_value)}', using default\"\n )\n else:\n self.log.warning(f\"Proxy provider '{proxy_provider_name}' not found, using default proxy\")\n else:\n # No specific provider, try all providers\n for proxy_provider in ctx.obj.proxy_providers:\n mapped_proxy_uri = proxy_provider.get_proxy(mapped_value)\n if mapped_proxy_uri:\n proxy = mapped_proxy_uri\n self.log.info(\n f\"Using mapped proxy from {proxy_provider.__class__.__name__}: {sanitize_proxy_for_log(proxy)}\"\n )\n break\n else:\n self.log.warning(\n f\"No provider could resolve mapped value '{sanitize_proxy_for_log(mapped_value)}', using default\"\n )\n\n if not proxy:\n # don't override the explicit proxy set by the user, even if they may be geoblocked\n with console.status(\"Checking if current region is Geoblocked...\", spinner=\"dots\"):\n if self.GEOFENCE:\n # Service has geofence - need fresh IP check to determine if proxy needed\n try:\n current_region = get_ip_info(self.session)[\"country\"].lower()\n if any(x.lower() == current_region for x in self.GEOFENCE):\n self.log.info(\"Service is not Geoblocked in your region\")\n else:\n requested_proxy = self.GEOFENCE[0] # first is likely main region\n self.log.info(\n f\"Service is Geoblocked in your region, getting a Proxy to {requested_proxy}\"\n )\n for proxy_provider in ctx.obj.proxy_providers:\n proxy = proxy_provider.get_proxy(requested_proxy)\n if proxy:\n self.log.info(f\"Got Proxy from {proxy_provider.__class__.__name__}\")\n break\n except Exception as e:\n self.log.warning(f\"Failed to check geofence: {e}\")\n current_region = None\n else:\n self.log.info(\"Service has no Geofence\")\n\n if proxy:\n self.session.proxies.update({\"all\": proxy})\n # Don't set Proxy-Authorization manually: both rnet (Proxy.all) and\n # requests authenticate from the credentials embedded in the proxy URL.\n # A manual header here was malformed (no \"Basic \" scheme) and broke\n # plaintext-http forward-proxy requests with HTTP 407.\n # Always verify proxy IP - proxies can change exit nodes\n try:\n proxy_ip_info = get_ip_info(self.session)\n self.current_region = proxy_ip_info.get(\"country\", \"\").lower() if proxy_ip_info else None\n except Exception as e:\n self.log.warning(f\"Failed to verify proxy IP: {e}\")\n # Fallback to extracting region from proxy config\n self.current_region = get_region_from_proxy(proxy)\n else:\n # No proxy, use cached IP info for title caching (non-critical)\n try:\n ip_info = get_ip_info(self.session, cached=True)\n self.current_region = ip_info.get(\"country\", \"\").lower() if ip_info else None\n except Exception as e:\n self.log.debug(f\"Failed to get cached IP info: {e}\")\n self.current_region = None\n
"},{"location":"reference/api/service/#unshackle.core.service.Service.get_session","title":"get_session staticmethod","text":"get_session()\n
Creates a Python-requests Session, adds common headers from config, cookies, retry handler, and a proxy if available. :returns: Prepared Python-requests Session
Source code in unshackle/core/service.py @staticmethod\ndef get_session() -> requests.Session:\n \"\"\"\n Creates a Python-requests Session, adds common headers\n from config, cookies, retry handler, and a proxy if available.\n :returns: Prepared Python-requests Session\n \"\"\"\n session = requests.Session()\n session.headers.update(config.headers)\n session.mount(\n \"https://\",\n HTTPAdapter(\n max_retries=Retry(total=5, backoff_factor=0.2, status_forcelist=[429, 500, 502, 503, 504]),\n pool_maxsize=64,\n pool_block=True,\n ),\n )\n session.mount(\"http://\", session.adapters[\"https://\"])\n return session\n
"},{"location":"reference/api/service/#unshackle.core.service.Service.authenticate","title":"authenticate","text":"authenticate(cookies=None, credential=None)\n
Authenticate the Service with Cookies and/or Credentials (Email/Username and Password).
This is effectively a login() function. Any API calls or object initializations needing to be made, should be made here. This will be run before any of the following abstract functions.
You should avoid storing or using the Credential outside this function. Make any calls you need for any Cookies, Tokens, or such, then use those.
The Cookie jar should also not be stored outside this function. However, you may load the Cookie jar into the service session.
Source code in unshackle/core/service.py def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:\n \"\"\"\n Authenticate the Service with Cookies and/or Credentials (Email/Username and Password).\n\n This is effectively a login() function. Any API calls or object initializations\n needing to be made, should be made here. This will be run before any of the\n following abstract functions.\n\n You should avoid storing or using the Credential outside this function.\n Make any calls you need for any Cookies, Tokens, or such, then use those.\n\n The Cookie jar should also not be stored outside this function. However, you may load\n the Cookie jar into the service session.\n \"\"\"\n if cookies is not None:\n if not isinstance(cookies, CookieJar):\n raise TypeError(f\"Expected cookies to be a {CookieJar}, not {cookies!r}.\")\n self.session.cookies.update(cookies)\n\n # Store credential for cache key generation\n self.credential = credential\n
"},{"location":"reference/api/service/#unshackle.core.service.Service.request_input","title":"request_input","text":"request_input(prompt)\n
Request interactive input from the user.
When running locally (CLI), prompts via the shared rich console so the prompt renders correctly alongside Live progress / log handlers. When running in serve mode with an :class:InputBridge attached, delegates to the bridge which relays the prompt to the remote client.
Source code in unshackle/core/service.py def request_input(self, prompt: str) -> str:\n \"\"\"Request interactive input from the user.\n\n When running locally (CLI), prompts via the shared rich console so the\n prompt renders correctly alongside Live progress / log handlers.\n When running in serve mode with an :class:`InputBridge` attached,\n delegates to the bridge which relays the prompt to the remote client.\n \"\"\"\n if self._input_bridge is not None:\n return self._input_bridge.request_input(prompt)\n indent = \" \" * 5\n padded = indent + prompt.replace(\"\\n\", \"\\n\" + indent)\n return console.input(Text(padded, style=\"text\"))\n
"},{"location":"reference/api/service/#unshackle.core.service.Service.search","title":"search","text":"search()\n
Search by query for titles from the Service.
The query must be taken as a CLI argument by the Service class. Ideally just re-use the title ID argument (i.e. self.title).
Search results will be displayed in the order yielded.
Source code in unshackle/core/service.py def search(self) -> Generator[SearchResult, None, None]:\n \"\"\"\n Search by query for titles from the Service.\n\n The query must be taken as a CLI argument by the Service class.\n Ideally just re-use the title ID argument (i.e. self.title).\n\n Search results will be displayed in the order yielded.\n \"\"\"\n raise NotImplementedError(f\"Search functionality has not been implemented by {self.__class__.__name__}\")\n
"},{"location":"reference/api/service/#unshackle.core.service.Service.get_widevine_service_certificate","title":"get_widevine_service_certificate","text":"get_widevine_service_certificate(\n *, challenge, title, track\n)\n
Get the Widevine Service Certificate used for Privacy Mode.
:param challenge: The service challenge, providing this to a License endpoint should return the privacy certificate that the service uses. :param title: The current Title from get_titles that is being executed. This is provided in case it has data needed to be used, e.g. for a HTTP request. :param track: The current Track needing decryption. Provided for same reason as title. :return: The Service Privacy Certificate as Bytes or a Base64 string. Don't Base64 Encode or Decode the data, return as is to reduce unnecessary computations.
Source code in unshackle/core/service.py def get_widevine_service_certificate(\n self, *, challenge: bytes, title: Title_T, track: AnyTrack\n) -> Union[bytes, str]:\n \"\"\"\n Get the Widevine Service Certificate used for Privacy Mode.\n\n :param challenge: The service challenge, providing this to a License endpoint should return the\n privacy certificate that the service uses.\n :param title: The current `Title` from get_titles that is being executed. This is provided in\n case it has data needed to be used, e.g. for a HTTP request.\n :param track: The current `Track` needing decryption. Provided for same reason as `title`.\n :return: The Service Privacy Certificate as Bytes or a Base64 string. Don't Base64 Encode or\n Decode the data, return as is to reduce unnecessary computations.\n \"\"\"\n
"},{"location":"reference/api/service/#unshackle.core.service.Service.get_widevine_license","title":"get_widevine_license","text":"get_widevine_license(*, challenge, title, track)\n
Get a Widevine License message by sending a License Request (challenge).
This License message contains the encrypted Content Decryption Keys and will be read by the Cdm and decrypted.
This is a very important request to get correct. A bad, unexpected, or missing value in the request can cause your key to be detected and promptly banned, revoked, disabled, or downgraded.
:param challenge: The license challenge from the Widevine CDM. :param title: The current Title from get_titles that is being executed. This is provided in case it has data needed to be used, e.g. for a HTTP request. :param track: The current Track needing decryption. Provided for same reason as title. :return: The License response as Bytes or a Base64 string. Don't Base64 Encode or Decode the data, return as is to reduce unnecessary computations.
Source code in unshackle/core/service.py def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]:\n \"\"\"\n Get a Widevine License message by sending a License Request (challenge).\n\n This License message contains the encrypted Content Decryption Keys and will be\n read by the Cdm and decrypted.\n\n This is a very important request to get correct. A bad, unexpected, or missing\n value in the request can cause your key to be detected and promptly banned,\n revoked, disabled, or downgraded.\n\n :param challenge: The license challenge from the Widevine CDM.\n :param title: The current `Title` from get_titles that is being executed. This is provided in\n case it has data needed to be used, e.g. for a HTTP request.\n :param track: The current `Track` needing decryption. Provided for same reason as `title`.\n :return: The License response as Bytes or a Base64 string. Don't Base64 Encode or\n Decode the data, return as is to reduce unnecessary computations.\n \"\"\"\n
"},{"location":"reference/api/service/#unshackle.core.service.Service.get_playready_license","title":"get_playready_license","text":"get_playready_license(*, challenge, title, track)\n
Get a PlayReady License message by sending a License Request (challenge).
This License message contains the encrypted Content Decryption Keys and will be read by the CDM and decrypted.
This is a very important request to get correct. A bad, unexpected, or missing value in the request can cause your key to be detected and promptly banned, revoked, disabled, or downgraded.
:param challenge: The license challenge from the PlayReady CDM. :param title: The current Title from get_titles that is being executed. This is provided in case it has data needed to be used, e.g. for a HTTP request. :param track: The current Track needing decryption. Provided for same reason as title. :return: The License response as Bytes or a Base64 string. Don't Base64 Encode or Decode the data, return as is to reduce unnecessary computations.
Source code in unshackle/core/service.py def get_playready_license(\n self, *, challenge: bytes, title: Title_T, track: AnyTrack\n) -> Optional[Union[bytes, str]]:\n \"\"\"\n Get a PlayReady License message by sending a License Request (challenge).\n\n This License message contains the encrypted Content Decryption Keys and will be\n read by the CDM and decrypted.\n\n This is a very important request to get correct. A bad, unexpected, or missing\n value in the request can cause your key to be detected and promptly banned,\n revoked, disabled, or downgraded.\n\n :param challenge: The license challenge from the PlayReady CDM.\n :param title: The current `Title` from get_titles that is being executed. This is provided in\n case it has data needed to be used, e.g. for a HTTP request.\n :param track: The current `Track` needing decryption. Provided for same reason as `title`.\n :return: The License response as Bytes or a Base64 string. Don't Base64 Encode or\n Decode the data, return as is to reduce unnecessary computations.\n \"\"\"\n # Delegates license handling to the Widevine license method by default if a service-specific PlayReady implementation is not provided.\n return self.get_widevine_license(challenge=challenge, title=title, track=track)\n
"},{"location":"reference/api/service/#unshackle.core.service.Service.get_clearkey_license","title":"get_clearkey_license","text":"get_clearkey_license(*, challenge, title, track)\n
Get a W3C ClearKey License (JWK Set) by sending a License Request (challenge).
Used for DASH org.w3.clearkey content. No CDM is involved: the challenge is the W3C EME JSON license request, e.g. {\"kids\": [\"<base64url>\"], \"type\": \"temporary\"}, and the license is a JWK Set, e.g. {\"keys\": [{\"kty\": \"oct\", \"k\": \"...\", \"kid\": \"...\"}]}.
:param challenge: The JSON license request bytes to POST to the license server. :param title: The current Title from get_titles that is being executed. This is provided in case it has data needed to be used, e.g. for a HTTP request. :param track: The current Track needing decryption. Provided for same reason as title. :return: The JWK Set license as a dict, JSON str, or raw bytes. Return None (the default) to let the framework POST the challenge to the manifest-provided Laurl, if any. Services with no license server can instead pre-populate the DRM object's content_keys in get_tracks.
Source code in unshackle/core/service.py def get_clearkey_license(\n self, *, challenge: bytes, title: Title_T, track: AnyTrack\n) -> Optional[Union[bytes, str, dict]]:\n \"\"\"\n Get a W3C ClearKey License (JWK Set) by sending a License Request (challenge).\n\n Used for DASH `org.w3.clearkey` content. No CDM is involved: the challenge is\n the W3C EME JSON license request, e.g. ``{\"kids\": [\"<base64url>\"], \"type\": \"temporary\"}``,\n and the license is a JWK Set, e.g. ``{\"keys\": [{\"kty\": \"oct\", \"k\": \"...\", \"kid\": \"...\"}]}``.\n\n :param challenge: The JSON license request bytes to POST to the license server.\n :param title: The current `Title` from get_titles that is being executed. This is provided in\n case it has data needed to be used, e.g. for a HTTP request.\n :param track: The current `Track` needing decryption. Provided for same reason as `title`.\n :return: The JWK Set license as a dict, JSON str, or raw bytes. Return None (the default)\n to let the framework POST the challenge to the manifest-provided Laurl, if any.\n Services with no license server can instead pre-populate the DRM object's\n `content_keys` in get_tracks.\n \"\"\"\n return None\n
"},{"location":"reference/api/service/#unshackle.core.service.Service.get_titles","title":"get_titles abstractmethod","text":"get_titles()\n
Get Titles for the provided title ID.
Return a Movies, Series, or Album objects containing Movie, Episode, or Song title objects respectively. The returned data must be for the given title ID, or a spawn of the title ID.
At least one object is expected to be returned, or it will presume an invalid Title ID was provided.
You can use the data dictionary class instance attribute of each Title to store data you may need later on. This can be useful to store information on each title that will be required like any sub-asset IDs, or such.
Source code in unshackle/core/service.py @abstractmethod\ndef get_titles(self) -> Titles_T:\n \"\"\"\n Get Titles for the provided title ID.\n\n Return a Movies, Series, or Album objects containing Movie, Episode, or Song title objects respectively.\n The returned data must be for the given title ID, or a spawn of the title ID.\n\n At least one object is expected to be returned, or it will presume an invalid Title ID was\n provided.\n\n You can use the `data` dictionary class instance attribute of each Title to store data you may need later on.\n This can be useful to store information on each title that will be required like any sub-asset IDs, or such.\n \"\"\"\n
"},{"location":"reference/api/service/#unshackle.core.service.Service.get_titles_cached","title":"get_titles_cached","text":"get_titles_cached(title_id=None)\n
Cached wrapper around get_titles() to reduce redundant API calls.
This method checks the cache before calling get_titles() and handles fallback to cached data when API calls fail.
Parameters:
Name Type Description Default title_id str Optional title ID for cache key generation. If not provided, will try to extract from service instance.
None Returns:
Type Description Titles_T Titles object (Movies, Series, or Album)
Source code in unshackle/core/service.py def get_titles_cached(self, title_id: str = None) -> Titles_T:\n \"\"\"\n Cached wrapper around get_titles() to reduce redundant API calls.\n\n This method checks the cache before calling get_titles() and handles\n fallback to cached data when API calls fail.\n\n Args:\n title_id: Optional title ID for cache key generation.\n If not provided, will try to extract from service instance.\n\n Returns:\n Titles object (Movies, Series, or Album)\n \"\"\"\n # Try to get title_id from service instance if not provided\n if title_id is None:\n # Different services store the title ID in different attributes\n if hasattr(self, \"title\"):\n title_id = self.title\n elif hasattr(self, \"title_id\"):\n title_id = self.title_id\n else:\n # If we can't determine title_id, just call get_titles directly\n self.log.debug(\"Cannot determine title_id for caching, bypassing cache\")\n return self.apply_title_map(self.get_titles())\n\n # Get cache control flags from context\n no_cache = False\n reset_cache = False\n if self.ctx and self.ctx.parent:\n no_cache = self.ctx.parent.params.get(\"no_cache\", False)\n reset_cache = self.ctx.parent.params.get(\"reset_cache\", False)\n\n # Get account hash for cache key\n account_hash = get_account_hash(self.credential)\n\n # Use title cache to get titles with fallback support\n titles = self.title_cache.get_cached_titles(\n title_id=str(title_id),\n fetch_function=self.get_titles,\n region=self.current_region,\n account_hash=account_hash,\n no_cache=no_cache,\n reset_cache=reset_cache,\n )\n return self.apply_title_map(titles)\n
"},{"location":"reference/api/service/#unshackle.core.service.Service.apply_title_map","title":"apply_title_map","text":"apply_title_map(titles)\n
Rewrite service-provided titles using the per-service title_map config.
title_map lives under services.<TAG> in unshackle.yaml. Applied after the title cache so config edits take effect without a cache reset, and before any --enrich override so enrich wins. See remap_titles for the match rules.
Source code in unshackle/core/service.py def apply_title_map(self, titles: Titles_T) -> Titles_T:\n \"\"\"\n Rewrite service-provided titles using the per-service ``title_map`` config.\n\n ``title_map`` lives under ``services.<TAG>`` in unshackle.yaml. Applied after the\n title cache so config edits take effect without a cache reset, and before any\n ``--enrich`` override so enrich wins. See ``remap_titles`` for the match rules.\n \"\"\"\n return remap_titles(titles, (self.config or {}).get(\"title_map\") or {})\n
"},{"location":"reference/api/service/#unshackle.core.service.Service.get_tracks","title":"get_tracks abstractmethod","text":"get_tracks(title)\n
Get Track objects of the Title.
Return a Tracks object, which itself can contain Video, Audio, Subtitle or even Chapters. Tracks.videos, Tracks.audio, Tracks.subtitles, and Track.chapters should be a List of Track objects.
Each Track in the Tracks should represent a Video/Audio Stream/Representation/Adaptation or a Subtitle file.
While one Track should only hold information for one stream/downloadable, try to get as many unique Track objects per stream type so Stream selection by the root code can give you more options in terms of Resolution, Bitrate, Codecs, Language, e.t.c.
No decision making or filtering of which Tracks get returned should happen here. It can be considered an error to filter for e.g. resolution, codec, and such. All filtering based on arguments will be done by the root code automatically when needed.
Make sure you correctly mark which Tracks are encrypted or not, and by which DRM System via its drm property.
If you are able to obtain the Track's KID (Key ID) as a 32 char (16 bit) HEX string, provide it to the Track's kid variable as it will speed up the decryption process later on. It may or may not be needed, that depends on the service. Generally if you can provide it, without downloading any of the Track's stream data, then do.
:param title: The current Title from get_titles that is being executed. :return: Tracks object containing Video, Audio, Subtitles, and Chapters, if available.
Source code in unshackle/core/service.py @abstractmethod\ndef get_tracks(self, title: Title_T) -> Tracks:\n \"\"\"\n Get Track objects of the Title.\n\n Return a Tracks object, which itself can contain Video, Audio, Subtitle or even Chapters.\n Tracks.videos, Tracks.audio, Tracks.subtitles, and Track.chapters should be a List of Track objects.\n\n Each Track in the Tracks should represent a Video/Audio Stream/Representation/Adaptation or\n a Subtitle file.\n\n While one Track should only hold information for one stream/downloadable, try to get as many\n unique Track objects per stream type so Stream selection by the root code can give you more\n options in terms of Resolution, Bitrate, Codecs, Language, e.t.c.\n\n No decision making or filtering of which Tracks get returned should happen here. It can be\n considered an error to filter for e.g. resolution, codec, and such. All filtering based on\n arguments will be done by the root code automatically when needed.\n\n Make sure you correctly mark which Tracks are encrypted or not, and by which DRM System\n via its `drm` property.\n\n If you are able to obtain the Track's KID (Key ID) as a 32 char (16 bit) HEX string, provide\n it to the Track's `kid` variable as it will speed up the decryption process later on. It may\n or may not be needed, that depends on the service. Generally if you can provide it, without\n downloading any of the Track's stream data, then do.\n\n :param title: The current `Title` from get_titles that is being executed.\n :return: Tracks object containing Video, Audio, Subtitles, and Chapters, if available.\n \"\"\"\n
"},{"location":"reference/api/service/#unshackle.core.service.Service.get_chapters","title":"get_chapters abstractmethod","text":"get_chapters(title)\n
Get Chapters for the Title.
Parameters:
Name Type Description Default title Title_T The current Title from get_titles that is being processed.
required You must return a Chapters object containing 0 or more Chapter objects.
You do not need to set a Chapter number or sort/order the chapters in any way as the Chapters class automatically handles all of that for you. If there's no descriptive name for a Chapter then do not set a name at all.
You must not set Chapter names to \"Chapter {n}\" or such. If you (or the user) wants \"Chapter {n}\" style Chapter names (or similar) then they can use the config option chapter_fallback_name. For example, \"Chapter {i:02}\" for \"Chapter 01\".
Source code in unshackle/core/service.py @abstractmethod\ndef get_chapters(self, title: Title_T) -> Chapters:\n \"\"\"\n Get Chapters for the Title.\n\n Parameters:\n title: The current Title from `get_titles` that is being processed.\n\n You must return a Chapters object containing 0 or more Chapter objects.\n\n You do not need to set a Chapter number or sort/order the chapters in any way as\n the Chapters class automatically handles all of that for you. If there's no\n descriptive name for a Chapter then do not set a name at all.\n\n You must not set Chapter names to \"Chapter {n}\" or such. If you (or the user)\n wants \"Chapter {n}\" style Chapter names (or similar) then they can use the config\n option `chapter_fallback_name`. For example, `\"Chapter {i:02}\"` for \"Chapter 01\".\n \"\"\"\n
"},{"location":"reference/api/service/#unshackle.core.service.Service.on_segment_downloaded","title":"on_segment_downloaded","text":"on_segment_downloaded(track, segment)\n
Called when one of a Track's Segments has finished downloading.
Parameters:
Name Type Description Default track AnyTrack The Track object that had a Segment downloaded.
required segment Path The Path to the Segment that was downloaded.
required Source code in unshackle/core/service.py def on_segment_downloaded(self, track: AnyTrack, segment: Path) -> None:\n \"\"\"\n Called when one of a Track's Segments has finished downloading.\n\n Parameters:\n track: The Track object that had a Segment downloaded.\n segment: The Path to the Segment that was downloaded.\n \"\"\"\n
"},{"location":"reference/api/service/#unshackle.core.service.Service.on_track_downloaded","title":"on_track_downloaded","text":"on_track_downloaded(track)\n
Called when a Track has finished downloading.
Parameters:
Name Type Description Default track AnyTrack The Track object that was downloaded.
required Source code in unshackle/core/service.py def on_track_downloaded(self, track: AnyTrack) -> None:\n \"\"\"\n Called when a Track has finished downloading.\n\n Parameters:\n track: The Track object that was downloaded.\n \"\"\"\n
"},{"location":"reference/api/service/#unshackle.core.service.Service.on_track_decrypted","title":"on_track_decrypted","text":"on_track_decrypted(track, drm, segment=None)\n
Called when a Track has finished decrypting.
Parameters:
Name Type Description Default track AnyTrack The Track object that was decrypted.
required drm DRM_T The DRM object it decrypted with.
required segment Optional[Segment] The HLS segment information that was decrypted.
None Source code in unshackle/core/service.py def on_track_decrypted(self, track: AnyTrack, drm: DRM_T, segment: Optional[m3u8.Segment] = None) -> None:\n \"\"\"\n Called when a Track has finished decrypting.\n\n Parameters:\n track: The Track object that was decrypted.\n drm: The DRM object it decrypted with.\n segment: The HLS segment information that was decrypted.\n \"\"\"\n
"},{"location":"reference/api/service/#unshackle.core.service.Service.on_track_repacked","title":"on_track_repacked","text":"on_track_repacked(track)\n
Called when a Track has finished repacking.
Parameters:
Name Type Description Default track AnyTrack The Track object that was repacked.
required Source code in unshackle/core/service.py def on_track_repacked(self, track: AnyTrack) -> None:\n \"\"\"\n Called when a Track has finished repacking.\n\n Parameters:\n track: The Track object that was repacked.\n \"\"\"\n
"},{"location":"reference/api/service/#unshackle.core.service.Service.on_track_multiplex","title":"on_track_multiplex","text":"on_track_multiplex(track)\n
Called when a Track is about to be Multiplexed into a Container.
Note: Right now only MKV containers are multiplexed but in the future this may also be called when multiplexing to other containers like MP4 via ffmpeg/mp4box.
Parameters:
Name Type Description Default track AnyTrack The Track object that was repacked.
required Source code in unshackle/core/service.py def on_track_multiplex(self, track: AnyTrack) -> None:\n \"\"\"\n Called when a Track is about to be Multiplexed into a Container.\n\n Note: Right now only MKV containers are multiplexed but in the future\n this may also be called when multiplexing to other containers like\n MP4 via ffmpeg/mp4box.\n\n Parameters:\n track: The Track object that was repacked.\n \"\"\"\n
"},{"location":"reference/api/service/#unshackle.core.service.sanitize_proxy_for_log","title":"sanitize_proxy_for_log","text":"sanitize_proxy_for_log(uri)\n
Sanitize a proxy URI for logs by redacting any embedded userinfo (username/password).
Examples:
- http://user:pass@host:8080 -> http://REDACTED@host:8080
- socks5h://user@host:1080 -> socks5h://REDACTED@host:1080
Source code in unshackle/core/service.py def sanitize_proxy_for_log(uri: Optional[str]) -> Optional[str]:\n \"\"\"\n Sanitize a proxy URI for logs by redacting any embedded userinfo (username/password).\n\n Examples:\n - http://user:pass@host:8080 -> http://REDACTED@host:8080\n - socks5h://user@host:1080 -> socks5h://REDACTED@host:1080\n \"\"\"\n if uri is None:\n return None\n if not isinstance(uri, str):\n return str(uri)\n if not uri:\n return uri\n\n try:\n parsed = urlparse(uri)\n\n # Handle schemeless proxies like \"user:pass@host:port\"\n if not parsed.scheme and not parsed.netloc and \"@\" in uri and \"://\" not in uri:\n # Parse as netloc using a dummy scheme, then strip scheme back out.\n dummy = urlparse(f\"http://{uri}\")\n netloc = dummy.netloc\n if \"@\" in netloc:\n netloc = f\"REDACTED@{netloc.split('@', 1)[1]}\"\n # urlparse(\"http://...\") sets path to \"\" for typical netloc-only strings; keep it just in case.\n return f\"{netloc}{dummy.path}\"\n\n netloc = parsed.netloc\n if \"@\" in netloc:\n netloc = f\"REDACTED@{netloc.split('@', 1)[1]}\"\n\n return urlunparse(parsed._replace(netloc=netloc))\n except Exception:\n if \"@\" in uri:\n return f\"REDACTED@{uri.split('@', 1)[1]}\"\n return uri\n
"},{"location":"reference/api/titles/","title":"Titles API","text":"Auto-generated reference for the title model: Movies, Series/Episodes, and Music/Albums/Songs. A service returns one of these collections from its title lookup. See Creating a Service.
"},{"location":"reference/api/titles/#unshackle.core.titles.Episode","title":"Episode","text":"Episode(\n id_,\n service,\n title,\n season,\n number,\n name=None,\n year=None,\n language=None,\n data=None,\n description=None,\n air_date=None,\n part=None,\n absolute=None,\n)\n
Bases: Title
"},{"location":"reference/api/titles/#unshackle.core.titles.Episode.matches_wanted","title":"matches_wanted","text":"matches_wanted(wanted)\n
Whether a parsed -w key set selects this episode.
A part-ful episode answers to both its base key and its part key, so -w s1e1 takes every part. A dated episode also answers to its ISO air date. ! keys are part-qualified exclusions resolved here.
"},{"location":"reference/api/titles/#unshackle.core.titles.Movie","title":"Movie","text":"Movie(\n id_,\n service,\n name,\n year=None,\n language=None,\n data=None,\n description=None,\n)\n
Bases: Title
"},{"location":"reference/api/titles/#unshackle.core.titles.Album","title":"Album","text":"Album(\n iterable=None,\n kind=\"album\",\n title=None,\n artist=None,\n year=None,\n total_tracks=None,\n total_discs=None,\n artwork_url=None,\n total_duration=None,\n owner=None,\n description=None,\n)\n
Bases: Music
Backward-compatible collection name for album-style music releases.
"},{"location":"reference/api/titles/#unshackle.core.titles.Music","title":"Music","text":"Music(\n iterable=None,\n kind=\"album\",\n title=None,\n artist=None,\n year=None,\n total_tracks=None,\n total_discs=None,\n artwork_url=None,\n total_duration=None,\n owner=None,\n description=None,\n)\n
Bases: SortedKeyList, ABC
A grouped music release, such as an album, EP, single, compilation, or playlist.
"},{"location":"reference/api/titles/#unshackle.core.titles.Song","title":"Song","text":"Song(\n id_,\n service,\n name,\n artist,\n album,\n track,\n disc,\n year,\n language=None,\n data=None,\n album_artist=None,\n release_type=\"album\",\n total_tracks=None,\n total_discs=None,\n genre=None,\n explicit=None,\n isrc=None,\n upc=None,\n copyright=None,\n label=None,\n lyrics=None,\n artwork_url=None,\n)\n
Bases: Title
"},{"location":"reference/api/titles/#unshackle.core.titles.remap_titles","title":"remap_titles","text":"remap_titles(titles, title_map)\n
Rewrite titles in-place using an exact-match title_map.
Some services name a title differently from how the user wants it stored, which can break library matching. title_map maps a source title string to the desired output title. Episodes are matched on their title (the show name), Movies and Songs on their name. Returns the same collection for convenient chaining.
"},{"location":"reference/api/tracks/","title":"Tracks API","text":"Auto-generated reference for the track model: Tracks, Track, and the media-specific subclasses. See Service Architecture for how these fit together.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Attachment","title":"Attachment","text":"Attachment(\n path=None,\n url=None,\n name=None,\n mime_type=None,\n description=None,\n session=None,\n)\n
Create a new Attachment.
If providing a path, the file must already exist. If providing a URL, download() fetches the file during the download phase. Either path or url must be provided.
If name is not provided it will use the file name (without extension). If mime_type is not provided, it will try to guess it.
Parameters:
Name Type Description Default path Union[Path, str, None] Path to an existing file.
None url Optional[str] URL to download the attachment from.
None name Optional[str] Name of the attachment.
None mime_type Optional[str] MIME type of the attachment.
None description Optional[str] Description of the attachment.
None session Optional[AnySession] Optional requests session to use for downloading.
None"},{"location":"reference/api/tracks/#unshackle.core.tracks.Attachment.id","title":"id property","text":"id\n
Compute an ID from the attachment data.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Attachment.download","title":"download","text":"download(session=None, *, no_proxy_download=False)\n
Download a URL-backed attachment to the temp directory.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Attachment.to_dict","title":"to_dict","text":"to_dict()\n
Serialise a URL-backed attachment for export/import.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Attachment.from_url","title":"from_url classmethod","text":"from_url(\n url,\n name=None,\n mime_type=None,\n description=None,\n session=None,\n)\n
Create an attachment from a URL.
Parameters:
Name Type Description Default url str URL to download the attachment from.
required name Optional[str] Name of the attachment.
None mime_type Optional[str] MIME type of the attachment.
None description Optional[str] Description of the attachment.
None session Optional[AnySession] Optional requests session to use for downloading.
None Returns:
Name Type Description Attachment 'Attachment' A new attachment instance.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Audio","title":"Audio","text":"Audio(\n *args,\n codec=None,\n bitrate=None,\n channels=None,\n joc=None,\n descriptive=False,\n **kwargs,\n)\n
Bases: Track
Create a new Audio track object.
Parameters:
Name Type Description Default codec Optional[Codec] An Audio.Codec enum representing the audio codec. If not specified, MediaInfo will be used to retrieve the codec once the track has been downloaded.
None bitrate Optional[Union[str, int, float]] A number or float representing the average bandwidth in bytes/s. Float values are rounded up to the nearest integer.
None channels Optional[Union[str, int, float]] A number, float, or string representing the number of audio channels. Strings may represent numbers or floats. Expanded layouts like 7.1.1 is not supported. All numbers and strings will be cast to float.
None joc Optional[int] The number of Joint-Object-Coding Channels/Objects in the audio stream.
None descriptive Union[bool, int] Mark this audio as being descriptive audio for the blind.
False Note: If codec, bitrate, channels, or joc is not specified some checks may be skipped or assume a value. Specifying as much information as possible is highly recommended.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Audio.atmos","title":"atmos property","text":"atmos\n
Return True if the audio track contains Dolby Atmos.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Audio.parse_channels","title":"parse_channels staticmethod","text":"parse_channels(channels)\n
Converts a Channel string to a float representing audio channel count and layout. E.g. \"3\" -> \"3.0\", \"2.1\" -> \"2.1\", \".1\" -> \"0.1\".
This does not validate channel strings as genuine channel counts or valid layouts. It does not convert the value to assume a sub speaker channel layout, e.g. 5.1->6.0. It also does not support expanded surround sound channel layout strings like 7.1.2.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Audio.get_track_name","title":"get_track_name","text":"get_track_name()\n
Return the base Track Name.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Chapter","title":"Chapter","text":"Chapter(timestamp, name=None)\n
Create a new Chapter with a Timestamp and optional name.
The timestamp may be in the following formats: - \"HH:MM:SS\" string, e.g., 25:05:23. - \"HH:MM:SS.mss\" string, e.g., 25:05:23.120. - a timecode integer in milliseconds, e.g., 90323120 is 25:05:23.120. - a timecode float in seconds, e.g., 90323.12 is 25:05:23.120.
If you have a timecode integer in seconds, just multiply it by 1000. If you have a timecode float in milliseconds (no decimal value), just convert it to an integer.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Chapter.id","title":"id property","text":"id\n
Compute an ID from the Chapter data.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Chapter.named","title":"named property","text":"named\n
Check if Chapter is named.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Chapters","title":"Chapters","text":"Chapters(iterable=None)\n
Bases: SortedKeyList, ABC
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Chapters.id","title":"id property","text":"id\n
Compute an ID from the Chapter data.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Chapters.loads","title":"loads classmethod","text":"loads(data)\n
Load chapter data from a string.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Chapters.load","title":"load classmethod","text":"load(path)\n
Load chapter data from a file.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Chapters.dumps","title":"dumps","text":"dumps(fallback_name='')\n
Return chapter data in OGM-based Simple Chapter format. https://mkvtoolnix.download/doc/mkvmerge.html#mkvmerge.chapters.simple
Parameters:
Name Type Description Default fallback_name str Name used for Chapters without a Name set.
'' The fallback name can use the following variables in f-string style:
- {i}: The Chapter number starting at 1. E.g.,
\"Chapter {i}\": \"Chapter 1\", \"Intro\", \"Chapter 3\". - {j}: A number starting at 1 that increments any time a Chapter has no name. E.g.,
\"Chapter {j}\": \"Chapter 1\", \"Intro\", \"Chapter 2\".
These are formatted with f-strings, directives are supported. For example, \"Chapter {i:02}\" will result in \"Chapter 01\".
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Chapters.dump","title":"dump","text":"dump(path, *args, **kwargs)\n
Write chapter data in OGM-based Simple Chapter format to a file.
Parameters:
Name Type Description Default path Union[Path, str] The file path to write the Chapter data to, overwriting any existing data.
required See Chapters.dumps for more parameter documentation.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Hybrid","title":"Hybrid","text":"Hybrid(videos, source)\n
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Hybrid.log","title":"log instance-attribute","text":"log = logging.getLogger('hybrid')\n
Takes the Dolby Vision and HDR10(+) streams out of the VideoTracks. It will then attempt to inject the Dolby Vision metadata layer to the HDR10(+) stream. If no DV track is available but HDR10+ is present, it will convert HDR10+ to DV.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Hybrid.level_5","title":"level_5","text":"level_5(input_video)\n
Generate Level 5 active area metadata via crop detection on the HDR10 stream.
This resolves mismatches where DV has no black bars but HDR10 does (or vice versa) by telling the display the correct active area.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Hybrid.sanitize_l6","title":"sanitize_l6 staticmethod","text":"sanitize_l6(max_mdl, min_mdl, max_cll, max_fall)\n
Clamp static L6 values to a valid relationship.
MaxCLL must not exceed the mastering-display peak (some HDR10+ sources ship MaxCLL 10000 on a 1000-nit master), and MaxFALL must not exceed MaxCLL. A value of 0 means \"unknown\" and is preserved as-is.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Hybrid.level_6","title":"level_6","text":"level_6()\n
Edit RPU Level 6 values using the static L6 luminance data from the RPU.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Hybrid.extract_hdr10plus","title":"extract_hdr10plus","text":"extract_hdr10plus(_video)\n
Extract HDR10+ metadata from the video stream
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Hybrid.convert_hdr10plus_to_dv","title":"convert_hdr10plus_to_dv","text":"convert_hdr10plus_to_dv()\n
Convert HDR10+ metadata to Dolby Vision RPU
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle","title":"Subtitle","text":"Subtitle(\n *args,\n codec=None,\n cc=False,\n sdh=False,\n forced=False,\n **kwargs,\n)\n
Bases: Track
Create a new Subtitle track object.
Parameters:
Name Type Description Default codec Optional[Codec] A Subtitle.Codec enum representing the subtitle format. If not specified, MediaInfo will be used to retrieve the format once the track has been downloaded.
None cc bool Closed Caption. - Intended as if you couldn't hear the audio at all. - Can have Sound as well as Dialogue, but doesn't have to. - Original source would be from an EIA-CC encoded stream. Typically all upper-case characters. Indicators of it being CC without knowing original source: - Extracted with CCExtractor, or - >>> (or similar) being used at the start of some or all lines, or - All text is uppercase or at least the majority, or - Subtitles are Scrolling-text style (one line appears, oldest line then disappears). Just because you downloaded it as a SRT or VTT or such, doesn't mean it isn't from an EIA-CC stream. And I wouldn't take the streaming services (CC) as gospel either as they tend to get it wrong too.
False sdh bool Deaf or Hard-of-Hearing. Also known as HOH in the UK (EU?). - Intended as if you couldn't hear the audio at all. - MUST have Sound as well as Dialogue to be considered SDH. - It has no \"syntax\" or \"format\" but is not transmitted using archaic forms like EIA-CC streams, would be intended for transmission via SubRip (SRT), WebVTT (VTT), TTML, etc. If you can see important audio/sound transcriptions and not just dialogue and it doesn't have the indicators of CC, then it's most likely SDH. If it doesn't have important audio/sounds transcriptions it might just be regular subtitling (you wouldn't mark as CC or SDH). This would be the case for most translation subtitles. Like Anime for example.
False forced bool Typically used if there's important information at some point in time like watching Dubbed content and an important Sign or Letter is shown or someone talking in a different language. Forced tracks are recommended by the Matroska Spec to be played if the player's current playback audio language matches a subtitle marked as \"forced\". However, that doesn't mean every player works like this but there is no other way to reliably work with Forced subtitles where multiple forced subtitles may be in the output file. Just know what to expect with \"forced\" subtitles.
False Note: If codec is not specified some checks may be skipped or assume a value. Specifying as much information as possible is highly recommended.
Information on Subtitle Types https://bit.ly/2Oe4fLC (3PlayMedia Blog on SUB vs CC vs SDH). However, I wouldn't pay much attention to the claims about SDH needing to be in the original source language. It's logically not true.
CC == Closed Captions. Source: Basically every site. SDH = Subtitles for the Deaf or Hard-of-Hearing. Source: Basically every site. HOH = Exact same as SDH. Is a term used in the UK. Source: https://bit.ly/2PGJatz (ICO UK)
More in-depth information, examples, and stuff to look for can be found in the Parameter explanation list above.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.get_track_name","title":"get_track_name","text":"get_track_name()\n
Return the base Track Name.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.strip_webvtt_timestamp_map","title":"strip_webvtt_timestamp_map staticmethod","text":"strip_webvtt_timestamp_map(text)\n
Remove X-TIMESTAMP-MAP header lines (RFC 8216 \u00a73.5, HLS only).
Cue times are already absolute, but SubtitleEdit reads the header and offsets every cue by it during conversion.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.sanitize_webvtt_timestamps","title":"sanitize_webvtt_timestamps staticmethod","text":"sanitize_webvtt_timestamps(text)\n
Fix invalid timestamps in WebVTT files, particularly negative timestamps.
Parameters:
Name Type Description Default text str The WebVTT content as string
required Returns:
Type Description str Sanitized WebVTT content
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.has_webvtt_cue_identifiers","title":"has_webvtt_cue_identifiers staticmethod","text":"has_webvtt_cue_identifiers(text)\n
Check if WebVTT content has cue identifiers that need removal.
Parameters:
Name Type Description Default text str The WebVTT content as string
required Returns:
Type Description bool True if cue identifiers are detected, False otherwise
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.sanitize_webvtt_cue_identifiers","title":"sanitize_webvtt_cue_identifiers staticmethod","text":"sanitize_webvtt_cue_identifiers(text)\n
Remove WebVTT cue identifiers that can confuse subtitle parsers.
Some services use cue identifiers like \"Q0\", \"Q1\", etc. that appear on their own line before the timing line. These can be incorrectly parsed as part of the previous cue's text content by some parsers (like pysubs2).
Parameters:
Name Type Description Default text str The WebVTT content as string
required Returns:
Type Description str Sanitized WebVTT content with cue identifiers removed
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.has_overlapping_webvtt_cues","title":"has_overlapping_webvtt_cues staticmethod","text":"has_overlapping_webvtt_cues(text)\n
Check if WebVTT content has overlapping cues that need merging.
Detects cues with start times within 50ms of each other and the same end time, which indicates multi-line subtitles split into separate cues.
Parameters:
Name Type Description Default text str The WebVTT content as string
required Returns:
Type Description bool True if overlapping cues are detected, False otherwise
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.merge_overlapping_webvtt_cues","title":"merge_overlapping_webvtt_cues staticmethod","text":"merge_overlapping_webvtt_cues(text)\n
Merge WebVTT cues that have overlapping/near-identical times but different line positions.
Some services use separate cues for each line of a multi-line subtitle, with slightly different start times (1ms apart) and different line: positions. This merges them into single cues with proper line ordering based on the line: position (lower percentage = higher on screen = first line).
Parameters:
Name Type Description Default text str The WebVTT content as string
required Returns:
Type Description str WebVTT content with overlapping cues merged
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.sanitize_webvtt","title":"sanitize_webvtt staticmethod","text":"sanitize_webvtt(text)\n
More thorough sanitization of WebVTT files to handle multiple potential issues.
Parameters:
Name Type Description Default text str The WebVTT content as string
required Returns:
Type Description str Sanitized WebVTT content
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.convert","title":"convert","text":"convert(codec, *, forced=False)\n
Convert this Subtitle to another format.
Backend selection is data-driven (see tracks/subtitle_convert.py): the best available backend that supports source->target is used, falling back through the capability chain on failure. The backend can be pinned via the conversion_method config key (auto | subby | pysubs2 | subtitleedit | pycaption), or nudged per-service via preferred_conversion_method; an explicit config value always wins.
forced marks an explicit user request (--sub-format). Lossy downconverts of styled formats (SSA/ASS -> SRT) are skipped unless forced is True.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.extract_fonts","title":"extract_fonts staticmethod","text":"extract_fonts(text)\n
Font names referenced by an ASS/SSA subtitle.
Covers both sources that need attaching for correct rendering: - the Fontname column of every Style: line in [V4+ Styles]/[V4 Styles] (column located from the section's Format: line, not assumed by index), and - inline \\fn font overrides inside Dialogue override blocks.
Leading @ (vertical-writing prefix) is stripped and names are de-duplicated case-insensitively, preferring a mixed-case spelling over an all-lowercase one.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.sanitize_broken_webvtt","title":"sanitize_broken_webvtt staticmethod","text":"sanitize_broken_webvtt(text)\n
Remove or fix corrupted WebVTT lines, particularly those with invalid timestamps.
Parameters:
Name Type Description Default text str The WebVTT content as string
required Returns:
Type Description str Sanitized WebVTT content with corrupted lines removed
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.space_webvtt_headers","title":"space_webvtt_headers staticmethod","text":"space_webvtt_headers(data)\n
Space out the WEBVTT Headers from Captions.
Segmented VTT when merged may have the WEBVTT headers part of the next caption as they were not separated far enough from the previous caption and ended up being considered as caption text rather than the header for the next segment.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.merge_same_cues","title":"merge_same_cues staticmethod","text":"merge_same_cues(caption_set)\n
Merge captions with the same timecodes and text as one in-place.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.filter_unwanted_cues","title":"filter_unwanted_cues staticmethod","text":"filter_unwanted_cues(caption_set)\n
Filter out subtitle cues containing only \u00a0 or whitespace.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.merge_segmented_wvtt","title":"merge_segmented_wvtt staticmethod","text":"merge_segmented_wvtt(data, period_start=0.0)\n
Convert Segmented DASH WebVTT cues into a pycaption Caption List. Also returns an ISO 639-2 alpha-3 language code if available.
Code ported originally by xhlove to Python from shaka-player. Has since been improved upon by rlaphoenix using pymp4 and pycaption functions.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.strip_hearing_impaired","title":"strip_hearing_impaired","text":"strip_hearing_impaired()\n
Strip captions for hearing impaired (SDH).
The SDH stripping method is determined by the 'sdh_method' setting in config: - 'auto' (default): Tries subby first, then SubtitleEdit, then filter-subs - 'subby': Uses subby's SDHStripper - 'subtitleedit': Uses SubtitleEdit when available - 'filter-subs': Uses subtitle-filter library
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.reverse_rtl","title":"reverse_rtl","text":"reverse_rtl()\n
Reverse RTL (Right to Left) Start/End on Captions. This can be used to fix the positioning of sentence-ending characters.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.DownloadContext","title":"DownloadContext dataclass","text":"DownloadContext(\n save_path,\n save_dir,\n progress,\n session=None,\n proxy=None,\n max_workers=None,\n license_widevine=None,\n cdm=None,\n)\n
Shared arguments passed to each manifest's download_track.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.DownloadContext.ensure_session","title":"ensure_session","text":"ensure_session()\n
Return the session, or a new Session if none was set.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Track","title":"Track","text":"Track(\n url,\n language,\n is_original_lang=False,\n descriptor=URL,\n needs_repack=False,\n name=None,\n drm=None,\n edition=None,\n session=None,\n downloader=None,\n downloader_args=None,\n from_file=None,\n data=None,\n id_=None,\n extra=None,\n)\n
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Track.data","title":"data property writable","text":"data\n
Arbitrary track data dictionary.
A defaultdict is used with a dict as the factory for easier nested saving and safer exists-checks.
Reserved keys:
- \"hls\" used by the HLS class.
- playlist: m3u8.model.Playlist - The primary track information.
- media: m3u8.model.Media - The audio/subtitle track information.
- segment_durations: list[int] - A list of each segment's duration.
- \"dash\" used by the DASH class.
- manifest: lxml.ElementTree - DASH MPD manifest.
- period: lxml.Element - The period of this track.
- adaptation_set: lxml.Element - The adaptation set of this track.
- representation: lxml.Element - The representation of this track.
- timescale: int - The timescale of the track's segments.
- segment_durations: list[int] - A list of each segment's duration.
You should not add, change, or remove any data within reserved keys. You may use their data but do note that the values of them may change or be removed at any point.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Track.download","title":"download","text":"download(\n session,\n prepare_drm,\n max_workers=None,\n progress=None,\n *,\n cdm=None,\n no_proxy_download=False,\n)\n
Download and optionally Decrypt this Track.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Track.move","title":"move","text":"move(target)\n
Move the Track's file from current location, to target location. This will overwrite anything at the target path.
Raises:
Type Description TypeError If the target argument is not the expected type.
ValueError If track has no file to move, or the target does not exist.
OSError If the file somehow failed to move.
Returns the new location of the track.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Track.to_dict","title":"to_dict","text":"to_dict()\n
Serialise the track for export/import (identity/URL/descriptor/language).
DRM is not serialised here; the export writer attaches the licensed DRM + keys. Subclasses add their own codec/quality fields.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Track.base_kwargs_from_dict","title":"base_kwargs_from_dict staticmethod","text":"base_kwargs_from_dict(data)\n
Build the shared Track constructor kwargs from a to_dict() payload.
DRM is not reconstructed here \u2014 to_dict does not serialise it, and the import flow attaches the licensed DRM + content keys separately.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Track.from_dict","title":"from_dict classmethod","text":"from_dict(data)\n
Reconstruct the correct Track subclass from a to_dict() payload.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Track.get_track_name","title":"get_track_name","text":"get_track_name()\n
Get the Track Name.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Track.get_drm_for_cdm","title":"get_drm_for_cdm","text":"get_drm_for_cdm(cdm)\n
Return the DRM matching the provided CDM, if available.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Track.get_key_id","title":"get_key_id","text":"get_key_id(init_data=None, *args, **kwargs)\n
Probe the DRM encryption Key ID (KID) for this specific track.
It currently supports finding the Key ID by probing the track's stream with ffprobe for enc_key_id data, as well as for mp4 tenc (Track Encryption) boxes.
It explicitly ignores PSSH information like the PSSH box, as the box is likely to contain multiple Key IDs that may or may not be for this specific track.
To retrieve the initialization segment, this method calls :meth:get_init_segment with the positional and keyword arguments. The return value of get_init_segment is then used to determine the Key ID.
Returns:
Type Description Optional[UUID] The Key ID as a UUID object, or None if the Key ID could not be determined.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Track.load_drm_if_needed","title":"load_drm_if_needed","text":"load_drm_if_needed(service=None)\n
Load DRM information for this track if it was deferred during parsing.
Parameters:
Name Type Description Default service Service | None Service instance that can fetch track-specific DRM info
None Returns:
Type Description bool True if DRM was loaded or already present, False if failed
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Track.load_drm_from_playlist","title":"load_drm_from_playlist","text":"load_drm_from_playlist()\n
Fallback method to load DRM by fetching this track's individual playlist.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Track.get_init_segment","title":"get_init_segment","text":"get_init_segment(\n maximum_size=20000,\n url=None,\n byte_range=None,\n session=None,\n)\n
Get the Track's Initial Segment Data Stream.
HLS and DASH tracks must explicitly provide a URL to the init segment or file. Providing the byte-range for the init segment is recommended where possible.
If byte_range is not set, it will make a HEAD request and check the size of the file. If the size could not be determined, it will download up to the first 20KB only, which should contain the entirety of the init segment. You may override this by changing the maximum_size.
The default maximum_size of 20000 (20KB) is a tried-and-tested value that seems to work well across the board.
Parameters:
Name Type Description Default maximum_size int Size to assume as the content length if byte-range is not used, the content size could not be determined, or the content size is larger than it. A value of 20000 (20KB) or higher is recommended.
20000 url Optional[str] Explicit init map or file URL to probe from.
None byte_range Optional[str] Range of bytes to download from the explicit or implicit URL.
None session Optional[Session] Session context, e.g., authorization and headers.
None"},{"location":"reference/api/tracks/#unshackle.core.tracks.Track.repackage","title":"repackage","text":"repackage(bsf_v=None)\n
Remux the track with ffmpeg -c copy.
When bsf_v is given it is folded into the same pass as -bsf:v (used to normalize video VUI colour metadata without a second full-file remux). Repackaging is mandatory; the bitstream filter is best-effort. If the combined run fails (and it isn't the AAC-retry case) it is retried once without bsf_v so the remux still succeeds. Returns True if the requested bsf_v was applied (always False when bsf_v is None, since nothing was requested).
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Tracks","title":"Tracks","text":"Tracks(*args, manifest_url=None)\n
Video, Audio, Subtitle, Chapter, and Attachment Track Store. It provides convenience functions for listing, sorting, and selecting tracks.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Tracks.exists","title":"exists","text":"exists(by_id=None, by_url=None)\n
Check if a track already exists by various methods.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Tracks.add","title":"add","text":"add(tracks, warn_only=False)\n
Add a provided track to its appropriate array and ensuring it's not a duplicate.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Tracks.sort_videos","title":"sort_videos","text":"sort_videos(by_language=None, exact_match=False)\n
Sort video tracks by resolution then bitrate, and optionally language.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Tracks.sort_audio","title":"sort_audio","text":"sort_audio(\n by_language=None, codec_priority=None, exact_match=False\n)\n
Sort audio tracks by bitrate, codec priority, Atmos, descriptive, and optionally language.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Tracks.sort_subtitles","title":"sort_subtitles","text":"sort_subtitles(\n by_language=None,\n type_priority=None,\n group_by=None,\n exact_match=False,\n)\n
Sort subtitle tracks by various track attributes to a common P2P standard. You may optionally provide a sequence of languages to prioritize to the top.
Section Order - by_language groups prioritized to top, and ascending alphabetically
- then rest ascending alphabetically after the prioritized groups (Each section ascending alphabetically, but separated)
Type Order - Forced
- Normal
- Hard of Hearing (SDH/CC) (Least to most captions expected in the subtitle)
type_priority overrides the Type Order with an explicit ranking of \"forced\", \"normal\", and \"sdh\" (cc counts as sdh); unlisted types fall to the end.
group_by sets the major key. \"type\" (default) keeps every forced track together, then every normal, then every SDH, each block ascending by language. \"language\" groups by language instead, so Finnish sits next to Finnish SDH, with the Type Order applied inside each language.
exact_match makes a by_language entry sort only its own tag. By default \"en\" also sorts \"en-US\" and \"en-GB\".
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Tracks.filter","title":"filter","text":"filter(predicate)\n
Return a new Tracks with tracks filtered by predicate, preserving metadata.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Tracks.merge_video_selections","title":"merge_video_selections staticmethod","text":"merge_video_selections(*groups)\n
Concatenate video selections, dropping duplicates (by track id, order-preserving).
A DV track can be chosen as both the hybrid ingredient (lowest) and an explicit deliverable; without dedup the same track would be muxed/downloaded twice.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Tracks.partition_hybrid_videos","title":"partition_hybrid_videos staticmethod","text":"partition_hybrid_videos(videos, non_hybrid_ranges)\n
Split videos into hybrid-ingredient candidates and the standalone-deliverable pool.
HDR10/HDR10+/DV tracks are hybrid ingredients; they only enter the standalone pool when their range was explicitly requested alongside HYBRID, so e.g. -r HYBRID muxes only the hybrid while -r HYBRID,HDR10P also delivers HDR10+.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Tracks.flag_hybrid_ingredients","title":"flag_hybrid_ingredients staticmethod","text":"flag_hybrid_ingredients(\n hybrid_selected, non_hybrid_selected\n)\n
Mark tracks selected only as hybrid ingredients so the standalone mux loop skips them.
A track that was also picked as an explicit deliverable (same track in both selections) stays unflagged and is muxed standalone alongside the hybrid.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Tracks.mux","title":"mux","text":"mux(\n title,\n delete=True,\n progress=None,\n audio_expected=True,\n title_language=None,\n skip_subtitles=False,\n output_path=None,\n)\n
Multiplex all the Tracks into a Matroska Container file.
Parameters:
Name Type Description Default title str Set the Matroska Container file title. Usually displayed in players instead of the filename if set.
required delete bool Delete all track files after multiplexing.
True progress Optional[partial] Update a rich progress bar via completed=.... This must be the progress object's update() func, pre-set with task id via functools.partial.
None audio_expected bool Whether audio is expected in the output. Used to determine if embedded audio metadata should be added.
True title_language Optional[Language] The title's intended language. Used to select the best video track for audio metadata when multiple video tracks exist.
None skip_subtitles bool Skip muxing subtitle tracks into the container.
False output_path Optional[Path] Explicit destination for the muxed container. When None (default) the path is derived from the first track, so callers muxing several track groups that share a video list must pass distinct paths to avoid clobbering each other.
None"},{"location":"reference/api/tracks/#unshackle.core.tracks.Video","title":"Video","text":"Video(\n *args,\n codec=None,\n range_=None,\n bitrate=None,\n width=None,\n height=None,\n fps=None,\n scan_type=None,\n closed_captions=None,\n dv_compatible_bitstream=False,\n **kwargs,\n)\n
Bases: Track
Create a new Video track object.
Parameters:
Name Type Description Default codec Optional[Codec] A Video.Codec enum representing the video codec. If not specified, MediaInfo will be used to retrieve the codec once the track has been downloaded.
None range_ Optional[Range] A Video.Range enum representing the video color range. Defaults to SDR if not specified.
None bitrate Optional[Union[str, int, float]] A number or float representing the average bandwidth in bytes/s. Float values are rounded up to the nearest integer.
None width Optional[int] The horizontal resolution of the video.
None height Optional[int] The vertical resolution of the video.
None fps Optional[Union[str, int, float]] A number, float, or string representing the frames/s of the video. Strings may represent numbers, floats, or a fraction (num/den). All strings will be cast to either a number or float.
None Note: If codec, bitrate, width, height, or fps is not specified some checks may be skipped or assume a value. Specifying as much information as possible is highly recommended.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Video.Range","title":"Range","text":" Bases: str, Enum
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Video.Range.from_cicp","title":"from_cicp staticmethod","text":"from_cicp(primaries, transfer, matrix)\n
Convert CICP (Coding-Independent Code Points) values to Video Range.
CICP is defined in ITU-T H.273 and ISO/IEC 23091-2 for signaling video color properties independently of the compression codec. These values are used across AVC (H.264), HEVC (H.265), VVC, AV1, and other modern codecs.
The enum values (Primaries, Transfer, Matrix) match the official specifications: - ITU-T H.273: Coding-independent code points for video signal type identification - ISO/IEC 23091-2: Information technology \u2014 Coding-independent code points \u2014 Part 2: Video - H.264 Table E-3 (Colour Primaries) and Table E-4 (Transfer Characteristics) - H.265 Table E.3 and E.4 (identical to H.264)
Note: Value 0 = \"Reserved\" and Value 2 = \"Unspecified\" per specification. While both effectively mean \"unknown\" in practice, the distinction matters for spec compliance. Value 2 was added based on user feedback (GitHub issue) and verified against FFmpeg's AVColorPrimaries/AVColorTransferCharacteristic enums.
Sources: - https://www.itu.int/rec/T-REC-H.273 - https://www.itu.int/rec/T-REC-H.Sup19-202104-I - https://github.com/FFmpeg/FFmpeg/blob/master/libavutil/pixfmt.h
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Video.change_color_range","title":"change_color_range","text":"change_color_range(range_)\n
Change the Video's Color Range to Limited (0) or Full (1).
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Video.vui_bsf","title":"vui_bsf","text":"vui_bsf()\n
Return the -bsf:v value that rewrites SPS VUI colour metadata to match self.range.
Returns None when no rewrite is needed: SDR/DV/HYBRID ranges, non-AVC/HEVC codecs, a missing file, or when the bitstream already ships the correct colour metadata. Otherwise returns a {h264,hevc}_metadata=... string suitable for ffmpeg -bsf:v.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Video.normalize_vui","title":"normalize_vui","text":"normalize_vui()\n
Rewrite SPS VUI colour metadata to match self.range.
Some services ship HDR10/HLG bitstreams with stale BT.709 VUI, which makes downstream tools mis-classify the file. The manifest-derived range is the source of truth. Skips SDR, DV, and HYBRID. Returns True if the bitstream was rewritten.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Video.ccextractor","title":"ccextractor","text":"ccextractor(track_id, out_path, language, original=False)\n
Return a TextTrack object representing CC track extracted by CCExtractor.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Video.extract_c608","title":"extract_c608","text":"extract_c608()\n
Extract Apple-Style c608 box (CEA-608) subtitle using ccextractor.
This isn't much more than a wrapper to the track.ccextractor function. All this does, is actually check if a c608 box exists and only if so does it actually call ccextractor.
Even though there is a possibility of more than one c608 box, only one can actually be extracted. Not only that but it's very possible this needs to be done before any decryption as the decryption may destroy some of the metadata.
Need a test file with more than one c608 box to add support for more than one CEA-608 extraction.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Video.remove_eia_cc","title":"remove_eia_cc","text":"remove_eia_cc()\n
Remove EIA-CC data from Bitstream while keeping SEI data.
This works by removing all NAL Unit's with the Type of 6 from the bistream and then re-adding SEI data (effectively a new NAL Unit with just the SEI data). Only bitstreams with x264 encoding information is currently supported due to the obscurity on the MDAT mp4 box structure. Therefore, we need to use hacky regex.
"},{"location":"reference/configuration/","title":"Configuration Reference","text":"Every key you can set in unshackle.yaml, with its name, type, default value, and behaviour, split into one page per area. A worked config is on the annotated example page.
Where the file lives, how unshackle discovers it, and how to edit it: The Configuration File.
How defaults work
Every key is optional except output_template, which dl requires. Without it, dl aborts with an error. If you omit any other key, unshackle uses the built-in default listed here. There is no schema validation: unknown keys are silently ignored (they never become settings), and the file is parsed with yaml.safe_load. An empty or missing file means every other key falls back to its default.
Two keys behave specially on load
curl_impersonate is a deprecated alias of network and emits a DeprecationWarning. scene_naming has been removed. If it is set to any value, unshackle exits immediately with an error telling you to use output_template instead.
See Deprecated & removed keys.
"},{"location":"reference/configuration/#quick-index","title":"Quick index","text":"Area Keys Directories directories, filenames Services & authentication services, credentials, firefox_cookies, remote_services, serve Download & processing dl, subtitle, audio, muxing, language_tags, dual_multi_mode Output & naming output_template, tag, tag_rules, tag_group_name, tag_imdb_tmdb, chapter_fallback_name, unicode_filenames DRM & CDM cdm, remote_cdm, decryption Network & proxy network, headers, proxy_providers Key vaults key_vaults, vault_timeout External API keys omdb_api_key, tmdb_api_key, tvdb_api_key, tvdb_pin, tvdb_order, simkl_client_id, metadata_providers, anilist_title_language, disable_metadata, decrypt_labs_api_key, ipinfo_api_key Caching & updates title_cache_enabled, title_cache_time, title_cache_max_retention, update_checks, update_check_interval Appearance theme Logging, privacy & debug redact_paths, debug, debug_keys, debug_requests, set_terminal_bg Deprecated & removed curl_impersonate, downloader, scene_naming"},{"location":"reference/configuration/directories/","title":"Directories","text":"The directories key is a dict mapping directory names to filesystem paths. Only the names listed below are honoured; a handful are protected and cannot be moved (attempts to override them are silently skipped). All defaults are computed relative to the installed package, and every user-settable path is passed through Path(...).expanduser(), so ~ works.
unshackle.yamldirectories:\n downloads: ~/Media/unshackle\n temp: /mnt/fast-scratch/unshackle-temp\n wvds: ~/.unshackle/WVDs\n prds: ~/.unshackle/PRDs\n
Key Type Default Overridable Purpose downloads path <repo>/downloads \u2705 Default output directory for finished files. temp path <repo>/temp \u2705 Temporary working files during download/decrypt/mux. cache path <data>/cache \u2705 Generic cache, title cache, and the update-check store. cookies path <data>/cookies \u2705 Per-service cookie files (and VPN cookie files). logs path <data>/logs \u2705 Log files. exports path <data>/exports \u2705 Export JSON files. wvds path <data>/WVDs \u2705 Widevine device files (.wvd). prds path <data>/PRDs \u2705 PlayReady device files (.prd). dcsl path <data>/DCSL \u2705 DCSL data. commands path unshackle/commands \u2705 CLI command modules. services list | path [unshackle/services] \u2705 Service search paths and/or remote repo specs (see below). vaults path unshackle/vaults \u2705 Vault backend modules. fonts path unshackle/fonts \u2705 Bundled fonts. user_configs path unshackle/ \u274c protected Where unshackle.yaml lives. data path unshackle/ \u274c protected Base for the data subdirectories above. core_dir path unshackle/core \u274c protected Package core. namespace_dir path unshackle/ \u274c protected Package root. app_dirs - AppDirs(\"unshackle\", False) \u274c protected Internal AppDirs instance. The services directory is special
services may be a list, and each entry can be either a local directory or a repository spec: a git URL (https://..., ssh://..., git@..., or anything ending in .git) or owner/repo shorthand. Repo specs are cloned and updated automatically; plain paths are used as-is. List order is priority: the first source to define a service tag wins.
directories:\n services:\n - you/your-services # cloned from GitHub\n - https://example.com/private.git@stable\n - ~/my-services # local, lowest priority\n
How git-backed service repos are handled
unshackle's use of git is read-only on the remote: it only ever performs a shallow clone, fetch, pull, and a local reset; nothing is ever pushed. Private repos are authenticated through your existing git credential helper, and unshackle stores no tokens of its own. Clones live under <first-local-services-dir>/_repos/<host>__<owner>__<repo>/ (or the bundled unshackle/services), and nothing is written to the cache directory. After the first clone unshackle re-pulls at most once every 24 h, so it does not touch the network on every run.
Automatic and manual refresh behave differently
The automatic 24 h-TTL refresh that happens during a normal dl/search run refuses to refresh and exits (naming the offending clone) if that clone has uncommitted changes to tracked files or unpushed local commits. Untracked files (new service folders, __pycache__) never block it. The manual unshackle util refresh-services command instead hard-resets the clone to upstream, discarding any local edits.
Read-only installs and reinstalls
If the services directory lives inside the installed package, a reinstall can delete the _repos clones. They are re-cloned on next use. On read-only installs you must point services at a writable path, or cloning will fail.
"},{"location":"reference/configuration/directories/#filenames","title":"Filenames","text":"The filenames key is a dict of templated file/name patterns. Each value is used verbatim (no path processing). Braced fields like {time} and {service} are filled in at runtime.
Key Type Default Notes log str \"unshackle_{name}_{time}.log\" Written under directories.logs. debug_log str \"unshackle_debug_{service}_{time}.jsonl\" Structured debug log, under directories.logs. config str \"config.yaml\" Per-service config file, under that service's directory. root_config str \"unshackle.yaml\" The main config filename itself. chapters str \"Chapters_{title}_{random}.txt\" Under directories.temp. subtitle str \"Subtitle_{id}_{language}.srt\" Under directories.temp."},{"location":"reference/configuration/download/","title":"Download & processing","text":""},{"location":"reference/configuration/download/#dl","title":"dl","text":" - Type:
dict \u00a0\u00b7\u00a0 Default: {}
Persistent defaults for the dl command. The block is used as Click's default_map, so any dl flag can be given a default here. See Downloading and the CLI Reference for what each flag does.
Key naming. A key is the flag's long name with dashes replaced by underscores (--best-available \u2192 best_available, --sub-format \u2192 sub_format). A few keys are named after the flag's internal destination rather than its visible name; set these exact keys:
Flag Config key -r / --range range --list list --tmdb tmdb_id --imdb imdb_id --anilist anilist_id -naa / --noatmos no_atmos -o / --output output_dir --cdm-only / --vaults-only cdm_only Precedence (highest first): an explicit CLI flag \u2192 a per-service services.<TAG>.dl override \u2192 this global dl: block \u2192 the built-in default. In other words, config only fills in options you did not set on the command line.
Common keys (a useful subset; every dl flag works):
Key Type Default Sets lang list [\"orig\"] Video/audio language(s); orig = original language. A - prefix excludes, e.g. [all, -es]. a_lang / v_lang list [] Audio- / video-only language override. A - prefix excludes. s_lang list [\"all\"] Subtitle language(s). A - prefix excludes, e.g. [all, -es]. quality list [] (best) Resolution(s), e.g. [1080]. vcodec list [] (any) Video codec(s), e.g. [H265]. acodec list [] (any) Audio codec(s), e.g. [EC3]. range list [\"SDR\"] Colour range(s): SDR, HLG, HDR10, HDR10P, DV, HYBRID. channels float (unset) Audio channels, e.g. 6 for 5.1. sub_format str (unset) Convert subtitles to this format (srt, vtt, original, ...). forced_subs bool false Include forced subtitle tracks. forced_s_lang list [] Forced subtitle language(s); implies forced_subs. A - prefix excludes. no_subs / no_audio / no_chapters bool false Skip that track type. downloads int 1 Tracks downloaded concurrently. workers int (downloader default) Threads per track. speed_limit str (unlimited) Total download speed cap across all downloads combined, e.g. 500k, 5M, 1.5G or plain bytes/sec. Values are bytes, not bits (5M = 5.0 MB/s). slow str (unset) Inter-title delay, e.g. \"20-40\". best_available bool false Fall back to best quality if the request is unavailable. proxy str (unset) Default proxy URI or 2-letter country. no_folder bool false Do not create a per-title folder. Global download defaultsdl:\n lang: [en]\n quality: [1080]\n range: [HDR10]\n vcodec: [H265]\n sub_format: srt\n downloads: 2\n
Language exclusions in YAML
The language keys accept the same - exclusion tokens as the command line. Give them as a list or as one comma-separated string.
dl:\n s_lang: [all, -es]\n a_lang: \"orig,-es\"\n
See Excluding languages for the rules.
Per-service overrides
A dl block nested under a service in services takes the same keys and applies to that service only.
dl:\n lang: [en]\nservices:\n EXAMPLE1:\n dl:\n lang: [en, ja]\n range: [DV]\n
serve uses these same keys
The serve block accepts most of the same dl option keys at its top level to set server-wide download defaults for every request it handles. Keys the API does not know are ignored there (speed_limit, for example; serve has its own global_speed_limit).
"},{"location":"reference/configuration/download/#subtitle","title":"subtitle","text":" - Type:
dict \u00a0\u00b7\u00a0 Default: {}
Subtitle handling: SDH stripping, format conversion, and whether subtitles are muxed in or written as sidecar files. See Subtitles for the full guide.
Sub-key Type Default Description strip_sdh bool true Strip SDH/CC cues (hearing-impaired annotations) into a clean track. sdh_method str \"auto\" SDH-stripping backend to use. preserve_formatting bool true Keep styling/positioning when converting or stripping. convert_before_strip bool true Convert to the working format before stripping SDH. conversion_method str \"auto\" Subtitle conversion backend. output_mode str \"mux\" mux embeds subtitles in the MKV; sidecar writes separate files; both writes sidecars and still muxes. sidecar_format str \"srt\" Format for sidecar files when output_mode is sidecar or both. type_priority list (unset) Ordered ranking of subtitle types (forced, normal, sdh; CC counts as SDH). Types you leave out fall to the end. group_by str \"type\" Which key groups the subtitle tracks. type puts all forced tracks together, then all normal, then all SDH. language keeps each language next to its own variants. language_priority list (unset) Languages to sort to the top, in the order you give. The rest keep the default order: original language first, then alphabetical. Nothing is removed. subtitle:\n strip_sdh: true\n output_mode: sidecar\n sidecar_format: srt\n
group_by track order
group_by Result type Danish, Finnish, French, \u2026 Danish SDH, Finnish SDH, French SDH language Danish, Danish SDH, Finnish, Finnish SDH, French, French SDH type_priority still sets the order of the variants, so with group_by: language it controls the order inside each language.
language_priority sorts languages, it does not select them
subtitle:\n group_by: language\n language_priority: [en, es, fr]\n
Result for an English title Default English, Arabic, Bulgarian, Spanish, French, Japanese With the list above English, Spanish, French, Arabic, Bulgarian, Japanese Nothing is removed, unlike -sl, which drops every subtitle language it does not name.
Use orig in the list for the title's original language. A base tag also covers its regional forms, so en sorts en-US and en-GB, but zh does not cover zh-Hant, which you must name. The sort ignores entries that match no track, and it ignores entries that are not valid language tags.
--exact-lang applies here too: with it, en sorts only en, and en-US falls to the alphabetical tail with the rest. Name each tag you want when you use that flag.
type_priority picks the default subtitle only with muxing.default_language
At mux time the default flag goes to every forced subtitle whose language matches the first audio track, so a full dialogue track never becomes default on its own. type_priority alone does not change that. Name a subtitle language in muxing.default_language and the first track in that language gets the flag instead, which is where type_priority decides which track that is:
muxing:\n default_language:\n subtitle: en\nsubtitle:\n type_priority: [normal, sdh, forced]\n
"},{"location":"reference/configuration/download/#audio","title":"audio","text":" - Type:
dict \u00a0\u00b7\u00a0 Default: {}
Audio handling options. The most notable sub-key is codec_priority, an ordered list of audio codecs used to break ties in track selection. language_priority is an ordered list of languages that sort to the top of the audio tracks.
audio:\n codec_priority:\n - EAC3\n - AC3\n language_priority:\n - orig\n - en\n
codec_priority is a soft priority: nothing is dropped
codec_priority outranks bitrate: any listed codec sorts above every unlisted one, and among listed codecs the list order decides. Bitrate order survives only within a single codec rank. Nothing is ever removed. Two higher-level rules still override it: Atmos tracks always take precedence over codec_priority, and audio-description tracks are always forced to the end regardless of it. When codec_priority is unset, tracks sort by bitrate alone, with those same Atmos/descriptive rules still applied.
"},{"location":"reference/configuration/download/#muxing","title":"muxing","text":" - Type:
dict \u00a0\u00b7\u00a0 Default: {}
Matroska (MKV) muxing options.
Sub-key Type Default Description set_title bool true Write a human-readable title into the MKV container. default_language dict {} Force which language is flagged default per track type, e.g. {audio: en, subtitle: en}. merge_audio bool true Merge all audio into one file. --split-audio on the CLI flips this off. merge_video bool false Merge video tracks that share height, range, and codec into one file, so only language varies inside a file. --merge-video on the CLI flips this on. muxing:\n set_title: true\n default_language:\n audio: en\n subtitle: en\n
"},{"location":"reference/configuration/download/#language_tags","title":"language_tags","text":" - Type:
dict \u00a0\u00b7\u00a0 Default: {}
Language-tag remapping and the rule engine behind the {lang_tag} filename variable. When a rules list is present, each rule is evaluated in order and the first match's tag is used. A rule may test:
Condition Meaning audio Matches against the audio track languages. subs_contain At least one subtitle language matches. subs_contain_all Every listed language must be present (scalar or list). language_tags:\n rules:\n - audio: [ja]\n subs_contain: [en]\n tag: \"SUBBED\"\n
"},{"location":"reference/configuration/download/#dual_multi_mode","title":"dual_multi_mode","text":" - Type:
str \u00a0\u00b7\u00a0 Default: strict
Rule set for the {dual}, {multi}, and {dubbed} filename variables.
strict (default)count (legacy) Variable Set when {dual} Exactly two audio languages and one is the title's original language. {multi} Three or more audio languages, even without the original. {dubbed} A single audio language that is not the title's original language (requires a known original). Variable Set when {dual} Exactly two audio languages (original language ignored). {multi} Three or more audio languages. {dubbed} Never set. dual_multi_mode: strict\n
Dialects count as one language
Regional variants of the same base language (e.g. en-US and en-GB) collapse to a single language in both modes, so they never trigger {dual} on their own.
"},{"location":"reference/configuration/drm/","title":"DRM & CDM","text":"These are the config keys. For concepts, device provisioning, and the wvd/prd commands see the DRM & CDM guide. Device files themselves live in directories.wvds (.wvd) and directories.prds (.prd).
"},{"location":"reference/configuration/drm/#cdm","title":"cdm","text":" - Type:
dict \u00a0\u00b7\u00a0 Default: {}
Maps a service tag to the CDM (Widevine/PlayReady device) to use, with a default fallback. Resolution is case-insensitive: a per-request API override wins, then the per-service entry, then default.
cdm:\n default: chromecdm_l3\n EXAMPLE2: android_l1\n
A value may also be a nested dict for advanced selection by quality, DRM system, or profile:
cdm:\n EXAMPLE1:\n \">=1080\": android_l1 # by track height\n \"<1080\": chromecdm_l3\n widevine: android_l1 # by DRM system\n playready: sl3000\n default: chromecdm_l3\n
Quality keys support 1080, >=1080, >720, <=576, <480 style comparisons; DRM keys are widevine / playready. Quality keys are matched first, but only when a video height is known; if the value is still a dict after that, widevine/playready are used when present, otherwise the credential profile name, then default.
The resolved name is looked up in remote_cdm by name first. If no entry matches, it is loaded as a local device file, in this order: <name>.prd in directories.prds, <name>.prd in directories.wvds, then <name>.wvd in directories.wvds.
"},{"location":"reference/configuration/drm/#remote_cdm","title":"remote_cdm","text":" - Type:
list[dict] \u00a0\u00b7\u00a0 Default: []
A list of remote CDM server definitions. Each entry is matched by its name (referenced from cdm), and its type selects the backend:
type Backend Key fields decrypt_labs Decrypt Labs KeyXtractor host, device_name, secret (fields) custom_api Fully YAML-configurable remote API host (required), device, auth, endpoints, request_mapping, response_mapping, caching, timeout (fields) (none), Device Type: PLAYREADY pyplayready RemoteCdm host, secret, device_name, security_level (default 3000) (none), otherwise pywidevine RemoteCdm host, secret, device_name, device_type, system_id, security_level (default 3000) remote_cdm:\n - name: keyxtractor\n type: decrypt_labs\n device_name: L1\n - name: my_wv_server\n host: https://cdm.example.com\n secret: s3cr3t\n device_name: android_l1\n device_type: ANDROID\n system_id: 26830\n security_level: 1\n
For the two pywidevine/pyplayready backends, field names are read case-insensitively in both styles: Device Type/device_type, System ID/system_id, Security Level/security_level, Host/host, Secret/secret, Device Name/device_name.
PlayReady host needs a /playready suffix
pyplayready's RemoteCdm treats host as a base URL and appends its own endpoint paths, so a PlayReady entry's host must include the trailing /playready segment (for example https://cdm.example.com/playready). Without it the server returns 404 rather than a configuration error.
"},{"location":"reference/configuration/drm/#remote-cdm-decrypt-labs","title":"decrypt_labs fields","text":"Field Default Notes host https://keyxtractor.decryptlabs.com device_name ChromeCDM ChromeCDM, L1, L2 (Widevine) or SL2, SL3 (PlayReady) secret from decrypt_labs_api_key Sent as the decrypt-labs-api-key header. An error is raised if neither is set. system_id 26830 (Widevine), 0 (PlayReady) security_level Widevine 3; PlayReady 2000 for SL2, else 3000 PlayReady mode is used when device_type is PLAYREADY or device_name starts with SL.
"},{"location":"reference/configuration/drm/#remote-cdm-custom-api","title":"custom_api fields","text":"Field Type Notes host str Required. Base URL of the API. device dict name, type (CHROME, ANDROID, PLAYREADY), system_id, security_level auth dict type (default header): header uses header_name (default Authorization) and key; bearer uses bearer_token or key; basic uses username and password. custom_headers is merged in for any type. endpoints dict get_request and decrypt_response, each {path, method, timeout} request_mapping dict Per endpoint: param_names, static_params, conditional_params, transforms, nested_params, exclude_params response_mapping dict Per endpoint: fields, transforms, response_types, success_conditions, error_fields, key_fields caching dict enabled, use_vaults, check_cached_first legacy dict Legacy mode options. timeout int Default 30. Request timeout in seconds. Transforms cover base64/hex/JSON encoding and kid:key parsing. PlayReady mode is used when device.type is PLAYREADY or the device name is SL2/SL3.
remote_cdm:\n - name: my_custom\n type: custom_api\n host: https://cdm.example.com\n device:\n name: ChromeCDM\n type: CHROME\n system_id: 26830\n security_level: 3\n auth:\n type: bearer\n key: your-token\n endpoints:\n get_request:\n path: /get-challenge\n method: POST\n decrypt_response:\n path: /get-keys\n method: POST\n timeout: 30\n
"},{"location":"reference/configuration/drm/#decryption","title":"decryption","text":" - Type:
str or dict \u00a0\u00b7\u00a0 Default: \"shaka\"
Selects the tool used to physically decrypt CENC content. The value is compared case-insensitively; \"mp4decrypt\" selects Bento4's mp4decrypt, and anything else (including \"shaka\") selects shaka-packager. When the value is a dict, the service key is matched case-insensitively and the default entry is the fallback.
HLS AES-128 ClearKey is decrypted in-process and ignores this setting.
GlobalPer-service decryption: shaka\n
decryption:\n default: shaka\n EXAMPLE1: mp4decrypt\n
"},{"location":"reference/configuration/example/","title":"A complete annotated example","text":"The following config sets a handful of common keys. Every key not shown keeps its default.
unshackle.yamldirectories:\n downloads: ~/Media/unshackle\n temp: /mnt/scratch/unshackle\n\nnetwork:\n browser: Chrome131\n\nheaders:\n Accept-Language: en-US,en;q=0.9\n\ncdm:\n default: chromecdm_l3\n EXAMPLE2: android_l1\n\ndecryption: shaka\n\ncredentials:\n EXAMPLE2: user@example.com:hunter2\n\noutput_template:\n movies: \"{title}.{year}.{quality?}.{source}-{tag}\"\n series: \"{title}.{season_episode}.{quality?}.{source}-{tag}\"\n folder:\n movies: \"{title} ({year})\"\n series: \"{title} ({year})\"\n\ntag: MYGRP\n\nkey_vaults:\n - type: SQLite\n name: Local\n path: ~/.unshackle/keys.db\n\nproxy_providers:\n basic:\n us: http://user:pass@1.2.3.4:8080\n\nomdb_api_key: \"your-omdb-key\"\ntmdb_api_key: \"your-tmdb-key\"\ntvdb_api_key: \"your-tvdb-key\"\ntitle_cache_time: 3600\nredact_paths: true\n
For how these interact at runtime, continue to Downloading, DRM & CDM, and Output & naming.
"},{"location":"reference/configuration/misc/","title":"Other Settings","text":"Smaller configuration areas: external metadata API keys, caching and update checks, logging and debug flags, and deprecated keys.
"},{"location":"reference/configuration/misc/#external-api-keys","title":"External API keys","text":"All are unset by default and enable optional metadata/geolocation features. For which fields each metadata provider can actually answer with, see What each metadata provider supplies.
Key Type Default Description omdb_api_key str \"\" OMDb API key for IMDb metadata lookups; a fallback for when the keyless imdb provider has no answer. Free keys are available on the OMDb site. tmdb_api_key str \"\" TMDB API key for metadata enrichment and external-ID tags. simkl_client_id str \"\" SIMKL client ID for metadata lookups; an alternative/fallback source to TMDB. tvdb_api_key str \"\" TheTVDB v4 API key; a fallback source to TMDB, strongest on TV series. Also the source --enrich --tvdb reads. Free keys are available on the TVDB site. tvdb_pin str \"\" Subscriber PIN, only needed for a user-supported TVDB key. Leave empty for a normal project key. tvdb_order str \"\" Default for --tvdb-order: renumber episodes to a TVDB season order (official, dvd, absolute, alternate, regional). Empty keeps the numbering the service gives. metadata_providers list or map (see below) Metadata providers to use, in the order they are tried. anilist_title_language str english Which AniList title variant to use: english, romaji, or native. disable_metadata bool false Set to true to stop all automatic metadata lookups. An ID you give yourself is still looked up. decrypt_labs_api_key str \"\" Global Decrypt Labs API key (used by remote CDM / vault). ipinfo_api_key str \"\" ipinfo.io API key for IP/region lookups. How metadata_providers is applied
A lookup goes to each provider in turn and stops at the first good match, so the order decides which source wins. metadata_providers sets that order:
metadata_providers: [tvdb, tmdb, simkl]\n
Names not in the list are never used, so this both orders and filters. Unknown names are ignored with a warning. Leave it unset for the default order:
metadata_providers: [imdb, omdb, simkl, tmdb, tvdb, anilist]\n
Whatever the order, unshackle skips a provider that has no API key. imdb and anilist need no key, so they are always available. Available names are imdb, omdb, simkl, tmdb, tvdb, and anilist. The old name imdbapi still works and is read as imdb.
anilist is last in the default order. It only answers for anime and returns nothing for anything else, so it is harmless there. Move it earlier if you mostly download anime.
The order applies to a title search and to a direct lookup of an ID you give with --tmdb, --imdb, --tvdb, or --anilist. A supplied ID always goes to the providers that read that kind of ID, and it never falls back to a title search.
Which AniList title anilist_title_language picks
AniList holds up to three titles for a work: the English one, the romaji transliteration, and the native one. This option says which of them unshackle uses:
anilist_title_language: romaji\n
A work does not always have all three. When the one you asked for is unset on AniList's side, unshackle falls back in the order english \u2192 romaji \u2192 native. It takes the first one that is set, so you always get a title.
What disable_metadata turns off
With disable_metadata: true, unshackle never contacts a metadata provider on its own. The title search is dead, and that includes the keyless imdb provider. Titles, years and languages then come only from the service.
disable_metadata: true\n
An ID you give with --tmdb, --imdb, --tvdb, or --anilist is your permission to use that ID. That lookup still works. unshackle looks the ID up directly, cross references the other IDs from it, and --enrich still reads that source. Only the lookups you did not ask for are stopped.
The imdb provider needs no key
It replaces the earlier imdbapi provider, which read a third-party mirror (api.tiffara.com) and needed the imdb_api_enabled switch. That option is gone: delete it from your config, where it is now ignored.
A different order per title kind
Give a map instead of one list when the best source differs by kind. TVDB is strongest on series, TMDB on films. There are two kinds, tv and movie, and this example sets both of them and names every provider, so nothing is left to the default:
metadata_providers:\n tv: [tvdb, tmdb, simkl, imdb, omdb]\n movie: [tmdb, imdb, omdb, simkl, tvdb]\n
Each list is independent: it both orders and filters that kind on its own, so a provider you leave out of tv is still used for movie. Here TVDB is tried first for a series and last for a film.
You do not need to give both keys, or a full list in each. A kind you leave out uses the default order:
metadata_providers:\n tv: [tvdb, tmdb] # movies keep the default order\n
tv covers every episode and movie covers every film. unshackle only looks up metadata for those two kinds, so any other key, such as anime or music, is ignored.
ipinfo_api_key never touches your service sessions
The token is only ever sent to api.ipinfo.io as a per-request Authorization header; it is never attached to your service session, so it cannot leak to a streaming provider. Lookups degrade gracefully through a fallback chain: the authenticated Lite endpoint (higher rate limits, and ASN/continent data, but no city or region) \u2192 anonymous ipinfo \u2192 ip-api.in as a last resort.
"},{"location":"reference/configuration/misc/#caching-updates","title":"Caching & updates","text":""},{"location":"reference/configuration/misc/#title-cache","title":"Title cache","text":"unshackle caches fetched title metadata (region- and account-aware) to avoid repeat API calls.
Key Type Default Description title_cache_enabled bool true Master switch for the title cache. title_cache_time int (seconds) 1800 (30 min) Lifetime of fresh cached titles. title_cache_max_retention int (seconds) 86400 (24 h) Total retention window for a cached title, counted from when it was cached. Past title_cache_time the stale copy is only served as a fallback when a live fetch fails."},{"location":"reference/configuration/misc/#update-checks","title":"Update checks","text":"Key Type Default Description update_checks bool true Whether to check for new unshackle releases. update_check_interval int (hours) 24 Minimum hours between update checks. Note
Update checks query the GitHub releases API with a fixed 5-second timeout and cache the result in directories.cache/update_check.json.
"},{"location":"reference/configuration/misc/#appearance","title":"Appearance","text":"Key Type Default Description theme str catppuccin-mocha Named colour theme for all CLI output: banner, logs, progress bars, and help screens. One of catppuccin-mocha (aliases default, catppuccin, mocha), dracula, nord, gruvbox, one-dark (alias onedark), tokyo-night (alias tokyonight), monokai, flexoki, solarized-dark (alias solarized). Case-insensitive; underscores work in place of hyphens. Unknown names fall back to the default with a warning. Preview them all with unshackle env theme."},{"location":"reference/configuration/misc/#logging-privacy-debug","title":"Logging, privacy & debug","text":"Key Type Default Description redact_paths bool true Mask install-root / venv / home prefixes in logged paths (<unshackle>, <venv>, ~). Set false to show full paths. debug bool false Global debug mode. debug_keys bool false Log decryption keys. debug_requests bool false Log HTTP requests. set_terminal_bg bool false Append the theme's background colour to output styling. Effect of set_terminal_bg
When on, the theme's background colour is appended to the foreground styles, which makes the full colour palette render correctly on terminals whose default background differs from unshackle's theme. When off, such terminals can render the ASCII-art banner and coloured output incorrectly.
Key exposure
With debug_keys on, decryption content keys are written to the structured debug log at INFO level during the DRM handshake. Treat debug, debug_keys, and the unshackle_debug_*.jsonl files as sensitive, and keep redact_paths enabled when sharing logs.
debug_keys affects only content-encryption keys (the content_key/key fields). Passwords, tokens, cookies, and session tokens are always redacted regardless of this setting, and KIDs, key counts, and other metadata are always logged either way.
"},{"location":"reference/configuration/misc/#deprecated-removed-keys","title":"Deprecated & removed keys","text":"Key Status Behaviour curl_impersonate Deprecated \u2192 use network Emits a DeprecationWarning; still honoured only if network is absent. downloader Deprecated Any value other than \"requests\" emits a DeprecationWarning; the value is otherwise ignored (the unified requests downloader is always used). scene_naming Removed If set to any non-null value, unshackle exits with an error directing you to configure output_template instead. imdb_api_enabled Removed Ignored. It gated the old imdbapi provider, which the keyless imdb provider replaces. Safe to delete from your config."},{"location":"reference/configuration/network/","title":"Network & proxy","text":""},{"location":"reference/configuration/network/#network","title":"network","text":" - Type:
dict \u00a0\u00b7\u00a0 Default: {}
TLS-fingerprinting and HTTP client settings for the rnet-based session.
Sub-key Type Default Description browser str \"Chrome131\" Impersonation preset. Must be an exact preset name (e.g. Chrome131, Firefox135, Edge101, Safari18, OkHttp4_12, OkHttp5, Opera118); unknown names raise an error. http1_only bool (unset) Force HTTP/1.1. http2_only bool (unset) Force HTTP/2. pool_max_idle_per_host int (unset) Connection-pool tuning. pool_max_size int (unset) Connection-pool tuning. tcp_nodelay bool (unset) Disable Nagle's algorithm. network:\n browser: Firefox135\n http2_only: true\n
Measured effect of http1_only
Both http1_only and http2_only are unset by default. In benchmarks, forcing HTTP/1.1 gained 30 to 50% on hosts that throttle per-connection or stall behind HTTP/2 flow control, and cost up to 27% on fast CDNs.
Renamed from curl_impersonate
The old curl_impersonate section is a deprecated alias. If you still use it, unshackle honours it (only when network is absent) but emits a DeprecationWarning. Rename it to network.
"},{"location":"reference/configuration/network/#headers","title":"headers","text":" - Type:
dict \u00a0\u00b7\u00a0 Default: {}
Default HTTP headers merged into every session unshackle creates.
headers:\n Accept-Language: en-US,en;q=0.9\n
Don't set Accept-Encoding (and similar) here
Compatibility headers such as Accept-Encoding are set by the rnet HTTP backend as part of its browser-impersonation profile; overriding them breaks the impersonation fingerprint. This block is for cross-service defaults only (for example Accept-Language, User-Agent); per-service headers belong in that service's own config.
"},{"location":"reference/configuration/network/#proxy_providers","title":"proxy_providers","text":" - Type:
dict \u00a0\u00b7\u00a0 Default: {}
Proxy/VPN provider configuration. Each sub-key names a provider, and its block is passed straight to that provider's constructor. See Proxies & VPN for the full provider guide. Recognised providers and their exit ports:
Provider Config key Credentials Proxy scheme/port Basic (static) basic country \u2192 URI(s) as specified NordVPN nordvpn service credentials (48 chars combined) https://...:89 Surfshark surfsharkvpn service credentials (48 chars combined) https://...:443 Windscribe windscribevpn service credentials https://...:443 ExpressVPN expressvpn device login (enable: true) / token cache https://cat:...@...:443 ProtonVPN protonvpn TV login or exported cookies https://...:4443 (or :443 Secure Core) Gluetun gluetun per-VPN keys/creds http://localhost:{port} (local Docker) Hola (none, auto) none http://...:{peer} proxy_providers:\n basic:\n us: http://user:pass@1.2.3.4:8080\n de:\n - http://a.example:8080\n - socks5://b.example:1080\n nordvpn:\n username: <service username>\n password: <service password>\n
Provider loading differs between CLI and REST server
The dl CLI loads all providers, including windscribevpn and gluetun. The REST API / remote-client path uses a separate resolver that does not load windscribevpn or gluetun. ExpressVPN and ProtonVPN also auto-load when their cached session exists, and Hola auto-loads whenever the hola-proxy binary is present.
"},{"location":"reference/configuration/output/","title":"Output & naming","text":"Keys that control the output filename, the per-title subfolder, release-group tagging, and filename character handling. For template cookbook material and worked naming examples, see the Output & naming guide.
"},{"location":"reference/configuration/output/#output_template","title":"output_template","text":" - Type:
dict \u00a0\u00b7\u00a0 Required (no usable default; dl exits with an error if this is missing)
Filename templates keyed by media kind, using {variable} placeholders. Recognised kinds are movies, series, and songs (and albums for folder templates). A trailing ? on a variable (e.g. {quality?}) marks it optional: if empty, it and one adjacent separator are dropped.
unshackle.yamloutput_template:\n movies: \"{title}.{year}.{quality?}.{source}-{tag}\"\n series: \"{title}.{season_episode}.{episode_name?}.{quality?}.{source}-{tag}\"\n songs: \"{track_number}. {title}\"\n folder: # special nested key, see below\n movies: \"{title} ({year})\"\n series: \"{title} ({year})\"\n
The nested folder sub-key controls output subfolders and is popped out of output_template:
- A dict sets per-kind folder templates (kinds:
movies, series, songs, albums). - A str sets a single folder template for everything.
unshackle validates templates on load and emits warnings for unknown variables, non-string values, filesystem-unsafe characters (< > : \" / \\ | ? *), empty templates, and unknown folder-kind keys.
All valid template variables title, year, season, episode, season_episode, episode_name, part, absolute, date, quality, resolution, source, tag, track_number, artist, album_artist, album, disc, track_total, disc_total, release_type, genre, explicit, isrc, upc, label, audio, audio_channels, audio_full, atmos, dual, multi, dubbed, video, hdr, hfr, edition, repack, lang_tag, title_type.
{part?} is already inside {season_episode}
part holds the bare part index of a split episode (2) and is empty on every other title. unshackle folds it into {episode} and {season_episode}, so the shipped series template names split episodes correctly with no edit. Putting {part?} next to either of them renders the part twice (S01E01.Part.2.2). Use the standalone variable only in a template that names neither {episode} nor {season_episode}, and note that it is always empty in a folder template, since folders do not carry the part.
{absolute} counts episodes across seasons
absolute holds the absolute episode number, zero-padded to 3 digits (007), and is empty on titles that have none. It is added to the name and never replaces {season} or {episode}. Any series with a TVDB absolute order can use it, anime most often. Services can supply it, and --enrich fills it in from TVDB. Use {absolute?} so the name stays clean when it is unknown.
"},{"location":"reference/configuration/output/#tagging-naming-keys","title":"Tagging & naming keys","text":"Key Type Default Description tag str \"\" Release-group tag. Fills the {tag} template variable and is written into MKV/audio tags. tag_group_name bool true Include the group name (tag) in the MKV Group tag. tag_imdb_tmdb bool true Write IMDb/TMDB/TVDB external-ID tags into the MKV (needs metadata providers). chapter_fallback_name str \"\" Fallback chapter-name template, e.g. \"Chapter {i:02}\". unicode_filenames bool false Allow Unicode in filenames; when false, names are ASCII-sanitised."},{"location":"reference/configuration/output/#tag_rules","title":"tag_rules","text":" - Type:
list \u00a0\u00b7\u00a0 Default: []
Conditionally replaces tag for a given download. Each rule pairs a when dict of conditions with the tag to use when they hold. Rules are evaluated in order and the first match wins; if no rule matches, the global tag is kept.
unshackle.yamltag_rules:\n - when: { title_type: movie, lang_tag: SUBBED }\n tag: SUBMOVIES\n - when: { quality: 2160p, hdr: [DV, DV.HDR, DV.HDR10P, HDR, HDR10P] }\n tag: UHDGROUP\n
Rule semantics:
- Every condition in
when must hold (they are AND-ed). - A value may be a scalar or a list; a list matches if any entry matches.
- Comparison is case-insensitive string equality.
- The keys of
when are the filename template variables that describe the release itself: title_type, quality, resolution, source, video, hdr, hfr, audio, audio_channels, audio_full, atmos, dual, multi, dubbed, edition, repack, and lang_tag. Only these are available. The title's own fields (title, year, season, episode, and so on) are added after the rules run and cannot be matched. - An unknown key in
when is a mistake: unshackle logs a warning that names the key and skips that rule. A rule without conditions or without a tag is also warned about and skipped.
Put the more specific rules first, because the first match ends the search.
Match hdr on the exact value
Matching is exact, and hdr is a composite label for Dolby Vision titles: a DV title with an HDR10 base layer gives DV.HDR, and with an HDR10+ base layer DV.HDR10P. Plain DV does not match those. List every variant you want, as the example above does. Note also that HDR10 gives HDR, not HDR10. The full set of values is HDR, HDR10P, DV, DV.HDR, DV.HDR10P, and HLG, as listed in the variable table.
"},{"location":"reference/configuration/output/#operators","title":"Operators","text":"A condition value may start with a comparison operator, which is applied to the rest of the value:
unshackle.yamltag_rules:\n - when: { resolution: \"<1080\" }\n tag: SDGROUP\n - when: { quality: \">=2160\" }\n tag: UHDGROUP\n - when: { video: \"!=H.265\" }\n tag: AVCGROUP\n
>, <, >=, <= compare numbers. unshackle reads the first number out of the actual value, so \">=2160\" matches a quality of 2160p. If the actual value has no number, the condition fails. =, ==, and != compare text, case-insensitively, when the operand is not a number. With a numeric operand they compare numbers like the operators above. A value with no number in it is never equal to a number, so \"!=2160\" holds for it and \"==2160\" does not. - An ordering operator with a text operand (for example
\">DV\") is a mistake: unshackle logs a warning and the condition fails. So does an operator with nothing after it (\">=\"). - A value with no operator keeps the plain equality behaviour described above.
Quote operator values in YAML
Two of these operators break an unquoted YAML value, and both stop the config from loading:
> opens a folded block scalar, so quality: >=2160 fails with a \"while scanning a block scalar\" error. ! starts a tag, so hdr: !=DV fails with a \"could not determine a constructor\" error.
Quote every operator value: quality: \">=2160\", resolution: \"<1080\", video: \"!=H.265\".
Keep a negation out of lists
A list matches if any entry matches, so two or more != entries in one list are always true. Give a negation as a single value, not as a list entry.
title_type is the media kind of the title: movie, series, or music. It is useful to give each kind its own group:
tag_rules:\n - when: { title_type: movie }\n tag: MOVIEGRP\n - when: { title_type: series }\n tag: TVGRP\n
The resolved tag applies to the {tag} filename variable only. The Group metadata tag written into MKV and music files always uses tag.
--tag wins
An explicit dl --tag on the command line disables the rules completely and uses the given tag.
"},{"location":"reference/configuration/services/","title":"Services & authentication","text":""},{"location":"reference/configuration/services/#services","title":"services","text":" - Type:
dict keyed by service tag \u00a0\u00b7\u00a0 Default: {}
Per-service configuration, keyed by the canonical service tag (for example EXAMPLE1, EXAMPLE2, EXAMPLE3). unshackle reads several well-known sub-keys out of each service's block:
proxy_map: remaps the region query you passed to -p/--proxy for this service. A key is that query, or \"provider:query\" when the flag also named a provider; the value is the query asked of the proxy provider instead. It has no effect when -p was given a full proxy URL. title_map: an exact-match rename map applied to fetched titles (source name \u2192 desired name), so a service that names a title differently from your library still matches. dl: per-service download defaults, using the same keys as the global dl block.
Individual services may read any additional keys they define. The merged result (the service's own config.yaml with this block layered on top) is handed to the service as self.config.
User- or device-specific values (API keys, account IDs, device attributes) belong in this block rather than in the service's own config.yaml, which holds shared defaults; see creating a service.
services:\n EXAMPLE1:\n proxy_map:\n us: us-nyc\n \"nordvpn:ca\": ca-toronto\n title_map:\n \"My Show: The Movie\": \"My Show Presents The Movie\"\n
"},{"location":"reference/configuration/services/#credentials","title":"credentials","text":" - Type:
dict keyed by service tag \u00a0\u00b7\u00a0 Default: {}
Per-service login credentials. Each value is a username:password[:extra] string, the same data as a [username, password] (or [username, password, extra]) list, or a dict of profile name to either of those. With the dict form, -p/--profile selects the entry, and the default key is used when no profile was given or the named profile is missing. These are parsed into Credential objects; the credential's SHA-1 is also used as an account hash for cache keys.
credentials:\n EXAMPLE2: user@example.com:hunter2\n EXAMPLE1:\n default: primary@example.com:pw1\n second: secondary@example.com:pw2\n
Cookies vs credentials
Cookies are stored as files under directories.cookies, not in this key. A service's authenticate() accepts cookies, credentials, or both.
"},{"location":"reference/configuration/services/#firefox_cookies","title":"firefox_cookies","text":" - Type:
dict keyed by service tag \u00a0\u00b7\u00a0 Default: {}
Settings for extracting cookies directly from a local Firefox profile. A service block is expected to provide hosts (a list of cookie hostnames; entries shorter than 3 characters are ignored to prevent dumping the whole cookie store) and an optional local_storage boolean to also pull matching entries from webappsstore.sqlite, which only services that keep auth tokens in localStorage rather than in HTTP cookies need. Extraction is read-only.
Firefox does not need to be closed
The extractor copies both cookies.sqlite and its WAL file into a 0700 temp directory, so writes Firefox has not yet flushed to the main DB are included. Extraction fails only if Firefox holds an exclusive write lock at the instant of the copy. The live profile is not modified.
Extraction falls back silently to file cookies
If extraction yields no cookies or fails for any reason, unshackle silently falls back to the normal file-based cookie path (cookies/<SERVICE>.txt or cookies/<SERVICE>/<profile>.txt), with no error reported.
"},{"location":"reference/configuration/services/#remote_services","title":"remote_services","text":" - Type:
dict \u00a0\u00b7\u00a0 Default: {}
Definitions of remote unshackle service servers, used by the --remote mode. Each entry is named by you (pick it with --server, or omit that flag when only one is configured) and gives the server's url (required), an optional api_key, an optional auth_headers list, an optional server_cdm boolean, and an optional services sub-dict of per-service local overrides such as title_map.
auth_headers lists extra header names to send the API key in, tried before the defaults X-Secret-Key and X-Api-Key, which are always appended as fallbacks. unshackle sends the first name; if the server answers 401, it retries the same request with the next name, and keeps the one that works for the rest of the session. Names you list keep your spelling and are not repeated in the fallbacks, so auth_headers: [\"Authorization\", \"x-secret-key\"] is tried as Authorization, x-secret-key, X-Api-Key.
In --remote mode unshackle turns the server's service list into synthetic CLI commands that run against it, falling back to the tags in that services sub-dict when the list cannot be fetched. Each synthetic command carries the server-side service's options and documentation, so unshackle dl --remote <TAG> -h shows the same help text as it does on the server. See remote sessions for the full setup.
"},{"location":"reference/configuration/services/#serve","title":"serve","text":" - Type:
dict \u00a0\u00b7\u00a0 Default: {}
Configuration for the serve command (the built-in REST API server). The full server guide is the REST API section; these are the config keys.
Sub-key Type Default Description api_secret str (unset) Master secret accepted in the X-Secret-Key header. Required unless the server is started with --no-key. users dict {} Per-user API keys and their allowlists (see below). services list (unset) Global service allowlist. Omit to allow all. remote_only bool false Expose only the remote service session endpoints (health, services, search, session) and disable the rest of the REST API. session_ttl int (s) 300 Lifetime of an interactive auth session. max_sessions int 100 Maximum concurrent sessions. history_limit int 100 How many finished jobs to retain in history. compression_level int 1 gzip level for responses. global_speed_limit str (unlimited) Server-wide download speed cap, e.g. 10M, 1.5G or plain bytes/sec (same format as speed_limit). One shared budget across all concurrent jobs; per-job speed limits are ignored while it is set. cdm_overrides list or bool (unset) Allowed per-request CDM overrides: a list of permitted device names, or true for any. Unset rejects every override. allow_job_credentials bool false Permit clients to supply credentials per job. devices list (auto) Widevine devices offered; auto-filled from directories.wvds. playready_devices list (auto) PlayReady devices; auto-filled from directories.prds. Each entry under users is keyed by that user's API key and may set its own services, devices, and playready_devices allowlists, narrowing the global ones, plus an optional username used as the log label for that key (defaults to a truncated form of the key). A user with no playready_devices key gets no PlayReady access at all, not the global list.
server_cdm decides whether the server runs the CDM licensing for that key. It is false unless the entry sets it, so a remote client configured with server_cdm: true is told to license with its own local CDM instead, and a client that asks anyway gets a FORBIDDEN error. Because a download job always licenses with the server's CDM, a key without server_cdm also cannot submit or retry /api/download jobs. Keys that have no users entry, such as api_secret, keep server CDM access.
serve:\n api_secret: change-me\n remote_only: true\n services: [EXAMPLE1, EXAMPLE2]\n # server-wide download defaults (same keys as the `dl:` block)\n downloads: 3\n best_available: true\n users:\n a1b2c3d4: # this user's API key\n services: [EXAMPLE1] # may only use EXAMPLE1\n e5f6a7b8:\n server_cdm: true # this key may have the server do the licensing\n
dl keys inside serve
Most dl flag keys (downloads, workers, best_available, and so on) can be set directly inside serve, where they apply to every request the server handles. The server recognises a fixed subset of download parameters, so a few CLI-only flags are ignored here.
"},{"location":"reference/configuration/vaults/","title":"Key vaults","text":"The full guide is at Vaults. Two keys configure them.
"},{"location":"reference/configuration/vaults/#key_vaults","title":"key_vaults","text":" - Type:
list[dict] \u00a0\u00b7\u00a0 Default: []
An ordered list of key-vault backends. unshackle queries them in order and reuses content keys instead of re-licensing. Each entry needs a type (the backend module name) and a name, plus backend-specific keys.
type Purpose Required keys Notes SQLite Local SQLite database name, path Loaded critically; a failure aborts the run. MySQL Remote MySQL database name, host, database, username Extra keys (e.g. password, port) forwarded to pymysql. API RESTful JSON API name, uri, token Optional headers map is sent with every request. Honours vault_timeout. HTTP HTTP API with modes name, host, one of password/api_key, and username in query mode api_mode: query (default), json, decrypt_labs. Honours vault_timeout. key_vaults:\n - type: SQLite\n name: Local\n path: ~/.unshackle/keys.db\n - type: MySQL\n name: Team\n host: db.example.com\n database: keys\n username: unshackle\n password: hunter2\n no_push: false\n
Per-entry options
no_push: true makes a vault read-only (keys are fetched but never written to it). - A vault of
type: API whose name contains decrypt_labs auto-fills its token from decrypt_labs_api_key when not set inline. Vault type values are case-sensitive module names. - An all-zero content key (32 zeros) is treated as \"no key\" everywhere and is never stored.
"},{"location":"reference/configuration/vaults/#vault_timeout","title":"vault_timeout","text":" - Type:
float \u00a0\u00b7\u00a0 Default: 10.0
Timeout in seconds for vault operations. Injected automatically into any backend whose constructor accepts a timeout parameter (a per-vault timeout still wins).
"}]}
\ No newline at end of file