NIG^&3g?@G~1l}9tRUo!woIzvCRsG?D|7jL^^w73-z zrqw;|rQvaJ03vS1`k8XwH~`sM0{x6EZz+J}drXMvdm7RAzM}J+4=~N_?5XFx9|6)f z5dEx}FI9j%j8Qi6e#6yp2_sB%c-~v-4dg7Ao|bnuV8t+i{H3=~TNpHVNhE(Pec0}N z01JPL^S7~bPXy@3NcA_hPpZZ2V~>Q3*~z`qE@pLt;??BV!OeE^E0IenA$iwxm2{?0s Jw_N4Y%l$HzCT-JYB4B@QjA8y3R<9oc-B`E)W8{voR_LeBH37I) &mm_w7 zLHETgs5KKcz|`8EEvPLsH`JK(!WYV#febWfyDvszZ_2hAZ2n{avP+neIVMQd{tXzE zi8 9RV^!?2*eS)Ss5biBicA`_OQsN^ z2H_{RxQYtW>nMJbi#w|z$&TVFxq7)3=D~vXGqrjx7Uao7^|VyIp!KU4PqFn|w;%&H zte+|4eO-``5h+00bh&>QE`Os1SfHtfG0G<1{P8A#RohZvezh1N(;>XYj2Do>x+9v8 zgZ jk&%aF1dlf0B&SDZlxmOvjL+pmHrYIj-%Pc(N~1& z1PBH{kO0dHu~9NEj;$I^;&qWWxIY2>Ha2DmXG>=EAg=l+m&rd c8BAGz>i5b6j8&J{ydiJfQ@;D=|5=_;Zz)}>q5uE@ delta 1825 zcmV++2j2MS9oikR&j)|>nGjbT62fb?%c>%w;;r0u4Pvcd=Jdc-vCL3qUjq)jyk-jf zW)11cm0*w0^~Tloa)qr!uV)r4Ot+8d!qZeFtjtQD;EVXs?-^Tnx_n;5yj?uiS#|)B zQjb|tE3Pab==k@W;@sMG#<{tIlNBqc6n&)Uv~}%f1AqOtfUke}1h=kUZxP9uK(_;} zE4F#t{v(yh?ohWaHe8^p7fFd!*kZImJ)OuCONEF ->b9+je1|2^t>5k-@H4jTZXCCso^G3nvx!T&Or2l&STDqGs~$7=b_je> z(Sz#FO~g_JFQ$JEadpipO1UK|8WdG=s{=Yxose*^_j5PnKipPw(CV9^4kA@mAw13q ztfN1@9t#;`%(aC2QsR@YK6Z(VAAiV`O0hbeej+e8TrL>U6wpLG>1g!tbg1_vsCKW3 zmt`T3RVm;k-JOo2E;6P)@s%$gc~gh&u>|+JU#erX6vlrvyq@s}mN q9L9fN$|!%vy}Y)(Moon)d1a#p2n+KEM#AR_OxPMXN?u~eEExYufFB1*Ji8kiTZ zn2z&{gy*?T&+J(opURT3rPKm9sD97-6y`~~4>$(!-@>AAX=aCY-(cb@inG|ZQd bsJRdVdI_Quy0KET&M3>`w&tAo5o7WD`xul2_>L`=yZm3tGO z3$+1#DDaiL4(t4{{Vgu&TEZ3dqm3;Cc`%4#H_m?;qEon|EHZnLjUig%W(;x>31eVH z5w0_kBLtfwJt%Y*r$8~Thyltpg#b_%#UAtga5$83;4wkXDZP5v?MhLQ+C@7FDdA^! zX_8jD*Pr$DC`C$RvW?ZB>SOSFEJ;K(d>8>uu3jiG^;tAoo9YXo@k-)QWktz&f5w4? zrn-OFfX*Mm>&|XS00D QaAMLGV`fX6(5>=a%TG zq2MWU#BQP6XJI>?I~_FX)OJbuw%UERH+uiWKL{#lpM301dyi!7P5oY(*qfp;NQJ77 zU~c8Nkn5Z|*&)%{I@mg=*Q%shapg&!AbEePBtbvjFPYcs7ovV?CM0K3MDRZM%M7-9 zx)#_`olwsC?X;`usBoD55g|wqKh>F7VHOPMkiixPR+xpAPw;rl%g}dU|7?r5w)OKZ zy@BSW*X&Sqpjz{=hk)VGG(f4N_~`8)M33Fr4p>9VV9N4Q-#$v7sNE@6o@nk8F^_*8 z>=WqD9qbYA&OO{K=$-2lguX_YkN#T-fQ^232!qEywho5JTGlh)kTx)bdFk&KLXTbC zFYq0!I**G(%HIIA`RKk&wmf4F`kI`t3@$<(L45U=$JJqyEoVhl-KjLyh93d6Db3H> z!}MB8n5d#r4S83JR;oOjsr{TkP||-H`k6%)jjFwP+Z~(5vHUQt?rAR#kAwRVaVyr( zl k~zQ=Gqzm5YBGKsQFJ zzo~svEoL8kBwWl+?v-{is}m$&Y&?G>es@NIu3Ydii>v3R5e_YEe}iu!ljU%=Lq2Qi z#`hu%upZs`P@AU9Feqa;V6YY26&jqMDK)^<#>vU0qV*aMumEPf03*BQDwkgFm$5Wy zn{M*}Yhw)ax3GHu2k6Ga1sH!LcZ9V %acZ$3e-nWzD#)-Fv! zZJD{D#+)~ x@L8A7Lu%JxLv2IVZdvAa#%}3x3 zB#p*yw$Is2)b4cDWTLrChG^`7s+PlQcAu=v+`%6Cmbr&}Wm@LC_db8yJ=T8
$6EU>+Qu&Kw_F>mVvog6DNaST(O>Xm(wN=yga|bVKe5GKP>^0n@snI! z8U;yq6i><3+odoM7ObDC)qANRPZp}DrRoK(U%hyWtzUZu8L(mfOc^iCf_#if0n(=1 zwQ%_xEx-azHH=X<@#cSzH~HJmmICv;y#SdG;Vovo0Swk1(R>{2zXlw;G2a`8l*O3j zrR=(63^K3;`uNy>6+BV9(<*qPxyvee?7(tS8B)s=l#lj4YvH+rJ=Vf=5BFLN&voy; z79Q)rg|)EJ&kk$hv5&3S!ecGl{bxw`pR#<^_gjgNUD)nSL&_Oo%JNgcR~}%jy1Zu% PdE1`y-8cUSekTPmW#*aj diff --git a/reference/api/config/index.html b/reference/api/config/index.html index 74607c8a..e0960fc4 100644 --- a/reference/api/config/index.html +++ b/reference/api/config/index.html @@ -55,6 +55,8 @@ + + diff --git a/reference/api/drm/index.html b/reference/api/drm/index.html index 7f78e55e..3182a19b 100644 --- a/reference/api/drm/index.html +++ b/reference/api/drm/index.html @@ -53,6 +53,8 @@ + + diff --git a/reference/api/service/index.html b/reference/api/service/index.html index f4cb3916..520e22fa 100644 --- a/reference/api/service/index.html +++ b/reference/api/service/index.html @@ -55,6 +55,8 @@ + + diff --git a/reference/api/titles/index.html b/reference/api/titles/index.html index db962cd0..1a275066 100644 --- a/reference/api/titles/index.html +++ b/reference/api/titles/index.html @@ -55,6 +55,8 @@ + + diff --git a/reference/api/tracks/index.html b/reference/api/tracks/index.html index 88a1b05c..eccdcc58 100644 --- a/reference/api/tracks/index.html +++ b/reference/api/tracks/index.html @@ -55,6 +55,8 @@ + + @@ -1918,6 +1920,17 @@ + + + + + + + delete + + + +@@ -3039,6 +3052,17 @@ + + ++ + + + delete + + + +@@ -4294,6 +4318,26 @@ If mime_type is not provided, it will try to guess it. ++ ++
+ + +delete + + +¶+ ++ +Delete the file only if it is in the temp directory. Files in other locations belong to the user.
+ + ++ +
from_url diff --git a/reference/configuration/directories/index.html b/reference/configuration/directories/index.html index b6353e47..4e7b1bf9 100644 --- a/reference/configuration/directories/index.html +++ b/reference/configuration/directories/index.html @@ -55,6 +55,8 @@ + + diff --git a/reference/configuration/download/index.html b/reference/configuration/download/index.html index 7c1b7eef..c50ddf0d 100644 --- a/reference/configuration/download/index.html +++ b/reference/configuration/download/index.html @@ -55,6 +55,8 @@ + + diff --git a/reference/configuration/drm/index.html b/reference/configuration/drm/index.html index 985e982d..640120b2 100644 --- a/reference/configuration/drm/index.html +++ b/reference/configuration/drm/index.html @@ -55,6 +55,8 @@ + + diff --git a/reference/configuration/example/index.html b/reference/configuration/example/index.html index b97a8779..abf73939 100644 --- a/reference/configuration/example/index.html +++ b/reference/configuration/example/index.html @@ -55,6 +55,8 @@ + + diff --git a/reference/configuration/index.html b/reference/configuration/index.html index 5e11d47a..f5fd55e9 100644 --- a/reference/configuration/index.html +++ b/reference/configuration/index.html @@ -55,6 +55,8 @@ + + diff --git a/reference/configuration/misc/index.html b/reference/configuration/misc/index.html index 3b39f044..9e51c118 100644 --- a/reference/configuration/misc/index.html +++ b/reference/configuration/misc/index.html @@ -55,6 +55,8 @@ + + diff --git a/reference/configuration/network/index.html b/reference/configuration/network/index.html index a344aa82..11010c84 100644 --- a/reference/configuration/network/index.html +++ b/reference/configuration/network/index.html @@ -55,6 +55,8 @@ + + diff --git a/reference/configuration/output/index.html b/reference/configuration/output/index.html index 547f0702..787776a7 100644 --- a/reference/configuration/output/index.html +++ b/reference/configuration/output/index.html @@ -55,6 +55,8 @@ + + diff --git a/reference/configuration/post-scripts/index.html b/reference/configuration/post-scripts/index.html index 8e0a06b5..f8fadc36 100644 --- a/reference/configuration/post-scripts/index.html +++ b/reference/configuration/post-scripts/index.html @@ -55,6 +55,8 @@ + + diff --git a/reference/configuration/services/index.html b/reference/configuration/services/index.html index 5c7c2364..0051fc37 100644 --- a/reference/configuration/services/index.html +++ b/reference/configuration/services/index.html @@ -55,6 +55,8 @@ + + diff --git a/reference/configuration/vaults/index.html b/reference/configuration/vaults/index.html index 7a1749bf..25941ee8 100644 --- a/reference/configuration/vaults/index.html +++ b/reference/configuration/vaults/index.html @@ -55,6 +55,8 @@ + + diff --git a/search/search_index.json b/search/search_index.json index 804cef2a..7523ecd5 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, and a built-in HTTP server.
Use responsibly
The GPL-3.0 licence covers unshackle. Do not use it for content you do not have the rights to. Keep the core free and open, keep your own services 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:
"},{"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, DTS-X, 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 Control D, 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":"
- 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.
- Get the content keys through a Widevine or PlayReady CDM, with optional key vaults that let unshackle reuse a content key instead of licensing it again.
- 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.
A git clone with the install script is the recommended way to install unshackle.
Linux (recommended)Windows (recommended)Advanced: uv toolgit clone https://github.com/unshackle-dl/unshackle.git\ncd unshackle\nsh install.sh\nuv run unshackle env check\ngit clone https://github.com/unshackle-dl/unshackle.git\ncd unshackle\n.\\install.bat\nuv run unshackle env check\nuv tool install git+https://github.com/unshackle-dl/unshackle.git\nunshackle setup\nThis installs unshackle into the tool folder of
uv, not a folder that you choose.unshackle setupthen puts the tools, config, and data in your user data and config directories.The install script installs
uvand unshackle, then runsunshackle setup. In a clone, run every command asuv run unshackle .... The examples on this site use the bareunshacklecommand.External tools
unshackle shells out to FFmpeg, MKVToolNix, shaka-packager, Bento4, and
"},{"location":"#a-first-download","title":"A first download","text":"dovi_toolfor decryption, repacking, muxing, and Dolby Vision work.unshackle setupdownloads them when a portable build exists for your system. See Installation for the full list and recommended versions.Every download is three parts: the
Download a 1080p title with English audiodlcommand, a service tag, and the service's title argument (a URL, ID, or slug).unshackle dl -q 1080 -l en EXAMPLE 81234567\nThe
"},{"location":"#where-to-go-next","title":"Where to go next","text":"dlcommand carries all the quality, language, and output flags. The service tag (EXAMPLEabove) selects which streaming service to talk to, and unshackle passes the value after it to that service. See the Quickstart to go from a fresh install to your first file.
- Installation. Install unshackle and the external tools it depends on.
- Quickstart. Configure
unshackle.yamland do your first download.- Downloading. The full
dlcommand guide: quality, codecs, languages, track selection, and output.- Configuration. Where
unshackle.yamllives, the directory layout, and how to edit values. Full config key list in the Configuration Reference.- REST API. Operate the
serveHTTP server and drive downloads programmatically.Getting help
Operate
"},{"location":"dev/creating-a-service/","title":"Creating a Service","text":"unshackle --helporunshackle dl --helpat any time. The community Discord is the fastest place to ask questions.A service is a plugin that teaches unshackle how to talk to one streaming platform: how to log in, how to look up a title, what video/audio/subtitle tracks exist, and how to license any DRM. The unshackle core does everything else (track selection, downloading, decryption, muxing, naming).
Who this page is for
This is a developer guide. If you only want to use services, see downloading and configuration. You do not need anything here to operate the CLI. The code of a service is deliberately separate from the core, so it can live in its own private repository.
unshackle ships two reference services. EXAMPLE, at
"},{"location":"dev/creating-a-service/#how-unshackle-finds-a-service","title":"How unshackle finds a service","text":"unshackle/services/EXAMPLE/, is a non-runnable showcase of every framework feature in a single file. Read it alongside this page. Most snippets below come from it. MUSIC_EXAMPLE, atunshackle/services/MUSIC_EXAMPLE/, is the same kind of showcase for a music service. If your platform has songs, read that one too.At startup unshackle scans every path in the
directories.servicesconfig key (a list). Each entry is either a local directory of services or a remote repo spec (a git URL orowner/reposhorthand, which unshackle clones for you). The default is the bundledunshackle/servicesdirectory.Within a services directory, every subfolder that contains an
__init__.pyis 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\nTwo rules the loader enforces:
- The class name must match the folder/tag name exactly. Folder
EXAMPLEmust defineclass EXAMPLE. A mismatch raises aRuntimeError.- List order is priority. The first services path to define a given tag wins, and unshackle ignores the later duplicates. Put your own local overrides last to use them as fallbacks, or first to override a repo.
After installation, you call a service as a subcommand of
dl:unshackle dl EXAMPLE 20914\nunshackle dl EX 20914 # via an ALIAS\nConfig lives next to the code
A service's own settings go in
config.yamlinside the service folder (directories.services/<TAG>/config.yaml). This is separate from the globalunshackle.yaml. Access it in code asself.config[...]. Never hardcode URLs, user agents, or certificates. Put them here.
config.yamlis shared: keep secrets out of itTreat
config.yamlas a checked-in file of defaults: it belongs in the service repo, and everyone who runs the service shares it. Per-user secrets (API keys, device IDs, account tokens) must not live here. Those go in the user's ownunshackle.yamlunderservices.<TAG>, which is merged intoself.configat runtime. That checked-in-defaults / per-user-overrides split is exactly why every URL lives inconfig.yamlwith{}/{name}placeholders while the secrets that fill them stay out of it. Both halves read back through the sameself.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\nA service loaded from a remote repo lives outside the
"},{"location":"dev/creating-a-service/#the-service-base-class","title":"The Service base class","text":"unshacklepackage, so Python cannot find the absoluteunshackle.services.<TAG>.*path and the import fails withno known parent package. Relative imports work both locally and from a cloned repo.Every service subclasses
"},{"location":"dev/creating-a-service/#class-variables","title":"Class variables","text":"unshackle.core.service.Service. The minimum is a Clickclientry point plus three abstract methods.Declared at class level to configure framework behaviour:
Variable Type PurposeALIASEStuple[str, ...]Extra tags that resolve to this service (e.g.(\"EX\", \"DOMAIN\")). Case does not matter. Default().GEOFENCEtuple[str, ...]IP region codes the service requires. Empty = no geofence. unshackle treats the first entry as the main region for auto-proxy.GEOBLOCKtuple[str, ...]IP region codes where the service refuses to work. Every other region is allowed. Default().VAULT_TAGOptional[str]Overrides the key-vault namespace so sibling services can share one vault. DefaultNone(use the service's own tag).AUTH_METHODSOptional[tuple[str, ...]]Auth methods accepted (\"cookies\"/\"credentials\"). WhenNone, the REST/servicesendpoint infers them fromauthenticate().NO_SUBTITLESboolSetTrueon a service with no subtitle tracks to skip subtitle handling entirely.ANIMEboolSetTruewhen the catalogue is anime, so metadata lookups prefer AniList. A title's ownanimeflag overrides it.DAILYboolSetTruewhen the catalogue is daily/date-based (talk shows, news, sports), so unshackle names episodes by air date. A title's owndailyflag overrides it.
NO_SUBTITLESis a convention, not a base-class attribute
dl.pyuseshasattrto findNO_SUBTITLES, and theServicebase class deliberately does not declare it, so it only exists if your service defines it. SettingNO_SUBTITLES = Trueis what makes the pipeline skip subtitle work for services that never carry subtitles. Leave it off otherwise.
GEOFENCEdrives 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 toGEOFENCE[0]from your configured proxy providers.
GEOBLOCKis the negative form: the service works everywhere except the listed regions. With noGEOFENCEto name a main region, aGEOBLOCKhit stops the run with a \"not available in your region\" error; pass--proxyyourself."},{"location":"dev/creating-a-service/#what-service__init__-wires-up","title":"Whatclass EXAMPLE(Service):\n ALIASES = (\"EX\", \"DOMAIN\")\n GEOFENCE = (\"US\", \"UK\")\n VAULT_TAG = \"DIFFERENT_NAME\"\n\n\nclass WORLDWIDE(Service):\n GEOBLOCK = (\"GB\",)\nService.__init__wires up","text":"When you call
Attribute What it issuper().__init__(ctx), the base class sets up the attributes you will use throughout the service:self.configThis service'sconfig.yamlcontents (a dict), orNoneif absent.self.logAlogging.Loggernamed after your class.self.sessionA preparedrequests.Sessionwith config headers and a retry adapter (5 retries, backoff 0.2, retries on 429/500/502/503/504).self.cacheACacherfor arbitrary key/value data, for example authentication tokens.self.title_cacheA region/account-awareTitleCacherused byget_titles_cached.self.cache_dirconfig.directories.cache / <ClassName>.self.track_requestATrackRequestbuilt from the CLI--vcodec/--range/--best-availableflags.self.credentialNoneuntilauthenticate()runs.self.current_regionThe two-letter region resolved from proxy/IP.
self.track_requestis 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\nRead the request, don't second-guess the user
Services may narrow
track_requestfor hard technical constraints (e.g. \"this service delivers HDR only as HEVC\"), but must not filter the tracks they return by resolution, bitrate, or language. All selection is done by the core afterget_tracksreturns.Override
get_session()to defeat TLS-fingerprint bot detectionThe base
"},{"location":"dev/creating-a-service/#the-methods-you-write","title":"The methods you write","text":"get_session()returns a plainrequests.Session(config headers + retry adapter) and deliberately does not do TLS impersonation. When a streaming platform blocks you on TLS fingerprint alone, override it to returnsession(\"Chrome131\")(anyrnet.Impersonatepreset).RnetSessionis a drop-inrequests.Sessionreplacement (same.get/.post/cookie-jar interface), so nothing else in the service changes. If instead you need specific SSL cipher behaviour, mountunshackle.core.utils.sslciphers.SSLCiphersonto a plainrequests.Session.unshackle calls the methods in the order shown. Only the three abstract ones are mandatory.
"},{"location":"dev/creating-a-service/#cli-the-click-entry-point","title":"graph LR\n A[cli] --> B[__init__] --> C[authenticate] --> D[get_titles] --> E[get_tracks] --> F[get_chapters]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 commandunshackle chooses the command from the folder/class name, not from the
name=in@click.command. A mismatchedname=still loads and runs. It only produces confusing--help/usage output that names the wrong command. That confusing output is the reason to keep it in sync, not any requirement of the command lookup.@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\nYour docstring becomes the CLI
--helptext (help=__doc__), so document the accepted URL/ID formats and options there.CDM-aware behaviour
The resolved CDM is on
"},{"location":"dev/creating-a-service/#authenticate-optional-login","title":"ctx.obj.cdm(may beNonefor DRM-free runs). Useis_widevine_cdm()/is_playready_cdm()fromunshackle.core.cdm.detectto classify it. These work correctly for local and remote CDMs, so never hand-roll anisinstancecheck. Services often pick a device profile or manifest endpoint based on which DRM the CDM speaks.authenticate: optional login","text":"Override to log in with cookies and/or credentials. Call
super().authenticate()first: the base implementation loads the cookie jar intoself.session.cookiesand storesself.credential. Do all token fetching here. It runs beforeget_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\"]\nCookies come from files under
directories.cookies. Credentials come from thecredentialsmap inunshackle.yaml. For interactive prompts (OTP, captcha) useself.request_input(prompt), never a bareinput(). Underservemode there is no local terminal, so a bareinput()would hang the server waiting on stdin that never arrives.request_inputinstead relays the prompt to the client: a remote-dl session gets it through the attachedInputBridge, and a REST download job shows it asinput_prompt. Locally it routes through the shared Rich console (prompt_user) so the prompt renders correctly alongside progress and log output.Make each token cache entry unique
The cache above uses
tokens_{device}_{profile}on purpose. A token cache must include every dimension that changes the token (device, profile, orcredential.sha1), or two profiles/devices will share one cache entry and stomp each other's tokens. This is not obvious from the caching API, which happily lets you use a single flat name.Use the credential carefully
Use the
"},{"location":"dev/creating-a-service/#get_titles-required","title":"Credentialonly insideauthenticate()to get tokens. Do not stash it or the raw cookie jar elsewhere. The base class already caches identity for you.get_titles: required","text":"Return a titles collection for the given ID. The return type depends on the kind of title:
Return Contains Use forMoviesMovieobjects FilmsSeriesEpisodeobjects Shows (flatten seasons into episodes)AlbumSongobjects MusicEvery title carries a
language(its original recorded language) and an arbitrarydatadict you can use to stash metadata for later methods. You must return at least one title. If you return none, unshackle treats the ID 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 ])\nCommon constructor fields:
MovieEpisodeSongMovie(id_, service, name, year=None, language=None, data=None, description=None)\nEpisode(id_, service, title, season, number, name=None, year=None,\n language=None, data=None, description=None, air_date=None,\n part=None)\nPass
air_datefor daily and sports titles to name by date instead ofSxxExx. Return aSeries([...])of episodes. Passpartonly when the service splits one episode into several videos, see Split episodes.Song(id_, service, name, artist, album, track, disc=1, year=None,\n language=None, data=None, ...)\nReturn an
Album([...])of songs.discdefaults to 1 andyearis optional. Optional metadata keywords such asalbum_artist,genre,isrc,label,lyricsandartwork_urlfeed the tagger and the naming templates. Read the class for the current list.From
get_tracks(), give oneAudiotrack for each codec and bitrate you offer for the song. The framework then selects one with its usual audio options. ASongskips the muxer, so restore the real container suffix on the track path inon_track_downloaded. The MUSIC_EXAMPLE service shows the full music surface.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
"},{"location":"dev/creating-a-service/#split-episodes","title":"Split episodes","text":"self.get_titles_cached()instead ofget_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-servicetitle_mapconfig.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.numberstays anint. The optionalpartcarries the index.Episode(..., season=1, number=1, part=1, name=\"The Reckoning\")\nEpisode(..., season=1, number=1, part=2, name=\"The Reckoning\")\n
partcounts from 1, contiguous. unshackle rejectspart=0, and-wcan only address parts up to 99.- Every part of one episode shares the same
seasonandnumber.- Each part is a separate
Episodewith its own uniqueid_.- Unsplit episode: leave
partunset. Never passpart=1for a whole episode.- Do not put the part in
nameornumber. Pass a cleanname(orNone) and setpart.partis 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.
partaccepts anintor a digit string. A value that is not an integer raisesTypeError. A value of0or less raisesValueError.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
partargument.Clean the part out of the name
Episodediscards anynamethat starts withEpisode <number>, so a raw\"Episode 1 (Part 2): The Reckoning\"leaves you with no episode name at all. Remove the prefix before you pass it, or passNone.Once
partis set, the filename gains a part token (S01E01.Part.2, see Output & naming),-w S01E01.2selects a single part while a bare-w S01E01selects them all, and the--list-titlestree labels the episode01.2. The REST API reports it as an extrapartfield on the serialized title, described in Endpoints.Opt-in and additive
Leaving
"},{"location":"dev/creating-a-service/#get_tracks-required","title":"partunset costs you nothing: filenames, sort order,-wmatching and the API JSON are identical to a plain episode, so a service that does not split episodes has nothing to adopt.get_tracks: required","text":"Given one title, return a
Tracksobject holdingVideo,Audio, andSubtitletracks. In almost all cases you assemble these by parsing a manifest, not by hand.The manifest parsers live in
unshackle.core.manifests:from unshackle.core.manifests import DASH, HLS, ISM\nEach exposes
DASH (.mpd)HLS (.m3u8)ISM (Smooth Streaming)from_url(url, session=...)(orfrom_text(text, url)) and then.to_tracks(language=...):def get_tracks(self, title: Title_T) -> Tracks:\n return DASH.from_url(manifest_url, session=self.session) \\\n .to_tracks(language=title.language)\ndef get_tracks(self, title: Title_T) -> Tracks:\n return HLS.from_url(manifest_url, session=self.session) \\\n .to_tracks(language=title.language)\nThe 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)\nAlways pass
language=Pass the title's original language to
to_tracks(language=...). For DASH and HLS this is required as a fallback. Ifto_trackscannot derive a track's language and you gave no valid fallback, it raisesValueError. It is also what lets the parser flagis_original_langon each track (throughis_close_match), which drives-l best/allselection 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 theTrackRequest, includingHYBRID(fetch HDR10 + DV and merge) and--best-availableskip-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)\nAfter 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 anAttachment(e.g. cover art). Do not filter for resolution/bitrate. Seefetch_dash_manifestin 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 bitstream, 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 is wrong. Only the service author knows the platform embeds HDR10+, so it is on you to correct
video.rangeto HDR10+ here for those services.
get_tracksis your only chance to capture license inputsThe license callbacks (
get_widevine_licenseand the others) fire much later, in the download/decrypt step, long afterget_trackshas returned, and they receive onlychallenge,title, andtrack. Nothing else from the manifest response reaches them. So you must stash anything a license call needs (the license URL,dt-custom-data, a session token) now, duringget_tracks, ontoself.license_dataortitle.data. This is a lifecycle-timing consequence you cannot infer from the callback signatures.Give the KID if you cheaply can
If you can get a track's Key ID (32-char hex) without downloading the track data, set
"},{"location":"dev/creating-a-service/#get_chapters-required","title":"track.kid. It speeds up the decryption and key-lookup path. And be sure encrypted tracks carry the correctdrmso the core licenses them.get_chapters: required","text":"Return a
Chaptersobject of 0 or moreChapters. It sorts by timestamp, numbers the chapters at mux time, and adds a00:00:00.000chapter if none exists. Timestamps take\"HH:MM:SS[.mmm]\", int milliseconds, or float seconds.def get_chapters(self, title: Title_T) -> Chapters:\n chapters = [Chapter(name=c.get(\"name\"), timestamp=c[\"start\"]) for c in title.data.get(\"chapters\", [])]\n return Chapters(sorted(chapters, key=lambda c: c.timestamp))\nSort by timestamp before you build the
Chapters
Chapters.add()adds its own unnamedChapter(0)once you add a chapter that is not at0, and a duplicate timestamp raisesValueError. Sort first, so yours lands before it.You never need to add
Chapter(0). An API chapter at0keeps its name. Drop duplicate timestamps.Close a named range with an unnamed chapter
An unnamed
Chapterends the range before it:Chapter(name=\"Intro\")at the start, an unnamed one where the intro ends.Don't invent chapter names
Never write
"},{"location":"dev/creating-a-service/#handling-drm","title":"Handling DRM","text":"\"Chapter 1\"; leave unnamed chapters unnamed. Users setchapter_fallback_namein their config (for example,\"Chapter {i:02}\").The core drives licensing. Your job is to answer the license challenges the CDM produces. Mark encrypted tracks with the right DRM in
Method For Returnsget_tracks(the manifest parsers do this automatically for PSSH/EXT-X-KEYfound in the manifest), then write the license callbacks you need. Each receives the challenge plus the currenttitleandtrack:get_widevine_service_certificateWidevine privacy mode The service certificate (bytes or base64 str), orNone.get_widevine_licenseWidevine The raw license response (bytes or base64 str), unmodified.get_playready_licensePlayReady The license response. Defaults to delegating toget_widevine_license.get_clearkey_licenseDASHorg.w3.clearkeyThe JWK Set (dict/JSON/bytes), orNoneto 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\nReturn 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 levelsPick the lowest level that works for the platform:
- The manifest carries a
<Laurl>: write nothing. The framework POSTs the challenge to that URL itself.- Your service needs a custom endpoint or extra headers: override
get_clearkey_licenseto do the POST yourself.- Keys arrive obfuscated or through a bespoke channel: fetch and unwrap the content key in
get_tracksand pre-populatedrm.content_keys[kid] = key_hexon the track'sClearKeyCENC. The non-obvious payoff: when every KID is already keyed, the framework skips the license round-trip entirely.Things the service does not do:
- HLS AES-128 (
ClearKey) tracks and DRM-free tracks have no license callback. The content key comes from the manifest, and unshackle applies it directly.- Key vaults cache
KID:KEYpairs 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 overridesearch()to yieldSearchResultobjects forunshackle search."},{"location":"dev/creating-a-service/#minimal-service-skeleton","title":"Minimal service skeleton","text":"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\"))\nA complete, minimal single-manifest service. Save as
services/MYSVC/__init__.pyservices/MYSVC/__init__.py(class nameMYSVC), with an optionalservices/MYSVC/config.yamlbeside it.services/MYSVC/config.yamlfrom __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\nendpoints:\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\nTry it with:
"},{"location":"dev/creating-a-service/#checklist","title":"Checklist","text":"unshackle dl MYSVC https://myservice.com/watch/abc123\n
- Folder name, class name, and Click
name=all match the tag exactly.cliis a@staticmethodthat returns an instance.__init__callssuper().__init__(ctx).get_titles,get_tracksandget_chapterswritten. Titles carry the originallanguage.- Tracks come from a manifest parser with
to_tracks(language=title.language). No filtering by quality or language.- Encrypted tracks carry their DRM. The license callbacks you need return responses unmodified.
- URLs, user agents, and certificates live in
config.yaml, read throughself.config[...].- Multi-file services import their own modules relatively (
from .helpers import x), so they work when loaded from a repo.Read
"},{"location":"dev/service-architecture/","title":"Service Architecture","text":"unshackle/services/EXAMPLE/end to end. It annotates every feature touched above in one place. For a music service, readunshackle/services/MUSIC_EXAMPLE/too.This page is the conceptual map of the internal object model that a service author works against. It ties together the moving parts: the
Servicebase class, theTitlehierarchy a service returns, theTrackmodel those titles carry, the manifest parsers that make tracks, the DRM hooks that license and decrypt them, and the request and HTTP session objects wired up for you at construction time.Who this page is for
This is a developer reference. If you only want to download titles, you never touch these classes directly. See Downloading and the CLI reference instead. If you write or read a service plugin, this page explains what each object is and how the pieces connect. For a guided, step-by-step walkthrough, 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 only answers questions. The
dlcommand 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 (movie / episode)\n \u2502 a Song skips the muxer and keeps its own container\n
get_titles()returns aTitlescollection: the movies, episodes, or songs the platform exposes for the requested ID.get_tracks(title)returns aTracksobject for a single chosen title. In practice this means fetching a manifest and handing it to a parser, which builds theVideo/Audio/Subtitleobjects.get_chapters(title)returns any chapter markers.The core does everything after that (track selection, licensing, decryption, repacking, subtitle conversion, and muxing) with the objects your service returned. Your job is to give accurate
"},{"location":"dev/service-architecture/#the-service-base-class","title":"TheTitleandTrackobjects. The framework does the rest.Servicebase class","text":"Every service subclasses
"},{"location":"dev/service-architecture/#class-variables","title":"Class variables","text":"unshackle.core.service.Service, an abstract base class. Two kinds of members matter: class variables that give the details of the service, and methods that the pipeline calls.Declared at the top of your service class, these are static descriptors:
Class variable Type PurposeALIASEStuple[str, ...]Alternate tags the service answers to, in any case (resolved byServices.get_tag).GEOFENCEtuple[str, ...]ISO region codes the service requires; empty means no geofence. unshackle treats the first entry as the \"main region\" for automatic proxy selection.GEOBLOCKtuple[str, ...]ISO region codes where the service refuses to work; every other region is allowed. Use it for a service that works worldwide except in its home market.VAULT_TAGOptional[str]Overrides the key-vault namespace; defaults to the service's own tag.AUTH_METHODSOptional[tuple[str, ...]]Accepted auth methods (\"cookies\"/\"credentials\"). WhenNone, the REST/servicesendpoint infers them fromauthenticate().ANIMEboolThe catalogue is anime, so metadata lookups prefer AniList. A title's ownanimeflag overrides it.DAILYboolThe catalogue is daily/date-based, so unshackle names episodes by air date. A title's owndailyflag overrides it."},{"location":"dev/service-architecture/#required-methods","title":"Required methods","text":"These are abstract. A service will not load unless it defines all three:
"},{"location":"dev/service-architecture/#optional-methods-and-hooks","title":"Optional methods and hooks","text":"
get_titles() -> Titles_T: find the title collection for the requested ID.get_tracks(title) -> Tracks: assemble the track set for one title.get_chapters(title) -> Chapters: return chapter markers (may be empty)."},{"location":"dev/service-architecture/#what-__init__-wires-up-for-you","title":"What
authenticate(cookies=None, credential=None): the base implementation loads aCookieJarintoself.session.cookiesand storesself.credential. Override it to do a real login handshake.search(): raisesNotImplementedErrorby default. Write it so that thesearchcommand can operate.- DRM license hooks:
get_widevine_service_certificate,get_widevine_license,get_playready_license(delegates to Widevine by default), andget_clearkey_license(returnsNoneby default). See DRM hooks below.request_input(prompt): prompts the user. It goes through the remote-dl input bridge, the REST download job relay, or the rich console.- Event callbacks:
on_segment_downloaded,on_track_downloaded,on_track_decrypted,on_track_repacked,on_track_multiplexlet a service react to pipeline events (for example, MonaLisa decrypts each segment inon_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 theServicebase class at all:dl.pyprobes for it withhasattrand calls it (once, after track selection) only if your service defines it. Use it when your service must fetch or inject keys in bulk before download, separate from the per-track license callbacks below.__init__wires up for you","text":"You rarely override
Attribute What it is__init__. The base constructor takes the Click context and sets up the objects your methods rely on:self.configThe per-service config slice (see Request and HTTP session objects).self.logAlogging.Loggernamed after your class.self.sessionA ready-to-use HTTP session (headers, retries, and proxy applied).self.cacheA generic key/valueCachernamespaced to the service.self.title_cacheA region- and account-awareTitleCacher.self.cache_dirconfig.directories.cache / <ServiceName>.self.credentialThe activeCredential, orNoneuntil authenticated.self.track_requestATrackRequestdescribing the requested codecs, ranges, and best-available flag (see below).The constructor also resolves proxies: it reads
--proxyand theproxy_queryandproxy_providercontext params thatdlderives from it, consults the per-serviceproxy_map, and, ifGEOFENCEis set and you did not give an explicit proxy, does a live IP check and auto-fetches a proxy toGEOFENCE[0]when it detects a geoblock. AGEOBLOCKhit with noGEOFENCEstops the run with a \"not available in your region\" error, because there is no main region to fetch a proxy for; pass--proxyyourself. A proxy whose exit is in aGEOBLOCKregion stops the run the same way. See Proxies and VPN.
TrackRequest"},{"location":"dev/service-architecture/#titles-and-the-title-hierarchy","title":"Titles and the
self.track_requestis a small dataclass built from the CLI:codecs(from--vcodec, empty means \"accept any\"),ranges(from--range, default[Video.Range.SDR]), andbest_available. Use it inget_tracksto decide which variants to fetch.Titlehierarchy","text":"Media kind Item (
get_titles()returns a title collection, one of theTitles_Ttypes. Each collection holds one or moreTitleobjects (Title_T). There are three parallel families, one per media kind:Title_T) Collection (Titles_T) MoviesMovieMoviesTVEpisodeSeriesMusicSongAlbum, andMusic(the base collection)All items subclass
Title, which carries the shared identity: a uniqueid, the originatingserviceclass, an optional originallanguage, arbitrarydata, and a freshly-constructedself.tracks = Tracks()thatget_trackswill fill. Collections use sorted lists, so titles stay in a stable order.The unique ID is validated
"},{"location":"dev/service-architecture/#constructors-at-a-glance","title":"Constructors at a glance","text":"
Title.__init__rejects a falsyid_, 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.Each subclass adds its own required fields on top of
MovieEpisodeSongid_andservice:Movie(id_, service, name, year=None, language=None, data=None, description=None)\nYou must give
name.yearis optional, but it must be a positive integer if you give it.Episode(id_, service, title, season, number,\n name=None, year=None, language=None, data=None,\n description=None, air_date=None)\n
titleis the show name. You must giveseasonandnumber, and0is a valid value. unshackle converts anair_dateto adateso the{date}naming token stays clean, and it discards a placeholder episodenamelike\"Episode 4\"automatically.Song(id_, service, name, artist, album, track, disc=1, year=None,\n language=None, data=None, ...)\nYou must give
name,artist,album, andtrack, and unshackle validates them.discdefaults to 1, andyearis optional. A single therefore needs nodisc, and an undated release needs noyear.A
Songalso takes optional metadata keywords that the tagger and the naming templates read:album_artist,release_type,total_tracks,total_discs,genre,explicit,isrc,upc,copyright,label,lyrics, andartwork_url. Read the class for the current list.The tagger also falls back to
Song.datafor fields the constructor did not get, such as the composer or a full release date. It reads the top level of that dict and a nestedmetadatasub-dict, with the nested keys winning, so either shape works.From
get_tracks(), a music service gives oneAudiotrack for each codec and bitrate it offers for aSong. The framework then selects one with its usual audio options,-a/--acodecand--abitrate, and its usual audio sorting. A movie or an episode uses the same options.-q/--qualitysets the video height only and does not apply to music.Audiohas nobit_depthorsample_ratefield. Thereforebitrateseparates a hi-res FLAC from a CD FLAC, and the audio sort already ranks it.A
Songis an ordinaryTitleon the one generic download path. unshackle has no music-specific service API and no separate music download loop. TheGROUP_AUDIO_DOWNLOADSflag,get_music_track_optionsandget_music_collection_labelno longer exist, and a music service does not need them.Restore the container in
on_track_downloadedEvery track downloads to a temp file named
Audio_<id>.mp4whatever it holds, and the final move keeps that suffix. ASongskips the muxer, so its download is the delivered file, and the tagger picks its metadata format from the suffix. Rename the track path to the true container inon_track_downloaded, or a FLAC lands as an untaggable.mp4. The event fires twice, once before decryption and once at the end, so wait untiltrack.drmis clear before you rename.The MUSIC_EXAMPLE reference service, at
"},{"location":"dev/service-architecture/#title-remapping","title":"Title remapping","text":"unshackle/services/MUSIC_EXAMPLE/, shows the full music surface.After the service fetches the titles (and after any cache), the framework applies
remap_titles(titles, title_map)with the per-servicetitle_mapconfig. This does an exact-string rename (it comparesEpisodes on their showtitle, andMovies andSongs on theirname), so you can change a platform's odd naming to what your library expects.get_titles_cached()also cache-wraps the titles, and it is region- and account-aware. See the TitleCacher notes.The ordering here is deliberate on both sides. The framework applies
"},{"location":"dev/service-architecture/#tracks-and-the-track-model","title":"Tracks and thetitle_mapafter 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.Trackmodel","text":"A
Tracksobject is the containerget_tracksreturns. It has typed buckets (videos,audio,subtitles,chapters, andattachments) plus amanifest_url. Iterating aTracksyields 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
"},{"location":"dev/service-architecture/#the-base-track","title":"The base
Tracks.addrefuses 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 assemble tracks by hand, keep IDs deterministic oraddwill raise.Track","text":"Descriptor Delivery
Trackis the base forVideo,Audio, andSubtitle. (Chapter,Chapters, andAttachmentare standalone classes, notTracksubclasses.) Every track records its delivery method inTrack.Descriptor, which tells unshackle how to download it:URLA single direct URL.HLSHTTP Live Streaming.DASHDynamic Adaptive Streaming over HTTP.ISMSmooth Streaming.The main attributes include
"},{"location":"dev/service-architecture/#video","title":"url(a string or list of strings),language(alangcodes.Language),drm(an optional iterable of DRM objects),needs_repack, and descriptor-specific state stashed intrack.data[\"hls\"]/track.data[\"dash\"]. If you do not give aname, unshackle derives a human-readable name from the language.Video","text":"
Videoaddscodec,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), andHYBRID.HYBRIDis special: it tells the pipeline to select both an HDR10(+) base and a DV track and merge the DV RPU onto the HDR10 track withdovi_tool(see Dolby Vision hybrid).Bitrate units
Both
"},{"location":"dev/service-architecture/#audio","title":"VideoandAudiostorebitratein bits per second (rounded up). The string form displays kb/s, and--vbitrateand--abitratecompare in kb/s.Audio","text":""},{"location":"dev/service-architecture/#subtitle","title":"
Audioaddsbitrate,channels,joc(Joint Object Coding, the Atmos indicator), anddescriptive.Audio.CodeccoversAAC,AC3(DD),EC3(DD+),AC4,OPUS,OGG(Vorbis),DTS,DTSX,ALAC, andFLAC. Theatmosproperty isTruewhenjocis set (or whenextraflags it).Subtitle","text":""},{"location":"dev/service-architecture/#chapters-and-attachments","title":"Chapters and attachments","text":"
Subtitleadds three mutually-constrained flags:cc(closed captions),sdh(subtitles for the deaf and hard-of-hearing), andforced. A track cannot be bothccandsdh, andforcedcannot combine with either.Subtitle.CodecincludesSubRip(SRT),WebVTT(VTT),SubStationAlpha/SubStationAlphav4(SSA/ASS),TimedTextMarkupLang(TTML),SAMI, and the box-encapsulated DASH formatsfTTML(STPP) andfVTT(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/#selection-sorting-and-muxing","title":"Selection, sorting, and muxing","text":"
Chapter(timestamp, name=None): a single marker. The timestamp acceptsHH:MM:SS[.mmm], integer milliseconds, or float seconds.Chapters: a sorted collection that auto-inserts a00:00:00.000chapter and reads/writes OGM Simple format.Attachment: a font or image attached to the mux (for example, fonts an ASS subtitle references), built from a localpathor aurl."},{"location":"dev/service-architecture/#manifest-parsers","title":"Manifest parsers","text":"
Tracksis also the selection engine thedlcommand uses. It offerssort_videos/sort_audio/sort_subtitles(P2P-standard ordering), predicate-basedselect_*filters,by_resolutions, andby_language. When downloads finish, the same object multiplexes everything:Tracks.mux(title, ...)builds and runs anmkvmergecommand, applying language, default-track, forced, and hearing-impaired flags, attaching chapters and fonts, and writing a.muxed.mkv(or.mka/.mksfor audio- or subtitle-only sets). As a service author you never callmuxyourself. You only return correct tracks.Most services do not assemble tracks by hand. They give a manifest to one of three parsers in
Format Class Notes MPEG-DASH (unshackle.core.manifests. Each parser has the same three-part contract:.mpd)DASHParses<MPD>; enumerates tracks from the first content period. HLS (.m3u8)HLSRequires a variant (master) playlist, not a media playlist. Smooth Streaming (.ism)ISMSynthesizes init segments fromCodecPrivateData."},{"location":"dev/service-architecture/#the-parser-contract","title":"The parser contract","text":"
- Construct:
DASH.from_url(url, session=...)orDASH.from_text(text, url)(and likewise forHLS/ISM). Always pass aurl, even tofrom_text, because the parser needs it to make relative segment paths absolute.to_tracks(...): parses the manifest into aTrackscontainer ofVideo,Audio, andSubtitleobjects, each carrying descriptor-specific state.download_track(...): a staticmethod the core calls to fetch, decrypt, and merge segments. You do not call it. You only return the tracks it will operate on.A typical
get_trackstherefore looks like fetching the manifest text withself.sessionand returningDASH.from_text(mpd, url).to_tracks(language=title.language).Pass the original language
DASH and HLS
"},{"location":"dev/service-architecture/#segment-filtering-hooks","title":"Segment-filtering hooks","text":"to_tracksraiseValueErrorif they cannot derive a track's language and you gave no valid fallback. Always pass the title's original recorded language. For HLS,to_tracksuses this fallback directly for the primary track, because HLS variants carry no language."},{"location":"dev/service-architecture/#format-gotchas-worth-knowing","title":"Format gotchas worth knowing","text":"
- HLS: set
track.OnSegmentFilter(segment)to drop unwanted segments (ads/bumpers). A dropped segment is invisible to the whole download: unshackle ignores itsEXT-X-KEYand itsEXT-X-DISCONTINUITY, so a clear ad break inside an encrypted track does not disturb decryption.- DASH: pass a
period_filtercallable toto_tracksto drop whole periods. unshackle automatically drops the caching-breadth periods that some services send, and the non-contentsegment types."},{"location":"dev/service-architecture/#drm-hooks","title":"DRM hooks","text":"
- DASH enumerates tracks from only the first content period, but downloads segments from all content periods matching the representation. The parser reads
SegmentTemplate,SegmentList, andSegmentBaseaddressing, and$Number%0Nd$padding.- HLS re-fetches the media playlist at download time, recovers
SUPPLEMENTAL-CODECSfrom raw text (python-m3u8 drops it) to detect Dolby Vision composite streams, and uses the media-sequence-number IV for AES-128 keys that have no explicit IV.- ISM has no list of segments and no init segments: the parser synthesizes segment URLs from the
<c>fragment timeline, and rebuilds amoov/ftypinit box fromCodecPrivateData(supported for AVC, HEVC, DV-over-HEVC, AAC, EC-3, and TTML). A track id covers the manifest URL, so a title you build from one ISM manifest per codec or range keeps every variant:Tracks.addno longer reads the second manifest's tracks as duplicates of the first.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 flowWidevineWidevine CDM challenge \u2192 license server \u2192 content keys (.wvddevice)PlayReadyPlayReady CDM challenge (WRM header) \u2192 license server \u2192 content keys (.prddevice)ClearKeyCENCW3Corg.w3.clearkeyover CENC JSON KID request \u2192 JWK set; no CDMClearKeyHLS AES-128 content key fetched directly from a URI; no CDM, no challengeMonaLisaProprietary WASM DRM keys extracted locally; no license server
ClearKeyvsClearKeyCENCDespite the shared prefix these are entirely different systems.
"},{"location":"dev/service-architecture/#the-service-side-hooks","title":"The service-side hooks","text":"ClearKeyis HLS AES-128, decrypted in-process.ClearKeyCENCis the W3C EME JWK flow over MPEG-CENC, decrypted with shaka-packager or mp4decrypt like Widevine.The framework supplies the CDM and the decryption backend. Your service gives the network calls that must carry the CDM's challenge. Override the hooks that your platform needs:
Hook Return Purposeget_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 /NoneForClearKeyCENC; returnNoneto let the framework POST to the manifestLaurl.You do not open CDM sessions or operate shaka-packager yourself. The
"},{"location":"dev/service-architecture/#how-unshackle-chooses-a-cdm-and-finds-keys","title":"How unshackle chooses a CDM and finds keys","text":"Widevine/PlayReadyclasses drive the CDM handshake and call the decryption backend. Your hook is only the \"send this challenge, give me back the license\" step.CDM selection is config-driven: the
"},{"location":"dev/service-architecture/#dolby-vision-and-hybrid","title":"Dolby Vision and hybrid","text":"cdmmap resolves a service tag (and optionally the track quality or DRM type) to a device name, whichload_cdminstantiates as a local.wvd/.prdor a remote CDM. Before ever requesting a license, the framework tries an in-process cache of content keys and the configured key vaults. Only unresolved KIDs trigger an actual license request, and the framework writes newly licensed keys back to every vault. This is why the same content key is rarely re-licensed twice. Users configure all of this in DRM and CDM and Key vaults. The DRM API reference gives the API surface.Two DV mechanisms sit on top of the track model. DV fixup re-exposes DV signalling on HLS streams that carry a valid DV RPU but advertise plain HEVC (
"},{"location":"dev/service-architecture/#request-and-http-session-objects","title":"Request and HTTP session objects","text":"dv_compatible_bitstream). Hybrid processing (Video.Range.HYBRID) selects an HDR10(+) base and a DV donor, extracts the RPU withdovi_tool, and injects it onto the base layer, to make a single track that carries both HDR10 metadata and the DV RPU. Both are automatic once the pipeline selects the right tracks. A service only needs to give accurate ranges.Everything a service does over the network flows through objects the base constructor prepared, all derived from the Click context (
ctx) thatdlpasses in.
self.config: your service's slice ofunshackle.yaml, i.e.config.services[<ServiceName>]. This is a plain dict. Use it for per-service settings like base URLs, an API key, or atitle_map. See Configuration.self.session: arequests.Sessionwithconfig.headersapplied, a retry policy (total=5, backoff, retry on 429/5xx), and any resolved proxy already mounted. Pass this HTTP session into the manifest parsers and DRM calls so headers, cookies, and proxies flow everywhere. (unshackle also ships an rnet-based TLS-fingerprinting HTTP session that the newersession()factory uses and that thenetworkconfig key configures.)self.credential: the activeCredential(username:password[:extra]), populated byauthenticate. unshackle uses itssha1as the account hash for cache keys.self.cache: a genericCacher: JSON key/value storage under the service's cache dir, with CRC32 integrity checks, versioning, and optional expiry (auto-derived from a JWTexpclaim when present). Good for auth tokens and API responses.self.title_cache: aTitleCacherthat wrapsget_titlesresults, keyed by title ID, region, and account hash. It serves fresh data withintitle_cache_time, falls back to stale data withintitle_cache_max_retentionwhen an API call fails, and--no-cachebypasses it.self.track_request: theTrackRequestdescribing what the user asked for (codecs, ranges, best-available), for use insideget_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\nThe directory name (
"},{"location":"dev/service-architecture/#where-to-go-next","title":"Where to go next","text":"EXMP) must match the class name exactly. That stem is the service tag. Put your service in one of thedirectories.servicespaths."},{"location":"dev/rest-api/","title":"REST API Overview","text":"
- Creating a Service: a guided walkthrough that builds on this model.
- Service API: exact signatures for the
Servicebase 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.
unshackle ships with a built-in HTTP server. Alongside the command-line tool, you can operate
unshackle serveand 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 for people who make 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
dlcommand, but as discrete HTTP calls you can script against:
- Discovery: show the services available on the server, use 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, examine 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 job 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
Start the REST API on the default host and portservecommand:unshackle serve\nBy default the server binds to
127.0.0.1:8786and 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
"},{"location":"dev/rest-api/#common-options","title":"Common options","text":"--no-key, the server needs an API secret configured under theservesection of your config. If none is set,unshackle serverefuses to start. See Authentication for how to setapi_secretand how theX-Secret-Keyheader works.The
Option Default Effectservecommand accepts the following flags:-h,--host127.0.0.1Address to bind to. Use0.0.0.0to accept connections from other machines.-p,--port8786Port to bind to.--api-onlyoff Serve only the REST API, without the Widevine/PlayReady CDM HTTP endpoints.--no-widevineoff Disable the Widevine CDM endpoints. Cannot be combined with--api-only.--no-playreadyoff Disable the PlayReady CDM endpoints. Cannot be combined with--api-only.--no-keyoff Disable API-key authentication entirely; every request is allowed.--remote-onlyoff Expose only the remote-session subset of routes. Implies--api-only.--debug-apioff Include tracebacks and worker stderr in error responses.--debugoff Enable debug-level logging.--caddyoff Also launch Caddy as a reverse proxy, using theCaddyfilein your user-config directory. Serve the REST API only, on all interfacesunshackle serve --api-only --host 0.0.0.0 --port 8786\nBinding to a public interface
"},{"location":"dev/rest-api/#server-modes","title":"Server modes","text":"
--no-keydisables authentication completely. Anyone who can reach the port can find titles, download them, and read your configuration. Only combine it with a host like127.0.0.1, or keep the default key-based auth when binding to0.0.0.0. Put a TLS-terminating reverse proxy (Caddy, nginx) in front for anything internet-facing.The routes the server mounts depend on how you start the server:
Default (CDM + REST)--api-only--remote-onlyThe server mounts both the pywidevine/pyplayready CDM endpoints and the full REST API. This is the mode you get when you operate
unshackle servewith no mode flags.The server mounts only the REST API. It does not mount the CDM HTTP endpoints. Authentication uses the single configured API key (or none, with
--no-key).The server mounts only the remote-session subset of routes (health, services, search, and everything under
"},{"location":"dev/rest-api/#base-url","title":"Base URL","text":"/api/session/*). This mode implies--api-only, and the interactive Swagger UI is not mounted.All REST endpoints live under the
/apiprefix. With the default bind address the base URL is:http://127.0.0.1:8786/api\nEvery 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/. The server generates it from the same route table it serves, so it always matches the version you operate. Note that in--api-onlymode the docs page is itself behind the API key. Only/api/healthis exempt from authentication.A quick smoke test that needs no API key:
Confirm the server is upResponsecurl http://127.0.0.1:8786/api/health\n"},{"location":"dev/rest-api/#endpoint-map","title":"Endpoint map","text":"{\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}\nEvery REST path uses JSON request and response bodies and lives under
"},{"location":"dev/rest-api/#discovery","title":"Discovery","text":"Method Path Purpose/api. The table below is a map of the surface. The Endpoints page gives each request and response in full.GET/api/healthLiveness and version check. Never requires an API key.GET/api/servicesList the services available on this server.POST/api/searchRun a service's search for a query.POST/api/list-titlesList the titles behind a service title ID.POST/api/list-tracksList the video, audio, and subtitle tracks for a title."},{"location":"dev/rest-api/#downloads-and-jobs","title":"Downloads and jobs","text":"Method Path PurposePOST/api/downloadQueue a download; returns ajob_id.GET/api/download/jobsList jobs, with filtering and sorting.POST/api/download/jobs/clear-finishedRemove all finished jobs.GET/api/download/jobs/{job_id}Full detail for one job.GET/api/download/jobs/{job_id}/eventsStream 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}/retryRe-queue a finished job's parameters as a new job.POST/api/download/jobs/{job_id}/priorityMove a queued job to the front.POST/api/download/jobs/{job_id}/inputAnswer the prompt that a running job waits on."},{"location":"dev/rest-api/#history-and-server-info","title":"History and server info","text":"Method Path PurposeGET/api/historyRead the persisted log of finished jobs.DELETE/api/history/{job_id}Delete a history entry.GET/api/profilesNamed credential profiles per service.GET/api/configThe server's effective, redacted configuration.GET/api/env/checkPresence and versions of external tools."},{"location":"dev/rest-api/#maintenance","title":"Maintenance","text":"Method Path PurposePOST/api/maintenance/clear-cacheClear the cache directory.POST/api/maintenance/clear-tempClear the temp directory.POST/api/maintenance/refresh-servicesgit pulleach configured service repository and re-import the services that changed."},{"location":"dev/rest-api/#remote-download-sessions","title":"Remote-download sessions","text":"Method Path PurposePOST/api/session/createStart a server-side, authenticated service session.GET/api/session/{session_id}Remote session info and time-to-live.DELETE/api/session/{session_id}Tear down a session.GET/api/session/{session_id}/titlesTitles for the session's title ID.POST/api/session/{session_id}/tracksTracks (with URLs) for a chosen title.POST/api/session/{session_id}/segmentsPer-track segment/download details.POST/api/session/{session_id}/segment_filterServer-side HLS segment filter results for one track.POST/api/session/{session_id}/licenseProxy or server-side DRM licensing.POST/api/session/{session_id}/keys/badReport a server-vault content key that did not decrypt.GET/api/session/{session_id}/logsDrain the service's server-side log output.GET/api/session/{session_id}/promptPoll for interactive auth status/prompt.POST/api/session/{session_id}/promptSubmit an answer to an auth prompt.Remote-only routes
The health, services, search, and
"},{"location":"dev/rest-api/#authentication-in-one-line","title":"Authentication in one line","text":"/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-onlymodes.With the default configuration, send your API secret in the
An authenticated requestX-Secret-Keyheader on every request except/api/health:curl -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\"}'\nA missing or unknown API key returns
"},{"location":"dev/rest-api/#where-to-go-next","title":"Where to go next","text":"401. Authentication gives the full model, including per-key service allowlists."},{"location":"dev/rest-api/authentication/","title":"REST API Authentication","text":"
- Quick Start: start the server, set an API key, and operate your first end-to-end download over the API.
- Authentication: the
X-Secret-Keyheader,--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
queuedto 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.
The unshackle REST API protects every endpoint (with one exception) behind a single API key. Clients present that API key in a request header, and the server examines it against the API keys in your configuration. This page explains how that check works, how to configure API keys, and how clients send them. It also explains the extra controls on top of the API key: per-key service allowlists, per-key CDM device access, and remote session IP binding.
You start the API with the
unshackle servecommand. 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 servecan operate in two shapes, and each uses a slightly different piece of code to examine your API 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 the developer notes below give them.
"},{"location":"dev/rest-api/authentication/#the-short-version","title":"The short version","text":"A minimal authenticated request
- Add an
api_secretto theservesection of yourunshackle.yaml.- Start the server with
unshackle serve.- Send that secret in the
X-Secret-Keyheader on every request exceptGET /api/health."},{"location":"dev/rest-api/authentication/#configuring-authentication","title":"Configuring authentication","text":"curl -H \"X-Secret-Key: your-secret-key-here\" \\\n http://127.0.0.1:8786/api/services\nAll authentication settings live under the
"},{"location":"dev/rest-api/authentication/#a-single-api-key","title":"A single API key","text":"servesection of your unshackle config file (unshackle.yaml).The simplest setup defines one shared secret with
unshackle.yamlapi_secret:serve:\n api_secret: \"a-long-random-secret-string\"\nThis value becomes the one and only API key that the server accepts. The server allows requests that present it in the
X-Secret-Keyheader. The server rejects everything else.The server will not start without a key
If you start
unshackle servewith noapi_secretconfigured, 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.\nChoose a long, unpredictable value. The API 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 withusers","text":"For finer control you can define named users, each with their own API key. A user entry can also grant CDM device access and restrict which services that API 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\"]\nThe config key of each entry under
users(for examplea-secret-key-for-alice) is the secret that client sends inX-Secret-Key. Theusernameis a human-readable label used in logs and in job/history metadata.How
api_secretanduserscombine (integrated server)On the integrated server, the server automatically adds the configured
api_secretas an extra accepted API key alongside everyone inusers. It gives that API key access to all loaded Widevine and PlayReady devices, and labels itapi_user. Soapi_secretbehaves as an admin-level API key in addition to any named users you define.On the API-only server, the server accepts only
"},{"location":"dev/rest-api/authentication/#cdm-device-access-per-api-key","title":"CDM device access per API key","text":"api_secret: the single-key model. (See the developer notes for the exact difference.)Under the integrated server, an API key can do DRM licensing only through a CDM device that its user entry lists:
Config key Grants access todevicesWidevine.wvddevices, by nameplayready_devicesPlayReady.prddevices, by namePlayReady access is opt-in per user
If a user entry omits
playready_devices, the server gives that API key no PlayReady access, and logs a warning at startup. You must name the PlayReady devices explicitly for any API key that needs them. Widevine and PlayReady device files themselves are auto-loaded from your WVDs and PRDs directories.Grants decide whether the server returns content keys
The devices on an API key decide which CDM the server can use. Two grants in the same user entry decide whether the server returns content keys (
"},{"location":"dev/rest-api/authentication/#disabling-authentication","title":"Disabling authentication","text":"KID:KEY) for a remote session.server_cdmlets the server license with those devices.server_vaultlets a remote session that the client's own device licenses read the server vault, and the server then makes no live licence. Without either grant, the server can only proxy CDM challenges, so the client must operate its own CDM. Seeservefor both grants.The
--no-keyflag turns authentication off entirely. The server allows every request, ignoresX-Secret-Key, and needs no API key in your configuration.unshackle serve --no-key\nOnly use
--no-keyon a trusted, isolated networkWith
"},{"location":"dev/rest-api/authentication/#how-clients-present-credentials","title":"How clients present credentials","text":"--no-key, any client that can reach the port can show your services, start downloads, and use your CDM devices. Never put a--no-keyserver on an untrusted network.Clients authenticate by sending the secret in a single HTTP request header on every call:
X-Secret-Key: <your-secret>\nThe value must match one of the configured API keys exactly (
api_secret, or one of the keys underusers). There is no login step, no HTTP session cookie, and no token exchange for API access. The client sends the same static API key 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 API key in asecret_keyquery parameter. This is for browsers: theEventSourceAPI 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}`);\nThe header always wins. If the request has an
X-Secret-Keyheader, the server examines that header and ignores the query parameter. The server rejects a wrong header even when the query parameter holds a valid API key.An API key in a URL is easier to leak than an API 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\nimport 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())\nconst 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());\nThe reference client sets it for you
unshackle's own remote download client (
"},{"location":"dev/rest-api/authentication/#the-health-check-is-always-open","title":"The health check is always open","text":"RemoteService) sendsX-Secret-Keyautomatically, reading the API key from yourremote_servicesconfig. It also sends aUser-Agentofunshackle/<version>. You only deal with the header directly when writing your own client.One endpoint is exempt from authentication:
No key neededGET /api/health. It never requires an API key, so you can use it for liveness and version probes without embedding a secret in your monitoring:Responsecurl http://127.0.0.1:8786/api/health\n{\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}\nEvery other endpoint (including the Swagger UI at
"},{"location":"dev/rest-api/authentication/#authentication-failures","title":"Authentication failures","text":"/api/docs/on the API-only server) requires a valid API key.When the server rejects a request for authentication reasons, the response has an HTTP
Missing headerWrong key401 Unauthorizedstatus and a small JSON body.Sending a request with no
X-Secret-Keyheader:{\n \"status\": 401,\n \"message\": \"Secret Key is Empty.\"\n}\nSending an API key that is not in your config:
{\n \"status\": 401,\n \"message\": \"Secret Key is Invalid.\"\n}\nAuth 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\": \"...\"}, wherestatusis an integer, and there is noerror_codeortimestamp.In your client, treat any
"},{"location":"dev/rest-api/authentication/#rate-limit-rejections","title":"Rate limit rejections","text":"401as an authentication problem and parse both shapes, because the standard error body is not always present.An API key that carries a rate limit gets
429 Too Many Requestsonce it goes over, from the same middleware and in the same shape as the401bodies above, with aRetry-Afterheader in seconds:{\n \"status\": 429,\n \"message\": \"Rate limit exceeded.\"\n}\nA limit comes from
serve.tiers.<name>.rate_limitor from the API key's ownserve.users.<key>.rate_limit, in requests per hour. An API key with neither has no limit, andGET /api/healthis never limited. See dashboard rate limits for the config and the counters, and services configuration for the keys themselves.
429is not theRATE_LIMITEDerror body"},{"location":"dev/rest-api/authentication/#access-controls-on-top-of-the-api-key","title":"Access controls on top of the API key","text":"
RATE_LIMITEDin Errors is a service rate limit that a handler raises, and it arrives in the standard envelope with anerror_code. The429here comes from the middleware before any handler runs, and has the integer-statusshape shown above. A client that readserror_codeto classify a429must cope with it being absent.After the server authenticates a request, several additional controls decide what that specific API key can do.
"},{"location":"dev/rest-api/authentication/#per-key-service-allowlists","title":"Per-key service allowlists","text":"You can restrict which services an API key may use. Two layers combine:
- Global allowlist:
serve.services, applied to every API key.- Per-key allowlist:
servicesinside that key'susersentry.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\nEndpoints that name a service (listing services, searching, listing titles/tracks, downloading, profiles, history, and remote sessions) all filter against this allowlist. The server rejects a request for a service outside the API key's allowlist as an invalid service. The server matches service tags after normalisation, so casing and aliases give the same result.
"},{"location":"dev/rest-api/authentication/#cdm-overrides-and-per-job-credentials","title":"CDM overrides and per-job credentials","text":"The server config gates two download-time capabilities. Unless you enable them, the server refuses them with
Config key Default Controls403 Forbidden, even for an authenticated API key:serve.cdm_overridesoff Whether a request may pick a specific CDM device via thecdmfield. Set totrueto allow any, or to a list/set of device names to allow only those.serve.allow_job_credentialsoff Whether a request may pass its owncredential/credentialslogin 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\nWhen to enable these gates
The two gates are fully independent, and you can combine them. For a locked-down or public-facing deployment, leave both unset. They then default off and reject with
403, so client requests can never steer the server's CDM or feed it their own logins. Enable them only 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 you enable
allow_job_credentialsand a download job supplies its owncredential/credentials, the server gives each distinct credential its own isolated token cache. The server never shares or merges client-supplied logins with its configured-credential caches. They cannot cross-contaminate or read the server's own token state, and they do not clobber each other.See Downloads for how the download endpoint uses these fields.
"},{"location":"dev/rest-api/authentication/#session-ip-binding-remote-sessions","title":"Session IP binding (remote sessions)","text":"The remote-download session endpoints (
"},{"location":"dev/rest-api/authentication/#transport-security-https","title":"Transport security (HTTPS)","text":"/api/session/*) add an extra check on top of the API key. When the server creates a remote session, it records the client's IP address. Any later request for that remote session must come from the same IP, or the server rejects it with403 Forbidden. Thus the server binds a remote session both to the API key that created it and to the address it came from.The API server speaks plain HTTP. Because the client sends your API key on every request, do not use it over an unencrypted connection on an untrusted network. To serve over HTTPS, operate a reverse proxy in front of unshackle. The
servecommand can launch Caddy for you:unshackle serve --caddy\nWith
"},{"location":"dev/rest-api/authentication/#quick-reference","title":"Quick reference","text":"Item Value Auth header--caddy, unshackle also startscaddy runusing theCaddyfilelocated next to your unshackle config, letting Caddy end the TLS connection and reverse-proxy to the API. Configuring Caddy itself (certificates, domains, upstream) is done in thatCaddyfile.X-Secret-KeyQuery-parameter alternative?secret_key=onGET /api/download/jobs/{job_id}/eventsonly Default bind address127.0.0.1:8786Exempt endpointGET /api/healthFailure status401 Unauthorized, or429 Too Many Requestsfor a key over its rate limit Missing-header body{\"status\": 401, \"message\": \"Secret Key is Empty.\"}Invalid-key body{\"status\": 401, \"message\": \"Secret Key is Invalid.\"}Over-limit body{\"status\": 429, \"message\": \"Rate limit exceeded.\"}, plus aRetry-Afterheader Rate limit configserve.tiers.<name>.rate_limit, overridden byserve.users.<key>.rate_limitDisable auth--no-key(all requests allowed) Config locationserve.api_secretandserve.usersinunshackle.yaml"},{"location":"dev/rest-api/authentication/#developer-notes-how-the-server-does-the-check","title":"Developer notes: how the server does the check","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-onlywhich forces API-only). Anapi_key_authenticationmiddleware runs on every request. It letsGET /api/healththrough unconditionally, then readsX-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 yourapi_secret(labelledapi_user, with no devices) plus every key underserve.users. A per-user key therefore authenticates here exactly as it does on the integrated server, and that user'sservicesallowlist applies as usual. With--no-key, this middleware is not installed and the users map is empty.Integrated server (default). A
serve_authenticationmiddleware 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 fullserveconfig:api_secret(auto-added with access to all loaded devices) plus every entry underusers, each with its declareddevices/playready_devices.Because the API-only middleware guards all non-health paths, the Swagger UI at
/api/docs/is also behindX-Secret-Keythere. In--remote-onlymode the Swagger UI is not mounted at all.Both middlewares read the caller's API key through the shared
"},{"location":"dev/rest-api/authentication/#cors-and-the-authorization-header","title":"CORS and therequest_secret_key()helper inunshackle/core/api/handlers.py. That helper returns theX-Secret-Keyheader, and falls back to thesecret_keyquery parameter only when the matched route is the job event stream. The integrated server needs one extra branch for this. pywidevine'sauthenticationis header-only. Soserve_authenticationcompares the API key from the query parameter itself when the header is absent, and delegates to pywidevine in every other case. That is why both modes reject a present-but-wrong header.Authorizationheader","text":"A
cors_middlewareruns on every response in both modes (and answersOPTIONSpreflight 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\nThe REST API authenticates with
"},{"location":"dev/rest-api/authentication/#where-device-access-is-read","title":"Where device access is read","text":"X-Secret-Key. The middleware advertisesAuthorizationbecause the co-hosted pywidevine / pyplayready CDM endpoints use it.PUTappears in the allowed-methods list, but no REST route uses it. OnlyGET,POST, andDELETEhave routes.The map of accepted API keys lives at
"},{"location":"dev/rest-api/dashboard/","title":"Developer Dashboard","text":"request.app[\"config\"][\"users\"]. For DRM operations, an API key's permitted Widevine devices come from itsdeviceslist, and PlayReady devices fromplayready_devices. The server uses the first configured device when a request does not (or may not) override it. A server-wideconfig.cdm[<service>]mapping, if present, takes precedence over the API key's device list when the server chooses which device to use.The dashboard endpoints show the operator of a
serveinstance what the server is doing: version and uptime, request counts, live remote sessions, download jobs, and the recent log lines. They exist so you can watch a headless server (serve --quiet) from a web page instead of a terminal.The dashboard endpoints are for the person who runs the server, not for API users. They have their own key: a user key or a tier key never opens them.
"},{"location":"dev/rest-api/dashboard/#enable","title":"Enable","text":"Set a dashboard key under
serve.dashboardinunshackle.yaml:serve:\n api_secret: ...\n dashboard:\n key: a-long-random-string\nWithout
dashboard.keythe server does not register the routes, and they answer404.serve --no-keyturns off user authentication only; the dashboard key stays required. Once the dashboard key is set, every/api/dashboard/request must carry it inX-Secret-Key. Every other endpoint refuses the dashboard key, and the dashboard refuses user keys. The routes are available in--remote-onlymode too.Run the server headless with
--quiet: no banner, no Rich output, one plain log line per event on stderr, andapi.*loggers stay atINFOso remote session activity reaches the log ring. Add--debugto raise the level toDEBUGand also write the full log todirectories.logsasunshackle_serve_<time>.log. Nothing else the dashboard shows reaches the disk: stats, the log ring and the event stream live in memory and reset on restart, so a dashboard UI that wants history must keep its own copy."},{"location":"dev/rest-api/dashboard/#endpoints","title":"Endpoints","text":"unshackle serve --remote-only --quiet\nAll are
Path ReturnsGET./api/dashboard/statusVersion,code_hash, bind host and port,mode(full,api_only,remote_only), uptime,requests_total,requests_rejected(401s),requests_by_key(label \u2192 count), configured service count (the same services as/api/dashboard/services), session count,max_sessions(nullwhen the operator configured no limit),session_ttl, job counts by status. The service count includes services that failed to import - use/api/dashboard/servicesfor the number that loaded./api/dashboard/sessionsEvery live remote session:id,owner(username or masked key),creator_ip,service,title_id,titles,tracks,title(the display name of the title the client is on: the last one it asked tracks for, else the first resolved title),auth_status,auth_error,server_account(the server profile lent to the remote session, elsenull),server_cdm(falsewhen the client's own device licenses the remote session),server_cdm_max_height(the tallest video the server CDM licenses live, elsenull),client,actions,created_atandlast_accessed(ISO 8601) withcreated_tsandlast_accessed_ts(Unix epoch, the same clock as logts),age_seconds,idle_seconds,log_seq.clientis whatever the remote client sent asclientin its session create request. The CLI sendsversion,code_hash(the commit the client runs,nullwhen its source cannot be read),platformandargv.argvis the command line the user ran: proxy and URL userinfo, secret query parameters and credential values become***, home and install paths shorten as in the logs, and the line is cut at 3000 characters. An older client sends less or nothing and the field is{}.log_seqis the last sequence number in the remote session's service log - poll/api/dashboard/sessions/{id}/logswhen it changes.actionsis the session's request log, newest last, capped at 500:ts,method,action(titles,tracks,segments,license,prompt, \u2026),query,status,ms,bytes_in,bytes_out./api/dashboard/jobsEvery download job with full detail, regardless of owner. Empty in--remote-onlymode./api/dashboard/logs{\"seq\": N, \"records\": [...]}: the last 1000 log records.?since=<seq>returns only newer records;?level=WARNINGsets the minimum level;?logger=servekeeps one logger and its children (aiohttp.accessis the noisiest).tsis a Unix epoch in seconds./api/dashboard/sessions/{id}/logsOne remote session's service log:{\"session_id\", \"last_seq\", \"records\": [...]}. Each record hasseq,ts,level,message.?since=<seq>returns only newer records. 404 when the remote session is unknown./api/dashboard/keysEvery configured API key: what it may do and what it has done. One row per key inserve.users, plusserve.api_secretwhen it is set./api/dashboard/servicesEvery service configured on this server and its load state, including the ones that failed to import. See Services for what configured means./api/dashboard/healthPreflight: whether this instance could finish a download. Cached 30 seconds./api/dashboard/eventsServer-Sent Events stream. With?since=<seq>it returns{\"seq\", \"stats\", \"events\": [...]}instead: every event afterseq(the server keeps the last 20000, log records included, so a poll every few seconds is safe even under a busy access log) as one JSON burst, so a UI that polls every few seconds still sees a remote session that opened and closed in between."},{"location":"dev/rest-api/dashboard/#event-stream","title":"Event stream","text":"
requests_rejectedcounts 401 responses; those never appear inrequests_by_key. The label is theusernamefrom theusersentry when one is set, else the first four characters of the API key.Event Data
/api/dashboard/eventssends astatsevent (the same body as/status) on connect and then every 5 seconds, so the connection stays alive and the counters stay current. Between those it sends:logOne log record:seq,ts,level,logger,msg.sessionaction(create,updateon every request the session makes and when titles or tracks load, ordelete) plus the full remote session summary, so each event is a complete snapshot. Adeletealso carriesreason:closed(the client ended it),expired(TTL sweep) orevicted(max_sessionsreached). A session opened by--skip-dlcan live only a few seconds, so a UI that wants history must keep its own record of these events.jobevent(queued,progress,status, or the terminal status) plus the full job object.serviceaction(staged,appliedorfailed) andtags, the services it applies to. Afailedaction carrieserrorsand an emptytags. Refetch/api/dashboard/serviceswhen one arrives, instead of polling for a reload.Every frame carries an
id:(the event seq). After a dropped connection anEventSourcereconnects withLast-Event-IDand the server replays the events it missed. The connection never closes on its own.A browser
EventSourcecannot set headers, so this route also accepts the dashboard key as?secret_key=. The dashboard key then appears in proxy and access logs and in browser history, so use the query parameter only for/eventsand send the header everywhere else.const es = new EventSource(`${server}/api/dashboard/events?secret_key=${key}`);\nes.addEventListener(\"log\", (e) => console.log(JSON.parse(e.data).msg));\nCORS is open (
"},{"location":"dev/rest-api/dashboard/#remote-session-logs","title":"Remote session logs","text":"Access-Control-Allow-Origin: *), so a dashboard page served from another origin works without a proxy.A remote session mirrors the service's own
self.loginto a 500-record buffer atINFO, whatever level the server prints at. The remote session summary carries only a truncatedauth_error; the buffer holds the real reason a login failed.GET /api/dashboard/sessions/{session_id}/logs?since=12\n{\"session_id\": \"...\", \"last_seq\": 14,\n \"records\": [{\"seq\": 13, \"ts\": 1756909254.1, \"level\": \"ERROR\", \"message\": \"login rejected: MFA required\"}]}\nThe field name is
records, the same one/api/dashboard/logsuses, so one dashboard reads both logs the same way. Each record has the shape the client route/api/session/{id}/logsreturns.last_seqis the buffer's own last sequence number, so an empty page still reports the true value.Reading is a cursor read, not a drain: the client draining the same buffer still gets every record. It also does not refresh the remote session's idle timer, so polling a remote session here never keeps it alive or makes it look active.
log_seqis a poll trigger, not a pushA remote session summary reaches the dashboard only when something publishes a
"},{"location":"dev/rest-api/dashboard/#keys","title":"Keys","text":"sessionevent - a request the remote session makes, or an auth transition. A log line on its own publishes nothing. Poll this route whenlog_seqchanges, and on a timer while a drawer is open.GET /api/dashboard/keys\n[{\"id\": \"3ed054be9371\", \"role\": \"user\", \"label\": \"tier1\", \"services\": [\"EXAMPLE\", \"DEMO\"],\n \"server_cdm\": false, \"server_cdm_max_height\": null, \"server_accounts\": false, \"server_proxy\": false,\n \"tier\": \"bot\", \"rate_limit\": 600, \"window_used\": 412,\n \"requests\": 41300, \"rejected\": 4, \"bytes_out\": 103079215104, \"last_seen\": 1756909188.2},\n {\"id\": \"7c1d0f52a884\", \"role\": \"dashboard\", \"label\": \"dash\u2026\", \"services\": [],\n \"server_cdm\": false, \"server_cdm_max_height\": null, \"server_accounts\": false, \"server_proxy\": false,\n \"tier\": null, \"rate_limit\": null, \"window_used\": 0,\n \"requests\": 8140, \"rejected\": 0, \"bytes_out\": 41200311, \"last_seen\": 1756909350.9}]\nField Meaning
idis a hash of the API key, so it stays stable across restarts and carries no key material. Two API keys with nousernamemerge into one label inrequests_by_keybut stay separate here, so useidas the identity andlabelonly for display.roleWhich key this is:user(aserve.usersentry),admin(serve.api_secret) ordashboard(serve.dashboard.key). Identity, not capability - read the grant fields for what the API key may do.servicesThe effective allowlist, the global list intersected with the API key's.nullmeans unrestricted;[]means the API key reaches no service route at all.server_cdm,server_accountsfalse,true, or the list of service tags the grant covers.server_cdm_max_heightThe configured limit on live server CDM licences: a height, a map of service tag to height, ornullfor no limit.server_proxyBoolean. Only a literaltruegrants it, and an API key with nousersentry does not get it.rate_limitRequests per hour, from the API key's own value or its tier's.nullmeans no limit.window_usedRequests counted in the current hourly window.rejectedResponses of 400 or worse that the server attributes to this API key, 429s included. The globalrequests_rejectedstays 401-only.bytes_outResponse bodies only. An event stream reports no body length, so it counts as zero.last_seennulluntil the API key makes its first request.Only a configured API key gets a row of counters. A request on an open route such as
/api/healthwith an unknown key counts asanonymous, so an unauthenticated caller cannot grow the table.Every API key the server counts has a row here, the dashboard key included, so a
requests_by_keybucket other thananonymousalways matches one. A \"traffic by key\" panel reads the rows here, which are unambiguous, and takesanonymousfromrequests_by_key. Readrolebefore you draw the bars: thedashboardrow is the panel measuring its own polling.The dashboard key gets
"},{"location":"dev/rest-api/dashboard/#rate-limits","title":"Rate limits","text":"services: []and no grants unless the operator also gave it aserve.usersentry, because it reaches nothing outside/api/dashboard/. An API key listed in more than one place still gets exactly one row.An API key over its limit gets
429with aRetry-Afterheader:serve:\n tiers:\n bot: { rate_limit: 600 } # requests per hour\n users:\n \"<api key>\":\n username: bot-relay\n tier: bot\n # rate_limit: 900 # optional, wins over the tier\nThe server keeps a fixed window, not a sliding one: it opens on the first request and resets an hour later. An API key with no tier and no override has no limit. A
"},{"location":"dev/rest-api/dashboard/#services","title":"Services","text":"tierthat names no entry underserve.tiers, or arate_limitthat is not a positive whole number, stops the server at startup, because the alternative is an API key that silently gets no limit. The rate limit never applies to the dashboard key, and never to/api/health.GET /api/dashboard/services\n[{\"tag\": \"EXAMPLE\", \"state\": \"staged\", \"error\": null,\n \"commit\": \"9cc230c98f13b83f6c6fe831595b9702d3ba2786\", \"staged_commit\": \"5f0aa17...\",\n \"staged_since\": 1756908900.0, \"sessions\": 1, \"jobs\": 1,\n \"aliases\": [\"EXMPL\"], \"geofence\": [\"US\"], \"geoblock\": []}]\nThe list holds only the services configured on this server:
- The global
serve.serviceslist, when it is set.- Else, the union of the
serviceslists of all keys inserve.users.- Else, every installed service.
The union does not show what every API key can reach
An API key with no
serviceslist can use every service, but it does not add to the union. Theapi_secretAPI key is the same. So withk1: {services: [A]}and ak2with no list, the list shows onlyA. To list every service, remove theserviceslists or setserve.services.
GET /api/dashboard/services?all=1lists every installed service./statusalways counts only the configured services.A configured tag that names no installed service does not show. Name a service by its tag: an alias resolves only for a service that imported.
stateisloaded,stagedorfailed. A service that failed to import keeps its error here instead of disappearing, and/statusstill counts it in the service total - count thestatevalues here for the number that loaded.A
stagedservice has an update on disk that a busy service blocks from the import: the repo pull already happened, socommit(what runs now) andstaged_commit(what waits) differ. It swaps in when the last job and the last remote session for that tag finish. Both commits arenullfor a service from a plain local directory rather than a git repo.Watch the
"},{"location":"dev/rest-api/dashboard/#health","title":"Health","text":"serviceevent rather than polling for the swap.GET /api/dashboard/health\n{\"generated_at\": 1756909350.4, \"status\": \"ok\",\n \"checks\": [{\"id\": \"ffmpeg\", \"label\": \"ffmpeg\", \"status\": \"ok\",\n \"detail\": \"7.1 \u00b7 /usr/bin/ffmpeg\", \"ms\": 4.1}]}\nAn instance can answer every request and still fail every download, because
mp4decryptis missing or a vault's credentials expired. This finds that before a job does.
statusisok,degraded(an optional dependency is missing) orfailing(a required one is). Player binaries are not checked: a headless server never has one and it plays nothing. Each check isok,warnorfail. Check ids are the binary names,cdm,vault:<name>(the vault's configuredname, so two vaults of the same type stay apart),bad_keysandproxies.bad_keyswarns when the vaults hold noSQLiteentry, because only that backend stores the flag for a content key a remote client proved wrong; without it the server serves the same bad pair again.The server caches the result for 30 seconds and keeps every probe shallow: a vault answers one lookup that should miss, and no probe allocates anything. A failing probe's
detailhas the vault's password or token masked. This is a panel, not a liveness probe - do not poll it faster than the cache: every refresh that the cache does not answer costs one real lookup against each configured vault, which a vault that meters or rate limits its callers will count.An unreachable vault costs a slow, partial answer
The probes run one after another, so a cold read takes as long as the probes that finish inside the deadline. The endpoint gives up after 15 seconds and answers with the checks that did complete, plus one
probecheck with the statusfailnaming how many finished. A database vault that accepts the connection and then stalls is what this deadline is for: its driver applies no timeout of its own, so without one the request would never answer.The probe that timed out keeps running in its thread until its driver returns, and the server caches the partial answer for the full 30 seconds, so callers cannot stack up more of them. A second caller waits behind the first, because the server shares one refresh rather than running it twice.
Proxy providers are only checked at construction
The proxy check builds each proxy provider but never asks it for a proxy, because for some proxy providers that allocates a connection. A proxy provider that only fails on its first real use still reads
"},{"location":"dev/rest-api/dashboard/#counters-reset-on-restart","title":"Counters reset on restart","text":"ok.Stats, the log ring and the event history are all in memory. A restart drops every number to zero.
"},{"location":"dev/rest-api/endpoints/","title":"REST API Endpoints","text":"started_atin thestatsframe (withversionandcode_hash) is the signal: it arrives in the first frame after a reconnect, so a dashboard can tell a restart from a bug without any extra field.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 give. This page groups the routes into general, download jobs, server management, and remote-dl sessions.
Start the server with
unshackle serve. By default it binds to127.0.0.1:8786, and every route lives under the/api/prefix. The Authentication page gives authentication, server modes, and theX-Secret-Keyheader in detail. 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 so that they match your ownunshackle serve -h <host> -p <port>invocation.Interactive docs
The server mounts a live Swagger UI at
"},{"location":"dev/rest-api/endpoints/#conventions","title":"Conventions","text":"/api/docs/whenever it does not operate in--remote-onlymode. Its title is \"Unshackle REST API\" and its version tracks the installed package version. In API-only mode with an API key configured, the Swagger UI itself sits behindX-Secret-Key. Only/api/healthis exempt.
- Authentication. Every request except
GET /api/healthrequires theX-Secret-Keyheader when you configure an API key.--no-keydisables the check entirely. See Authentication.- Content type. Request bodies are JSON (
Content-Type: application/json). All success responses are JSON unless this page documents a204 No Content.- CORS. Every response carries permissive CORS headers, and the server answers
OPTIONSpreflight requests automatically.- Compression. JSON responses of 256 bytes or more are gzip-compressed when the client sends
Accept-Encoding: gzip. A slow response is not compressed.- Service allowlist. The effective allowlist for your API key filters the service-facing endpoints (the intersection of the global
serve.serviceslist and your per-key list). The server treats services you cannot use as unknown.Error responses have two shapes
Structured application errors (raised inside a handler) return
"},{"location":"dev/rest-api/endpoints/#general-endpoints","title":"General endpoints","text":"{\"status\": \"error\", \"error_code\": \"...\", \"message\": \"...\", \"timestamp\": \"...\"}. The authentication middleware, however, returns a different shape:{\"status\": 401, \"message\": \"...\"}with an integerstatusand noerror_code. The same middleware answers429in that shape when an API key goes over its rate limit. Clients must accept both shapes. The Error responses section at the bottom of this page gives the full error model.These endpoints report server state and enumerate what is available.
"},{"location":"dev/rest-api/endpoints/#get-apihealth","title":"GET /api/health,GET /api/services, andPOST /api/searchare also exposed in--remote-onlymode.GET /api/health","text":"Health check. This is the only route exempt from authentication: you can call it without an
RequestResponseX-Secret-Keyheader. It reports the running version and a best-effort update check.200curl 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_hashfingerprints the framework source the server operates, so it names 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. It changes when you edit anything underunshackle/core,unshackle/commands,unshackle/utils, orunshackle/vaults. Service modules are not included. unshackle sets the value tonullonly when it cannot read the source files.If the update check fails (for example, no network),
Status Meaningupdate_availableandlatest_versionarenullwhilecurrent_versionstill reports the installed version.200Server is healthy."},{"location":"dev/rest-api/endpoints/#get-apiservices","title":"GET /api/services","text":"Show the streaming services available on this server, filtered by your allowlist. Each entry gives the service's tag, matching rules, capability flags, and its CLI parameters (useful when you make a UI that accepts service-specific options).
RequestResponseload_errorslists the services the server skipped because they failed to import, at startup or after a service repository refresh; the server also writes each one to its log. An entry markedpending_updatehas newer code staged: it keeps its current code until its running or queued jobs and its live remote sessions finish.200curl -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 \"geoblock\": [],\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 \"load_errors\": []\n}\nField notes:
Field DescriptiontagCanonical service tag; use this as theservicevalue on other endpoints.aliasesAlternate tags the service also answers to.geofenceRegion codes the service is geofenced to (may be empty).geoblockRegion codes where the service refuses to work (may be empty).title_regexURL/ID pattern the service matches, as a string or list of strings (ornull).urlService URL, taken from the service's CLI short help.helpThe service module's docstring.cli_paramsArray 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, achoiceslist.needs_authWhether the service overridesauthenticate().has_searchWhether the service supportsPOST /api/search.has_drmWhether the service overrides a Widevine or PlayReady license hook.pending_updateSet totruewhile a service repository refresh waits for this service's jobs to finish before it swaps in the new code. Absent otherwise.server_accountsPresent when the server lends its own accounts for this service:{regions: [\"ca\", \"gb\"], global: bool}, the regions those accounts cover. A client sends no credentials, cookies, or cache for such a service.auth_methodsDeclared or inferred auth methods, e.g.[\"cookies\"],[\"credentials\"]. Status Meaning200Service list returned.500Unexpected server error (INTERNAL_ERROR)."},{"location":"dev/rest-api/endpoints/#post-apisearch","title":"POST /api/search","text":"Find titles in a service by query string. The service must have a
search()method (seehas_searchabove).Request body
Field Type Required Default Descriptionservicestring yes - Service tag.querystring yes - Search query.profilestring nonullCredential/cookie profile to use.proxystring nonullFull proxy URI, a Control D resolver ascontrold://<resolver>@dns.controld.com, or a country code when the API key hasserver_proxy.no_proxyboolean nofalseForce-disable all proxy use.credentials,cookies,cache- nonullYour own login material, on a--remote-onlyserver only. See Client cache on a remote-only server. RequestResponse200curl -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\"}'\nStatus Error code Meaning{\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}\n200- Search completed.400INVALID_PARAMETERSMissingquery.400INVALID_SERVICEUnknown or disallowed service.400INVALID_PROXYProxy could not be resolved.502SERVICE_ERRORService failed to initialise, or does not support search."},{"location":"dev/rest-api/endpoints/#post-apilist-titles","title":"POST /api/list-titles","text":"Get the list of titles behind a title ID, for example episodes or a movie, without downloading anything.
Request body
Field Type Required Descriptionservicestring yes Service tag.title_idstring yes Title identifier.profilestring no Credential/cookie profile.proxystring no Full proxy URI, a Control D resolver ascontrold://<resolver>@dns.controld.com, or a country code when the API key hasserver_proxy.no_proxyboolean no Force-disable proxy.cdm_typestring no Preferred CDM type.credentials,cookies,cache- no Your own login material, on a--remote-onlyserver only. See Client cache on a remote-only server.You can also pass service-specific CLI options as additional keys.
"},{"location":"dev/rest-api/endpoints/#client-cache-on-a-remote-only-server","title":"Client cache on a remote-only server","text":"A
--remote-onlyserver keeps nothing that a client sends. When the request does not use a server account, the server runssearch,list-titlesandlist-trackson a new service cache directory for that request only, and removes the directory when the request ends, on success and on error. A--remote-onlyserver exposes onlysearchof these three endpoints, so the rule applies tolist-titlesandlist-tracksonly if a later release exposes them in that mode.
credentials({username, password, extra?}) andcookies(base64 of a zlib-compressed Netscape cookie file) are the same as onPOST /api/session/create.cacheis a map of cache files in the same form: each cache key is the file path relative to the service cache directory, with/separators and no.jsonsuffix, and each value is base64 of the zlib-compressed file. The server writes these files into the request cache directory before the service authenticates.When you send
RequestResponsecredentials,cookiesorcache, a successful response also has acachefield in the same form, with the updated files (for example a refreshed token), but notitles_files. Save them to send with the next request. A request that uses a server account, or a request to a full-mode server, uses the server's own cache and returns nocachefield.200curl -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}\nEach serialized title carries
type(\"episode\",\"movie\", or\"other\"),name,id,language,description,date, andcover_url. Episodes and movies addyear. Episodes also addseries_title,season, andnumber.A song serializes as
\"other\"A song has no
typeof its own. The server sends it as\"other\", and the response carries none of its music fields: no artist, no album, no track or disc number, and noyear.nameholds the song name. To read the disc and track numbers of a release, operate the CLI with--list-titles. You can still select songs over the API, through thewantedparameter.These keys are sent only when the title carries them, so a title without them serializes exactly as before:
Key Type On Meaningpartinteger episode Part index of a split episode, counting from 1.air_datestring episode ISO air date of daily and date-based content. unshackle names the episode by date instead ofSxxExx.absoluteinteger episode Absolute episode number across all seasons.dailyboolean episode The episode is daily/date-based. Set from the title, or from the service'sDAILYclass attribute.animeboolean episode, movie The title is anime, so metadata lookups prefer AniList. Set from the title, or from the service'sANIMEclass attribute.
parton a split episodeA service that splits one episode into several separately playable videos reports each one as its own episode. Those titles share a
Status Error code Meaningseasonandnumberand add apartinteger counting from 1. No other title carries thepartfield, so a client can ignore it safely. See Split episodes.200- Titles returned.400INVALID_INPUTMissingserviceortitle_id.400INVALID_SERVICEUnknown or disallowed service.401AUTH_FAILEDAuthentication with the service failed.404NOT_FOUNDTitle not found.500INTERNAL_ERRORUnexpected error."},{"location":"dev/rest-api/endpoints/#post-apilist-tracks","title":"POST /api/list-tracks","text":"Show 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 defaultlangoforigselects. unshackle finds it with the same language matcher the downloader uses. Thus it stays correct for regional variants a client cannot separate from tag text alone (ptmarkspt-BRrather thanpt-PT). Services need not report a title's language, so every track can legitimately come backfalse.Request body
Field Type Required Descriptionservicestring yes Service tag.title_idstring yes Title identifier.wantedstring / string[] no Episode/season range (e.g.\"S01E01-S01E03\"), or a song selector (e.g.\"1-5\",\"1,3,7\", or\"2x3\"for disc 2 track 3). A list of selectors is accepted too.seasonint/string no Season number (combined withepisode).episodeint/string no Episode number (combined withseason).partint/string no Part index of a split episode (combined withseasonandepisode).profile,proxy,no_proxy,cdm_type- no As onlist-titles.credentials,cookies,cache- no As onlist-titles.dl_paramsobject no Thedltrack selection that the service reads fromctx.parent.params:lang,v_lang,a_lang,acodecandforced_subs, as onPOST /api/session/create. Some services select the manifests they fetch from these values.When you give both
Single title / movie: Responseseasonandepisode, the server combines them into a\"{season}x{episode}\"selector.partnarrows that to\"{season}x{episode}.{part}\", one part of a split episode. Withoutpartyou get every part of the episode. The server readspartonly when you give bothseasonandepisode, andwantedtakes precedence over all three. To scope a part by range, put it inwantedinstead (\"S01E01.2\").200Multiple episodes: Response200{\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, \"bitrate_bps\": 8000000, \"width\": 3840, \"height\": 2160,\n \"resolution\": \"2160p\", \"fps\": \"24\", \"range\": \"HDR10\",\n \"range_display\": \"HDR10\", \"scan_type\": null, \"closed_captions\": [],\n \"dv_compatible_bitstream\": false, \"language\": \"en\", \"is_original_lang\": true,\n \"name\": null, \"needs_repack\": false, \"edition\": [], \"data\": null,\n \"drm\": [], \"descriptor\": \"DASH\"\n }\n ],\n \"audio\": [\n {\n \"id\": \"a-1\", \"codec\": \"EC3\", \"codec_display\": \"DD+\",\n \"bitrate\": 640, \"bitrate_bps\": 640000, \"channels\": \"5.1\", \"language\": \"en\",\n \"is_original\": true, \"is_original_lang\": true, \"atmos\": false, \"joc\": 0,\n \"descriptive\": false, \"name\": null, \"needs_repack\": false, \"edition\": [],\n \"data\": null, \"drm\": [], \"descriptor\": \"DASH\"\n }\n ],\n \"subtitles\": [\n { \"id\": \"s-1\", \"codec\": \"vtt\", \"language\": \"en\",\n \"forced\": false, \"sdh\": false, \"cc\": false, \"is_original_lang\": false,\n \"name\": null, \"needs_repack\": false, \"edition\": [], \"data\": null,\n \"drm\": [], \"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}\nThe server sorts tracks by bitrate, descending. Episodes the server cannot get go into
unavailable_episodes, rather than failing the whole request. Track objects here do not include a downloadurl. Only the remote session endpoints give segment URLs.The
drmarray on a track is a list of{type, pssh?, kids?, content_keys?, license_url?}objects.Every track carries the same fields as a local track:
Status Error code Meaningis_original_lang,name,needs_repack,edition, anddata.bitrateis in kb/s, andbitrate_bpsgives the exact bits/s.dataholds the part of the service-settrack.datathat fits in JSON, without thehls,dash, andismparser keys. Audio keepsis_originalandatmosfor clients that read them, and adds the exactjocobject count.200- Tracks returned.400INVALID_INPUTMissingserviceortitle_id.400INVALID_PARAMETERSInvalidwantedrange.400INVALID_SERVICEUnknown or disallowed service.404NO_CONTENTNo matching episodes, or all matches failed."},{"location":"dev/rest-api/endpoints/#download-jobs","title":"Download jobs","text":"Downloads are asynchronous.
POST /api/downloadenqueues a job and returns immediately with ajob_id. The server runs each job in a background job worker subprocess. You then poll the job endpoints for progress and results.Job states
A job moves through
"},{"location":"dev/rest-api/endpoints/#post-apidownload","title":"queued\u2192downloading\u2192 one of the terminal statescompleted,failed, orcancelled. You can prioritise only queued jobs. You can retry or remove only terminal jobs.POST /api/download","text":"Make a download job. It requires
RequestResponseserviceandtitle_id. Every other field is an optional download parameter with a server-side default. On success this returns202 Accepted, not200.202curl -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\": \"HEVC\",\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}\nCommon parameters (all optional). These mirror the
Field Type Default Notesdlcommand flags:profilestringnullCredential/cookie profile.qualityint[][]Target resolution(s); best available if empty.vcodecstring/string[]nullVideo codec filter.acodecstring/string[]nullAudio codec filter.vbitrate/abitrateintnullVideo / audio bitrate in kbps (positive).vbitrate_range/abitrate_rangestringnull\"MIN-MAX\"bitrate window.rangestring[][\"SDR\"]Dynamic range(s).channelsnumbernullAudio channel layout.no_atmosbooleanfalseExclude Atmos tracks.wantedstring / string[][]Episode/season selectors, as a list or as one comma-separated string. Accepts the part form,\"S01E01.2\", and the air-date form,\"2026-08-11\"or\"2026-08-01:2026-08-31\". For a music release, a selector is a track number,\"1-5\"or\"1,3,7\", or\"{disc}x{track}\"such as\"2x3\".latest_episodebooleanfalseOnly the newest episode.latest_episodesintegernullOnly the N newest episodes. A positive integer. When set, it overrideslatest_episode.lang/v_lang/a_lang/s_langstring[][\"orig\"]/[]/[]/[\"all\"]Language filters.require_audiostring[][]Audio languages that must exist. The job fails if one is missing, even withbest_available.require_videostring[][]Video languages that must exist. The job fails if one is missing, even withbest_available.require_subsstring[][]Subtitle languages that must exist. The job fails if one is missing.s_langstill decides what to keep.forced_subsbooleanfalseInclude forced subtitles.forced_s_langstring[][]Forced subtitle language(s); impliesforced_subs.exact_langbooleanfalseExact language matching.sub_formatstringnullOutput subtitle format.video_only/audio_only/subs_only/chapters_onlybooleanfalseTrack-type restriction (at most one).no_subs/no_audio/no_chapters/no_video/no_attachmentsbooleanfalseSkip a track type.audio_descriptionbooleanfalseDownload audio description.slowboolean/stringnullRandomized delay between downloads.split_audiobooleannullSeparate files per audio codec.skip_dlbooleanfalseOnly fetch keys, do not download.exportbooleanfalseExport manifest/keys/subs to JSON.cdm_onlybooleannullForce CDM-only (true) or vault-only (false) key retrieval.proxy/no_proxy/no_proxy_download/proxy_downloadstring / bool / bool / stringnull/false/false/nullProxy controls.proxyandproxy_downloadmust be full proxy URIs unless the API key hasserver_proxy. A Control D resolver,controld://<resolver>@dns.controld.com, counts as a full proxy URI: the server runs a forwarder for it.no_folder/no_source/no_muxbooleanfalseOutput/mux controls.workersintnullThreads per track.adaptive_workersbooleanfalseScale segment workers to measured CDN throughput, up to theworkerscap.download_processesint1Split a track's segments across this many processes. Only engages for large batches.downloadsint1Concurrent tracks.worstbooleanfalseLowest bitrate within quality.best_availablebooleanfalseFall back to best available.repackbooleanfalseAdd REPACK tag.tagstringnullRelease group tag.tmdb_id/imdb_id/tvdb_id/anilist_id-nullExternal ID overrides. Each resolves its metadata directly instead of by a title search, and is used for tagging. Setenrichto also take the title, year and original language. Give at most one oftmdb_id,imdb_idandtvdb_id, since unshackle resolves the others from it. Sending two returns400.anilist_idstill combines with one of them.tmdb_idandtvdb_idmust be positive integers,imdb_idmust look likett1375666, andanilist_idmust be a positive integer or a string likemal:12345, or the request returns400. An ID whose provider is unconfigured, such astmdb_idwith notmdb_api_key, fails the job rather than returning400.anilist_idneeds no API key.tvdb_orderofficial,dvd,absolute,alternate,regionalnullRenumber episodes to a TVDB season order. Falls back to thetvdb_orderconfig option.enrichbooleanfalseOverwrite title, year and original language with the external source's. Needs one oftmdb_id,imdb_id,tvdb_idoranilist_id. Without one the job fails instead of returning400.dailybooleanfalseTreat the title as daily/date-based content and fill missing episode air dates from TVDB. The fill needsenrichand a TVDB ID. An air date the service already set is kept.output_dirstringnullOutput directory, relative to the server's downloads directory. The server rejects a path that resolves outside it with400.no_cache/reset_cachebooleanfalseTitle cache controls.Validation. Invalid values return
400 INVALID_PARAMETERS.vcodec,acodecandsub_formattake the same spellings as the CLI options of the same name (unshackle dl --helpprints each list). The API and the CLI read one list, so they cannot disagree.rangemust be one of SDR/HDR10/HDR10P/DV/HLG/HYBRID, andHDR10+is also valid. The bitrate, download worker, and download counts must be positive integers. You may set at most one of the*_onlyflags. You cannot combineno_subswithsubs_only, orno_audiowithaudio_only.Service options. A service's own CLI options travel in the same body. Send them in a nested
Requestservice_paramsobject, or as flat top-level keys.service_paramswins over a flat key of the same name. An option you leave out takes the default declared on the service'sclicommand, so the job matches the equivalentdlrun.profileat the top level always means the credential profile, never a service's own--profileoption.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 \"service_params\": {\"device\": \"chrome\", \"movie\": true}\n }'\nRejected parameters
postscript,post_scriptandpost_scriptsare never accepted. A body containing any of them gets400 INVALID_PARAMETERS, because a command string that arrives from an HTTP caller would be remote code execution. You configure post-scripts only inunshackle.yaml, where they still operate for API jobs. See Post-scripts.Gated parameters (developer)
The server accepts a few keys, but permits them only when the server config opts in. Otherwise the server rejects the request with
403 FORBIDDEN:
cdm(per-request CDM device name): allowed only ifserve.cdm_overridesistrueor a list containing that device name.credential(\"user:pass\") andcredentials(a map): allowed only ifserve.allow_job_credentialsis truthy (off by default).On the server, unshackle merges the effective parameters in this order: defaults \u2192
Status Error code Meaningserve:config overrides \u2192 service-specific defaults \u2192 your request body.202- Job queued.400INVALID_INPUTMissingservice/title_idor malformed JSON.400INVALID_PARAMETERSA parameter failed validation.400INVALID_SERVICEUnknown or disallowed service.403FORBIDDENA gated parameter is not permitted.403SERVER_CDM_CAPPEDThe API key has aserver_cdm_max_heightfor the service, and the job has noqualityat or under it, setsbest_available, or hasHYBRIDinrange."},{"location":"dev/rest-api/endpoints/#get-apidownloadjobs","title":"GET /api/download/jobs","text":"Show download jobs, with optional filtering and sorting.
Query parameters
Param Values Default Descriptionstatusqueued,downloading,completed,failed,cancelled- Filter by status.serviceservice tag - Filter by service.sort_bycreated_time,started_time,completed_time,progress,status,servicecreated_timeSort field.sort_orderasc,descdescSort direction.fulltrue,falsefalseInclude full per-job details. RequestResponse200curl -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 \"input_prompt\": null\n }\n ]\n}\nWith
Status Error code Meaningfull=true, each job additionally includesparameters(with secrets redacted),started_time,completed_time,output_files,error_message,error_details, anderror_code. When you start the server with--debug-api, each job also includeserror_tracebackandworker_stderr.200- Jobs returned.400INVALID_PARAMETERSInvalidsort_byorsort_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
Status Error code Meaningfull=truelist entry).200- Job returned.404JOB_NOT_FOUNDNo 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>\nThe
Event When it is sentdatafield of every event is the same job object thatGET /api/download/jobs/{job_id}returns.snapshotImmediately, as the first event. Gives the job's current state.statusThe job leaves the queue and starts to download.progressThe 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.completedThe job finished. The server then closes the event stream.failedThe job failed. The server then closes the event stream.cancelledThe job was cancelled. The server then closes the event stream.The server sends a
: keep-alivecomment 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
snapshotevent, then the terminal event, and closes the event stream immediately.Authentication from a browser
EventSourcecannot set headers. For this endpoint only, the server also accepts the API key in thesecret_keyquery 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());\nThe header always wins. Send the API key in the query parameter or in the header, not both. If an
RequestResponseX-Secret-Keyheader is present, the server examines that header and ignores the query parameter. See Authentication.200curl -N -H \"X-Secret-Key: $KEY\" \\\n \"http://127.0.0.1:8786/api/download/jobs/b0f7c8e2-.../events\"\nStatus Error code Meaningevent: 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,...}\n200- Event stream started.404JOB_NOT_FOUNDNo 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 Contentwith an empty body.- Queued or downloading job: cancelled, returning
200{\"status\": \"success\", \"message\": \"Job cancelled\"}.Warning
A
Status Error code Meaning204response has no body. Do not try to parse JSON from it.200- Job cancelled.204- Terminal job removed (empty body).400INVALID_PARAMETERSJob is in a state that cannot be cancelled.404JOB_NOT_FOUNDNo 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.
Response200"},{"location":"dev/rest-api/endpoints/#post-apidownloadjobsjob_idretry","title":"{ \"removed\": 3 }\nPOST /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
202with the new job's ID.Retry reuses the original, unredacted parameters
A retry replays the finished job's stored parameters exactly as the original client submitted them, including any secrets such as
Responsecredential/credentials. It does not use the redactedparametersview thatGET /api/download/jobsandGET /api/historygive. This is deliberate: it lets a retried job reproduce a credentialed download that the redacted job record alone never could. The API masks those credentials everywhere else. But anyone who can retry a job can operate it again with the original credentials.202Status Error code Meaning{\n \"job_id\": \"9c1e2f3a-...\",\n \"status\": \"queued\",\n \"created_time\": \"2026-07-03T12:05:00+00:00\"\n}\n202- New job queued.404JOB_NOT_FOUNDNo such job.409CONFLICTJob is not in a terminal state.400INVALID_SERVICEService is no longer allowed.403FORBIDDENA gated parameter is no longer permitted.403SERVER_CDM_CAPPEDThe job is no longer inside theserver_cdm_max_heightof the API key."},{"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.
Response200Status Error code Meaning{ \"job_id\": \"b0f7c8e2-...\", \"position\": \"front\" }\n200- Job moved to front.404JOB_NOT_FOUNDNo such job.409CONFLICTJob is not queued."},{"location":"dev/rest-api/endpoints/#post-apidownloadjobsjob_idinput","title":"POST /api/download/jobs/{job_id}/input","text":"Answer the prompt that a running job waits on. A service can ask for input during a job, for example an OTP code, a PIN, or a device-code confirmation. The job then shows the prompt in its
RequestResponseinput_promptfield and in itsprogressevents. Show the prompt to the user and send the answer here.200{ \"response\": \"123456\" }\nStatus Error code Meaning{ \"status\": \"accepted\" }\n200- Answer sent to the job. Theinput_promptfield goes back tonull.400INVALID_INPUTThe body is not JSON, or it has noresponse.404JOB_NOT_FOUNDNo such job.409CONFLICTThe job does not wait on a prompt.Warning
The job keeps its download slot while it waits. It fails with
"},{"location":"dev/rest-api/endpoints/#server-management","title":"Server management","text":"AUTH_FAILEDif nobody answers the prompt within 10 minutes.Endpoints for profiles, effective configuration, download history, environment checks, and maintenance actions.
"},{"location":"dev/rest-api/endpoints/#get-apiprofiles","title":"GET /api/profiles","text":"Show the named credential profiles configured per service (usable as the
Responseprofileparameter elsewhere). The response holds only services whose credentials are a mapping of profile name to credential. It leaves out services with a single unnamed credential. Your allowlist filters the response.200"},{"location":"dev/rest-api/endpoints/#get-apiconfig","title":"{\n \"profiles\": {\n \"EXAMPLE\": [\"default\", \"premium\"],\n \"OTHER\": [\"us-account\"]\n }\n}\nGET /api/config","text":"Return a read-only, redacted view of the effective server configuration, suitable for a settings page in a UI. The server never sends secrets: it masks every config key whose name looks like a secret (
Responsesecret,password,passwd,token,api_key,credential,auth,cookie,bearer,private) and any userinfo in proxy URLs. Thedirectoriesblock holds the server's absolute paths and reaches only an admin key (admin: trueor theapi_secret); a plain key gets the response without it.200"},{"location":"dev/rest-api/endpoints/#get-apihistory","title":"{\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}\nGET /api/history","text":"Read the persisted history of jobs that reached a terminal state, newest first. Each API key sees only the jobs it submitted, plus entries written before the history recorded an owner. The same rule applies to
DELETE /api/history/{job_id}andPOST /api/download/jobs/clear-finished. unshackle stores the history inapi_history.jsonlin the cache directory. It skips corrupt lines, and a missing file yields an empty list.Query parameters
Param Type Default Descriptionlimitint (\u2265 1)100Maximum entries to return.servicestring - Case-insensitive service filter. Response200Status Error code Meaning{\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}\n200- History returned.400INVALID_PARAMETERSInvalidlimit."},{"location":"dev/rest-api/endpoints/#delete-apihistoryjob_id","title":"DELETE /api/history/{job_id}","text":"Remove a single persisted history entry. Returns
Status Error code Meaning204 No Content(empty body) on success.204- Entry removed (empty body).404NOT_FOUNDNo such entry (or outside your allowlist)."},{"location":"dev/rest-api/endpoints/#post-apimaintenanceclear-cache-and-post-apimaintenanceclear-temp","title":"POST /api/maintenance/clear-cacheandPOST /api/maintenance/clear-temp","text":"Delete and recreate the cache or temp directory. Both, and refresh-services, need
admin: trueon the API key or theapi_secretitself; any other key gets403 FORBIDDEN. Neither takes a request body. The server blocks both with409 CONFLICTif any job is currentlydownloading(thedetailsinclude the offendingactive_jobs).The
409guard 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. The removal of either directory mid-download would corrupt those jobs, so the endpoints refuse while a download is in progress.clear-cache also wipes download history
The persistent history read by
ResponseGET /api/historylives inapi_history.jsonlinside the cache directory. Because clear-cache deletes and recreates that whole directory, calling it also erases your entire download history, not only cached title data. If you want to keep history, do not clear the cache. Remove individual entries withDELETE /api/history/{job_id}instead.200Status Error code Meaning{ \"cleared\": true, \"freed_bytes\": 1048576 }\n200- Directory cleared.409CONFLICTA 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
Responsedirectories.servicesand re-import the services that changed, without a restart.refreshedbecomestruewhen all repos updated successfully, or when you configure no repository.deferredlists the services that have a running or queued job: they keep their current code until that job finishes, then swap.load_errorslists the services whose new code failed to import; the server drops them until the next successful refresh.200"},{"location":"dev/rest-api/endpoints/#get-apienvcheck","title":"{\n \"refreshed\": true,\n \"repos\": [\n {\n \"spec\": \"github.com/example/services\",\n \"updated\": true,\n \"changes\": [\"~EXAMPLE\", \"+EXAMPLE2\"],\n \"deferred\": [\"EXAMPLE\"],\n \"load_errors\": []\n }\n ]\n}\nGET /api/env/check","text":"Report the install status of the external binaries unshackle depends on (the same set
Responseunshackle env checkinspects), with best-effort version detection.200"},{"location":"dev/rest-api/endpoints/#remote-dl-sessions","title":"Remote-dl sessions","text":"{\n \"checks\": [\n { \"name\": \"ffmpeg\", \"installed\": true, \"version\": \"6.1.1\", \"required\": true },\n { \"name\": \"ccextractor\", \"installed\": false, \"version\": null, \"required\": false }\n ]\n}\nDeveloper feature
The
/api/session/*endpoints power unshackle's remote download mode. A thin local client authenticates once against a remote server, then fetches titles, tracks, and segments, and proxies DRM licensing back through it. The server mounts these endpoints in--remote-onlymode, and they are what the built-inRemoteServiceclient drives. Most end users never call them directly.The server holds remote sessions in memory. They expire after
"},{"location":"dev/rest-api/endpoints/#remote-session-lifecycle-overview","title":"Remote session lifecycle overview","text":"serve.session_ttl(default 300s) without a request, except while awaiting interactive authentication input, which uses a 600s window. A remote session binds to the IP that made it, so the server rejects a request from a different IP with403 FORBIDDEN."},{"location":"dev/rest-api/endpoints/#slow-responses","title":"Slow responses","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}/segment_filter \u2192 HLS segments the service drops (ads, bumpers)\nPOST /api/session/{id}/license \u2192 proxy or server-CDM DRM licensing\nPOST /api/session/{id}/keys/bad \u2192 report a server-vault content key that did not decrypt\nDELETE /api/session/{id} \u2192 tear down, harvest updated cache\nThe
titles,tracksandlicenseroutes can take minutes on a large catalogue. When the answer is not ready after 30 seconds, the route sends status200and the headers. It then sends a newline every 30 seconds until the JSON body is ready. JSON parsers ignore the leading newlines. This keeps the connection open through a reverse proxy that closes an idle request.A late failure has status
200The server sent the status before the work ended. A failure after the first 30 seconds arrives as the standard error body with status
200. Checkstatusin the body, not only the HTTP status. The body has no HTTP status field. Useerror_codeandretryableto decide what to do.A slow response is not gzip-compressed. Gzip output cannot start before the body is ready, and the newlines must go out at once.
"},{"location":"dev/rest-api/endpoints/#post-apisessioncreate","title":"POST /api/session/create","text":"Make a remote 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 acceptsserviceandtitle_id(both required). It also accepts a broad set of optional keys, because the body allowsadditionalProperties. These arecredentials({username, password, extra?}),cookies(base64 of zlib-compressed Netscape cookie file),proxy,no_proxy,profile,cache(map of forwarded cache files, keyed by the file path relative to the service cache directory with/separators and no.jsonsuffix, so a nested key such assession_web/<sha1>lands in a subdirectory),client_region,proxy_region,cdm_type,range_,vcodec,quality,best_available,dl_params,client, and any service CLI options.proxy_regionis the country code the client resolvedproxyfrom; the server matches it against its own accounts.clientis a freeform object the dashboard shows as sent (the CLI putsversion,code_hash,platformand a redactedargvin it); the server ignores it above 4096 bytes of JSON.
dl_paramsis an object with thedltrack selection:lang,v_langanda_lang(arrays of language strings),acodec(an array of audio codec names ordl -aaliases such asddp) andforced_subs(boolean). The service reads these values fromctx.parent.params. The server uses thedldefault for each absent or malformed value, and drops an unknown codec name.Service CLI options also travel in a nested
service_paramsobject, which wins over a flat key with the same name.profileat the top level always means the credentials profile, never a service's own--profileoption.The
Responseproxyvalue must be a full proxy URI, unless the operator gives your API keyserver_proxyin theserve.usersconfig. Withoutserver_proxy, the server does not resolve country codes with its own proxy providers. It rejects the request withINVALID_PROXYwhen noproxyis set and the reportedclient_regiondiffers from the server's region. A request that reports noclient_regionis not blocked. Passproxywith your own proxy, orno_proxyto accept the server's own connection. Withserver_proxy, the server resolves a country code and picks a proxy for yourclient_regionitself. A Control D resolver,controld://<resolver>@dns.controld.com, counts as a full proxy URI: the server runs a forwarder for it and needs no Control D configuration of its own.200{\n \"session_id\": \"d4e5f6a7-...\",\n \"service\": \"EXAMPLE\",\n \"status\": \"authenticating\",\n \"server_account\": false\n}\nStatus Error code Meaning
server_accountistruewhen the server authenticated with one of its own accounts (serve.server_accounts). Otherwise the server uses only what the client sent, and never falls back to its own credentials. Only a client that sent its own cookies, credentials, or cache files gets cookies, auth headers, and the remote session cache back. A client that answered a login prompt (a device code or an OTP) gets them too. This applies only when the login then succeeds and the remote session does not use a server account. For any other remote session (a server account, or an anonymous login through the server's proxy) the tracks and segments responses carry no cookies and no auth headers. The server also drops secret-looking keys from track data, and the cache stays on the server. A server-account service also refuses a client proxy URI, because the server's login would pass through it.200- Session created; authentication in progress.400INVALID_INPUTMissingserviceortitle_id.400INVALID_SERVICEUnknown 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\" }\nStatus Error code Meaning{ \"status\": \"failed\", \"error\": \"Invalid credentials\" }\n200- Status returned.403FORBIDDENRequest IP differs from the remote session creator.404SESSION_NOT_FOUNDNo 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{ \"response\": \"...\" }(required).200Status Error code Meaning{ \"status\": \"accepted\" }\n200- Response accepted.400INVALID_INPUTMissingresponse, or no prompt is pending.404SESSION_NOT_FOUNDNo such session."},{"location":"dev/rest-api/endpoints/#get-apisessionsession_idlogs","title":"GET /api/session/{session_id}/logs","text":"Drain the service's server-side log output for this remote session. The server mirrors every log call the service makes (
Responseself.log.info, warnings, errors) into a bounded per-session buffer, so a remote client can see why an auth, title, or license step failed. Passsince=<seq>to get only records newer than the last drained sequence number. Works in every auth state, includingfailed.200Status Error code Meaning{\n \"session_id\": \"a1b2c3\",\n \"logs\": [\n { \"seq\": 1, \"level\": \"INFO\", \"message\": \"Signed in as profile 'Main'\", \"ts\": 1724990000.1 },\n { \"seq\": 2, \"level\": \"WARNING\", \"message\": \" - ThePlatform returned no streams\", \"ts\": 1724990002.7 }\n ],\n \"last_seq\": 2\n}\n200- Records returned (empty list when nothing new).404SESSION_NOT_FOUNDNo such session.The buffer keeps the newest 500 records. Only the service instance's own logger is mirrored; core pipeline loggers stay on the server. A server-account session gets no log relay at all - auth-time service output can carry the operator account's identity, so it stays server-side.
"},{"location":"dev/rest-api/endpoints/#get-apisessionsession_idtitles","title":"GET /api/session/{session_id}/titles","text":"Fetch the titles for an authenticated remote session. A slow answer arrives as a slow response.
Response200{\n \"session_id\": \"d4e5f6a7-...\",\n \"titles\": [ { \"type\": \"episode\", \"name\": \"Pilot\", \"id\": \"ep-1\" } ]\n}\nRequires an authenticated remote session. Otherwise it returns
"},{"location":"dev/rest-api/endpoints/#post-apisessionsession_idtracks","title":"404 SESSION_NOT_FOUND.POST /api/session/{session_id}/tracks","text":"Get tracks and chapters for a specific title within the remote session. Body:
{ \"title_id\": \"...\" }(required). Unlikelist-tracks, track objects here include download URLs, along with session headers/cookies and manifest data the client needs to download directly. A slow answer arrives as a slow response.Response
manifestsholds each served DASH/ISM manifest once. A service can build an AdaptationSet of its own that the served manifest does not contain.track_manifestsholds a one-AdaptationSet MPD for each such track, and the client re-parses that in place of the whole manifest.200Status Error code Meaning{\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 \"track_manifests\": [ { \"track_id\": \"s-1\", \"type\": \"dash\", \"url\": \"https://...\", \"data\": \"<base64 zlib xml>\" } ],\n \"session_headers\": { \"User-Agent\": \"...\" },\n \"session_cookies\": { \"sid\": \"...\" },\n \"server_cdm_type\": \"widevine\"\n}\n200- Tracks returned.400INVALID_INPUTMissing or unknowntitle_id.404SESSION_NOT_FOUNDNo such session."},{"location":"dev/rest-api/endpoints/#post-apisessionsession_idsegments","title":"POST /api/session/{session_id}/segments","text":"Get the download URL, DRM info, headers, cookies, and raw track data for the selected tracks. Body:
Response{ \"track_ids\": [\"v-1\", \"a-1\"] }(required).200Status Error code Meaning{\n \"tracks\": {\n \"v-1\": { \"descriptor\": \"DASH\", \"url\": \"https://...\", \"drm\": [ ... ], \"headers\": { ... }, \"cookies\": { ... }, \"data\": { ... } }\n }\n}\n200- Segment info returned.400INVALID_INPUTMissingtrack_ids.404TRACK_NOT_FOUNDAn unknown track ID was requested."},{"location":"dev/rest-api/endpoints/#post-apisessionsession_idsegment_filter","title":"POST /api/session/{session_id}/segment_filter","text":"Get the HLS segments a service drops (ads, bumpers, dub cards). The server runs the service's own segment filter over the track's media playlist and returns only the URIs, so the client drops the same segments the service does. Body:
Response{ \"track_id\": \"v-1\" }(required).unwantedisnullwhen the track has no filter. The client keeps every segment when the server does not have this route.200Status Error code Meaning{ \"unwanted\": [\"https://cdn.example/hls/ad1.ts\"] }\n200- Unwanted segment URIs, ornull.400INVALID_INPUTMissingtrack_id.404TRACK_NOT_FOUNDAn unknown track ID was requested.500SERVICE_ERRORThe playlist request or the filter failed."},{"location":"dev/rest-api/endpoints/#post-apisessionsession_idlicense","title":"POST /api/session/{session_id}/license","text":"Get the content keys for the DRM. The
modefield selects one of two modes. A slow answer arrives as a slow response.Proxy mode (
RequestResponsemode: \"proxy\", the default) forwards the client's CDMchallengeto the service's license endpoint and returns the raw license bytes:200{ \"track_id\": \"v-1\", \"challenge\": \"<base64 CDM challenge>\", \"drm_type\": \"widevine\" }\n{ \"license\": \"<base64 license bytes>\" }\nServer-CDM mode (
Single: Responsemode: \"server_cdm\") runs the full CDM flow on the server using the device configured for your API key, returning content keys directly. Usetrack_idfor a single track ortrack_idsfor a batch:200Batch: Response200{ \"keys\": { \"<kid_hex>\": \"<key_hex>\" }, \"vault_keys\": [\"<kid_hex>\"], \"drm_type\": \"widevine\" }\nField Description{ \"keys\": { \"v-1\": { \"<kid_hex>\": \"<key_hex>\" } }, \"vault_keys\": [\"<kid_hex>\"], \"drm_type\": \"widevine\" }\ntrack_id/track_idsSingle track, or a batch of tracks.challengeBase64 CDM challenge (proxy mode).psshBase64 PSSH (server-CDM mode).drm_typewidevine(default) orplayready. In server-CDM mode the server'scdmmapping for the service decides the DRM system and the responsedrm_typenames the system that licensed. The client's value decides only when the server has no mapping for the service.modeproxy(default) orserver_cdm.clear_tracks(response) Requested track ids that carry no DRM, so they have no keys. Absent when every requested track is encrypted.vault_keys(response) KIDs whose content key came from a server vault, not the CDM. The client proves such a content key with a decode before it trusts it; the field is absent when every content key came from the CDM. Status Error code Meaning200- License/keys returned.400INVALID_INPUTMissing challenge/PSSH, or no CDM device configured for the API key.400INVALID_PARAMETERSUnsupporteddrm_type.404TRACK_NOT_FOUNDUnknown track ID.404NO_CONTENTServer CDM produced no keys.502SERVICE_ERRORThe service aborted during licensing."},{"location":"dev/rest-api/endpoints/#post-apisessionsession_idkeysbad","title":"POST /api/session/{session_id}/keys/bad","text":"Report a content key from
vault_keysthat did not decrypt the track. The server flags the pair in its local vaults and reports it to the vault that served it, and the next licence for that KID reaches the CDM. The client never learns the server vault names.Status Error code Meaning{ \"kid\": \"<kid_hex>\", \"key\": \"<key_hex>\" }\n200- The pair is flagged.400INVALID_INPUTThis remote session was not served that pair.404SESSION_NOT_FOUNDUnknown remote session."},{"location":"dev/rest-api/endpoints/#get-apisessionsession_id","title":"GET /api/session/{session_id}","text":"Return remote session metadata and validity.
Response200{\n \"session_id\": \"d4e5f6a7-...\",\n \"service\": \"EXAMPLE\",\n \"valid\": true,\n \"expires_in\": 300,\n \"track_count\": 4,\n \"title_count\": 10\n}\n"},{"location":"dev/rest-api/endpoints/#delete-apisessionsession_id","title":"
404 SESSION_NOT_FOUNDif the remote session does not exist or has expired.DELETE /api/session/{session_id}","text":"Tear down a remote session. The server cancels its input bridge, harvests any updated cache files, and deletes the remote session and its cache directory.
Response200{\n \"status\": \"ok\",\n \"cache\": { \"tokens\": \"<base64 zlib bytes>\", \"session_web/<sha1>\": \"<base64 zlib bytes>\" }\n}\nThe
"},{"location":"dev/rest-api/endpoints/#error-responses","title":"Error responses","text":"cachefield is present only if the remote session produced cache files and the login belongs to the client. Each cache key is the file path relative to the session cache directory, with/separators and no.jsonsuffix, so a file in a subdirectory keeps its place on the client. The server rejects a cache key with a drive letter, a root, a..segment, or an empty segment, and so does the client.404 SESSION_NOT_FOUNDif the remote session does not exist.The server serialises every structured error raised inside a handler 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
detailsappears only when non-empty.retryableappears only when the error is retryable (e.g. network errors, rate limiting).debug_infoappears only when you start the server with--debug-api.Authentication errors differ
Errors raised by the API-key middleware use a distinct shape (
"},{"location":"dev/rest-api/endpoints/#error-codes-and-default-status","title":"Error codes and default status","text":"Error code HTTP status{\"status\": 401, \"message\": \"Secret Key is Empty.\"},{\"status\": 401, \"message\": \"Secret Key is Invalid.\"}, or{\"status\": 429, \"message\": \"Rate limit exceeded.\"}with aRetry-Afterheader) with an integerstatusand noerror_code,timestamp, ordetails. A429from the middleware therefore carries noerror_code, unlike theRATE_LIMITEDcode below, which a handler raises for a rate limit the service imposed. A malformed JSON body on any POST, by contrast, returns the standard envelope witherror_code: \"INVALID_INPUT\".INVALID_INPUT400INVALID_SERVICE400INVALID_PROXY400INVALID_PARAMETERS400AUTH_FAILED401FORBIDDEN403SERVER_CDM_CAPPED403GEOFENCE403NOT_FOUND404NO_CONTENT404JOB_NOT_FOUND404SESSION_NOT_FOUND404TRACK_NOT_FOUND404CONFLICT409RATE_LIMITED429INTERNAL_ERROR500DOWNLOAD_ERROR500WORKER_ERROR500SERVICE_ERROR502DRM_ERROR502NETWORK_ERROR503SERVICE_UNAVAILABLE503When 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
"},{"location":"dev/rest-api/endpoints/#see-also","title":"See also","text":"NETWORK_ERROR(503, retryable), \"rate limit\" or \"429\" becomeRATE_LIMITED(429, retryable), and anything unrecognized collapses toINTERNAL_ERROR(500)."},{"location":"dev/rest-api/errors/","title":"Error Responses","text":"
- REST API overview. What the server is and when to use it.
- Authentication. Keys, server modes, and the
X-Secret-Keyheader.- Quickstart. A first end-to-end request walkthrough.
- Downloading. The
dlflags mirrored byPOST /api/download.Every endpoint of the unshackle REST API reports failures in a single, predictable shape. The cause can be a missing field, a service that refuses to authenticate, or a download worker that crashed. In each case 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 send.
If you write a client against the API, read this page alongside the endpoint reference. Each endpoint lists the specific codes it raises, and this page gives the meanings of those codes.
Where this comes from
"},{"location":"dev/rest-api/errors/#the-standard-error-shape","title":"The standard error shape","text":"
unshackle/core/api/errors.pydefines the error format, and the wrapper that turns raised errors into responses lives inunshackle/core/api/routes.py. Nothing on this page is invented. It reflects the actual server behaviour.A structured error is always a JSON object with
Error response bodystatusset to the literal string\"error\". The full set of fields:Field Type Always present? Meaning{\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}\nstatusstring Yes Always the literal\"error\". Lets a client distinguish an error body from a success body at a glance.error_codestring Yes A stable, machine-readable code from the catalogue below. Match on this, not onmessage.messagestring Yes A human-readable description of what went wrong. Wording may change between versions, so do not parse it.timestampstring Yes ISO 8601 timestamp (UTC) of when the error response was built.detailsobject 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).retryableboolean Only whentruePresent and set totruewhen retrying the same request may succeed later (network blips, rate limiting, transient outages). Absent otherwise.debug_infoobject Only with--debug-apiTechnical diagnostics; see Debug information.Match on
error_code, branch on HTTP statusTreat
"},{"location":"dev/rest-api/errors/#the-details-object","title":"Theerror_codeas 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.detailsobject","text":"When present,
detailscarries extra context specific to the error. It is only included when it is non-empty, so a client must treat a missingdetailsas \"no extra information\". Some concrete examples you may encounter:"},{"location":"dev/rest-api/errors/#the-retryable-hint","title":"The
- A validation failure may include the offending field or the list of valid values.
- A \"title not found\" error on a remote session includes
available_titles.- A maintenance conflict includes
active_jobs, the jobs blocking the operation.- Categorised exceptions add a
reasontag, such as\"network_connectivity\"or\"geofence_restriction\".retryablehint","text":"The
retryablefield appears only when it istrue. It is set for conditions the server believes are transient: network errors, rate limiting, and temporary service unavailability. When you see it, you can back off and try the request again. When it is absent, assume the request will keep failing until you change something.A retryable network error
"},{"location":"dev/rest-api/errors/#error-code-catalogue","title":"Error code catalogue","text":"{\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}\nEvery code below is a member of the
"},{"location":"dev/rest-api/errors/#client-errors-4xx","title":"Client errors (4xx)","text":"Code Status MeaningAPIErrorCodeenumeration. 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 to503), but the defaults hold for the overwhelming majority of responses.INVALID_INPUT400 A required field is missing, or the request body could not be parsed. Also used as the generic \"your input was malformed\" code.INVALID_SERVICE400 The named service is unknown, or is not permitted for your API key.INVALID_PROXY400 The supplied proxy specification could not be resolved or is malformed, or the proxy failed the exit check.INVALID_PARAMETERS400 One or more download or query parameters failed validation (bad codec, bitrate, sort field, and so on).AUTH_FAILED401 Authentication with the streaming service failed (bad credentials or cookies).FORBIDDEN403 The action is not allowed. Raised by server-side gates (per-key restrictions on CDM or credential overrides) and by remote session IP binding.SERVER_CDM_CAPPED403 The server CDM does not license live above theserver_cdm_max_heightof the API key.details.reasonisserver_cdm_max_height(withdetails.max_height),client_device, for a remote session where the client's own device licenses, orserver_device, for a remote session that runs on the server's device. A remote client licenses the track with its own device, except forserver_device: then only its own vaults can supply the key.GEOFENCE403 The title is not available in the applicable region.NOT_FOUND404 A requested resource (title, history entry, and so on) does not exist.NO_CONTENT404 The request was valid but produced nothing: no matching titles, tracks, episodes, or keys.JOB_NOT_FOUND404 The referenced download job does not exist.SESSION_NOT_FOUND404 The remote-dl session does not exist or has expired.TRACK_NOT_FOUND404 A referenced track ID is not present in the remote session.CONFLICT409 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_LIMITED429 The streaming service is rate-limiting requests. Retryable.Not every
429isRATE_LIMITEDThe server has a rate limit of its own. An API key over
"},{"location":"dev/rest-api/errors/#server-errors-5xx","title":"Server errors (5xx)","text":"Code Status Meaningserve.tiers.<name>.rate_limitorserve.users.<key>.rate_limitgets a429from the authentication middleware, before any handler runs, so it carries noerror_codeand is not theRATE_LIMITEDcode above. It is the shape{\"status\": 429, \"message\": \"Rate limit exceeded.\"}with aRetry-Afterheader. ReadRetry-Afterand wait it out rather than retrying on a backoff of your own: the window is fixed, so an early retry only spends the next window. See Authentication.INTERNAL_ERROR500 An unexpected server-side error. This is the fallback for any exception the server could not categorise.DOWNLOAD_ERROR500 The download process itself failed.WORKER_ERROR500 The download worker subprocess errored out.SERVICE_ERROR502 The streaming service's API returned an error, or the service does not support the requested operation.DRM_ERROR502 DRM or license acquisition failed. Retryable.NETWORK_ERROR503 A network connectivity problem (connection, timeout, DNS, TLS). Retryable.SERVICE_UNAVAILABLE503 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
"},{"location":"dev/rest-api/errors/#how-the-server-produces-errors","title":"How the server produces errors","text":"500. In practice every code above has an explicit mapping.Understanding where an error comes from helps interpret it.
Explicitly raised errors. Most endpoints raise a structured
APIErrordirectly. For example, a missingservicefield yieldsINVALID_INPUT, and a disallowed service yieldsINVALID_SERVICE. These carry a precise code, message, and often a populateddetailsobject.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 Retryableauth,login,credential,unauthorized,forbidden,tokenAUTH_FAILED(401) noconnection,timeout,network,unreachable,socket,dns,resolve(orConnectionError,TimeoutError,URLError,SSLError)NETWORK_ERROR(503) yesgeofence,geoblock,region,not available in,territoryGEOFENCE(403) nonot found,404,does not exist,invalid idNOT_FOUND(404) norate limit,too many requests,429,throttleRATE_LIMITED(429) yesdrm,license,widevine,playready,decryptDRM_ERROR(502) noservice unavailable,503,maintenance,temporarily unavailableSERVICE_UNAVAILABLE(503) yesinvalid,malformed,validation(orValueError,ValidationError)INVALID_INPUT(400) no anything elseINTERNAL_ERROR(500) noCategorised errors include a
reasontag insidedetails(such as\"network_connectivity\"or\"drm_failure\") so a client can react without parsing the message text.Invalid JSON bodies. If a
Malformed request bodyPOSTendpoint receives a body that is not valid JSON, the server returnsINVALID_INPUT(400) with the parse error indetails.error:"},{"location":"dev/rest-api/errors/#the-authentication-error-exception","title":"The authentication error exception","text":"{\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}\nThere 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
Missing keyInvalid keyX-Secret-Keyheader, the middleware short-circuits with a legacy body that uses an integerstatusand noerror_codeortimestamp.{ \"status\": 401, \"message\": \"Secret Key is Empty.\" }\n{ \"status\": 401, \"message\": \"Secret Key is Invalid.\" }\nHandle both shapes
A client must expect two error formats: the standard structured error (string
"},{"location":"dev/rest-api/errors/#late-failures-with-status-200","title":"Late failures with statusstatusof\"error\", witherror_code), and this authentication error (integerstatus, noerror_code). TheGET /api/healthendpoint is exempt from authentication, so it never returns the key-error shape.200","text":"The remote session
titles,tracksandlicenseroutes send status200and the headers when the work takes longer than 30 seconds. They then send a newline every 30 seconds to keep the connection open. See Slow responses. A failure after that time arrives as the standard error body, but with status200. The body has no field for the HTTP status that the error would otherwise have.error_codeandretryablestay in the body.Check the body on these routes
On these routes a
"},{"location":"dev/rest-api/errors/#responses-with-no-body","title":"Responses with no body","text":"200does not always mean success. Parse the body and treat\"status\": \"error\"with anerror_codeas a failure.Not every failure or completion has a JSON body. Some endpoints answer with
204 No Contentand an empty body, notably deleting a terminal download job and deleting a history entry.Do not parse
204bodiesA
"},{"location":"dev/rest-api/errors/#debug-information","title":"Debug information","text":"204response has no body. Clients must examine the status code before they decode JSON, or the decode will fail on an empty string.By default, error responses never include tracebacks or internal diagnostics. If you start the server with the
Error with --debug-api enabled--debug-apiflag, every structured error gains adebug_infoobject:{\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}\nThe
debug_infoobject always carriesexception_typeand, for exception-derived errors, a formattedtraceback. Some endpoints add extra diagnostics such as captured job worker stderr.Developer use only
"},{"location":"dev/rest-api/errors/#client-handling-recommendations","title":"Client handling recommendations","text":"
--debug-apiexposes internal tracebacks in responses. Enable it only for local debugging, never on a server reachable by untrusted clients.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 it lets you find the
204/no-body and authentication-error cases before you read the body.- Branch on
error_code. For structured errors, this is the stable identifier to switch on. Do not usemessageas the identifier.- Respect
retryable. When it istrue, back off and try the request again. The reference client retries429,500,502,503, and504with exponential backoff. When absent, fix the request rather than repeating it.- Fall back gracefully. For an unrecognised code, show the
messageand the HTTP status.Reference consumer
unshackle's own remote-download client treats any status of
"},{"location":"dev/rest-api/job-lifecycle/","title":"Download Job Lifecycle","text":"400or above as fatal, and a200body with\"status\": \"error\"and anerror_codetoo: it logsServer error [<error_code>]: <message>and exits. It also wraps requests in a retry adapter (five attempts, backing off on429,500,502,503, and504). This is a reasonable template for your own client's error handling.When you submit a download over the REST API, the server does not block the request until it writes the file. 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 the server reports live progress, and how to cancel or retry. Clearly marked developer sections tell you how the job worker and the manager behind the queue operate 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 give the internal job worker and manager model, for contributors and for people who debug 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 the server reports errors, 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 Meaningstatusfield:queuedCreated and waiting for a free job worker. No download has started yet.downloadingA job worker has picked the job up and the download is in progress.completedThe download finished successfully;output_fileslists the results.failedThe download stopped with an error;error_messageanderror_codeexplain why.cancelledThe job was cancelled before it could finish, from either the queued or downloading state.The last three (
"},{"location":"dev/rest-api/job-lifecycle/#how-a-job-moves-between-states","title":"How a job moves between states","text":"completed,failed, andcancelled) are terminal: once a job reaches one of them it never changes state again. You can remove, retry, or read terminal jobs from history, but they will not resume.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\u2192downloadinghappens 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\u2192completedis the happy path: the download engine wrote all the requested files and reported success.downloading\u2192failedis 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\u2192cancelledhappens when you send aDELETEfor 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
"},{"location":"dev/rest-api/job-lifecycle/#creating-a-job","title":"Creating a job","text":"queueduntil a job worker picks it up. It is not a state change.You queue a download with
Queue a downloadPOST /api/download. The request needs at least aserviceand atitle_id, plus any download options you want to set. On success the server replies with202 Acceptedand the new job's identity:202 Acceptedcurl -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{\n \"job_id\": \"b1e6c7de-6d0a-4f2f-9d1e-3a2b4c5d6e7f\",\n \"status\": \"queued\",\n \"created_time\": \"2026-07-03T12:00:00.000000\"\n}\nThe job starts in
queued. The job workers auto-start on the first job you submit, so you switch nothing else on. Keep thejob_id. Every later call uses it.The full parameter set
"},{"location":"dev/rest-api/job-lifecycle/#reading-a-jobs-status-and-progress","title":"Reading a job's status and progress","text":"
POST /api/downloadaccepts the same download options as thedlcommand (quality, codecs, language selection,wantedepisode ranges, and many more), with server-side defaults filling in anything you do not write. The Endpoints page gives the complete list, their defaults, and the validation rules.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/jobslists every job the manager currently holds. By default each entry is a summary. Passfull=trueto 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\"\nYou can filter the list by
"},{"location":"dev/rest-api/job-lifecycle/#the-progress-fields","title":"The progress fields","text":"statusandservice, and order it withsort_by(created_time,started_time,completed_time,progress,status, orservice) andsort_order(asc/desc). See Endpoints for the query-parameter reference.While a job is
Field Type Meaningdownloading, 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:progressnumber0to100Overall percent complete.phasestring Human-readable phase, e.g.downloading video 1080p HDR10, audio en 5.1.titlestring Title being processed.current_titlestring The specific episode/movie in progress (for a batch).completed_tracksint Number of tracks finished so far.total_tracksint Total tracks selected for the download.active_trackslist of strings Labels of the tracks downloading right now.track_progresslist Per-active-track{label, progress, speed}entries.segments_donenumber Segments downloaded for the track in progress.segments_totalnumber Total segments for the track in progress.speedstring Transfer speed of the track in progress, e.g.4.20 MB/s.skipped_subtitleslist Subtitles that were skipped as non-fatal failures (whenskip_subtitle_errorsis set).input_promptstring ornullThe prompt the service waits on. Answer it withPOST /api/download/jobs/{job_id}/input.The full detail adds
started_time,completed_time,output_files, the redactedparameters, and, for failed jobs,error_message,error_details, anderror_code. With--debug-api, it also addserror_tracebackandworker_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\nOr subscribe instead of polling
GET /api/download/jobs/{job_id}/eventspushes the same job object as Server-Sent Events, and closes the event stream when the job ends. See Endpoints.What the percentage means
progressclimbs from0toward90while tracks download, then the final stretch to100covers post-download work such as decryption cleanup, repackaging, and muxing. A job that has finished downloading but is still muxing can sit near90for a moment before completing. On success the manager sets it to exactly100.For a batch,
progressis per-episode, not whole-batchA multi-episode request (a
"},{"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":"wantedrange) is a single job that processes its episodes sequentially. As each new episode begins,progressand 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 growingoutput_filesarray againstparameters.wantedrather than trustingprogress.A
"},{"location":"dev/rest-api/job-lifecycle/#failed","title":"Failed","text":"completedjob has itsoutput_filespopulated with the paths of the muxed results, andprogressis100. This is the state you want.A
failedjob carries the error.error_messageis the short human message,error_codeis one of the API's structured error codes (for exampleNETWORK_ERROR,DRM_ERROR,AUTH_FAILED, orDOWNLOAD_ERROR), anderror_detailsgives more context. When you start the server with--debug-api,error_tracebackandworker_stderrare also filled in, to help you find the cause.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, andworker_stderrbefore 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":"The server stopped a
"},{"location":"dev/rest-api/job-lifecycle/#cancelling-a-job","title":"Cancelling a job","text":"cancelledjob on request, before it finished. It may have partial output, but it is not considered complete. See below.Send
Job state when you DELETE What happens ResponseDELETE /api/download/jobs/{job_id}. The behaviour depends on the job's current state, and this is important because the response differs:queuedordownloadingThe job is cancelled. A running download's worker process is terminated.200with{\"status\": \"success\", \"message\": \"Job cancelled\"}completed,failed, orcancelled(terminal) The job is removed from the manager entirely.204 No Content, empty body Not found -404with error codeJOB_NOT_FOUNDIn other words,
DELETEon a live job cancels it, whileDELETEon 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
Cancel a running job204 No Contentwith an empty body. A client must not try to decode JSON from it. Examine the status code first.curl -X DELETE http://127.0.0.1:8786/api/download/jobs/b1e6c7de-6d0a-4f2f-9d1e-3a2b4c5d6e7f \\\n -H \"X-Secret-Key: your-api-secret\"\nTo remove all terminal jobs at once, use
"},{"location":"dev/rest-api/job-lifecycle/#retrying-a-job","title":"Retrying a job","text":"POST /api/download/jobs/clear-finished, which returns{\"removed\": <count>}.
POST /api/download/jobs/{job_id}/retryre-runs a finished job by creating a new job with the same service, title, and parameters. The retry does not change the original job. It stays in its terminal state, and the response gives you the new job's ID with202.202 Accepted, a fresh job
- You can retry only terminal jobs. A retry of a
queuedordownloadingjob returns409with error codeCONFLICT.- A missing job returns
404(JOB_NOT_FOUND).- The server examines the service allowlist and any download gates again for the new job, exactly as for the original submission. Thus the server can reject a retry with
INVALID_SERVICEorFORBIDDENif permissions changed."},{"location":"dev/rest-api/job-lifecycle/#prioritising-a-job","title":"Prioritising a job","text":"{\n \"job_id\": \"c2f7d8ef-7e1b-5a3a-ae2f-4b3c5d6e7f80\",\n \"status\": \"queued\",\n \"created_time\": \"2026-07-03T12:30:00.000000\"\n}\n
POST /api/download/jobs/{job_id}/prioritymoves a queued job to the front of the line, so it will be the next one a free job worker picks up.
- The job must be
queued. Prioritising a job in any other state returns409(CONFLICT). A missing job returns404.- On success the response is
{\"job_id\": \"<id>\", \"position\": \"front\"}.This only reorders the queue. The job's status stays
"},{"location":"dev/rest-api/job-lifecycle/#history-and-retention","title":"History and retention","text":"queueduntil a job worker starts it.The server also writes every job that reaches a terminal state once to a persistent history log. The job survives there even after the server removes it from the live list.
- Read it with
GET /api/history(newest first, withlimitandservicefilters).- Delete a single entry with
DELETE /api/history/{job_id}(returns204).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
unshackle puts
"},{"location":"dev/rest-api/job-lifecycle/#under-the-hood-the-job-worker-and-manager-model","title":"Under the hood: the job worker and manager model","text":"api_history.jsonlunder 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.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 the jobs it bumped to the front. On the first job, it starts a fixed pool of job worker tasks plus one cleanup task. The start is idempotent, so later jobs reuse the same pool.
- Concurrency:
max_concurrent_downloads(default 2) caps it. That many jobs can be indownloadingat once. The rest wait inqueued.- Retention:
job_retention_hours(default 24) controls it. 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_downloadscaps how many separate download jobs operate at once. The separateserve.downloadssetting 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_downloadsandconfig.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.GET /api/configgives the live values. The relatedserve.history_limit(default 100) instead controls how many entries the on-disk history file keeps.Each job worker loop drains the priority deque first, then the FIFO queue. The loop skips the stale FIFO copy of a promoted job, so it does not do the job twice. When a job worker takes a job it flips it to
"},{"location":"dev/rest-api/job-lifecycle/#each-job-runs-in-its-own-subprocess","title":"Each job runs in its own subprocess","text":"downloading, stampsstarted_time, does the download, and on finish stampscompleted_timeand records history.The manager does not operate 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>\nThree temporary files bridge the parent and the job worker:
payload.json: the manager writes it before launch. It contains the job ID, service, title ID, and merged parameters.progress.json: the job worker writes it continuously as the download proceeds.result.json: the job worker writes it at the end.The job worker has no terminal. When a service asks the user for input, the job worker writes the prompt to
progress.jsonasinput_promptand waits. The answer fromPOST /api/download/jobs/{job_id}/inputgoes to the job worker on its stdin.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 job worker's exit codes are
"},{"location":"dev/rest-api/job-lifecycle/#how-progress-reaches-the-api","title":"How progress reaches the API","text":"0for success,1for a download error, and2for bad arguments. Itsresult.jsonis 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 jobfailedand adopts the job worker'serror_code(or derives one by categorising the exception).While the job worker operates, the manager polls
progress.jsonevery 0.5 s and copies the fields it finds onto the liveDownloadJob. That is what theGETendpoints return.The job worker aggregates progress across all selected tracks. It weights each track's completion fraction 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 job worker scales the download portion to fill
"},{"location":"dev/rest-api/job-lifecycle/#how-cancellation-works","title":"How cancellation works","text":"0to90. The download engine drives the remaining90to100as it repackages and muxes. A track counts as done when it reaches a terminal state such asDownloaded,Decrypted, or a skipped subtitle.Each job carries a cancellation event. When you
DELETEa live job:"},{"location":"dev/rest-api/job-lifecycle/#secret-handling-in-serialized-jobs","title":"Secret handling in serialized jobs","text":"
- If it is
queued, it flips straight tocancelled, the manager records its history immediately (it never reaches a job worker), and the job 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 tocancelled. The polling loop notices the event,terminate()s the job worker subprocess (escalating tokill()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.Job parameters may carry secrets: a raw
"},{"location":"dev/rest-api/job-lifecycle/#see-also","title":"See also","text":"user:passcredential, 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. The manager also scrubs free-text fields that a service might have leaked a secret into (error_message,error_details,error_traceback,worker_stderr, and job worker stdout/stderr in the logs). It removes the job's known secret values, longest-match first, before it returns or logs them."},{"location":"dev/rest-api/quickstart/","title":"REST API Quick Start","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-Keyheader and per-key service allowlists that gate job creation and retry.- Downloading: the equivalent options on the
dlcommand line.Unshackle ships with a built-in REST API that exposes the same download engine you drive from the command line. The
unshackle servecommand serves it 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. The
curlcommands here come 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
"},{"location":"dev/rest-api/quickstart/#before-you-begin","title":"Before you begin","text":"curl. If you only want to download something, the command line is simpler. The REST API is meant for building tools, UIs, and automation on top of unshackle.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 dlworks for you, the API will too, since it runs the identical download pipeline.If you have no install yet, follow Installation. Then check your tools:
From a cloneInstalled as a uv tooluv run unshackle env check\nunshackle env check\nThis page uses the bare
"},{"location":"dev/rest-api/quickstart/#step-1-configure-an-api-secret","title":"Step 1: Configure an API secret","text":"unshacklecommand. From a git clone, the recommended install, run each command asuv run unshackle ....The server authenticates every request (except the health check) with an API key that you send in the
X-Secret-Keyheader. The server reads this API key from theservesection of yourunshackle.yamlconfig.At minimum you must set
unshackle.yamlapi_secret:serve:\n api_secret: \"choose-a-long-random-secret\"\nIf you want the API to license and download DRM-protected titles, you must also grant CDM devices to the API key. Configure that under
unshackle.yamlserve.users, keyed by the secret:serve:\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\nThe secret must exist
If
api_secretis not set and you do not pass--no-key,unshackle serverefuses 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
"},{"location":"dev/rest-api/quickstart/#step-2-start-the-server","title":"Step 2: Start the server","text":"
--no-keyturns authentication off entirely: the server allows every request and ignores theX-Secret-Keyheader. This is convenient for a throwaway local test, but never put a--no-keyserver on a network you do not fully trust.Start the REST API with the
servecommand:unshackle serve --api-only\nBy default the server binds to
127.0.0.1:8786. The--api-onlyflag serves only the REST API and omits the Widevine/PlayReady CDM HTTP endpoints. Drop it when you also want those endpoints.Common options:
Option Default Effect-h, --host127.0.0.1Address to bind to-p, --port8786Port to bind to--api-onlyoff Serve only the REST API, no CDM endpoints--no-keyoff Disable API-key authentication (allow all requests)--debug-apioff Include tracebacks and stderr in error responses--debugoff Enable DEBUG-level logging--remote-onlyoff Expose only the remote-session subset of routes--caddyoff 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/\nSwagger UI is also behind auth
In
--api-onlymode (without--no-key), the Swagger UI at/api/docs/requires theX-Secret-Keyheader, the same as every other route. Only/api/healthis exempt from authentication.For the rest of this page we will assume the server is reachable at
http://127.0.0.1:8786and store the secret in an environment variable:"},{"location":"dev/rest-api/quickstart/#step-3-make-sure-that-the-server-is-alive","title":"Step 3: Make sure that the server is alive","text":"export SECRET=\"choose-a-long-random-secret\"\nexport BASE=\"http://127.0.0.1:8786\"\nThe health endpoint is the one route that never requires an API key. Use it to make sure that the server operates, and to read its version:
200 OKcurl \"$BASE/api/health\"\n{\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}\nThe
"},{"location":"dev/rest-api/quickstart/#step-4-authenticate-a-real-request","title":"Step 4: Authenticate a real request","text":"update_checkblock is best effort. If unshackle cannot reach the update source,update_availableandlatest_versioncome back asnullrather than failing the request.Every other endpoint needs the
X-Secret-Keyheader. A quick way to prove your API key works is to show the services the server can see:200 OK (truncated)curl \"$BASE/api/services\" \\\n -H \"X-Secret-Key: $SECRET\"\n{\n \"services\": [\n {\n \"tag\": \"EXAMPLE\",\n \"aliases\": [\"EX\"],\n \"geofence\": [],\n \"geoblock\": [],\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}\nIf the API key is missing or wrong, you get a
401 Unauthorized401with a plain body:{ \"status\": 401, \"message\": \"Secret Key is Invalid.\" }\nTwo 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. Parse both shapes in your client.The
"},{"location":"dev/rest-api/quickstart/#step-5-submit-a-download-job","title":"Step 5: Submit a download job","text":"tagvalue for the service you want is the identifier you pass asservicein the calls below.Downloads are asynchronous. You
POSTa job to/api/download, the server immediately hands back ajob_id, and the actual download runs in the background so your request never blocks.The only required fields are
serviceandtitle_id. Everything else mirrors the flags of thedlcommand 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 }'\nA 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\": \"HEVC\",\n \"range\": [\"SDR\"],\n \"wanted\": [\"S01E01\", \"S01E02\"]\n }'\nOn success the server responds with
202 Accepted202 Acceptedand the identifier you will poll:{\n \"job_id\": \"3f9c1e0a-8d2b-4e6f-9a1c-77b0f2a1c4d5\",\n \"status\": \"queued\",\n \"created_time\": \"2026-07-03T12:00:00.000000+00:00\"\n}\nCapture the
job_idfor the next step:"},{"location":"dev/rest-api/quickstart/#frequently-used-download-fields","title":"Frequently used download fields","text":"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'])\")\nThe request body accepts the full download parameter set. A few of the most useful:
Field Default Meaningservice- Service tag (required)title_id- Title identifier (required)profilenullNamed credential/cookie profile to usequality[]Resolution(s), e.g.[1080]; empty means best availablevcodecnullVideo codec, e.g.\"H265\"acodecnullAudio codec, e.g.\"EC3\"range[\"SDR\"]Dynamic range:SDR,HDR10,HDR10+,DV,HLG,HYBRIDwanted[]Episodes to fetch, e.g.[\"S01E01\"], or music tracks, e.g.[\"1-5\"]. Empty means alllang[\"orig\"]Video/audio language(s)s_lang[\"all\"]Subtitle language(s)downloads1Number of tracks to download concurrentlyproxynullProxy URI or country codeValidation is strict: invalid codecs, subtitle formats, dynamic-range names, non-positive counts, or mutually exclusive flags (for example both
video_onlyandaudio_only) come back as400with error codeINVALID_PARAMETERS. See the full reference on the download reference page for every accepted field.Gated fields
The server refuses a per-request CDM (
"},{"location":"dev/rest-api/quickstart/#step-6-poll-the-job-to-completion","title":"Step 6: Poll the job to completion","text":"cdm) or inline credentials (credential/credentials) with403 FORBIDDEN, unless the server operator has explicitly opted in withserve.cdm_overridesandserve.allow_job_credentials. Both are off by default.A job moves through these states:
queued \u2192 downloading \u2192 completed | failed | cancelled\nThe 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\"\nWhile the download runs you will see
200 OK (in progress)status: \"downloading\"and aprogressvalue climbing from0toward100:{\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}\nWhen it finishes you will see
200 OK (completed)status: \"completed\"and the produced files underoutput_files:{\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}\nA 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\nPolling 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}/eventsstreams the same job record as Server-Sent Events, and closes the event 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\"\nIf a job ends in
"},{"location":"dev/rest-api/quickstart/#step-7-clean-up","title":"Step 7: Clean up","text":"failed, the full record includeserror_message,error_code, and (when you start the server with--debug-api)error_tracebackandworker_stderrto help you find the cause. The server scrubs sensitive values in these fields before it returns them.Once you have collected the output you can remove the finished job.
DELETEon 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), the manager removes it. You get
204 No Contentwith an empty body.- If the job is still queued or downloading, the manager cancels it instead. You get
200with{\"status\": \"success\", \"message\": \"Job cancelled\"}.Do not parse JSON on 204
A
204response has no body. This applies to terminal-job removal here and to deleting history entries. Examine the status code before you decode JSON.To clear every finished job at once instead of one at a time:
200 OKcurl -X POST \"$BASE/api/download/jobs/clear-finished\" \\\n -H \"X-Secret-Key: $SECRET\"\n"},{"location":"dev/rest-api/quickstart/#managing-the-queue","title":"Managing the queue","text":"{ \"removed\": 3 }\nA handful of endpoints round out job control. All require the
Method Path PurposeX-Secret-Keyheader.GET/api/download/jobsList all jobs, with optional filtering and sortingGET/api/download/jobs/{job_id}Full detail for one jobDELETE/api/download/jobs/{job_id}Cancel a running job, or remove a terminal onePOST/api/download/jobs/{job_id}/retryRe-queue a new job from a terminal onePOST/api/download/jobs/{job_id}/priorityMove a queued job to the front of the queuePOST/api/download/jobs/clear-finishedRemove all terminal jobsListing supports query parameters for filtering by
statusorservice, sorting withsort_by(created_time,started_time,completed_time,progress,status,service) andsort_order(asc/desc), and requesting full per-job detail withfull=true:200 OKcurl \"$BASE/api/download/jobs?status=downloading&sort_by=progress&sort_order=desc\" \\\n -H \"X-Secret-Key: $SECRET\"\n{\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}\nA retry reuses the original job's service, title, and parameters to enqueue a brand new job, and returns
"},{"location":"dev/rest-api/quickstart/#beyond-a-single-download","title":"Beyond a single download","text":"202with a freshjob_id. A retry works only on terminal jobs, and returns409 CONFLICTotherwise. A priority change works only on jobs still in thequeuedstate.The download loop above is the core of the API, but the same server exposes more:
- Discovery:
POST /api/searchfinds titles by query, andPOST /api/list-titles/POST /api/list-tracksinspect what a title offers before you commit to downloading it. See titles and tracks.- History:
GET /api/historyreads a persisted log of jobs that reached a terminal state (newest first), andDELETE /api/history/{job_id}removes a single entry. See history.- Introspection:
GET /api/profileslists configured credential profiles per service,GET /api/configreturns a redacted view of the effective server config, andGET /api/env/checkreports which external binaries the machine has.- Maintenance:
POST /api/maintenance/clear-cache,.../clear-temp, and.../refresh-servicesdo housekeeping (the server blocks the cache and temp clears with409while 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
"},{"location":"dev/rest-api/quickstart/#error-handling","title":"Error handling","text":"/api/docs/documents every route and lets you try them in the browser (send yourX-Secret-Keythere too).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
detailsappears only when the server has more to add.retryable: trueappears only for transient failures. The server includes adebug_infoblock (exception type and traceback) only when it runs with--debug-api.The
Error code HTTP Typical causeerror_codemaps to an HTTP status. The ones you will meet most often:INVALID_INPUT400 Malformed JSON body or missing required fieldINVALID_SERVICE400 Unknown or disallowed service tagINVALID_PARAMETERS400 A download field failed validationAUTH_FAILED401 Service login/credential failureFORBIDDEN403 A gated field was used without server opt-inNOT_FOUND404 Title or resource does not existJOB_NOT_FOUND404 No job with thatjob_idCONFLICT409 Illegal state transition (e.g. retry a running job)RATE_LIMITED429 Upstream throttling (retryable)INTERNAL_ERROR500 Unexpected server-side failureSERVICE_ERROR502 The service raised an errorNETWORK_ERROR503 Connection/timeout reaching the service (retryable)Retry politely
Codes marked retryable (
"},{"location":"dev/rest-api/remote-sessions/","title":"Remote Service Sessions","text":"NETWORK_ERROR,RATE_LIMITED,SERVICE_UNAVAILABLE) reflect transient conditions. Back off and retry rather than hammering the server.A remote 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 unshackleserveinstance 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 remote session.Why you'd use this
"},{"location":"dev/rest-api/remote-sessions/#the-big-picture","title":"The big picture","text":"
- The server has service accounts, cookies, or a CDM you do not want to copy to every machine.
- You want to do downloads on a fast/local machine but keep credentials and licensing centralised.
- A service is region-locked to where the server lives, and the client is not.
\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\nThe client implements the same interface
dlexpects 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_dlis goneThe
RemoteServiceadapter lives inside the ordinarydlcommand and replaces the old standaloneremote_dlcommand. If you have prior scripts or docs that invokeremote_dl, usedlwith a configured remote server instead. The functionality now rides ondlthroughRemoteService.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
unshackle.yaml (client)remote_servicesblock to yourunshackle.yaml. Each entry names a server and provides its URL and API key.Key Required Meaningremote_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)\"\nurlyes Base URL of theunshackle serveinstance (no trailing slash needed)api_keyno Sent as theX-Secret-Keyheader on every requestserver_cdmno Whentrue, DRM keys are resolved by the server's CDM rather than a local device (defaultfalse)servicesno Per-service local config overrides, keyed by service tagPort default
The
"},{"location":"dev/rest-api/remote-sessions/#selecting-a-server","title":"Selecting a server","text":"unshackle servedefault port is8786(not8080). Use the port your server binds. Some older example snippets show8080, which is not the default.If you configure exactly one server, unshackle uses it implicitly:
unshackle dl EXAMPLE 0ABCDEF\nIf you configure more than one server, you must pick one with
--server:unshackle dl --server my_server EXAMPLE 0ABCDEF\nWith no
"},{"location":"dev/rest-api/remote-sessions/#what-happens-during-a-download","title":"What happens during a download","text":"remote_servicesconfigured at all, unshackle raises a clear error telling you to add the block shown above.From your point of view the command looks like an ordinary download. Under the hood the client walks a remote session through its lifecycle.
1. Create + authenticate2. Interactive prompts (if needed)3. Titles + tracks4. Licensing5. Download + closeThe client calls
POST /api/session/create, forwarding whatever it can so the server does not have to prompt:
- Local credentials for the service/profile (
{username, password, extra?})- Local cookies, compressed and base64-encoded
- A resolved proxy, or, if you did not set one, your detected client region. A client that reports no region is not blocked. The proxy must be a full proxy URI unless the operator gives your API key
server_proxy. Withoutserver_proxythe server does not resolve country codes with its own proxy providers, and it rejects the remote session when it sits in a different region from the reported one, asking the client to pass--proxywith its own proxy, or--no-proxyto accept the server's own connection. Withserver_proxythe server picks a proxy that matches the client region itself. For--proxy controld:cathe client points its Control D profile at the region and sends only the resolver, ascontrold://<resolver>@dns.controld.com. The server counts it as a full proxy URI and runs its own forwarder for it. A bare region skips Control D, because its proxy is a forwarder on the client. See Control D- Track-selection hints (
range_,vcodec,quality,best_available) so the server fetches the right manifests- Your language and audio codec selection (
-l,-vl,-al,-a,-fs) in adl_paramsobject. Some services fetch one manifest for each audio language, and pick the languages from these values- Your local per-service cache files (e.g. refreshed tokens), including files in subdirectories of the service cache directory, but only the files for the active profile. The client withholds a file whose path, in any directory or file name, embeds a hash of another credential or the name of another profile. One profile's tokens therefore never reach the server while you use a different profile. At worst, a withheld file makes the server authenticate again
The server uses its own accounts only when the operator lists the service in
serve.server_accountsand gives your API keyserver_accountsinserve.users. For any other service, a client that sends no credentials or cookies authenticates with nothing. For a listed service the client sends no credentials, cookies, or cache at all: the server picks one of its own accounts that works in your region and rotates through them.--proxy capicks acaaccount.
GET /api/servicesadvertises the regions those accounts cover. When your own region is not one of them and you set no--proxy, the client resolves a proxy for the first advertised region itself, so both sides sit in a region the account works in. This is a bare region query, so it skips Control D. A server-account service does not accept acontrold://resolver from the client.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 remote 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\nA client older than the
service_paramschange sends service options as flat body keys only. A flat key that shares a name with a transport field (such asprofile, which at the top level always means the credentials profile) no longer reaches the service; the server logs a warning naming the dropped key. Update the client to send service options underservice_params.The server also mirrors the service's own log output (
self.log) into a per-session buffer. The client drains it throughGET /api/session/{id}/logsafter each step and prints the records locally, so you see the server-side reason when a step fails.Once authenticated, the client fetches the title list, then the tracks for the chosen title. The tracks come back with playback URLs, and the server ships DASH/ISM manifests as compressed XML so the client can re-parse them locally for downloading. A service can build an AdaptationSet of its own that the served manifest does not contain. Each such track gets a one-AdaptationSet MPD in
track_manifests, and the client re-parses that instead. Anysession_headers/session_cookiesthe server used are merged into the client's local HTTP session.The tracks response repeats the title. A service can rewrite title fields inside
get_tracks, so the client takes this copy in place of the one from the title list. Each track carries the full local field set. After the manifest re-parse the client overlays the JSON-safe part of the service-settrack.data, so a service value wins over the parser's. An attachment that the service wrote to a file on the server travels as base64, and the client writes it to its own temp directory.For a DRM-protected title the client either proxies its CDM challenge through the server, or, if
server_cdm: true, asks the server to do the full CDM flow and return the content keys directly. See Server-side vs. proxied CDM below.The client downloads, decrypts, and muxes locally. While it works, it sends a keep-alive request to the remote session at an interval of one third of
session_ttl, so a long download does not let the remote session expire. On completion, and also whendlexits early (--list, Ctrl+C, an error), it deletes the remote session. If the login belongs to the client, theDELETErequest returns the updated cache files (for example a refreshed token) and the client saves them locally, so the next remote session can skip interactive authentication. The login belongs to the client when the client sent cookies, credentials, or cache files, or answered a login prompt (a device code or an OTP) that led to a successful login. A server-account login never does, and neither does a remote session on the server's device (see below). The client saves only the returned files that pass the same profile check it applies before it sends them, so a file for another profile cannot overwrite that profile's local file.Renaming remote titles locally
You can rename titles for a remote service you do not have installed locally by adding a
title_mapunder that service in your clientremote_services.<name>.servicesconfig. The server sends raw titles. Your local map wins.This is deliberate: the server does no
"},{"location":"dev/rest-api/remote-sessions/#server-side-vs-proxied-cdm","title":"Server-side vs. proxied CDM","text":"title_mapremapping 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.There are two ways DRM keys get resolved, chosen by the client's
Proxied CDM (default)Server-side CDM (server_cdm: true)server_cdmflag.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 only relays the license request.
- Used when
server_cdmisfalse(the default).- Also used for a run with
dl --cdm <name>, and for a service whose own entry in yourcdmconfig named the loaded device. In each case the client sendsserver_cdm: falsewhen it creates the remote session, and the server builds the service on your device even when the API key has the server CDM.- A service option that the service lists in
SERVER_DEVICE_OPTIONSkeeps the server CDM. The server reads the option fromservice_params, then from the top level of the request. The create response gives the result inserver_cdm, and the client shows a warning when the server did not apply its choice.- The service on the server sees your device, not the server's: its DRM system, security level, system ID and device type. A CDM call that the service makes (for example a session-key exchange at login) goes to your client as a
cdm_call, and your local CDM answers it. See Your own device identity.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:KEYpairs directly. The client needs no CDM.
- Enable with
server_cdm: truein the server entry.- The server tells the client which DRM type it used (
widevineorplayready).- A content key the server took from its own vault is unproven. The response lists its KID in
vault_keys, the client decrypts and checks that the output decodes, and a wrong pair goes back toPOST /api/session/{id}/keys/bad. The server flags the pair in every local vault it holds, reports it to the vault that served it, and the next licence for that KID skips the pair and reaches the CDM. A server with no local SQLite vault cannot store the flag. The server accepts a report only for a pair it served to that remote session. The client never learns the server vault names.- A content key in the client's own vaults goes first, even when the server already answered. The client proves it, and a server key waits as the next candidate.
A height limit on the server CDM
An API key with
"},{"location":"dev/rest-api/remote-sessions/#your-own-device-identity","title":"Your own device identity","text":"server_cdm_max_heightgets the server CDM only up to that height. The session create response tells the client who licenses the session inserver_cdmand gives the limit inserver_cdm_max_height. In a server CDM session, the batch licence response lists each track the server refused to license live undercapped_tracks. A single-track server CDM licence answers403 SERVER_CDM_CAPPEDfor such a track. In both cases the client licenses those tracks in proxy mode with its own local CDM. Seeserver_cdm_max_height.A service can need more than a licence challenge from your device: an ESN, model keys, or a session-key exchange that runs on the CDM. The device never leaves your machine. Only these cross the wire:
- Device facts. When it creates the remote session, the client sends its DRM system, security level, system ID and device type. The service sees a stand-in with these facts.
- CDM calls. When the service calls the CDM, the server sends a
cdm_callto the client and waits. The client makes the challenge, or parses the licence, on its own CDM. When the service closes a CDM session, the server sends aclosecall, because a CDM holds only a few sessions. The client polls for calls during login and while one of its own requests runs service code, so an idle remote session sends nothing extra.- Session keys only. A
keyscall returns WidevineOPERATOR_SESSIONandSIGNINGkeys. It never returns a content key, and the client refuses a PlayReadykeyscall. So a server cannot use your device to license titles for itself.- Identity config. A service names the config keys a client may send (for example
esn,Kpe,Kph,esn_map). The server lists them in the service listing asclient_config. The client sends only those keys, from its ownservices: <TAG>:block inunshackle.yaml. When the client sends an identity, the server drops its own value for each of those keys, so the two identities never mix. A mapping (such asesn_map) merges the client's entries over the server's. A client entry replaces a server entry with the same key, also when the server's key is a number. The server keeps these values in the remote session's memory only.A service can also offer an option that builds the remote session on the server's own device and identity, for the quality that device reaches. The session then makes no live licence at all: each content key comes from the server vault or the client's own vaults, and a content key that no vault holds stops the download with an error. The create response sets
server_devicetotruefor such a remote session. The service then uses the server's own config: the client's identity values do not apply. The client reads the server vault only when the API key has theserver_vaultgrant (orserver_cdm); without it, the client reads only its own vaults. The login tokens of such a remote session hold the server's device identity, so the server keeps them:DELETEreturns no cache files, and the HTTP session headers andtrack.datalose their secret-looking keys, as for a server login.The server vault in a session your device licenses
With the
"},{"location":"dev/rest-api/remote-sessions/#remote-session-lifecycle-and-expiry","title":"Remote session lifecycle and expiry","text":"server_vaultgrant (orserver_cdm) on the API key, a remote session that your own device licenses still takes content keys from the server vault. The server tries the vault first. A track that no server vault holds goes back to your device. Seeserver_vault.Remote sessions live in an in-memory store on the server and expire on a timer. You generally never touch this directly, but it explains behaviour you might observe.
Behavior Value Notes Idle session TTL 300s (5 min) default Refreshed on every request to the session and when atitlesortracksrequest ends. A remote session does not expire while a request works on it Max concurrent sessions 100 default Oldest (least recently used) is evicted when full. A remote session that a request works on goes last 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 cancelledAuth is never rushed
A remote session that is still
authenticatingor waiting onpending_inputis 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 remote session vanishing mid-prompt.The TTL and max-session limits are read from server config:
unshackle.yaml (server)"},{"location":"dev/rest-api/remote-sessions/#security-properties-worth-knowing","title":"Security properties worth knowing","text":"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/#developer-reference","title":"Developer reference","text":"
- API key required. Every remote session request carries
X-Secret-Key. The server rejects requests without a valid API key (health check aside). See Authentication.- IP binding. A remote session records the creator's IP when the server makes it. If a later request for the same remote session comes from a different IP, the server returns
403 FORBIDDEN. Remote sessions are not portable between hosts.- Namespaced, isolated cache. Each remote session gets its own cache directory, namespaced by a hash of the API key and the remote session ID, so remote sessions cannot read each other's cached tokens. The server deletes the directory when the remote session ends. A remote session that uses a server account (
serve.server_accounts) gets a persistent per-account cache instead, so the account's tokens outlive the remote session.- The server redacts secrets in logs. It redacts remote session IDs, service tags, and other user values before it writes them to the log.
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/#remote-session-endpoints","title":"Remote session endpoints","text":"The server mounts all these routes, even in
--remote-onlymode. Paths use thesession_idthatcreatereturns.Why
--remote-onlyexistsMethod Path Purpose
--remote-onlynarrows the server to only the health, services, search, and remote 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-onlymode, 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 want only the local HTTP API.POST/api/session/createBuild + authenticate a service instance; returns a session ID immediatelyGET/api/session/{id}/titlesFetch and cache the title listPOST/api/session/{id}/tracksResolve tracks, manifests, chapters, headers/cookies for a titlePOST/api/session/{id}/segmentsResolve per-segment/track download descriptorsPOST/api/session/{id}/segment_filterUnwanted HLS segment URIs for one track (ads, bumpers)POST/api/session/{id}/licenseDRM licensing (proxy or server CDM)POST/api/session/{id}/keys/badReport a server-vault content key that did not decryptGET/api/session/{id}/logsDrain the service's server-side log outputGET/api/session/{id}/promptPoll interactive auth status / pending promptPOST/api/session/{id}/promptSubmit an answer to a pending promptGET/api/session/{id}Session info (validity, TTL, counts)DELETE/api/session/{id}Close the remote 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/createrequiresserviceandtitle_id. It also acceptscredentials,cookies(base64 of zlib-compressed Netscape cookie file),proxy,no_proxy,profile,cache(a map offilename \u2192 base64(zlib(bytes))),client_region,server_cdm(falsewhen the user picked the local CDM),cdm_type,cdm_security_level,cdm_relay(truewhen the client answers CDM calls),cdm_system_idandcdm_device_type(Widevine only),service_config(the identity config keys the service lists inclient_config),client(a freeform identity object the dashboard shows as sent), and the track-selection hintsrange_,vcodec,quality,best_available, plus arbitrary service CLI options (additionalProperties: true).
dl_paramsholds thedltrack selection:lang,v_langanda_lang(arrays of language strings),acodec(an array of audio codec names, such asEC3), andforced_subs(boolean). The server gives these values to the service inctx.parent.params, never as service options, so a service option with the same name, such as an own--lang, does not get them. The server uses thedldefault for each absent or malformed value:langis[\"orig\"],v_lang,a_langandacodecare[], andforced_subsisfalse. The server drops an unknown codec name. An empty array is a valid value and stays empty.The server does not forward the
dlmode flags:video_only,audio_only,subs_only,chapters_only,list_,skip_dl,sub_formatandno_attachments. A service that reads one of them to skip work fetches more on the server, but the result is the same.In a client-login session (not a server account), the server gives the service the client's
profileinctx.parent.params, the same as a local run with that profile. The profile name keys the service's token cache, so the cache files the client sends and gets back belong to that profile. A service can also use the name to select per-profile entries in the server's service config, such as a per-profile ESN.The response returns before authentication finishes:
{ \"session_id\": \"...uuid4...\", \"service\": \"EXAMPLE\", \"status\": \"authenticating\",\n \"server_cdm\": false, \"server_cdm_max_height\": null, \"server_vault\": true, \"server_device\": false }\n
server_vaulttells the client that the server vault serves a remote session its own device licenses.server_devicetells it that the service runs on the server's device, so every content key comes from a vault.Authentication runs on a background thread (
"},{"location":"dev/rest-api/remote-sessions/#interactive-auth-the-inputbridge","title":"Interactive auth: theasyncio.to_thread(authenticate, ...)). The remote session starts inAUTHENTICATING. The client must poll the prompt endpoint until it reachesauthenticated(orfailed).InputBridge","text":"When the service calls its input function during
authenticate()on the server thread, anInputBridgepauses that thread and exposes the prompt to the HTTP layer.
AuthStatusvalues:authenticating,pending_input,authenticated,failed.InputBridge.request_input(prompt, timeout=600)blocks the sync auth thread on athreading.Eventuntilsubmit_response()orcancel()fires.- A timeout raises
TimeoutErrorand marks the remote sessionFAILED.- A prompt after authentication (in
get_titles(),get_tracks()or licensing) raisesRuntimeErrorat once. The client polls for prompts only during authentication, so no answer can come.- A CDM call (
InputBridge.request_cdm) works during and after authentication, because the client also polls while its own requests run. A call that no client fetches within 10 seconds fails then, because no client is listening. A fetched call waits 60 seconds for its answer. The call leaves the auth status as it was. An answer never marks the remote session as client-authenticated.AUTH_INPUT_TIMEOUT = 600.0seconds. This is also the TTL granted toAUTHENTICATING/PENDING_INPUTsessions in the store.
GET /api/session/{id}/promptreturns one of:{ \"status\": \"authenticated\" }\n{ \"status\": \"authenticating\" }\n{ \"status\": \"pending_input\", \"prompt\": \"Enter code: \" }\n{ \"status\": \"pending_input\", \"prompt\": \"...\", \"cdm_call\": { \"op\": \"challenge\", \"drm\": \"widevine\", \"init_data\": \"...\", \"license_type\": \"OFFLINE\", \"privacy_mode\": true, \"service_certificate\": \"...\" } }\n{ \"status\": \"failed\", \"error\": \"...message...\" }\nA
cdm_callcomes first, in any auth state. The client answers it with a JSON object inresponse:{\"session\": \"...\", \"challenge\": \"<base64>\"}forchallenge,{\"keys\": [{\"kid\", \"type\", \"key\", \"permissions\"}]}forkeys,{}forclose, or{\"error\": \"...\"}when its CDM refuses. An older client refusesclose, and the server ignores that refusal. An answered call stops being pending at once, so a client that polls again straight away does not see it twice.A missing remote session returns
"},{"location":"dev/rest-api/remote-sessions/#sessionstore-internals","title":"404 SESSION_NOT_FOUND. An IP mismatch returns403 FORBIDDEN.POST /api/session/{id}/prompttakes{ \"response\": \"...\" }and returns{ \"status\": \"accepted\" }. A post with no pending prompt is anINVALID_INPUTerror.SessionStoreinternals","text":"Source:
unshackle/core/api/session_store.py. A singleton you get fromget_session_store().
- Config-driven limits.
serve.session_ttl(default300) andserve.max_sessions(default100) are read as properties, so config changes take effect without recreating the store.create()evicts the least-recently-accessed remote session when at capacity, then stores a newSessionEntry. It defaultsauth_statustoAUTHENTICATED, and thecreatehandler overrides it toAUTHENTICATING.get()refresheslast_accessedthroughtouch(). It returnsNone(and deletes the entry) if an authenticated remote session has been idle longer than the TTL. Remote sessions inAUTHENTICATING/PENDING_INPUTare exempt from TTL expiry.cleanup_expired()runs every 60s: authenticated remote sessions expire atttl, in-flight-auth remote sessions expire atAUTH_INPUT_TIMEOUT. The removal of a remote session cancels itsInputBridgeand deletes its cache directory (pruning empty parent dirs up to, but not including, the cache root).- The server calls
cancel_all_bridges()on shutdown, to unblock any waiting auth threads.Field Description
SessionEntryfields:session_idUUID4 stringservice_tagNormalized service tagservice_instanceThe authenticated service object kept alive between callstitles/title_mapResult ofget_titles()and atitle_id \u2192 Titlemaptracks/tracks_by_title/chapters_by_titleCached resolved tracks and chapterscreator_ipIP recorded at create time for IP-binding checkscache_tagPer-session cache directory taginput_bridgeInputBridgefor interactive auth, if anyauth_status/auth_errorCurrentAuthStatusand last error messagecreated_at/last_accessedTimestamps;last_accesseddrives TTL and LRU eviction"},{"location":"dev/rest-api/remote-sessions/#per-session-cache-namespacing","title":"Per-session cache namespacing","text":"The
createhandler builds aCachernamespaced as:_sessions/<pbkdf2_hmac(sha256, X-Secret-Key, \"unshackle-session-ns\", 100000)[:12]>/<session_id>/<service>\nThe handler writes forwarded
cachefiles into that directory before authentication. OnDELETE, when the login belongs to the client (client_auth) and the remote session does not run on the server's device, the handler harvests updated cache files from the whole directory tree (compressing each with zlib and base64-encoding, excludingtitles_*files) and returns them under acachefield, so the client can keep refreshed tokens. Each cache key is the file path relative to that directory, with/separators and no.jsonsuffix, which is theCachercache key the service reads it with:{\n \"status\": \"ok\",\n \"cache\": {\n \"tokens\": \"...base64(zlib(bytes))...\",\n \"session_web/<sha1>\": \"...base64(zlib(bytes))...\"\n }\n}\nBoth sides reject a cache key with a drive letter, a root, a
"},{"location":"dev/rest-api/remote-sessions/#remote-session-info-response","title":"Remote session info response","text":"..segment, an empty segment, or more than 8 segments before it becomes a path, so a peer cannot write outside the cache directory. Both sides also refuse acachemap with more than 64 entries. The server skips a cache key it cannot write, such as one that names an existing file as a directory, and logs a warning.
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_inis the configured TTL, not time remainingThe
"},{"location":"dev/rest-api/remote-sessions/#reference-client-remoteservice","title":"Reference client:expires_invalue reports the store's configuredsession_ttl, not the seconds left before this specific remote session expires.RemoteService","text":"Source:
unshackle/core/remote_service.py. This is the canonical consumer of the remote session API and a good template for any client."},{"location":"dev/rest-api/remote-sessions/#troubleshooting","title":"Troubleshooting","text":"
RemoteClient.requestsetsX-Secret-KeyandUser-Agent: unshackle/<version>, uses the server'stimeoutfromremote_services(default 120s) as the read timeout, and treats anystatus_code >= 400as fatal: it logsServer error [<error_code>]: <message>and raisesSystemExit(1). A200body with\"status\": \"error\"is also fatal (see the heartbeat below).- Heartbeat. The
titles,tracksandlicenseroutes send a newline every 30s while the service works. The read timeout starts again at each byte, so a slow request stays open for as long as the server works on it, and a reverse proxy does not close it as idle. A route that answers within 30s sends the usual response. After 30s the status is already200, so a failure after that time arrives as the usual error body with status200. Your client must checkstatusin the body. JSON parsers ignore the leading newlines. The server does not gzip-compress a heartbeat response, because gzip output cannot start before the body is ready.- 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()\u2192create(+ pollpromptevery 2s up to a 600s deadline, answeringpending_inputprompts. With stdin closed the client logs an error and raisesSystemExit(1)instead of posting an empty answer) \u2192get_titles()\u2192get_tracks()(merging returnedsession_headers/session_cookies, re-parsingmanifests) \u2192 get the licence in proxy mode orserver_cdmmode \u2192close()(DELETE, saving any returnedcache).- Server resolution.
resolve_server()readsconfig.remote_services.<name>into{url, api_key, services, server_cdm}, injecting theserver_cdmflag into the services map as_server_cdm.\\\"Could not connect to remote server ... Is it running?\\\"
The client could not reach the URL. Make sure that
unshackle serveoperates on the server, theurl/port inremote_servicesare correct (default port8786), and any firewall or reverse proxy allows the connection.
403 FORBIDDENmid-downloadThe request came from a different IP than the one that made the remote session. Remote sessions are IP-bound. Do not 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 server marks the remote session
failedand cleans it up. Operate the download again. If the server never prompts you, make sure that the credentials and cookies the client forwarded are valid for the service.\\\"Multiple remote services configured. Use --server ...\\\"
You have more than one entry under
"},{"location":"getting-started/configuration-file/","title":"The Configuration File","text":"remote_services. Pass--server <name>to pick one.A single configuration file named
unshackle.yamlcontrols almost everything about how unshackle behaves. It sets where unshackle saves downloads, which CDM it uses, your credentials, proxies, filename templates, and more. This page gives where that file lives, how unshackle finds it, and the shape of its contents. It also gives the directory layout unshackle makes 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 uses plain YAML. You can open and edit it in any text editor, or manage individual values with theunshackle cfgcommand.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<clone>/unshackle/unshackle.yaml(auvtool install:.../site-packages/unshackle/unshackle.yaml) 2 The parent of the package folder<clone>/unshackle.yaml(auvtool install:.../site-packages/unshackle.yaml) 3 Your OS user-config directory see the table belowWhere the file belongs depends on how you installed unshackle:
- Git clone (recommended). Keep the file at
unshackle/unshackle.yamlinside the clone, which is location 1.unshackle setupwrites a new config there, and it stays with the clone when you update it.uvtool install. Use location 3, your per-user config directory. Auvtool upgrade replaces the package folder, so a file at location 1 or 2 is lost.unshackle setupwrites a new config there.The path of the per-user config directory depends on your operating system:
LinuxWindowsmacOS~/.config/unshackle/unshackle.yaml\n%LOCALAPPDATA%\\unshackle\\unshackle.yaml\n~/Library/Application Support/unshackle/unshackle.yaml\nNot sure which file is being used?
Operate
unshackle env info. It prints the path unshackle loaded the config from, or tells you that it found none. It also prints every directory unshackle currently uses.The config is read exactly once, when unshackle starts. If you edit the file, the change takes effect on the next command.
"},{"location":"getting-started/configuration-file/#top-level-structure","title":"Top-level structure","text":"
unshackle.yamlis a flat map of top-level config keys. Each config key configures one area of the program. You include only the config 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\nThe top-level keys group loosely into these areas:
Area Example keys Downloading & DRMdl,cdm,remote_cdm,decryptionNetworkingnetwork,headersCredentials & cookiescredentials,firefox_cookiesTracks & muxingsubtitle,audio,muxing,language_tagsKey vaultskey_vaults,vault_timeoutProxies & remoteproxy_providers,remote_services,serve,servicesNaming & taggingtag,output_template,chapter_fallback_nameExternal API keystmdb_api_key,tvdb_api_key,simkl_client_id,ipinfo_api_key,metadata_providersBehaviour & loggingupdate_checks,redact_paths,debug,unicode_filenamesPost-scriptspost_scriptsPathsdirectories,filenamesFor 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 config key, unshackle skips it and uses the default. You will not get an error. Examine the config 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
unshackle.yamldlflags (a language, a resolution, a codec), put them under adl:config key once and unshackle applies them to every download. Any flag from Downloading works here. The config key is the flag's long name with dashes turned into underscores (--best-available\u2192best_available).dl:\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\nYou 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
Per-service defaultsdl:block under it:dl:\n lang: [en] # default for everything\nservices:\n EXAMPLE:\n dl:\n lang: [en, ja] # Example downloads English + Japanese\nA few keys are named after the flag's internal name
Most keys are obvious, but set these exact ones:
"},{"location":"getting-started/configuration-file/#the-directory-layout","title":"The directory layout","text":"range(-r),list(--list),tmdb_id(--tmdb),imdb_id(--imdb),tvdb_id(--tvdb),no_atmos(--noatmos), andoutput_dir(-o). The Configuration Reference has the full list and every available config key.The
unshackle.yamldirectoriesconfig 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. A path can use~for your home directory.directories:\n downloads: ~/Videos/unshackle\n temp: /mnt/fast/unshackle-temp\n cache: ~/.cache/unshackle\nThe directories unshackle uses:
Name Purpose OverridabledownloadsDefault output folder for finished downloads YestempTemporary working files during a download YescacheCache store (title cache, update checks, service caches) YescookiesPer-service cookie files YeslogsLog files YesexportsExport JSONs YeswvdsWidevine devices (.wvdfiles) YesprdsPlayReady devices (.prdfiles) YesdcslDCSL data YesservicesSearch paths for service code (see below) YescommandsCLI command modules YesvaultsVault modules YesfontsBundled fonts YesSome directories cannot be moved
unshackle protects five internal entries:
"},{"location":"getting-started/configuration-file/#the-services-directory-is-special","title":"Theapp_dirs,core_dir,namespace_dir,user_configs, anddata. If you write any of them underdirectories, unshackle ignores the override. This is intentional. Those paths come from the package location and your OS user directories.servicesdirectory is special","text":"Unlike the other entries,
unshackle.yamlservicesis a list, and each entry can be either a local folder or a remote repository. This lets you mix your own local services with services pulled from Git:directories:\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\nunshackle examines the entries in the order you wrote them, and the first source to define a given service tag wins. Put local folders last if you want them to act as fallbacks and not 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-config-key","title":"Thefilenamesconfig key","text":"Alongside
"},{"location":"getting-started/configuration-file/#editing-the-config-with-unshackle-cfg","title":"Editing the config withdirectories, thefilenamesconfig key lets you override the templates unshackle uses when it names its own working and log files. Two examples are the log filename and the temporary chapters file. Most users never need to touch this. The Configuration Reference gives the available names and their default templates.unshackle cfg","text":"You do not have to edit
unshackle.yamlby hand. Theunshackle cfgcommand 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 config key. Nested config keys use dot notation:
$ unshackle cfg tag\n$ unshackle cfg cdm.default\nSet a value by passing a config key and a value:
$ unshackle cfg tag MYGRP\n$ unshackle cfg cdm.default my_device_l3\nunshackle parses the value as a Python literal, so write booleans as
True/Falseand quote the strings inside a list (\"['en']\"). unshackle stores anything that is not valid Python literal syntax as a plain string:$ unshackle cfg update_checks False\n$ unshackle cfg vault_timeout 30\nRemove a value with
--unset:$ unshackle cfg cdm.default --unset\nShow everything currently set with
--list:$ unshackle cfg --list\nWhen it writes,
unshackle cfgtargets the config file that unshackle loaded. If none exists yet, it makesunshackle.yamlin theunshacklepackage folder (location 1 above):
- Git clone. That is
unshackle/unshackle.yamlin the clone, which is the correct place.uvtool install. A tool upgrade replaces the package folder and deletes that file. Rununshackle setupfirst, which writes the config to your user config directory, or make the file there yourself. Thenunshackle cfgwrites to it.Editing with
cfgstrips commentsBecause
"},{"location":"getting-started/configuration-file/#next-steps","title":"Next steps","text":"unshackle cfgrewrites the whole file when it saves, a write removes any comments inunshackle.yaml. If you keep important notes as comments, edit the file by hand instead, or keep those notes elsewhere."},{"location":"getting-started/installation/","title":"Installation","text":"
- Browse the Configuration Reference for every config key and default.
- Set up your first download in Downloading.
- Learn how unshackle discovers and updates services in Creating a Service.
unshackle is a modular archival tool for movies, TV, and music. It ships as a single Python package named
unshacklethat installs one console command, also calledunshackle. This page covers the install script, theunshackle setupcommand, the Python version unshackle needs, and the external command-line tools it expects to find.What you get
Installing the package registers a single entry point, the
"},{"location":"getting-started/installation/#install-from-a-git-clone-recommended","title":"Install from a git clone (recommended)","text":"unshacklecommand. It covers everything: downloading, key-vault management, device provisioning, the REST server, and the helper utilities.A git clone with the install script is the recommended way to install unshackle. A clone keeps unshackle, its external tools, its config, its data folders, and your services together in one folder that you can see.
The repository has an install script for Linux (
LinuxWindowsinstall.sh) and for Windows (install.bat). The script installsuvif it is not found, installs unshackle, and then runsunshackle setup.git clone https://github.com/unshackle-dl/unshackle.git\ncd unshackle\nsh install.sh\ngit clone https://github.com/unshackle-dl/unshackle.git\ncd unshackle\n.\\install.bat\nIn a clone, the script runs
uv syncand thenuv run unshackle setup. The last lines of setup tell you to check your tools withuv run unshackle env check. Run unshackle from the clone withuv run unshackle ....To install without a clone, see Advanced: install as a uv tool.
"},{"location":"getting-started/installation/#what-unshackle-setup-does","title":"Whatunshackle setupdoes","text":"From a cloneInstalled as a uv tool
unshackle setupprepares a new installation. The install script runs it for you, and you can run it yourself at any time:uv run unshackle setup\nunshackle setup\nThe command does these steps:
- It shows which external tools are missing, and where to get the ones that it cannot download.
- It asks once for each tool or group of tools to download. The required tools come first. shaka-packager and
mp4decryptshare one DRM question, somp4decryptcomes with the required tools, butenv checkstill shows it as optional. The HDR and subtitle groups are optional. The prompts show the version that setup downloads. On Windows, the subtitle question also shows the download size of CCExtractor, which is much larger than the other tools. It puts the downloads in abinariesfolder (see Where setup puts files).- If no
unshackle.yamlexists, it asks for a downloads folder and writes a minimal config file.- It makes the data folders for cookies, WVDs, PRDs, and services.
- It asks for
.wvdand.prddevices to import. You can give a file or a folder of devices, and it asks again until you give an empty answer. It validates each file, copies it into the WVDs or PRDs folder, and prints one line for each file. If setup wrote a new config in this run, it setscdm.defaultto the imported device. If you imported more than one, it asks which one is the default.- It prints a summary of the tools that it found. If it wrote a config, it gives the path of that config and a link to the configuration reference. Then it tells you to run
env checknext.You can run the command again. It skips the tools that it finds and never overwrites an existing config.
Not every tool has a download
Setup downloads tools only on Linux and Windows, and only when the tool has a portable build for your system. For other tools, setup prints where to get them, for example a package manager. On macOS, setup downloads nothing and prints these links for each missing tool.
On Linux, setup cannot download MKVToolNix, which is a required tool. Install the
mkvtoolnixpackage with your package manager (for exampleapt,dnf, orpacman).CCExtractor on Linux needs FUSE
On Linux, setup downloads CCExtractor as an AppImage, which needs FUSE to run. Setup runs the AppImage once after the download. If it does not run, setup removes it and prints the manual install link. Install FUSE with your package manager and run setup again, or install the
"},{"location":"getting-started/installation/#where-setup-puts-files","title":"Where setup puts files","text":"Item From a git clone From accextractorpackage instead.uvtool install External toolsunshackle/binariesin the clonebinariesin the user data directory New config (unshackle.yaml)unshackle/unshackle.yamlin the clone The user config directory Data folders (cookies, WVDs, PRDs, cache, logs, services, temp) The default folders in the clone The user data directory Downloads The folder that you give at the prompt The folder that you give at the promptOn Windows, CCExtractor and its libraries go into their own
ccextractorsubfolder of the tools folder.On Linux, the user data directory is usually
~/.local/share/unshackleand the user config directory is usually~/.config/unshackle. On Windows, both are%LOCALAPPDATA%\\unshackle. Runuv run unshackle env infoto see the paths that unshackle uses. ItsUser_Binariesrow shows thebinariesfolder in the user data directory.Setup writes a new config only when it finds no
unshackle.yamlin any of the config locations. It keeps an existing config where it is. In a clone, the new config sets onlydirectories.downloads, so the data folders stay at their defaults in the clone.A
"},{"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, or \u2265 3.10.0 DRM decryption Bento4 \u2265 1.6.0-639 Alternate DRM decryption (uvtool upgrade replaces the package folder. For auvtool install, setup puts the tools in the user data directory and writes adirectoriesblock that moves the data folders there, so an upgrade keeps them. If an existing config keeps data folders inside the package of auvtool install, setup shows a warning and prints thedirectorieslines to add.mp4decrypt) dovi_tool \u2265 2.1 Dolby Vision handling SubtitleEdit \u2265 5.0 Subtitle conversion (optional)The sections below tell you about each of these, and how to make sure that your setup is complete.
"},{"location":"getting-started/installation/#python-versions","title":"Python versions","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 refuses to assemble the environment.You do not usually need to install a matching Python interpreter by hand, since
"},{"location":"getting-started/installation/#installing-by-hand","title":"Installing by hand","text":"uvcan download and manage one for you. If you work inside an existing virtual environment, make sure that it is on a supported version.If you do not use the install script, install unshackle with
"},{"location":"getting-started/installation/#running-from-a-clone","title":"Running from a clone","text":"uvand then rununshackle setup, or install the external tools yourself.Use
Run from a git cloneuv runinside the clone. It keeps the project's virtual environment active for the duration of the command, so you do not have to activate it yourself. It also installs the dependencies on the first run.git clone https://github.com/unshackle-dl/unshackle.git\ncd unshackle\nuv run unshackle setup\nEvery command shown elsewhere in these docs works the same way from a clone. Prefix it with
uv run:"},{"location":"getting-started/installation/#advanced-install-as-a-uv-tool","title":"Advanced: install as a uv tool","text":"uv run unshackle env check\nThis way is for users who know
uvtools and want theunshacklecommand on theirPATHwithout a clone.Where a uv tool install keeps files
uv tool installputs unshackle into the tool folder ofuv, not into a folder that you choose.unshackle setuptherefore puts the external tools, the config, and the data folders in your user data and config directories (see Where setup puts files). A git clone is the easier setup: its tools, config, data, and services stay in one folder that you can see. On the first run from auvtool install,unshackle setuptells you this and asks if you want to continue.Install the tool and run setup:
Install as a uv tooluv tool install git+https://github.com/unshackle-dl/unshackle.git\nunshackle setup\nThe install scripts also do this when you run one alone, without a clone: when the script does not find the unshackle
pyproject.tomlnext to it, it runsuv tool installand thenunshackle setup. When it finishes, it tells you to open a new terminal, because thePATHchange only applies to new terminals. Rununshackle env checkthere.Upgrading and removing
Because you installed it from a git URL, re-run the same
uv tool installcommand (uv will update in place) to get a newer version, and useuv tool uninstall unshackleto remove it.Developer note
Contributors will also want the development and test dependency groups. The project defines
"},{"location":"getting-started/installation/#external-tools-on-your-path","title":"External tools on your PATH","text":"dev(linters, type checkers) andtest(pytest and friends) groups inpyproject.toml. Install them withuv sync --group dev --group testinside the clone. End users can ignore these entirely.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, then in thebinariesfolder of your user data directory, and then on your systemPATH.unshackle setupinstalls tools into the first folder in a clone and into the second for auvtool install. Installing a tool system-wide or putting it into one of those folders both work."},{"location":"getting-started/installation/#required-tools","title":"Required tools","text":"
unshackle setupinstalls the required tools and some optional ones for you. Use the tables below when you install a tool by hand.These must be present for core downloading, decryption, and muxing to work:
Tool Binary looked up Why it is needed FFmpegffmpegMedia processing: remuxing, conversion, track handling FFprobeffprobeMedia analysis: inspecting tracks and container details MKVToolNixmkvmergeMuxing downloaded tracks into a final MKV mkvpropeditmkvpropeditEditing MKV metadata after muxing shaka-packagershaka-packager/packagerDecrypting 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
decryptionconfig key if you want to switch tomp4decrypt.Do not use shaka-packager 3.0 through 3.9.x
These versions stop with a segmentation fault on a single-track MP4 whose internal
"},{"location":"getting-started/installation/#recommended-and-optional-tools","title":"Recommended and optional tools","text":"track_idis 2 or more. A track that unshackle downloads from a DASH or HLS manifest usually has such atrack_id.These extend unshackle's capabilities. Install the ones relevant to what you download. For example,
Tool Binary looked up Why it is needed Bento4 (dovi_toolonly matters if you download Dolby Vision video.mp4decrypt)mp4decryptAlternative DRM decryptor to shaka-packager MP4Box (GPAC)MP4BoxMuxing DTS:X Profile 2 (DTS-UHD) audio dovi_tooldovi_toolDolby Vision metadata handling HDR10Plus_toolhdr10plus_toolHDR10+ metadata handling SubtitleEditseconv,SubtitleEditSubtitle conversion (theSeConvCLI from 5.x) CCExtractorccextractor,ccextractorwin,ccextractorwinfullExtracting closed captions FFplayffplaySimple preview player MPVmpvAdvanced preview player (used byutil crop/util rangepreviews) HolaProxyhola-proxyHola proxy provider support CaddycaddyOptional reverse proxy forunshackle serve --caddyDockerdockerGluetun VPN proxy support GitgitFetching and updating remote service repositoriesMP4Box is only used for DTS:X Profile 2
Titles with DTS:X Profile 2 (DTS-UHD) audio mux to MP4; everything else muxes to MKV with
mkvmerge. Install withapt install gpac.SubtitleEdit specifics
unshackle looks for
"},{"location":"getting-started/installation/#verifying-your-installation","title":"Verifying your installation","text":"seconvfirst, the batch CLI shipped with SubtitleEdit 5.x, before falling back to aSubtitleEdit(4.x) binary. The 5.0 GUI has no batch mode, so theSeConvCLI is what you want for automated subtitle conversion.After installation, make sure that unshackle can see the tools it depends on.
"},{"location":"getting-started/installation/#examine-the-external-dependencies","title":"Examine the external dependencies","text":"The
From a cloneInstalled as a uv toolenv checksubcommand is the first check. It inspects your environment and reports which tools it found:uv run unshackle env check\nunshackle env check\nIt 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 \u2713orMissing required: <names>.If a required tool shows as missing,
env checktells you to rununshackle setup. You can also install the tool by hand and put it on yourPATH(or in one of thebinariesfolders), then re-run the check.
unshackleis not found after a uv tool installThe bin directory of
"},{"location":"getting-started/installation/#inspect-environment-paths","title":"Inspect environment paths","text":"uvtools is probably not on yourPATH. Runuv tool update-shelland open a new terminal.To see where unshackle will read its configuration from and where it stores downloads, caches, cookies, and devices, use:
uv run unshackle env info\nIf no configuration file exists yet, this command lists the locations unshackle searches for one, so you know where to make it. See the configuration guide for the details of that file.
"},{"location":"getting-started/installation/#next-steps","title":"Next steps","text":""},{"location":"getting-started/quickstart/","title":"Quick Start","text":"
- Set up your configuration file (
unshackle.yaml), or letunshackle setupwrite a first one.- Start downloading titles when
env checkfinds every required tool.This page takes you from a fresh install to your first downloaded file. You will make the one piece of configuration unshackle insists on, supply the service module and CDM it needs to fetch and decrypt media, operate a basic
unshackle dlcommand, and learn where the finished file lands.Before you start
This guide assumes that you installed unshackle as Installation describes. The examples use the bare
unshacklecommand. From a git clone, the recommended install, run each one asuv run unshackle ..., for exampleuv run unshackle env check.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).
"},{"location":"getting-started/quickstart/#1-run-setup","title":"1. Run setup","text":"EXAMPLEin this guide stands in for the tag you give yours.unshackle drives several external tools (FFmpeg, MKVToolNix, shaka-packager, and others) to decrypt, repack, and mux media, and it needs a config file. The
unshackle setupcommand prepares both. If you used the install script, setup has already run. You can run it again at any time:unshackle setup\nSetup downloads the missing required tools and, if you agree, groups of optional tools. If no
unshackle.yamlexists, it writes a minimal one with your downloads folder. It also makes the data folders and lets you import.wvdand.prddevices, one file or a whole folder at a time. Installation tells you what each step does and where the files go.Then make sure that unshackle finds the tools:
unshackle env check\nThe 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.Set up by hand
To do these steps yourself, install the tools from the tables in Installation. Then run
"},{"location":"getting-started/quickstart/#2-check-the-output-template","title":"2. Check the output template","text":"unshackle env infoto see where unshackle looks forunshackle.yaml, and make the file in one of those locations. The configuration guide gives the details.unshackle refuses to start a download unless it knows how to name the output file. That means
unshackle.yamloutput_templateis the one setting a first run genuinely requires. Everything else has a sensible default. The config that setup writes copiestagandoutput_templatefrom the example config. A minimal, working config looks like this:output_template:\n movies: \"{title} ({year}) {quality} {source}\"\n series: \"{title} {season_episode} {episode_name?} {quality} {source}\"\nunshackle fills in each
{variable}from the title and the tracks you downloaded. Output and Naming gives the full set of valid variables, includingresolution,video,audio,hdr,edition,tag, and more.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 makes scene-style
Title.S01E01.1080pnames. 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
"},{"location":"getting-started/quickstart/#3-write-a-service","title":"3. Write a service","text":"unshackle cfg(for exampleunshackle cfg tag MYGROUPorunshackle 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.The service tag you pass to
dl(likeEXAMPLE) 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
"},{"location":"getting-started/quickstart/#4-add-a-cdm-for-drm","title":"4. Add a CDM for DRM","text":"directories.servicesfolder (unshackle env infoshows the path). The tag then works withdl,search, and the other service commands.Most streaming services encrypt their titles. To fetch content keys, unshackle needs a CDM, a Widevine device (
.wvd) or a PlayReady device (.prd). If you imported devices duringunshackle setup, they are already in place. If setup also wrote your config, it setcdm.defaultto the device that you chose. Otherwise, add a Widevine device you already have with:unshackle wvd add /path/to/device.wvd\nThis validates the file and moves it into your WVDs directory. Then point services at it in your config. The
unshackle.yamlcdmmap uses the service tag as its config key, with adefaultthat covers everything else:cdm:\n default: my_device # the .wvd file's name, without the extension\n EXAMPLE: my_other_device # override for a specific service\nPlayReady works the same way with
.prdfiles created and managed by theprdcommand. If a title is DRM-free, unshackle needs no CDM.Tip
Operate
"},{"location":"getting-started/quickstart/#5-supply-authentication","title":"5. Supply authentication","text":"unshackle wvd parse my_deviceto inspect a device's security level and contents, andunshackle env infoto show where unshackle stores WVDs and PRDs.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, thencookies/{SERVICE}/{profile}.txt, thencookies/{SERVICE}/default.txt. So unshackle finds a file atcookies/EXAMPLE.txtautomatically for theEXAMPLEservice.unshackle repairs the usual export damage in memory and does not change your file: a missing header line, a byte-order mark, spaces in place of tabs, and rows a text editor broke. unshackle refuses a JSON export. Export the cookies again in Netscape format.
Credentials: store a username and password per service in your config:
unshackle.yamlcredentials:\n EXAMPLE: \"email@example.com:your-password\"\nUse the
"},{"location":"getting-started/quickstart/#6-find-a-title-optional","title":"6. Find a title (optional)","text":"-p/--profileflag 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.If you have a URL or ID already, skip this. Otherwise, find a title on the service and note the
idit prints, because that is what you feed todl:unshackle search EXAMPLE \"My Show\"\nYou can also show what a service gives for a given title without downloading:
"},{"location":"getting-started/quickstart/#7-do-your-first-download","title":"7. Do your first download","text":"unshackle dl --list-titles EXAMPLE 81234567 # show seasons/episodes\nunshackle dl --list EXAMPLE 81234567 # show available tracks\nA download always has three parts:
unshackle dl <FLAGS> <SERVICE-TAG> <TITLE>\n
dlcarries 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\nunshackle will fetch the title, select the tracks that match your flags, and get the content keys through your CDM (and any key vaults). It then decrypts, muxes, and tags the result.
"},{"location":"getting-started/quickstart/#handy-first-flags","title":"Handy first flags","text":"Flag Meaning-q,--qualityTarget resolution(s), e.g.-q 1080or-q 1080,720. Defaults to best available.-l,--langLanguage(s) for video and audio, e.g.-l enor-l orig,en.orig= the title's original language. Defaults toorig.-sl,--s-langSubtitle language(s); defaults toall.-v,--vcodecVideo codec, e.g.-v H.265. Defaults to any.-r,--rangeDynamic range, e.g.-r HDR10or-r DV. Defaults toSDR.-w,--wantedWhich episodes, e.g.-w S01or-w S01E01-S01E03. For music, which tracks, e.g.-w 1-5or-w 2x3for disc 2 track 3.-o,--outputOverride the output directory for this run.--listList 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\nSee 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
downloadsdirectory.unshackle env infoshows its exact path. A config fromunshackle setupuses the folder that you gave at its prompt. Without that setting, the default is adownloadsfolder one level above the installedunshacklepackage. Override it for one command with-o:unshackle dl -q 1080 -o /mnt/media/incoming EXAMPLE 81234567\n
- unshackle writes movies as a single
.mkvfile named from yourmoviestemplate.- unshackle groups TV episodes into a per-show / per-season folder (from your
seriestemplate) unless you pass--no-folder.- unshackle builds the filename from your
output_templateand 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.yaml"},{"location":"getting-started/quickstart/#where-to-go-next","title":"Where to go next","text":"directories:\n downloads: /mnt/media/incoming\n
- Downloading. The full
dlcommand: quality, codecs, languages, track selection, hybrid Dolby Vision, and output control.- Configuration. Every
unshackle.yamlconfig key, plus directories, key vaults, proxies, and naming templates.- REST API. Operate the
serveserver to drive downloads over HTTP.You are never far from help
"},{"location":"guide/cli-reference/","title":"CLI Reference","text":"
unshackle --help,unshackle dl --help, andunshackle <command> --helpshow every option. When in doubt, add--listto adlcommand to preview what it would do before it downloads anything.A complete reference for every
unshacklecommand, subcommand, and flag. This page groups the commands 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.setup: install the external tools and make a first config.cfg,env: manage configuration and the environment.kv: Key Vault operations.wvd,prd: Widevine / PlayReady device management.serve: operate the local CDM and REST API server.util: helper media utilities.Running commands from a clone
This page uses the bare
unshacklecommand. From a git clone, the recommended install, run each command asuv run unshackle ....Getting help on any command
Every command accepts
"},{"location":"guide/cli-reference/#root-command","title":"Root command","text":"-h,--help, or-?. For exampleunshackle dl --helporunshackle wvd new --help. Each service defines its own arguments fordlandsearch(such as a title ID or a query), so examineunshackle dl SERVICE --helpfor those.unshackle [OPTIONS] COMMAND [ARGS]...\nunshackle: Modular Movie, TV, and Music Archival Software.
Every invocation prints a banner and, when you enable
Option Descriptionupdate_checksin your config, checks for a newer release.-v,--versionPrint version information and exit.-d,--debugEnableDEBUG-level logs and JSON debug logging. Silences noisy HTTP libraries unlessdebug_requestsis 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 thedllevel.SERVICE: a service tag (for exampleEXAMPLE1orEXAMPLE2). Tags are case-insensitive and honour each service's aliases (example+,EXAMPLE2, and the other alias forms all give the same tag).SERVICE ARGS: the title, URL, or ID, plus any service-specific options. These belong to the service, not todl.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\nConfig-driven defaults
You can give any
"},{"location":"guide/cli-reference/#quality-codec-bitrate-range","title":"Quality, codec, bitrate & range","text":"Flag Default Descriptiondlflag a default under thedl:section ofunshackle.yaml, and per-service underservices.<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.-p,--profile- Profile for credentials and cookies.-q,--qualitybest Resolution(s), comma-separated, e.g.1080,720.-q 1080also matches anamorphic tracks by 16:9 canvas.-v,--vcodecany Video codec(s). Accepts names or values:AVC/H.264/H264,HEVC/H.265/H265,VC1/VC-1,VP8,VP9,AV1.-a,--acodecany Audio codec(s), comma-separated. AcceptsAAC,AC3/DD,EC3/DD+/eac3/ddp,AC4/AC-4,OPUS,OGG/VORB/vorbis,DTS,DTSX/DTS-X,ALAC,FLAC.-vb,--vbitratehighest Exact video bitrate in kbps.-ab,--abitratehighest 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,--rangeSDRColour range(s):SDR,HLG,HDR10,HDR10P(HDR10+),DV,HYBRID.-c,--channels- Audio channels; matches sub-layouts (5.1 \u2248 6.0).-naa,--noatmosoff Exclude Dolby Atmos audio tracks.--worstoff Pick the lowest bitrate within the requested quality. Requires-q.--best-available,--warn-onlyoff Warn and continue when a requested resolution, range, or language is absent, instead of failing.-rvb,--real-video-bitrateoff Probe real media size for true video bitrates, overriding the manifest.-rab,--real-audio-bitrateoff Same for audio (slower).
HYBRIDrange"},{"location":"guide/cli-reference/#language-subtitles","title":"Language & subtitles","text":"Flag Default Description
-r HYBRIDfetches both HDR10/HDR10+ and Dolby Vision and merges them withdovi_tool. It requires thedovi_toolbinary on yourPATH.-l,--langorigLanguage(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-lfor video). A-prefix excludes.-al,--a-lang- Audio-only language (overrides-lfor audio). A-prefix excludes.-sl,--s-langallSubtitle language(s). A-prefix excludes, e.g.all,-es.--require-audio- Audio langs that must exist. The title fails even with--best-available. Does not select tracks.--require-video- Video langs that must exist. The title fails even with--best-available. Does not select tracks.--require-subs- Subtitle langs that must exist. Fails the title if one is missing.--s-langstill decides what to keep.-fs,--forced-subsoff Include forced subtitle tracks.-fsl,--forced-s-langnone Language(s) wanted for forced subtitles; implies-fs. A-prefix excludes.--exact-langoff Exact matching only:-l es-419matcheses-419, notes-ES. Applies to selection and to sort order.--sub-format- Output subtitle format (SRT/srt,VTT/webvtt,ASS/ssa,TTML,SMI, ...), ororiginalto keep the source format.These flags select languages, they do not order them
Naming languages with
-lor-slremoves the others. To keep every language but put your preferred ones first, useaudio.language_priorityandsubtitle.language_priorityin your configuration file.unshackle honours the special language tokens
"},{"location":"guide/cli-reference/#title-episode-selection","title":"Title & episode selection","text":"Flag Descriptionorig,all, andbesteverywhere it expects a language.-w,--wantedWanted episodes, e.g.S01-S05,S07,S01E01-S02E03. Supports exclusions with a leading-(e.g.-S03). For a split episode,.Npicks one part (S01E01.2), a range must stay inside the episode (S01E01.1-S01E01.3), andS01E01on its own takes every part. For a dated episode, 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). For a music release, a token is a track number (1-5,1,3,7), or{disc}x{track}(2x3) when the release has more than one disc.--select-titlesInteractively select what to download: episodes of a series, or films when a title has more than one. Cannot combine with-w.--latest-episodeDownload only the single most recent episode.--latest-episodes NDownload only the N most recent episodes.--list-titlesList 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.
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-nt,--no-attachments(cover art, subtitle fonts, and files the service attaches) Flag Description-ad,--audio-descriptionInclude descriptive (audio-description) tracks.--skip-subtitle-errorsSkip 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-audioWrite a separate output file per audio codec instead of merging. Defaults to configmuxing.merge_audio.--merge-videoMux video tracks that share a height, range, and codec into one file, so only language varies inside a file. Defaults to configmuxing.merge_video.-o,--outputOverride the output directory for this run.--no-folderDisable folder creation for TV shows.--no-sourceRemove the source tag from the filename/path.--no-muxDo not mux; keep individual track files.--tagGroup tag override.--repackAdd aREPACKtag to the filename.--postscriptRun a command after each output file, with{variable}placeholders substituted. Repeatable. Replaces thepost_scriptsconfig for this run. See Post-scripts.--no-postscriptRun no post-script for this run, neither the configured ones nor--postscript."},{"location":"guide/cli-reference/#metadata-tagging","title":"Metadata & tagging","text":"Flag Description--tmdbTMDB ID (integer). Used for the tags. Skips the title search.--enrichreads it too. Needstmdb_api_key.--imdbIMDb ID, e.g.tt1375666. Used for the tags. Skips the title search.--enrichreads it too. Needs no API key.--tvdbTVDB ID (integer). Used for the tags. Skips the series lookup that--tvdb-orderwould otherwise do.--enrichreads it too. Needstvdb_api_key.--anilistAniList ID (integer), e.g.--anilist 21. A MyAnimeList ID is accepted asmal:12345and resolved to the AniList entry. Used for the tags. Skips the title search.--enrichreads it too. Needs no API key.--enrichOverwrite show title, year and original language with the external source's. Requires one of--tmdb,--imdb,--tvdb, or--anilist.--dailyTreat the title as daily/date-based content and fill missing episode air dates from TVDB. The fill needs--enrichand a TVDB ID. See Daily and date-based content.--tvdb-orderRenumber episodes to a TVDB season order:official(aired),dvd,absolute,alternate, orregional. Needstvdb_api_key.One ID at a time
You cannot combine
"},{"location":"guide/cli-reference/#episode-ordering","title":"Episode ordering","text":"--tmdb,--imdband--tvdb. Give one and unshackle finds the others from it, and writes all three to the tags.--anilistis 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 metadata provider that can find it, such as--tmdbwith notmdb_api_key, fails before the download starts.--anilistneeds no API key, so it never fails this check.A service does not always number a series the way TVDB's aired order does. Some services use TVDB's
alternate(Streaming) order for Futurama, for example.--tvdb-orderworks 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 ignore episodes the service carries. TVDB's
"},{"location":"guide/cli-reference/#drm-keys-decryption","title":"DRM, keys & decryption","text":"Flag Descriptiondvdorder 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.--cdm-only/--vaults-onlyUse only the CDM, or only Key Vaults, for key acquisition.--cdm <name>Use the named CDM device from thecdmconfig mapping for this run, ignoring the service/default mapping. Over--remote, that device licenses the remote session, not the server CDM.--skip-dlSkip the download but still retrieve keys.--exportExport track info and keys to amediaexportJSON file in the exports directory."},{"location":"guide/cli-reference/#network-proxy","title":"Network & proxy","text":"Flag Description--proxyProxy URI, a country or location code resolved from configured providers, orprovider:region(e.g.nordvpn:ca,gluetun:us,protonvpn:de:berlin,controld:yul).--no-proxyForce-disable all proxy use.--no-proxy-downloadBypass the proxy for all downloads (manifest, licence, and auth stay proxied).--proxy-downloadUse a different proxy for all downloads, in the same forms as--proxy(manifest, licence, and auth stay on--proxy).--remoteUse a remote unshackle server.--serverName a remote server from theremote_servicesconfig."},{"location":"guide/cli-reference/#concurrency-caching-listing","title":"Concurrency, caching & listing","text":"Flag Default Description--workersdownloader default Per-track download threads.--adaptive-workersoff Scale per-track segment workers dynamically (up to the--workerscap) based on measured CDN throughput and errors.--download-processes1Split large segment batches (24+) across this many download processes to exceed the single-process throughput cap on multi-gigabit connections. Ignored while a speed limit is set: the cap is one shared budget, which extra processes cannot share, so the download stays in a single process. Also ignored when the service HTTP session carries state a child process cannot rebuild, such as a custom TLS adapter.--continue-downloadsoff Keep completed segment files across runs and resume a previously failed download. One-off enable of thecontinue_downloadsconfig option.--downloads1Number of tracks downloaded concurrently.--speed-limitunlimited Cap total download speed across all threads and tracks, e.g.500k,5M,1.5Gor plain bytes/sec. Values are bytes, not bits (5M= 5.0 MB/s).offdisables a configured limit.--no-cacheoff Bypass the title cache.--reset-cacheoff Clear the title cache.--listoff 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
"},{"location":"guide/cli-reference/#search","title":"
--select-titlesand--wantedare mutually exclusive.--worstneeds--quality.--vbitrateand--vbitrate-range(and the audio equivalents) are mutually exclusive.search","text":"Find titles on a service. Like
dl,searchis a group whose subcommands are the installed services, and it reusesdl's authentication, cookie, and proxy machinery.unshackle search [OPTIONS] SERVICE [QUERY...]\nEach service defines its own query syntax. unshackle prints the results as a tree of titles with their service IDs. Feed an ID straight into
Option Descriptiondl.-p,--profileProfile for credentials and cookies.--proxyProxy URI, country or location code, orprovider:region(e.g.controld:ca).--no-proxyForce-disable all proxy use.Example
"},{"location":"guide/cli-reference/#import","title":"unshackle search EXAMPLE \"My Show\"\nunshackle search -p myprofile EXAMPLE \"Another Show\"\nunshackle search --proxy nordvpn:ca SERVICE \"query\"\nimport","text":"Reconstruct a download (download \u2192 decrypt \u2192 mux) from an
--exportJSON file without re-contacting the service. It re-fetches the manifest, injects the stored keys, and then runs the normaldlpipeline. The service tag is read from the export file, not passed by you.unshackle import EXPORT_FILE [DL_ARGS...]\nunshackle forwards any
dloptions after the file verbatim, so you can override quality, range, proxy, and so on.Example
unshackle import export.json\nunshackle import export.json -r HDR10 --proxy US\nThe export file can be a
"},{"location":"guide/cli-reference/#setup","title":"mediaexportfile fromdl --export, an export from an older version of unshackle, or an export from unidl. The file must name the service it came from.setup","text":"Install the external tools and make a first config.
unshackle setup\nThe command takes no options and asks its questions at the prompt. It shows the missing external tools and downloads the ones you accept. It puts them in a
binariesfolder, in the package folder of a git clone or in your user data directory for auvtool install. If nounshackle.yamlexists, it writes a first one: inside the clone, or to your user config directory for auvtool install. It then makes the data folders and imports.wvdand.prddevices, one file or folder per answer, until you give an empty answer. It ends with a summary of the tools, the path of a config that it wrote, and a pointer toenv check. You can run it again: it skips the tools it finds and never overwrites a config. Installation gives the full steps and where each file goes.Example
"},{"location":"guide/cli-reference/#cfg","title":"uv run unshackle setup # from a git clone\nunshackle setup # from a uv tool install\ncfg","text":"Read, set, delete, or show configuration values in
unshackle.yamlwithout hand-editing YAML.Argument / Option Descriptionunshackle cfg [KEY] [VALUE] [OPTIONS]\nKEYDotted path into the config, e.g.tag,serve.api_secret,directories.downloads.VALUEValue to set. Parsed as a Python literal when possible, sotrue,123,['a','b'], and{'k':1}become real types; bare words stay strings.--unsetRemove the configuration value.--listList all set configuration values.Comments are stripped
Writing a value through
cfgrewrites the configuration file and removes all comments from it. If your config relies on comments, edit it by hand instead. Setting a value and using--unsettogether is an error.Example
"},{"location":"guide/cli-reference/#env","title":"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\nenv","text":"Inspect and manage the project environment.
"},{"location":"guide/cli-reference/#env-check","title":"unshackle env COMMAND [ARGS]...\nenv check","text":"unshackle env check\nPrints a dependency table (Category / Tool / Status / Required / Purpose) that shows which external tools unshackle finds on your
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, gitPATH. It also gives a summary of how many required tools are present. If a required tool is missing, it tells you to rununshackle setup.* required. All others are optional.
"},{"location":"guide/cli-reference/#env-info","title":"env info","text":"unshackle env info\nShows the location unshackle loaded the configuration file from. If it found no file, it shows the candidate locations instead. It then 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\nPrints a sample of every available CLI theme. Each sample has colour swatches, help text with option rows, and a track listing with video, audio and subtitle entries. It also has log lines, a progress bar, and the gradient pulse bar. unshackle marks the active theme and shows each theme's aliases. Set your choice with the
"},{"location":"guide/cli-reference/#env-clear","title":"themeconfig key.env clear","text":"Clear an environment directory. unshackle empties the directory, makes it again, and reports the number of files and bytes it freed.
Command Descriptionunshackle env clear cache [SERVICE]Clear the cache directory, or only one service's cache subdirectory.unshackle env clear tempClear the temp directory.Example
"},{"location":"guide/cli-reference/#kv","title":"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\nkv","text":"Manage Key Vaults. You configure vaults under
key_vaultsinunshackle.yaml, each with aname, atype(sqlite,mysql,http,api), and type-specific options. unshackle normalises service tags automatically."},{"location":"guide/cli-reference/#kv-copy-to_vault-from_vault","title":"unshackle kv COMMAND [ARGS]...\nkv copy TO_VAULT FROM_VAULT...","text":"Copy content keys from one or more source vaults into a destination vault. unshackle skips rows whose KIDs match, unless the existing row has no content key. unshackle never alters or deletes existing data.
Option Description-s,--serviceOnly copy data for a specific service.-l,--local-onlyOnly copy data for services installed locally. Mutually exclusive with--service."},{"location":"guide/cli-reference/#kv-sync-vault","title":"kv sync VAULT...","text":"Make sure that two or more vaults hold the same set of content keys, essentially a chained bidirectional copy. More than one vault is necessary. Accepts the same
"},{"location":"guide/cli-reference/#kv-add-file-service-vault","title":"--service/--local-onlyoptions ascopy.kv add FILE SERVICE VAULT...","text":"Add content keys to one or more vaults for a service.
"},{"location":"guide/cli-reference/#kv-search-kid","title":"FILEcontains oneKID:KEYpair per line (32 hex : 32 hex, UTF-8). unshackle skips every line that does not match that form.kv search KID","text":"Find a KID (32 hex characters, no dashes) in the configured vaults and report its content key.
Option Description-s,--serviceLimit the search to a specific service tag.-v,--vaultLimit the search to a specific configured vault by name.Note
unshackle cannot enumerate remote vaults without a service, so pass
"},{"location":"guide/cli-reference/#kv-prepare-vault","title":"--servicewhen you examine them.kv prepare VAULT...","text":"Make service tables on vaults that use tables, for every installed service, where they do not already exist.
Example
"},{"location":"guide/cli-reference/#wvd","title":"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\nwvd","text":"Manage Widevine Device (
.wvd) files. Devices live in the configured WVDs directory.Command Descriptionunshackle wvd COMMAND [ARGS]...\nwvd add PATHS...Validate and move one or more.wvdfiles into the WVDs directory.wvd delete NAMES...Delete.wvdfiles by name (without extension). Prompts for confirmation.wvd parse PATHParse a.wvdand print its System ID, security level, type, flags, and client info. Relative paths resolve against the WVDs directory.wvd dump WVD_PATHS... OUT_DIRExtract a device's contents (metadata, private key, client ID, VMP) intoOUT_DIR/<name>/. With no paths, dumps every WVD in the WVDs directory.wvd new NAME PRIVATE_KEY CLIENT_ID [FILE_HASHES]Create a new.wvdfrom a PEM private key and aClientIdentificationblob, optionally with a VMP (FileHashes) blob.Option Default Description
wvd newoptions:-t,--typeAndroidDevice type.-l,--level1Security level (1-3).-o,--outputWVDs dir Output directory.Example
"},{"location":"guide/cli-reference/#prd","title":"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\nprd","text":"Manage PlayReady Device (
.prd) files. Devices live in the configured PRDs directory. Built onpyplayready."},{"location":"guide/cli-reference/#prd-new-paths","title":"unshackle prd COMMAND [ARGS]...\nprd new PATHS...","text":"Make a new
Option Description.prd. Give either a single folder that containszgpriv.dat(group key) andbgroupcert.dat(group certificate), or two file paths (group key, then group certificate).-e,--encryption_keyOptional device ECC private encryption key (generated if omitted).-s,--signing_keyOptional device ECC private signing key (generated if omitted).-o,--outputOutput directory or.prdfile path."},{"location":"guide/cli-reference/#prd-reprovision-prd_path","title":"
prd newwill not overwrite an existing file.prd reprovision PRD_PATH","text":"Reprovision an existing device by replacing its leaf certificate and keys. Only a device of version 3 or higher can do reprovisioning. Accepts the same
"},{"location":"guide/cli-reference/#prd-test-device","title":"-e/-s/-ooptions. By default it overwrites the device in place.prd test DEVICE","text":"Do a test of a device against the Microsoft PlayReady demo server and print the returned content keys.
Option Default Description-c,--cktaesctrContent key encryption type:aesctroraescbc.-sl,--security-level2000Minimum security level:150,2000, or3000.Example
"},{"location":"guide/cli-reference/#serve","title":"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\nserve","text":"Serve your local Widevine/PlayReady devices and the REST API for remote access. Built on
aiohttp.Option Default Descriptionunshackle serve [OPTIONS]\n-h,--host127.0.0.1Host to serve from.-p,--port8786Port to serve from.--caddyoff Also serve through Caddy (requires thecaddybinary and aCaddyfile).--api-onlyoff Serve only the REST API, not the CDM endpoints. Incompatible with--no-widevine/--no-playready.--no-widevineoff Disable the Widevine CDM endpoints.--no-playreadyoff Disable the PlayReady CDM endpoints.--no-keyoff Disable API-key authentication (allows all requests).--debug-apioff Include tracebacks/stderr in API error responses.--debugoff Enable debug logging for API operations.--remote-onlyoff 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_secretfirstUnless you pass
unshackle.yaml--no-key,serve.api_secretmust be set in your config. Requests authenticate with theX-Secret-Keyheader.--no-keydisables authentication entirely. Only use it on a trusted, private network.serve:\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\nExample
"},{"location":"guide/cli-reference/#util","title":"unshackle serve\nunshackle serve -h 0.0.0.0 -p 8080\nunshackle serve --api-only\nunshackle serve --remote-only\nunshackle serve --caddy\nutil","text":"Various helper media utilities. When you give a command a directory, it processes every
.mkv/.mp4inside it in natural (S01E01 before S01E10) order."},{"location":"guide/cli-reference/#util-refresh-services","title":"unshackle util COMMAND [ARGS]...\nutil refresh-services","text":"unshackle util refresh-services\nForce a refresh (git pull / hard reset) of all service repos configured under
"},{"location":"guide/cli-reference/#util-crop-path-aspect","title":"directories.services. This updates the clones on disk. A server that is already running reloads the changed services throughPOST /api/maintenance/refresh-servicesorserve.services_refresh_intervalinstead.util crop PATH ASPECT","text":"Losslessly crop H.264/H.265 video at the bitstream level.
Option Default DescriptionASPECTis aW:Htarget such as2.39:1.--letter/--pillar--letterCrop top/bottom (--letter) or the sides (--pillar).-o,--offset0Fine-tune the computed crop area if not perfectly centred.-p,--previewoff 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,--previewoff 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. By default it does a test of every track. It cannot do a test of subtitles.
Option Default Description-m,--map0Do a test of specific tracks with FFmpeg's-map, for example0:v:0or0:a.Example
"},{"location":"guide/cli-reference/#see-also","title":"See also","text":"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/downloading/","title":"Downloading","text":"
- Installation: install unshackle and its dependencies.
- Quickstart: your first download.
- Configuration file: set defaults for any
dlflag and configure vaults, proxies, and directories.
unshackle dlis 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, use
"},{"location":"guide/downloading/#command-shape","title":"Command shape","text":"unshackle dl --help. Every flag shown here can also be given a default in your configuration file. See Configuration file.A download is always three layers: the root CLI, the
dlcommand (which is where unshackle parses all the flags below), and a service tag subcommand that carries the title argument.unshackle dl [OPTIONS] SERVICE [SERVICE ARGUMENTS]\nA minimal download
unshackle: the root command.dl: the download command. unshackle parses all of the options on this page here, so they go before the service tag.SERVICE: a service tag such asEXAMPLE1,EXAMPLE2,EXAMPLE3. Service tags are case-insensitive and honour each service's aliases (for exampleexample+resolves to its real tag).SERVICE ARGUMENTS: usually a title ID or URL. This positional argument belongs to the service, not todl, so it comes after the tag.Flags belong to dl, the title belongs to the serviceunshackle dl EXAMPLE 81234567\nunshackle dl -q 1080 -v H.265 -r HDR10 --lang en EXAMPLE 'https://www.example.com/...'\nSee what a service accepts
Because each service defines its own positional argument, use
"},{"location":"guide/downloading/#the-download-flow","title":"The download flow","text":"unshackle dl SERVICE --help(for exampleunshackle dl EXAMPLE --help) to see that service's own argument and any service-specific options.When you start a download,
dlmoves through the pipeline below. The order tells you why, for example,--liststops early, and why unshackle fetches the content keys 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,--latest-episodes, 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: get the 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."},{"location":"guide/downloading/#choosing-quality","title":"Choosing quality","text":""},{"location":"guide/downloading/#resolution","title":"Resolution","text":"
--listprints the tracks a title exposes and stops before selection.--list-titlesprints every title the service returned and stops before--wanted/--latest-episode/--latest-episodesfiltering.--skip-dlruns the license step but skips the actual segment download.Single resolution
-q/--qualitytakes one or more target resolutions (heights) as a comma-separated list. Without it, unshackle picks the best available resolution.Multiple resolutions in one rununshackle dl -q 1080 EXAMPLE 81234567\nunshackle dl -q 2160,1080,720 EXAMPLE 81234567\n16:9 canvas matching
Resolution matching is by track height first. If no track height matches exactly, unshackle falls back to a 16:9-canvas match, so
"},{"location":"guide/downloading/#best-available-and-worst","title":"Best-available and worst","text":"-q 1080also selects an anamorphic1920\u00d7804track (computed asint(width \u00d7 9 / 16)). unshackle applies the fallback to each range and codec on its own. A service that crops only its HDR10 tracks therefore keeps its3840\u00d71920HDR10 track, even when another range has an exact2160height match.By default a missing requested resolution is an error. Two flags change that:
Flag Behaviour--best-available(--warn-only) If the requested resolution(s) are not present, continue with the best that is available instead of failing. Also softens missing video/audio/subtitle languages and hybrid fallbacks.--worstWithin the specified quality, pick the lowest bitrate rendition. Requires-q/--quality. Never fail on a missing resolution"},{"location":"guide/downloading/#video-codec-and-colour-range","title":"Video codec and colour range","text":""},{"location":"guide/downloading/#codec","title":"Codec","text":"unshackle dl -q 2160 --best-available EXAMPLE 0ABC123\nName Value
-v/--vcodecselects one or more video codecs. The default is any codec. It accepts either enum names or their values, comma-separated.AVCH.264HEVCH.265VC1VC-1VP8VP8VP9VP9AV1AV1Either spelling works
H264andH265are aliases ofAVCandHEVC."},{"location":"guide/downloading/#colour-range","title":"Colour range","text":"unshackle dl -v HEVC EXAMPLE 81234567\nunshackle dl -v H.265 EXAMPLE 81234567\nunshackle dl -v hevc,avc EXAMPLE 81234567\nRange Meaning
-r/--rangeselects one or more colour ranges. The default isSDR.SDRStandard dynamic range (default)HLGHybrid Log-GammaHDR10HDR10HDR10PHDR10+DVDolby VisionHYBRIDFetch 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 '...'\nHYBRID requires dovi_tool
-r HYBRIDmakes a single hybrid track by injecting the Dolby Vision RPU metadata onto an HDR10/HDR10+ base layer with dovi_tool. It requires thedovi_toolbinary, resolved from unshackle'sbinaries/folder, thebinariesfolder of your user data directory, or yourPATH. 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 needshdr10plus_tool. With no DV and no HDR10+, the title fails. When HDR10+ is present, unshackle prefers it over HDR10 as the base layer.To keep an HDR10+ deliverable alongside the hybrid, request both ranges:
"},{"location":"guide/downloading/#bitrate-selection","title":"Bitrate selection","text":"-r HYBRID,HDR10P.-r HYBRIDon its own muxes only the merged hybrid.By default unshackle keeps the highest-bitrate rendition for each selected resolution/codec/range/language combination. You can constrain this.
Flag Purpose-vb/--vbitrateExact video bitrate in kbps.-ab/--abitrateExact audio bitrate in kbps.-vb-range/--vbitrate-rangeVideo bitrate range in kbps, e.g.6000-7000; picks the highest within it.-ab-range/--abitrate-rangeAudio bitrate range in kbps, e.g.128-256.Exact vs range are mutually exclusive
You cannot combine
"},{"location":"guide/downloading/#real-bitrate-probing","title":"Real bitrate probing","text":"--vbitratewith--vbitrate-range, and likewise for the audio pair. Pick one form.Manifest-declared bitrates are sometimes rounded or wrong. Services often advertise a peak or nominal bandwidth that is far from the track's real average. This matters because a track's bitrate drives the track listing, the sort order, and the
--vbitrate/--vbitrate-rangeselection 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
unshackle measures a single-file track (DASH
SegmentBase/BaseURL) 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. unshackle probes only the five highest declared-bitrate renditions of each quality tier, in parallel (video grouped by codec and range, audio by codec, channels, language, and descriptive flag). It 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. A track whose duration unshackle cannot find falls back to
"},{"location":"guide/downloading/#audio-codec-channels-and-atmos","title":"Audio codec, channels, and Atmos","text":""},{"location":"guide/downloading/#codec_1","title":"Codec","text":"ffprobe. A probe failure is non-fatal: the manifest value stays in place.Name Value Codec
-a/--acodecselects one or more audio codecs (comma-separated). The default is any.AACAACAdvanced Audio CodingAC3DDDolby DigitalEC3DD+Dolby Digital PlusAC4AC-4Dolby AC-4OPUSOPUSOpusOGGVORBVorbisDTSDTSDTSDTSXDTS-XDTS:X Profile 2 (DTS-UHD)ALACALACApple LosslessFLACFLACFLACunshackle accepts names, values, and a few aliases:
Prefer Dolby Digital Plus, fall back to AACeac3andddpboth giveEC3, andvorbisgivesOGG."},{"location":"guide/downloading/#channels-and-atmos","title":"Channels and Atmos","text":"unshackle dl -a EC3,AAC EXAMPLE 81234567\n5.1 audio, no Atmos
-c/--channels: desired channel layout, for example5.1or2.0. Matching is by ceiling, so5.1implicitly matches a6.0-reported layout. An immersive layout such as5.1.4matches on its total number of channels, so-c 10selects it.-naa/--noatmos: exclude Atmos audio tracks from selection."},{"location":"guide/downloading/#music-quality","title":"Music quality","text":"unshackle dl -c 5.1 --noatmos EXAMPLE 0ABC123\nA music service gives one
Audiotrack for each codec and bitrate it offers for a song. The audio options above choose between them:-apicks the codec and-abor-ab-rangepicks the bitrate. If you set neither, unshackle takes the best track.Take the lossless track
-q/--qualityis a video height. It has no effect on a music download.Take the lossy track at 320 kb/sunshackle dl -a FLAC EXAMPLE 81234567\nunshackle dl -a AAC -ab 320 EXAMPLE 81234567\nTwo FLAC tracks in one release
A service can offer both a CD FLAC and a hi-res FLAC. The two have the same codec, so the bitrate is what separates them. Use
"},{"location":"guide/downloading/#languages","title":"Languages","text":""},{"location":"guide/downloading/#video-and-audio-language","title":"Video and audio language","text":"--listto read the bitrates, then give-abthe one you want.Original language plus English
-l/--langsets the wanted language(s) for both video and audio. The default isorig, the title's original language.unshackle dl -l orig,en EXAMPLE 81234567\nunshackle changes the special token
origto the title's own original language everywhere you use it. You can override each track type independently:English audio over the original video
-vl/--v-lang: language for video only (overrides-lfor video). Useful when the burned-in video language differs from the audio you want.-al/--a-lang: language for audio only (overrides-lfor audio)."},{"location":"guide/downloading/#excluding-languages","title":"Excluding languages","text":"unshackle dl -al en EXAMPLE 81234567\nPut a
Every subtitle language except Spanish-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.unshackle dl -sl all,-es EXAMPLE 81234567\nunshackle subtracts the exclusions 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:
Only exclusions-sl -esis the same as-sl all,-es, and-l -esis the same as-l orig,-es.# 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\nCascade
-vland-alhave no default of their own: they cascade to-l. An override that names a language replaces-lfor that track type, exclusions included. An override that gives only exclusions adds them to the ones from-l.# video keeps the Japanese track; only the audio drops Spanish\nunshackle dl -l en,-es -vl ja EXAMPLE 81234567\nRules for exclusion tokens
"},{"location":"guide/downloading/#sort-order","title":"Sort order","text":"
origbecomes the title's own original language, so-sl -origdrops the subtitles in that language.--exact-langapplies to exclusions with the same matching rules as selection:-esremoves exactly the tracks thateswould select. Without it,-esalso removeses-419andes-ES.- Tracks with no language tag are never excluded.
- unshackle rejects
-all. Name the languages you do not want instead.- The comma form is the documented style.
-sl -esalso works, but a shell can read a lone-esas another option.unshackle.yaml
-land-slselect languages: naming some removes the rest. To keep every language but decide which ones come first, set a priority list in your configuration file:audio:\n language_priority: [orig, en]\nsubtitle:\n group_by: language\n language_priority: [en, es, fr]\nSubtitles then start with English, Spanish, and French. The languages you leave out follow alphabetically, after the title's original language. unshackle removes nothing. Audio works the same way, but the languages you leave out keep their bitrate and codec order instead of an alphabetical one.
See
"},{"location":"guide/downloading/#exact-vs-fuzzy-matching","title":"Exact vs fuzzy matching","text":"subtitle.language_priorityfor the details.By default language matching is fuzzy:
-l enalso acceptsen-US,en-GB, and the other regional forms (up to a small distance). Pass--exact-langwhen only an exact match is acceptable: with it,-l es-419selects onlyes-419, notes-ES.The
allandbesttokensFor audio, the tokens
"},{"location":"guide/downloading/#subtitles","title":"Subtitles","text":""},{"location":"guide/downloading/#selecting-subtitle-languages","title":"Selecting subtitle languages","text":"allandbestbypass the usual one-track-per-language pick and instead select the best track for each language present. For subtitles,allkeeps every subtitle language.Only English and Spanish subtitles
-sl/--s-langsets the wanted subtitle language(s). The default isall, so unshackle downloads every available subtitle language.unshackle dl -sl en,es EXAMPLE 81234567\nTo keep the default and drop one language, use the
"},{"location":"guide/downloading/#requiring-subtitles","title":"Requiring subtitles","text":"-prefix described in Excluding languages.-sl all,-eskeeps every subtitle language except Spanish, forced Spanish subtitles included.
--require-substakes a list of languages that must exist. If any is missing, the title fails, even under--best-available/--warn-only. Use it when a download is only worth keeping if a specific subtitle track is present.It gates the title. It does not select tracks:
--s-langstill decides what to keep, and it defaults toall:"},{"location":"guide/downloading/#requiring-audio-and-video-languages","title":"Requiring audio and video languages","text":"# every subtitle language, but only if English is one of them\nunshackle dl --require-subs en EXAMPLE 0ABC123\n\n# English, Korean and Japanese only, and only if English is present\nunshackle dl -sl en,ko,ja --require-subs en EXAMPLE 0ABC123\n
--require-audioand--require-videotake a list of languages that must exist. If one is missing, the title fails, even under--best-available/--warn-only. Like--require-subs, they gate the title without selecting tracks:-l/-al/-vlstill decide what unshackle downloads.This matters most with
-l bestor-l all, which accept whatever the service offers and so never report a missing language on their own:# every audio language except French, but only if English is one of them\nunshackle dl -l best,-fr --require-audio en EXAMPLE 0ABC123\n\n# English is required, French is optional\nunshackle dl -l en,fr --require-audio en --warn-only EXAMPLE 0ABC123\nThe check runs against the full track list, before any language selection or exclusion.
"},{"location":"guide/downloading/#forced-subtitles-and-output-format","title":"Forced subtitles and output format","text":"origresolves to the title language, and--exact-langapplies. Audio carried inside the video track counts.--no-audioand--no-videodrop the matching requirement.Value Format
-fs/--forced-subs: include forced subtitle tracks (signs/foreign dialogue). Without this flag, unshackle drops forced subtitle tracks from the selection.-fsl/--forced-s-lang: keep forced subtitles only in these languages (implies-fs). Works independently of--s-lang, so-sl all -fsl engrabs every full subtitle but only the English forced track. It accepts exclusions too:-fsl all,-eskeeps every forced subtitle except the Spanish one, and-fsl -esmeans 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, and the other codec aliases), or the literaloriginalto keep the source format.SRT/srtSubRipVTT/vttWebVTTASS/assAdvanced SubStation AlphaSSA/ssaSubStation AlphaTTML/ttmlTimed Text MarkuporiginalKeep the source format, no conversion Convert subtitles to SRTunshackle dl --sub-format srt EXAMPLE 81234567\nSDH stripping happens by default
When a subtitle track carries the SDH flag (for the deaf/hard-of-hearing) and there is no plain same-language subtitle, unshackle makes a stripped, non-SDH track automatically. The
"},{"location":"guide/downloading/#selecting-episodes-and-tracks","title":"Selecting episodes and tracks","text":"subtitle.strip_sdhconfig option controls this behaviour (default on).For series, several flags control which episodes unshackle downloads. The same flags select the tracks of a music release. By default, unshackle downloads all episodes and all tracks.
"},{"location":"guide/downloading/#wanted-ranges","title":"Wanted ranges","text":"Whole seasonsSpecific episodesExclusionsSplit episodesAir dates
-w/--wantedaccepts season and episode ranges, comma-separated. Prefix a token with-to exclude it. For a music release, the same option takes track numbers. Read music tracks below.unshackle dl -w S01 EXAMPLE 81234567\nunshackle dl -w S01-S03 EXAMPLE 81234567\nunshackle dl -w S01,S03,S05 EXAMPLE 81234567\nunshackle 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"},{"location":"guide/downloading/#music-tracks","title":"Music tracks","text":"# 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\nTracksMore than one discExclusions
-wselects the tracks of an album, EP, single, or playlist. Give a track by its number. A release with more than one disc uses the{disc}x{track}form. A number on its own is a track on disc 1, which is the number the track list shows for a single-disc release.# Track 3\nunshackle dl -w 3 EXAMPLE 81234567\n\n# Tracks 1 through 5\nunshackle dl -w 1-5 EXAMPLE 81234567\n\n# Tracks 1, 3 and 7\nunshackle dl -w 1,3,7 EXAMPLE 81234567\n# Disc 2, track 3\nunshackle dl -w 2x3 EXAMPLE 81234567\n\n# Disc 2, tracks 1 through 4\nunshackle dl -w 2x1-2x4 EXAMPLE 81234567\n\n# Disc 1 track 3, through disc 2 track 2\nunshackle dl -w 1x3-2x2 EXAMPLE 81234567\n# Tracks 1 through 8, but not track 4\nunshackle dl -w 1-8,-4 EXAMPLE 81234567\nRead the track list first
"},{"location":"guide/downloading/#daily-and-date-based-content","title":"Daily and date-based content","text":"
--list-titlesprints the release with its disc and track numbers, so you can see which numbers to give-w.Talk shows, news and sports have no official episode numbering, so unshackle names them by air date. unshackle writes an episode that carries an air date as
Show.YYYY.MM.DDinstead ofSxxExx, the{date}token holds the ISO date, and the season folder becomes the year.A service that only carries this kind of title sets
DAILY = Trueon its class, and a service can setair_dateon each episode itself. Add--dailyto mark any other title as date-based:unshackle dl --daily --tvdb 73871 --enrich EXAMPLE 81234567\nWith
--enrichand a TVDB ID,--dailyfills in the air date of every episode that has none. An air date the service already set is kept. unshackle skips dates before 1970 and dates in the future, because TVDB carries placeholder schedule dates for episodes that have not aired. Without--enrichunshackle has no source to fill from, and says so.A dated episode answers to its air date in
"},{"location":"guide/downloading/#split-episodes","title":"Split episodes","text":"-w, as well as to itsSxxExxtoken. 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. You can mix date tokens andSxxExxtokens in one-w.A few services split one episode into several separately playable videos. Where a service reports that, add
Token Selects.Nafter the episode to pick one part.S01E01All parts of episode 1.S01E01.2Part 2 of episode 1, and nothing else.S01E01.1-S01E01.3Parts 1 to 3 of episode 1.S01,-S01E01.2All of season 1 except episode 1 part 2. Parts 1 and 3 are kept.A season token such as
S01covers every episode and every part, so you only need.Nwhen you want a part on its own.A part range stays inside one episode
S01E01.1-S01E01.3is valid. unshackle rejectsS01E01.1-S01E02.3, because the parser cannot know how many parts episode 1 has and so cannot work out where the range ends. To span episodes, write the parts you want as separate tokens:-w S01E01.2,S01E02.1.A part of an unsplit episode selects nothing
"},{"location":"guide/downloading/#other-selection-flags","title":"Other selection flags","text":"Flag Behaviour
-w S01E02.2on 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. Use--list-titlesto see which episodes carry parts.--select-titlesInteractively pick episodes of a series, or films when a title has more than one. Cannot combine with-w.--latest-episodeDownload only the single most recent episode.--latest-episodes NDownload only the N most recent episodes. The newest episodes can come from more than one season. A split episode counts as one episode, and all its parts download.--list-titlesList every title the service returned, then stop.-w,--latest-episodeand--latest-episodesare not applied to this listing.When
Grab just the newest episode--latest-episodeor--latest-episodesis set,-whas no effect.Grab the three newest episodesunshackle dl --latest-episode EXAMPLE 81234567\n"},{"location":"guide/downloading/#including-and-excluding-track-types","title":"Including and excluding track types","text":"unshackle dl --latest-episodes 3 EXAMPLE 81234567\nYou can restrict a download to certain track categories, either positively (only these) or negatively (everything but these).
Only these types (
*-onlyflags):
-V/--video-only-A/--audio-only-S/--subs-only-C/--chapters-onlySkip these types (
no-*flags):
-nv/--no-video-na/--no-audio-ns/--no-subs-nc/--no-chapters-nt/--no-attachments(cover art, subtitle fonts, and files the service attaches)Additional track-type flags:
Subtitles only
-ad/--audio-description: include descriptive (audio-description) tracks, which unshackle drops 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.Everything except chaptersunshackle dl -S -sl en EXAMPLE 81234567\nunshackle dl -nc EXAMPLE 81234567\nNote
You can combine an
"},{"location":"guide/downloading/#listing-and-dry-runs","title":"Listing and dry runs","text":"*-onlyflag withno-*flags to fine-tune:*-onlychooses the starting set of categories, thenno-*subtracts from it. Attachments (for example fonts) are always kept.Before committing to a long download, inspect what unshackle would do:
Flag Effect--listList the tracks the service exposes for each title, then stop. No selection, no download.--list-titlesList every title the service returned, then stop.-w,--latest-episodeand--latest-episodesare not applied to this listing.--skip-dlSkip downloading but still acquire the decryption keys. See the track selection without downloading"},{"location":"guide/downloading/#output-and-muxing","title":"Output and muxing","text":""},{"location":"guide/downloading/#output-location-and-folders","title":"Output location and folders","text":"unshackle dl -q 1080 -v H.265 -r HDR10 --list EXAMPLE 81234567\nSend this download somewhere specific
-o/--output: override the output directory for this run (otherwise unshackle uses the configured downloads directory).--no-folder: do not make a per-show folder for TV downloads.--no-source: remove the service source tag from the filename and path."},{"location":"guide/downloading/#muxing-behaviour","title":"Muxing behaviour","text":"unshackle dl -o ~/Videos/incoming EXAMPLE 81234567\nBy default,
Flag Behaviour Default sourcemkvmergemuxes the tracks of a movie or an episode into a single Matroska (.mkv) file. These flags change how unshackle assembles the output:--no-muxDo not mux; keep the individual track files. ---split-audioWrite a separate output file per audio codec instead of merging all audio. configmuxing.merge_audio(on)--merge-videoMux video tracks that share a height, range, and codec into one file, so only language varies inside a file. configmuxing.merge_video(off)Music is never muxed
A song skips the muxer. unshackle keeps the container that the service delivered, such as
"},{"location":"guide/downloading/#after-the-download","title":"After the download","text":".flac,.m4a, or.mp3, and writes the metadata into that file. Read Music output files.Hand each finished file to an uploader
--postscript \"<command>\"runs your own command once per output file, with unshackle's metadata substituted into{variable}placeholders. It is repeatable, and it replaces thepost_scriptsconfig for that run. Nosuccesspost-script operates under--no-mux, because that run writes no muxed output. Afailurepost-script still operates if the download fails.unshackle dl --postscript \"python /opt/upload.py {filepath} --service={service}\" EXAMPLE 81234567\nSkip the uploader for one run
--no-postscriptoperates the run with no post-script at all. It silences thepost_scriptsconfig and--postscripttogether, for every event and mode.unshackle dl --no-postscript EXAMPLE 81234567\nFor post-scripts that continue across runs, for the
"},{"location":"guide/downloading/#naming-tags","title":"Naming tags","text":"seasonandrunmodes, forfailurepost-scripts and for the full variable list, see Post-scripts.Custom group tag and a REPACK label
--tag: set the release group tag (overrides the configured tag).--repack: add aREPACKtag to the output filename."},{"location":"guide/downloading/#proxies","title":"Proxies","text":"unshackle dl --tag MYGRP --repack EXAMPLE 81234567\nProxy forms
--proxyaccepts a full proxy URI, a country or location code (resolved through your configured proxy providers), or aprovider:regionform. See Proxies & VPN for the exact grammar.unshackle dl --proxy us EXAMPLE 81234567\nunshackle dl --proxy nordvpn:ca EXAMPLE 81234567\nunshackle dl --proxy controld:yul EXAMPLE 81234567\nunshackle dl --proxy 'http://user:pass@host:8080' EXAMPLE 81234567\nThree related flags:
"},{"location":"guide/downloading/#performance-and-caching","title":"Performance and caching","text":"Flag Purpose
--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 you need the proxy only to satisfy geo-checks, not to move the bulk of the data.--proxy-download: use a different proxy for all downloads. The manifest, license, and authentication requests still go through--proxy. Takes the same forms as--proxy.--workers NThreads used per track for segment downloads. Default depends on the downloader.--adaptive-workersOpt-in: start the per-track worker count at--workers, reduce it when the CDN returns errors, then raise it again while the extra workers add measured throughput. The count never goes above--workers. Off by default (fixed worker count).--download-processes NOpt-in: split a large segment batch acrossNdownload processes, each with its own worker pool. A single process tops out around 1.3 Gb/s (Python interpreter limit);2reaches 2.5GbE line rate on fast CDNs. Only engages for batches of 24+ segments. Ignored while--speed-limit(or serve'sglobal_speed_limit) is set: the cap is one shared budget, which extra processes cannot share, so the download stays in a single process. Also ignored when the service HTTP session carries state a child process cannot rebuild, such as a custom TLS adapter. The download stays in a single process instead of running with the wrong settings. Default1.--continue-downloadsOpt-in: keep completed segment files when a download fails so the next run resumes instead of restarting from zero. One-off enable of thecontinue_downloadsconfig option, which documents what can and cannot resume.--downloads NNumber of tracks downloaded concurrently. Default1.--slow [MIN-MAX]Add a delay between titles to look more like a real device.--slowalone means 60-120s;--slow 20-40sets a custom range. Minimum 20s.--no-cacheBypass the title cache for this download.--reset-cacheClear the title cache before fetching. Two tracks at a time, eight threads eachSpace out a season downloadunshackle dl --downloads 2 --workers 8 EXAMPLE 81234567\n"},{"location":"guide/downloading/#keys-vaults-and-export","title":"Keys, vaults, and export","text":"unshackle dl -w S01 --slow 30-60 EXAMPLE 81234567\nBy default, unshackle checks your key vaults first and only asks a CDM to license a content key when the vault misses. You can force one side or the other:
Fetch keys only, no download
--cdm-only: only use the CDM (skip vaults). Over aserver_cdmremote session the flag does not reach the server, whose vault shortcut still runs; it only skips the client's decode check on a server key.--cdm <name>: use the named CDM device for this run, ignoring thecdmconfig mapping (including its quality and Widevine/PlayReady sub-entries). Over--remote, that device licenses the remote session, not the server CDM.--vaults-only: only use key vaults and never license through the CDM. A missing content key fails.unshackle dl --skip-dl EXAMPLE 81234567\nExport track info and keys
--exportwrites amediaexportJSON file into the configured exports directory. It holds the manifest, the DRM init data, the content keys, the KIDs of each track and the title metadata for each title.unshackle importreads it to reconstruct a download later, and gives each track only the content keys for its KIDs. The importer also reads the older unshackleversion: 2files and unidl's own export files.unshackle dl --skip-dl --export EXAMPLE 81234567\nThe file name tells what the file holds, in the form
Export File name A movie{title}.{scope}.{resolutions}.{codecs}.{ranges}.{manifests}.{audio}-{SERVICE}.json:Example.Movie.2024.1080p.h264.sdr.hls.aac2.0-EXAMPLE.jsonA whole seasonExample.Show.S01.2160p.h265.sdr.hdr10.dash.dd5.1-EXAMPLE.jsonEpisodes 5 to 9 and 12Example.Show.S01E05-E09.S01E12.1080p.h264.sdr.dash.aac2.0-EXAMPLE.json
- A dot separates each part and each word of the title. The name has no spaces or underscores. It does not follow your
output_template: a template with spaces does not change the export name.- The scope is
S01only when the file holds every episode the service lists for that season. Otherwise it names each run of episodes. Whole seasons in a row becomeS01-S03. After three parts,+Ncounts the parts left out. A movie has no scope.- The quality parts come from the video and audio tracks selected for the titles in the file. Each value shows once.
- unshackle renames the file after each write, so the name always matches what the file holds. When a run stops early, the file is complete for the titles it holds.
- When another file already has the name, unshackle adds
-2,-3, and so on. It never writes over another export.The run logs the file name at the first write, the track and content key counts for each title, and at the end the path and the total counts.
The export holds a content key for each track your flags selected and no others. To make a file another person can use, select generously: for example
-a AAC,EC3and every language you want them to have. An import can only pick tracks whose key is in the file. A track that another tool exported as a direct file URL is added next to the manifest's own tracks. An export with no manifest, for example a unidl export of whole media files, gets all of its tracks from those URLs. If such an export does not give the title a language,orighas no value: select the languages with-land-vl.An import uses only the content keys in the file and in your vaults. It never gets a licence from a CDM. When a track needs a content key that neither has, the import stops and names the KID. To fix this, export the title again with that track selected.
The export file is a secret
It holds the content keys and usually a signed manifest URL. Hand it over on purpose, not by accident.
The mediaexport package specifies the
mediaexportformat and is the reader and writer unshackle uses.Region is recorded only with a proxy
When you use
--proxy, the export records the region so an import can reproduce the correct geofence. Without a proxy, the export stores no region.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.languageon 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.An HLS master playlist gives no language for a video track. When the export gives no title language, the video track takes the language of the default audio track. When the master playlist gives no audio language either, the import stops with a message.
"},{"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
Flag Example Purpose--enrich. Give at most one of--tmdb,--imdband--tvdb, and unshackle works the rest out from it. To stop the automatic lookups but keep the IDs you give, setdisable_metadatain your config.--tmdb--tmdb 27205Use this TMDB ID instead of an automatic search. Needstmdb_api_key.--imdb--imdb tt1375666Use this IMDb ID instead of an automatic search. Needs no API key.--tvdb--tvdb 73871Use this TVDB ID instead of looking the series up. Needstvdb_api_key.--anilist--anilist 21Use this AniList ID instead of an automatic search.mal:12345is also accepted. Needs no API 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 dvdRenumber episodes to a TVDB season order. Needstvdb_api_key. Force the right IMDb match, and keep the service's own namingForce the match and take IMDb's title, year and language toounshackle dl --imdb tt1375666 EXAMPLE 81234567\n"},{"location":"guide/downloading/#what-each-metadata-provider-supplies","title":"What each metadata provider supplies","text":"unshackle dl --imdb tt1375666 --enrich EXAMPLE 81234567\nMetadata providers differ in what they answer with, so the fields
Provider Config key Title and year Original language External IDs it returns TMDB--enrichcan replace depend on which ID you gave. Every metadata provider exceptimdbandanilistonly operates when you set its config key:tmdb_api_keyyes yes, alpha-2 such askoIMDb, TMDB, TVDB TVDBtvdb_api_keyyes yes, alpha-3 such askorTMDB, TVDB, sometimes IMDb OMDbomdb_api_keyyes yes, as an English name such asKoreanIMDb IMDb (no config key needed) yes yes, alpha-2 such askoIMDb SIMKLsimkl_client_idyes no, it publishes a country and no language IMDb, TMDB, TVDB AniList (no config key needed) yes yes, worked out from the country of origin, such asjaAniListWhatever the tag looks like, unshackle normalises it before use, so
ko,korandKoreanall end up as the same language.AniList only answers for anime
It is last in the default metadata provider order and returns nothing for a title that is not anime. The only cost is one search that misses. Put
anilistearlier inmetadata_providersif you mostly download anime.anilist_title_languagesets which of the three AniList title variants unshackle uses.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_providersorder:
--tmdbreads TMDB.--imdbreads IMDb, then falls back to OMDb. IMDb needs no API key, so this path works out of the box, and an OMDb API key only matters when IMDb has no answer.--tvdbreads TVDB. It needstvdb_api_key.--anilistreads AniList, which needs no API 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,--imdbor--tvdbwhen you want those tags as well.Give one of
--tmdb,--imdband--tvdb. You cannot combine them, because one ID does the job on its own: unshackle finds the other two from it and writes all three to the tags. Passing two is an error rather than a silent choice between them.--anilistis 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 metadata provider that can find it, so unshackle makes sure of that before it downloads anything.
--tmdbwithouttmdb_api_key, or an ID whose metadata providers yourmetadata_providerslist leaves out, fails immediately with a message naming what to set.--imdbneeds no API key, because theimdbmetadata 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 metadata provider with a thin record cannot blank out what the service told you.
--enrichalso 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 nothing here applies only to anime. This only adds the{absolute}naming variable.--enrichnever changes the season and episode numbers.With
--daily,--enrichalso fills in the air date of each episode from TVDB. See Daily and date-based content.
--enrichreplaces the original language, which affects more than the filenameTrack 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
"},{"location":"guide/downloading/#episode-ordering","title":"Episode ordering","text":"--enrich, unshackle keeps the service's own value and does not change track selection.A service does not always number a series the way TVDB's aired order does. Some services use TVDB's
Download Futurama in DVD orderalternate(Streaming) order for Futurama, for example, while TVDB'sofficialorder holds back four season-one episodes to the start of season two.--tvdb-orderworks out which order the service used, then renumbers the episodes into the order you asked for:unshackle dl --tvdb-order dvd EXAMPLE 81234567\nAvailable orders are
official(aired),dvd,absolute,alternate, andregional. Settvdb_orderin 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
"},{"location":"guide/downloading/#configuration-defaults","title":"Configuration defaults","text":"dvdorder does not contain 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.You can set every flag on this page as a default in your configuration file so you do not have to type it each time, and you can scope defaults 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 through an environment variable) always takes precedence over both the global
"},{"location":"guide/downloading/#quick-reference","title":"Quick reference","text":"dl:defaults and any per-servicedl:overrides. Config only fills in options you did not set explicitly.A condensed lookup of the most-used options. Use
Flag Short Meaningunshackle dl --helpfor the complete, authoritative list.--quality-qTarget resolution(s), e.g.1080,720.--vcodec-vVideo codec(s).--acodec-aAudio codec(s).--range-rColour range(s). DefaultSDR.--channels-cAudio channel layout.--noatmos-naaExclude Atmos audio.--lang-lVideo + audio language(s); defaultorig.-excludes, e.g.all,-es.--a-lang-alAudio-only language override.-excludes.--v-lang-vlVideo-only language override.-excludes.--s-lang-slSubtitle language(s); defaultall.-excludes, e.g.all,-es.--forced-subs-fsInclude forced subtitles.--forced-s-lang-fslForced subtitle language(s); implies-fs.-excludes.--sub-formatOutput subtitle format.--wanted-wEpisode/season range, an air date, or a track number for a music release (1-5,2x3).--dailyFill missing air dates from TVDB during--enrich.--select-titlesInteractively pick episodes or films.--latest-episodeOnly the newest episode.--latest-episodes NOnly the N newest episodes.--video-only/--audio-only/--subs-only-V/-A/-SRestrict track types.--no-video/--no-audio/--no-subs/--no-chapters/--no-attachments-nv/-na/-ns/-nc/-ntSkip track types.--worstLowest bitrate within-q.--require-audioAudio languages that must exist. The title fails if one is missing.--require-videoVideo languages that must exist. The title fails if one is missing.--best-availableDegrade gracefully instead of failing.--output-oOutput directory for this run.--split-audio/--merge-video/--no-muxMuxing behaviour.--postscript/--no-postscriptRun a command after each output file, or run none.--proxy/--no-proxy/--no-proxy-download/--proxy-downloadProxy control.--workers/--downloads/--slowConcurrency and pacing.--list/--list-titles/--skip-dlDry runs.--cdm-only/--vaults-onlyContent key source control.--cdm <name>Pin one CDM device, bypassing thecdmconfig mapping.--exportExport track info and keys to JSON.--tmdb/--imdb/--tvdb/--anilist/--enrichMetadata overrides.--tvdb-orderRenumber episodes to a TVDB season order."},{"location":"guide/drm-and-cdm/","title":"DRM & CDM Setup","text":"A Digital Rights Management (DRM) system protects most commercial streaming titles. To download a title and play it back, unshackle needs to get the content keys for the encrypted tracks and then decrypt them. This page explains the DRM systems unshackle supports, how to install 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 have a Developer mark. They give internal behaviour that is useful when you write a service, but you do not need them to download a protected title.
"},{"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. A device backs it: a file that carries the cryptographic identity of a real client, such as a phone or a browser.
- 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 AES content 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).wvdCDM challenge \u2192 license server \u2192 CDM parses \u2192 keys shaka-packager / mp4decrypt PlayReady (Microsoft).prdCDM challenge (WRM header) \u2192 license server \u2192 CDM parses \u2192 keys shaka-packager / mp4decrypt ClearKey (CENC), W3C EMEorg.w3.clearkeynone JSON{\"kids\":[...],\"type\":\"temporary\"}\u2192 license server returns a JWK Set. No CDM. shaka-packager / mp4decrypt ClearKey (HLS), AES-128EXT-X-KEYnone Key fetched directly from a URI ordata:URL. No CDM, no challenge. in-process AES-CBC MonaLisa (proprietary, WASM).mldTicket from the service API; keys extracted locally via WASM. No license server. ML-Worker binary + AES-ECB, per segmentTwo different ClearKeys
\"ClearKey (HLS)\" and \"ClearKey (CENC)\" are entirely different systems that happen to share a name. The HLS variant is the AES-128 content key that an
"},{"location":"guide/drm-and-cdm/#widevine-vs-playready","title":"Widevine vs PlayReady","text":"EXT-X-KEYtag references, and unshackle decrypts it in-process. The CENC variant is the W3C EMEorg.w3.clearkeyscheme. It delivers keys as a JWK Set, and shaka-packager or mp4decrypt decrypts it like Widevine. Neither involves a device file.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
.wvddevice. For PlayReady you need a.prddevice. You can hold both, and unshackle automatically selects the appropriate one for each track.Obtaining device files
A
"},{"location":"guide/drm-and-cdm/#providing-a-device-with-the-wvd-and-prd-commands","title":"Providing a device with the.wvdor.prdencodes the private keys of a real client device. unshackle does not ship with one and cannot make the underlying secret material for you. You must supply your own. Thewvdandprdcommands below manage and provision device files once you have the raw cryptographic material.wvdandprdcommands","text":"Device files live in dedicated directories, by default:
Kind Default directory Widevine.wvddata/WVDsPlayReady.prddata/PRDsYou can move these with the
unshackle.yamldirectoriessection of yourunshackle.yaml:"},{"location":"guide/drm-and-cdm/#widevine-devices-the-wvd-command","title":"Widevine devices: thedirectories:\n wvds: /home/george/unshackle-data/WVDs\n prds: /home/george/unshackle-data/PRDs\nwvdcommand","text":"The
Add an existing deviceInspect a deviceCreate from raw materialDelete / dumpwvdcommand group manages.wvdfiles.$ unshackle wvd add ./nexus_6p.wvd\nunshackle validates the file and moves it into the WVDs directory. You can add several at once by passing multiple paths.
$ unshackle wvd parse nexus_6p\nPrints the System ID, Security Level, Type, Flags, and whether a Private Key, Client ID, and VMP are present. unshackle finds a bare name (no extension) inside the WVDs directory. It uses a path with an extension as-is.
$ unshackle wvd new \"Nexus 6P\" ./private_key.pem ./client_id.bin ./vmp.bin -t ANDROID -l 3\nBuilds a
.wvdfrom a PEM private key, a Client ID blob, and an optional FileHashes (VMP) blob.-t/--typeis the device type (defaultAndroid) and-l/--levelis the security level 1 to 3 (default1). The VMP argument is optional.$ unshackle wvd delete nexus_6p\n$ unshackle wvd dump nexus_6p ./out\n
deleteremoves a device (with a confirmation prompt).dumpextracts 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:
"},{"location":"guide/drm-and-cdm/#playready-devices-the-prd-command","title":"PlayReady devices: the$ pywidevine migrate --help\nprdcommand","text":"The
Create a new deviceReprovisionTest against Microsoft's demo serverprdcommand group creates and maintains.prdfiles.$ unshackle prd new ./zgpriv.dat ./bgroupcert.dat\nProvision a new device from a group key and group certificate. You can instead point at a single folder that contains
zgpriv.datandbgroupcert.dat:$ unshackle prd new ./device_folder/\nunshackle makes a fresh leaf certificate and writes the device to the PRDs directory (or to
-o/--output). Use-e/--encryption_keyand-s/--signing_keyto supply your own ECC keys instead of generating them.$ unshackle prd reprovision ./data/PRDs/mydevice.prd\nRegenerates the leaf certificate and encryption/signing keys of an existing device in place. The device must be able to reprovision (a group key must be present, that is, a version 3 or higher device).
$ unshackle prd test ./data/PRDs/mydevice.prd -sl 2000 -c aesctr\nRuns a full challenge/license/keys cycle against Microsoft's public PlayReady test server and prints the returned keys, confirming the device works.
"},{"location":"guide/drm-and-cdm/#selecting-which-device-to-use-the-cdm-config","title":"Selecting which device to use: the-sl/--security-levelaccepts150,2000, or3000(default2000).-c/--cktacceptsaesctroraescbc.cdmconfig","text":"A device on disk is not enough. You must tell unshackle which device to use for which service. That is the job of the
unshackle.yamlcdmsection, which maps a service tag to a device name (the file's name without extension). Adefaultentry acts as the fallback:cdm:\n default: nexus_6p_26830_l3\n EXAMPLE1: chromecdm_l3\n EXAMPLE2: sm_g935f_l1\nLookups are case-insensitive.
The device name is the file name
A device named
"},{"location":"guide/drm-and-cdm/#advanced-selection-by-quality-drm-or-profile","title":"Advanced selection: by quality, DRM, or profile","text":"chromecdm_l3in the config resolves tochromecdm_l3.wvdin the WVDs directory (orchromecdm_l3.prdin the PRDs directory). It may also be thenameof a remote CDM defined inremote_cdm. See Remote CDMs below.Instead of a plain name, a
By qualityBy DRM systemBy profilecdmentry can be a nested mapping, letting unshackle pick a different device based on the track quality, the DRM system, or the active service profile.unshackle compares each config key against the video height. You can use exact heights or comparisons (
>=1080,>720,<=576,<480). The most specific one wins.cdm:\n SOMESERVICE:\n \">=1080\": sm_g935f_l1\n default: chromecdm_l3\nKeys
widevineandplayreadyselect a device per system. unshackle applies these automatically, using thewidevinedevice for Widevine tracks and theplayreadydevice for PlayReady tracks.cdm:\n SOMESERVICE:\n widevine: sm_g935f_l1\n playready: mydevice_sl3\nunshackle compares each config key against the active profile, with a
defaultfallback."},{"location":"guide/drm-and-cdm/#local-vs-remote-cdms","title":"Local vs remote CDMs","text":"cdm:\n SOMESERVICE:\n premium: sm_g935f_l1\n default: chromecdm_l3\nA CDM can operate in two places:
- A local CDM loads a
.wvdor.prddevice from your machine and performs the challenge/license/keys handshake locally. This is what thecdmmapping 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 CDM host. unshackle sends it PSSH/challenge data and receives keys back. This is useful for sharing a high-security-level device, using a hosted content key service, or keeping device secrets off your download machine.
The device name in your
"},{"location":"guide/drm-and-cdm/#remote-cdms","title":"Remote CDMs","text":"cdmmapping resolves to a remote CDM whenever it matches thenameof an entry in theremote_cdmlist. Otherwise it falls back to a local.prd/.wvdfile of that name.You declare remote CDMs as a list under
"},{"location":"guide/drm-and-cdm/#widevine-remote-cdm-pywidevine-serve","title":"Widevine remote CDM (pywidevine serve)","text":"remote_cdm. Each entry has aname(which you then reference from thecdmmapping) and a shape that depends on itstype. There are four kinds.With no
unshackle.yamltypefield and a non-PlayReady device type, unshackle treats the entry as a standard pywidevine \"serve\" endpoint:remote_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"},{"location":"guide/drm-and-cdm/#playready-remote-cdm","title":"PlayReady remote CDM","text":"
security_leveldefaults to3000if omitted. unshackle reads field names case-insensitively, soDevice Type/device_type,System ID/system_id, and the other field names are all accepted.If the device type is
unshackle.yamlPLAYREADY, unshackle routes the entry to the pyplayready remote CDM:remote_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\nThe
hostmust end in/playreadyFor a PlayReady remote CDM the
hostURL has to include the trailing/playreadypath segment (e.g.https://cdm.example.com/playready). pyplayready'sRemoteCdmtreatshostas 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 is404responses from the server rather than an obvious configuration error.A pywidevine or pyplayready serve API can operate behind a reverse proxy or a CDN. Refer to Serve APIs behind a proxy.
"},{"location":"guide/drm-and-cdm/#decrypt-labs-hosted-keyxtractor-api","title":"Decrypt Labs (hosted KeyXtractor API)","text":"Set
unshackle.yamltype: decrypt_labsto use Decrypt Labs' hosted KeyXtractor service, which includes an intelligent key-caching layer:remote_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\nunshackle sends the API key as the
"},{"location":"guide/drm-and-cdm/#custom-api-custom_api","title":"Custom API (decrypt-labs-api-keyheader. If you do not writesecreton the entry, unshackle falls back to the globaldecrypt_labs_api_keyvalue. If neither is present it raises an error. Thehostdefaults tohttps://keyxtractor.decryptlabs.com. PlayReady devices (SL2/SL3) default to security level2000/3000respectively. Widevine devices default tosystem_id: 26830,security_level: 3.custom_api)","text":"For an arbitrary CDM HTTP API,
unshackle.yamltype: custom_apiprovides a fully configurable client that adapts to almost any request/response shape through YAML alone. A minimal example:remote_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\nThe
custom_apitype supports much more: request/response field remapping, transforms (base64/hex/JSON encoding,kid:keyparsing), conditional parameters, success/error detection, and vault-backed caching. Its full schema is for developers who integrate a bespoke CDM API. See the developer note below.Developer: full
custom_apischemaA
custom_apientry accepts these sub-objects:
device(name,type,system_id,security_level)auth(typeofheader/bearer/basicwith the matching credential fields, pluscustom_headers)endpoints(get_requestanddecrypt_response, each{path, method, timeout})- per-endpoint
request_mapping(param_names,static_params,conditional_params,transforms,nested_params,exclude_params) andresponse_mapping(fields,transforms,response_types,success_conditions,error_fields,key_fields)caching(enabled,use_vaults,check_cached_first)legacyandtimeoutunshackle infers PlayReady mode when
"},{"location":"guide/drm-and-cdm/#decryption-backends","title":"Decryption backends","text":"device.type == PLAYREADYor the device name isSL2/SL3.Once unshackle knows the content keys, one of two external tools decrypts the encrypted file (for Widevine, PlayReady, and ClearKey-CENC):
- shaka-packager is the default.
- mp4decrypt is part of the Bento4 tools.
Both must be discoverable in unshackle's bundled
binaries/directory, thebinariesfolder of your user data directory, or on yourPATH. 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
Global defaultPer service unshackle.yamldecryptionconfig key. It can be a single value applied globally, or a per-service mapping:unshackle.yamldecryption: shaka\ndecryption:\n default: shaka\n SOMESERVICE: mp4decrypt\nHow the value is interpreted
The default is
shaka. Only the exact valuemp4decryptselects mp4decrypt. Anything else (includingshaka) selects shaka-packager. Per-service lookups are case-insensitive, with thedefault/DEFAULTentry used as the fallback.The two backends give equivalent output for a standard CENC title. In most conditions shaka-packager is the better backend. Switch a specific service to
mp4decryptif you find a track that shaka-packager cannot decrypt.ClearKey (HLS) does not use these tools
unshackle decrypts an HLS AES-128 ClearKey title in-process with AES-CBC, and ignores the
"},{"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":"decryptionsetting entirely. You do not need to configure anything.When an HLS playlist references a content key with an
"},{"location":"guide/drm-and-cdm/#clearkey-over-cenc-w3c-eme-orgw3clearkey","title":"ClearKey over CENC (W3C EMEEXT-X-KEYtag, unshackle fetches the content key directly (from the given URI or an inlinedata:URL) and decrypts each segment in-process with AES-CBC. There is no device, no CDM, and no license challenge, so you do not set anything up. unshackle decrypts such tracks automatically during download.org.w3.clearkey)","text":"The CENC ClearKey scheme is a proper license flow, but the \"license server\" returns the keys as a JWK Set and not a DRM-wrapped blob. unshackle builds a W3C EME request of the form:
{\"kids\": [\"<base64url-kid>\", ...], \"type\": \"temporary\"}\nIt 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. You do not need a device file. The decrypted file still goes through shaka-packager or mp4decrypt like any other CENC track, so the
"},{"location":"guide/drm-and-cdm/#widevine-and-playready-on-the-same-title","title":"Widevine and PlayReady on the same title","text":"decryptionsetting applies.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 cannot supply, it transparently switches to the appropriate device for that track using your
cdmmapping. 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.\nUse the
"},{"location":"guide/drm-and-cdm/#keys-vaults-and-caching","title":"Keys, vaults, and caching","text":"cdmmapping's DRM-based selection to configure a device for each system. unshackle selects the right one per track automatically.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 cache of content keys, for the current
dlrun.- Your configured key vaults.
- Only if still missing, a live license request.
After a successful license request, unshackle writes the recovered keys back to all configured vaults, so future downloads can skip the license server. Two flags let you control this trade-off on the
Flag Effectdlcommand:--vaults-onlyNever send a license request; use only cached/vault keys. Fails if a content key is missing.--cdm-onlyIgnore vaults for sourcing keys and always perform a fresh license request. Over aserver_cdmremote session the server still reads its own vaults; the flag only skips the client's decode check on the key it gets back.--cdm <name>Use the named CDM device for this run instead of the one thecdmconfig maps for the service. Over--remote, that device licenses the remote session, not the server CDM.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 EffectdebugfalseGlobal debug output.debug_keysfalseLog decryption keys.debug_requestsfalseLog HTTP requests. unshackle.yamldebug_keys: true\ndebug_requests: true\nKeys and paths in logs
When you enable key logging, unshackle writes content keys (KID:key pairs) to the logs. Treat those logs as sensitive. The unrelated
"},{"location":"guide/drm-and-cdm/#see-also","title":"See also","text":"redact_pathsoption (defaulttrue) masks local directory paths in log output."},{"location":"guide/output-and-naming/","title":"Output & Naming","text":"
- Downloading: the
dlcommand and its DRM-related flags.- Key vaults: caching content keys across downloads.
- Configuration: the full
unshackle.yamlreference.- Dependencies: installing shaka-packager and mp4decrypt.
This page tells you 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). Both take
{variable}placeholders that unshackle fills in from the downloaded media. This page also covers muxing options, release-group tagging, Unicode filenames, and how to relocate unshackle's working directories.If you have not configured
"},{"location":"guide/output-and-naming/#how-unshackle-assembles-an-output-path","title":"How unshackle assembles an output path","text":"unshackle.yamlyet, read Configuration File first. Everything below lives in that file.When a download finishes, unshackle builds the final path from three pieces:
<output directory> / <folder template> / <filename template><extension>\n
- Output directory:
directories.downloadsfrom your config, or whatever you pass to-o/--outputon the command line for a single run.- Folder template: an optional per-title subfolder. unshackle always puts TV episodes and songs in a folder. Movies only get one if you define a
moviesfolder template, or a single-stringfoldertemplate that applies to all title kinds (see Folder templates).- Filename template: the
output_templatefor the title kind (movie, series, or song).- Extension: chosen by the muxer for a movie or an episode:
.mkvfor video,.mkafor audio-only,.mksfor subtitle-only,.mp4for DTS:X Profile 2 audio. unshackle does not mux a song. It remuxes a song into the container of its codec, such as.flac,.opus, or.m4a. Read Music output files.A typical episode path
The folder name comes from the/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\nseriesfolder template and the file from theseriesoutput template.Two command-line flags change this layout for a single run:
Flag Effect-o,--outputUse this directory instead ofdirectories.downloads.--no-folderSkip the per-title subfolder; write the file directly into the output directory.--no-sourceOmit 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
unshackle.yamloutput_templateconfig key, keyed by title kind. Each value is a format string built from{variable}placeholders.output_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}\"\nThe recognized kinds are:
Kind Applies to Default when unsetmoviesMovies (required for movie downloads to be named, no built-in default)seriesTV episodes (required for episode downloads to be named)songsMusic tracks\"{track_number}. {title}\"albumsAlbum folders only (see below) -Note
"},{"location":"guide/output-and-naming/#required-vs-optional-variables","title":"Required vs. optional variables","text":"
moviesandserieshave no hardcoded filename fallback. If you download those title types, define their templates.songsfalls back to\"{track_number}. {title}\"when omitted.
{variable}: required. If it is missing from the download's context, formatting raises an error. Note that most variables are always present in context. A variable that does not apply becomes an empty string, so \"present but empty\" is different from \"missing\".{variable?}: optional. A trailing?marks the variable conditional. When it is empty, unshackle removes the placeholder and one adjacent separator (.,-, or space), so the surrounding text collapses cleanly.Use
?for anything that is not always thereEpisode names, editions, tags, HDR labels and audio extras are not present on every title. Mark them optional so you never get doubled dots or dangling separators:
For an episode with no on-screen name,series: \"{title}.{season_episode}.{episode_name?}.{quality}.{hdr?}.{source}-{tag?}\"\n{episode_name?}and its adjacent dot 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 sanitises with spaces. If not, it uses dots (the scene-style default). unshackle also applies this style to the auto-generated date separators of daily 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}\"\nThe.Movie.2024.1080p.EXAMPLE.WEB-DL.DDP5.1.H.264-TAG.mkv\noutput_template:\n movies: \"{title} ({year}) {quality} {source} WEB-DL {audio_full} {video}\"\n"},{"location":"guide/output-and-naming/#template-variables","title":"Template variables","text":"The Movie (2024) 1080p EXAMPLE WEB-DL DDP5.1 H.264.mkv\nEvery variable below is valid in both output and folder templates. unshackle takes the values from the downloaded media (through MediaInfo), from the title metadata, or from your config. A variable that does not apply to a title becomes an empty string.
"},{"location":"guide/output-and-naming/#common-video","title":"Common / video","text":"Variable Meaning ExampletitleTitle name (movie/show/song name;$is rendered asS)The Showtitle_typeMedia kind of the titlemovie,series,musicyearRelease year2024sourceService tag / class name (empty with--no-source)EXAMPLEqualityResolution with scan suffix1080p,2160p,576iresolutionResolution number only1080videoVideo codecH.264,H.265hdrDynamic-range labelHDR,HDR10P,DV,DV.HDR,DV.HDR10P,HLGhfrHigh frame rate marker (frame rate > 30)HFReditionEdition label from the track, if anyDirectors CuttagRelease-group tag (fromconfig.tagor--tag)TAGrepackREPACKwhen the--repackflag is usedREPACKlang_tagResult of yourlanguage_tagsrules, if configuredMULTiHow
language_tagsrules matchunshackle tries the rules in order, but language comparison is fuzzy: a rule condition of
"},{"location":"guide/output-and-naming/#episode-specific","title":"Episode-specific","text":"Variable Meaning Exampleenmatchesen-US,en-GB, and any otheren-*form. Within a single rule, all conditions must match (AND logic) for it to apply. The first rule that fully matches wins. If none match, unshackle removes{lang_tag?}from the filename cleanly.seasonZero-padded season (S%02d)S01episodeZero-padded episode (E%02d)E05season_episodeCombinedS01E05episode_nameEpisode titlePilotpartPart index of a split episode, empty otherwise2absoluteAbsolute episode number, zero-padded to 3 digits, empty when unknown007dateISO air date, empty when the episode has none2024-06-01Daily & sports content
When an episode has an air date, unshackle switches to date-based naming automatically.
"},{"location":"guide/output-and-naming/#split-episodes","title":"Split episodes","text":"seasonandseason_episodebecome the formatted air date, unshackle clearsepisodeandyear, and{date}holds the ISO date. The date's internal separator (dots or spaces) follows yourseriestemplate style.A few services split one episode into several separately playable videos. Where a service reports that, unshackle folds the part index into
{episode}and{season_episode}, so the stockseriestemplate names it with no change to your config.Show.Name.S01E01.Part.2.1080p.EXAMPLE.WEB-DL.DDP5.1.H.264-TAG.mkv\nThe 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 daily content does, so a spaced template gives
S01E01 Part 2instead.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\nThe same holds for a folder template of your own:
{season}never picks up the part, and{season_episode}drops it inside a folder template. unshackle names an episode with no part 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 uses a closed-source scanner, so what it does with text after the part token is not known.
For custom templates there is also a standalone
"},{"location":"guide/output-and-naming/#audio","title":"Audio","text":"Variable Meaning Example{part}variable. You rarely want it, see the caveat on that page.audioAudio codecDDP,DD,AAC,DTS-Xaudio_channelsChannel layout; bed.LFE.heights when the track has height channels5.1,2.0,5.1.4audio_fullCodec + channels combinedDDP5.1,DTS-X.5.1.4atmosAtmoswhen any track carries JOCAtmosdualDUALfor two audio languages (seedual_multi_mode)DUALmultiMULTifor more than two audio languagesMULTidubbedDUBBEDfor 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 Meaningtrack_numberZero-padded track numberartist/album_artistArtist / album artist (falls back to artist)albumAlbum namediscDisc number (only shown when greater than 1)track_total/disc_totalTotalsrelease_typealbum,single,ep, or another type the service suppliesgenreGenreexplicitExplicitwhen flaggedisrc/upc/labelISRC, UPC and record labelUnknown variables are ignored, not errors
If you use a variable that is not in the list above, unshackle gives a warning at startup but continues. Double-check spelling. A typo'd
"},{"location":"guide/output-and-naming/#folder-templates","title":"Folder templates","text":"{quailty}stays in the filename literally, or unshackle drops it. unshackle does not fill it in.Folder templates sit under a special
Single folder templatePer-kind folder templates unshackle.yamlfolderconfig key insideoutput_template. unshackle reads this config key separately from the filename kinds. You can give it a single string (for all title kinds) or a per-kind map.unshackle.yamloutput_template:\n movies: \"{title}.{year}.{quality}.{source}-{tag}\"\n series: \"{title}.{season_episode}.{quality}.{source}-{tag}\"\n folder: \"{title}.{year}\"\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})/Season {season}\"\n albums: \"{artist} - {album} ({year})\"\nThe per-kind folder config keys are
movies,series,songs, andalbums. Any other config key gives a startup warning. Folder templates permit path separators (/or\\). unshackle formats each segment independently, so you can assemble nested directory structures likeShow/Season 01. unshackle reads the separator style from the whole template, so it spaces every segment alike.Fallback behaviour:
- A per-kind folder template wins if present. If there is none, unshackle uses the single
folderstring. If there is nofolderstring either, 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
moviesfolder template (or a single-stringfoldertemplate) exists. Series fall back to a folder derived from theseriesoutput template (stripping{episode},{episode_name}, and collapsing{season_episode}down to{season}). Music albums fall back to{artist} - {album} ({year}). If the song has no year, the folder name is only{artist} - {album}.Movies are flat by default
Episodes and songs are always foldered. unshackle writes movies directly into the output directory unless you define a
"},{"location":"guide/output-and-naming/#music-output-files","title":"Music output files","text":"moviesfolder template (or a single-stringfoldertemplate, which folders every kind). Set one if you want each movie in its own directory.A song does not go through the muxer. Music services deliver audio in a fragmented MP4, which is not a music file. Its header states a length of zero, so a player stops after the first fragment. FLAC inside an MP4 is also not a FLAC stream. A rename cannot correct either fault, so unshackle remuxes each downloaded audio track into the container of its codec, then moves the file to its final path.
Codec Extension FLAC.flacOpus.opusVorbis.oggAAC, ALAC, and all other codecs.m4aunshackle re-encodes FLAC in this step. The audio does not change, but the new file states its true length. A plain copy cannot, because it keeps the source header and the zero in it.
unshackle writes no
.mkvfile for music, and the muxing options below do not apply.The
songsoutput template names the file. Thealbumsfolder template names the folder around it."},{"location":"guide/output-and-naming/#music-metadata","title":"Music metadata","text":"/home/user/Downloads/\n \u2514\u2500 The Artist - The Album (2024)/\n \u251c\u2500 01. First Track.flac\n \u2514\u2500 02. Second Track.flac\nAfter the file moves, unshackle writes the metadata into the audio file. It uses Vorbis comments for FLAC and Ogg, ID3 frames for MP3, and MP4 atoms for M4A. The tags hold the track title, artist, album artist, track and disc numbers, date, genre, ISRC, UPC, copyright, and record label. When
tagandtag_group_nameare set, unshackle also writes the group name into aGROUPtag.unshackle embeds cover art when the service gives an artwork URL. It downloads the image and puts it in the file.
unshackle writes lyrics when the service supplies them: a
LYRICSVorbis comment for FLAC, aUSLTframe for MP3, and a\u00a9lyratom for M4A. Ogg uses the sameLYRICSfield as FLAC. unshackle does not write synchronized lyrics (SYLT).A metadata fault does not stop the run. The file is already in place, so unshackle writes a warning and continues.
--no-muxleaves music files untagged"},{"location":"guide/output-and-naming/#muxing-options","title":"Muxing options","text":"
--no-muxmoves each downloaded track file to the output path without the tagging step. A music file from such a run holds only the metadata that the service put in it.Height channels
A track with height channels is named bed.LFE.heights, such as
"},{"location":"guide/output-and-naming/#dtsx-profile-2-output","title":"DTS:X Profile 2 output","text":"5.1.4.A title whose audio holds a
dtsxordtsysample entry (DTS:X Profile 2, DTS-UHD) muxes to MP4 with MP4Box, because Matroska has no CodecID for it. Without MP4Box installed the mux stops.DTS:X Profile 1 is DTS-HD Master Audio and muxes to MKV as usual.
An MP4 title loses:
- the forced, hearing-impaired and original track flags
- attachments, such as subtitle fonts
- SubStation Alpha (
.ass) subtitles- container tags (
mkvpropedit)You configure muxing (the combination of video, audio, subtitle, chapter and attachment tracks into a single Matroska file with
unshackle.yamlmkvmerge) under themuxingconfig key. unshackle muxes only movies and episodes. Read Music output files for what a music download does instead.Key Type Default Effectmuxing:\n set_title: true\n merge_video: false\n merge_audio: true\n default_language:\n audio: en\n subtitle: en\nset_titlebooltrueWrite the title name into the MKV container title with--title. Set tofalseto omit it.merge_videoboolfalseGroup video tracks that share the same resolution, range, and codec into one file so only language varies inside it.merge_audiobooltrueMerge audio tracks of the same kind so multiple languages sit in one file.default_languagemap (unset) Preferred language per track type (video/audio/subtitle). A track in the preferred language is flagged as the default track.When no track has the preferred language, unshackle applies sensible defaults. The video default falls back to the title language, then the original-language track, then the first track. The audio default is the original-language track. The subtitle default is a forced track in the first audio's language.
default_languageonly sets the default-track flag
default_languagecontrols which track carries the MKV--default-trackflag. Nothing else. It does not change track selection (that stays with-l/--a-langand friends). It also does not touch which track carries the original flag:--original-flagstill 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 is not present in the manifest, each track type falls back to its normal default rule described above.
merge_videocollapses only the language dimension
merge_videogroups 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-videogives one file per range, never a single fused file. English and French video of identical resolution, range, and codec gives one file that holds both video tracks.merge_audioworks the same way for audio languages.A few muxing behaviors are automatic and not configurable:
--no-dateis 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.
- unshackle writes the chapters from the title's chapter list (see Chapters below).
The
"},{"location":"guide/output-and-naming/#tags-group-naming","title":"Tags & group naming","text":"--no-muxflag skips muxing entirely and writes the individual track files, each with a track-type suffix, into the same output/folder structure.The release-group tag is the
unshackle.yaml-TAGportion at the end of scene-style names, which the{tag}variable gives. You can swap it per release withtag_rules: ordered rules whose conditions read the release-attribute variables only (quality,resolution,hdr,source,lang_tag,title_type, and so on), and the first rule that matches wins.tag_rulescannot read the title's own fields, such astitleandseason_episode. See the reference page for the exact list.Key Type Default Effecttag: \"MYGROUP\"\ntag_group_name: true\ntag_imdb_tmdb: true\ntagstr\"\"The release-group tag used for{tag}.tag_group_namebooltrueWrite the group name (config.tag) into the MKVGroupmetadata tag.tag_imdb_tmdbbooltrueLook up and embed IMDb / TMDB / TVDB external-ID tags in the MKV metadata (usestmdb_api_key/tvdb_api_key/simkl_client_idwhen available).unshackle writes the IDs as the Matroska
IMDB,TMDB, andTVDB2tags.TVDB2values carry the entity prefix the Matroska tagging spec requires:series/73871for a show,movies/113for a film.You can override the tag per run without editing config:
unshackle dl --tag OTHERGROUP EXAMPLE \"...\"\nThe
--repackflag adds theREPACKmarker (surfaced through the{repack}variable) to the filename for that run:unshackle dl --repack EXAMPLE \"...\"\nEmpty tags collapse cleanly
If
"},{"location":"guide/output-and-naming/#unicode-filenames","title":"Unicode filenames","text":"config.tagis 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.By default unshackle transliterates non-ASCII characters into ASCII equivalents. Korean, Japanese, Chinese, and accented Latin are examples. This is safest for older tools, DDL, and P2P sharing. Set
unshackle.yamlunicode_filenamesto keep the native characters.Value Resultunicode_filenames: true\nfalse(default)\uae30\uc0dd\ucda9\u2192 transliterated ASCII; accents stripped.trueNative characters preserved in filenames and folders, with the vowel and tone marks of languages such as Thai, Hindi and Bengali.With each value, unshackle first normalises the name to Unicode NFC. A title that a service sends in composed form and in decomposed form gives the same name.
Regardless of this setting, unshackle always removes or replaces a set of filesystem-unsafe and structural characters during sanitisation. For example,
/and;become&, and unshackle removes characters like\\ * ! ? , ' \" < > | $ # ~. This is why you must never put path-unsafe characters directly in a template.unshackle warns at startup about a template that contains
"},{"location":"guide/output-and-naming/#directory-filename-configuration","title":"Directory & filename configuration","text":"< > : \" / \\ | ? *. It examines a folder template per path segment, so you can use/there as a directory separator.The
unshackle.yamldirectoriesconfig key relocates unshackle's working folders. You can move only the folders in the table below. unshackle protects the core package paths and silently ignores them if you try to override them.Name Purposedirectories:\n downloads: \"~/Media/unshackle\"\n temp: \"/mnt/fast/unshackle-temp\"\n cache: \"~/.cache/unshackle\"\n cookies: \"~/.config/unshackle/cookies\"\n logs: \"~/.config/unshackle/logs\"\ndownloadsDefault output directory for finished files.tempWorking directory for in-progress downloads, muxing, and intermediate files.cacheTitle/HTTP cache and update-check state.cookiesPer-service cookie files.logsLog files.exportsExport JSONs.services,vaults,fonts,commandsSearch paths for services, key-vault backends, bundled fonts, and CLI commands.wvds,prds,dcslWidevine devices, PlayReady devices, and DCSL data.Paths can hold a
"},{"location":"guide/output-and-naming/#filename-patterns","title":"Filename patterns","text":"~for expansion. unshackle protects the namesapp_dirs,core_dir,namespace_dir,user_configs, anddata, so you cannot change them.The
unshackle.yamlfilenamesconfig 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.Name Default Wherefilenames:\n log: \"unshackle_{name}_{time}.log\"\n chapters: \"Chapters_{title}_{random}.txt\"\n subtitle: \"Subtitle_{id}_{language}.srt\"\nlogunshackle_{name}_{time}.logdirectories.logsdebug_logunshackle_debug_{service}_{time}.jsonldirectories.logsconfigconfig.yamlper-service directoryroot_configunshackle.yamlmain config filenamechaptersChapters_{title}_{random}.txtdirectories.tempsubtitleSubtitle_{id}_{language}.srtdirectories.temp"},{"location":"guide/output-and-naming/#chapters-naming","title":"Chapters naming","text":"When a title has chapters, unshackle writes them to an OGM chapters file (
unshackle.yamlfilenames.chapters) and passes it to the muxer. Unnamed chapters use thechapter_fallback_nametemplate:chapter_fallback_name: \"Chapter {i:02}\"\nThe fallback can hold two placeholders:
"},{"location":"guide/output-and-naming/#removed-migrated-options","title":"Removed & migrated options","text":"{i}(chapter number, starting at 1) and{j}(which increments only for unnamed chapters). It permits standard format directives like{i:02}.
scene_namingwas removedThe old
"},{"location":"guide/output-and-naming/#worked-examples","title":"Worked examples","text":"scene_namingoption no longer exists. If it is present in your config, unshackle exits immediately with an error asking you to configureoutput_templateinstead. Migrate any scene-naming setup to the templates described on this page.Each tab shows a template and what it produces for a few different downloads, so you can see how the optional (
MoviesTV seriesMulti-languageDaily showsMusic?) variables appear and disappear.output_template:\n movies: \"{title}.{year}.{edition?}.{quality}.{source}.WEB-DL.{audio_full}.{atmos?}.{video}.{hdr?}-{tag?}\"\n folder:\n movies: \"{title} ({year})\"\nA 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\nThe 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\nA 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\noutput_template:\n series: \"{title}.{season_episode}.{episode_name?}.{quality}.{source}.WEB-DL.{audio_full}.{video}-{tag}\"\n folder:\n series: \"{title} ({year})/Season {season}\"\nThe Show (2023)/Season S02/\n \u2514\u2500 The.Show.S02E04.The.Reckoning.2160p.EXAMPLE.WEB-DL.DDP5.1.H.265-TAG.mkv\nunshackle decides the separator style once for the whole folder template, and every segment then follows it. Spaces outnumber dots in
{title} ({year})/Season {season}, soSeason {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\nPlace
{dual?},{multi?}and{dubbed?}next to each other. At most one of them is ever set (seedual_multi_mode), and the empty ones collapse:output_template:\n movies: \"{title}.{year}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{dubbed?}.{audio_full}.{video}-{tag}\"\nOriginal English audio plus a French dub (
-l en,fr):The.Movie.2024.1080p.EXAMPLE.WEB-DL.DUAL.DDP5.1.H.264-TAG.mkv\nOriginal plus several dubs (
-l en,fr,de,es):The.Movie.2024.1080p.EXAMPLE.WEB-DL.MULTi.DDP5.1.H.264-TAG.mkv\nOnly a dub, original audio left out (
-l deon an English-original title):The.Movie.2024.1080p.EXAMPLE.WEB-DL.DUBBED.DDP5.1.H.264-TAG.mkv\nTwo 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\nFor finer control (for example a
SUBBEDtag driven by subtitle languages), use{lang_tag?}withlanguage_tagsrules instead.Daily 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}\"\nA 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\nThe date separator follows your template style: dots here, spaces if your
seriestemplate uses spaces.In folder names,
{season}becomes the air year (Season 2024style grouping), and unshackle skips a{year?}in the folder template when it would only repeat that year.output_template:\n songs: \"{track_number}. {title}\"\n folder:\n albums: \"{album_artist} - {album} ({year})\"\nThe Artist - The Album (2024)/\n \u2514\u2500 01.Opening.Track.mka\nIn
\"{track_number}. {title}\"the number of dots and spaces is equal, and a tie goes 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"},{"location":"guide/output-and-naming/#see-also","title":"See also","text":"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/proxies-and-vpn/","title":"Proxies & VPN","text":"
- Configuration File: the full
unshackle.yamlstructure.- Downloading: the
dlcommand and per-run flags like-o,--no-folder,--tag, and--repack.Most streaming services are geo-restricted, region-priced, or 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
--proxyflag controls proxying, and the proxy providers unshackle supports change a query to a real proxy URL: basic static proxies, Control D, 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 proxy provider configuration lives under the top-level
"},{"location":"guide/proxies-and-vpn/#the-proxy-flag","title":"Theproxy_providers:config key in yourunshackle.yaml. Each proxy provider has its own sub-key, and the config keys inside it map directly onto that proxy provider's settings. See the Configuration file page for where the file lives and how unshackle loads it.--proxyflag","text":"Four options on the
Option Effectdlcommand control proxying at download time:--proxyThe proxy to use. Either an explicit URI, or a query that unshackle resolves against your configured providers.--no-proxyForce all proxy use off. No providers are initialised and no proxy query is resolved.--no-proxy-downloadUse the proxy for the manifest, licence, and authentication, but bypass it for the downloads themselves.--proxy-downloadUse--proxyfor the manifest, licence, and authentication, and a different proxy for the downloads. Takes the same forms as--proxy. Explicit proxy URIResolve a US server from your configured providersunshackle dl --proxy http://user:pass@1.2.3.4:8080 EXAMPLE 81234567\nTarget a specific providerunshackle dl --proxy us EXAMPLE 81234567\nunshackle dl --proxy nordvpn:us EXAMPLE 81234567\nSet a default proxy in config
Like every
"},{"location":"guide/proxies-and-vpn/#-no-proxy-download","title":"dlflag, you can give--proxya default in your configuration, so you do not have to pass it on every command. See Downloading for howdloptions and config defaults interact.--no-proxy-download","text":"Segment downloads are usually the largest share of traffic, and a residential-grade VPN proxy is frequently the slowest part of the chain.
Proxy the manifest and licence, download segments direct--no-proxy-downloadlets 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.unshackle dl --proxy gb --no-proxy-download EXAMPLE 10a1234\nWhen 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
"},{"location":"guide/proxies-and-vpn/#-proxy-download","title":"--no-proxy-download, remove it.--proxy-download","text":"Manifest and licence over NordVPN, downloads over Windscribe
--proxy-downloadis the middle ground: the manifest, licence, and authentication go through--proxy, and the downloads go through a second proxy. Use it when one proxy passes the geo-check and another one is faster for bulk traffic. The value takes the same forms as--proxy, so a country code orprovider:regionresolves against the same providers.unshackle dl --proxy nordvpn:us --proxy-download windscribevpn:us EXAMPLE 10a1234\n"},{"location":"guide/proxies-and-vpn/#how-proxy-resolution-works","title":"How proxy resolution works","text":"
--no-proxyand--no-proxy-downloadboth override--proxy-download. The same region warning applies: if the service ties segment delivery to the manifest region, keep both proxies in that region.unshackle interprets the value you pass to
--proxyin one of three ways. It tries them in this 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. unshackle uses anything shaped like
http://...,https://..., or asocks...URI exactly as you give it. unshackle logsUsing explicit Proxy: ...and does no lookup. Ahost:portvalue such aslocalhost:8080is also an explicit proxy.- A provider-prefixed query:
provider:query, for examplenordvpn:usorgluetun:windscribe:us. unshackle finds the proxy provider whose name matches the prefix (case-insensitive). It then asks only that proxy provider to find a proxy for the remainder. If unshackle loads no such proxy provider, or if that proxy provider returns no proxy, the download errors.- A bare query: a region like
us,gb, orus1234. unshackle asks each loaded proxy provider in order and uses the first proxy any of them returns. A bare city query such asus:seattledoes not work: unshackle readsusas a proxy provider name. Use the proxy provider prefix, for examplenordvpn:us:seattle. If no proxy provider has a proxy for the query, unshackle stops withNo proxy provider had a proxy for <query>and does not continue without a proxy.unshackle compares the query against a region grammar: a country or location code of two to four letters, with an optional
_codepart (res_yyz), then an optional server number and/or a:cityor-citypart. Ahost:portvalue never matches. It then lowercases the query and hands it to the proxy provider. Its section below gives the exact forms each proxy provider accepts.A Control D resolver,
"},{"location":"guide/proxies-and-vpn/#provider-load-order","title":"Provider load order","text":"controld://<resolver>@dns.controld.com, is a fourth form. A--remoteclient sends it to a server in place of a proxy URI, and the server starts a forwarder for it. See Control D.When you do a download without
--no-proxy, unshackle instantiates every configured proxy provider once, in this fixed order. It uses that same order for bare-query resolution:Basic \u2192 ExpressVPN \u2192 NordVPN \u2192 Proton VPN \u2192 Surfshark \u2192 Windscribe \u2192 Gluetun \u2192 Hola \u2192 Control D
So if you have both Basic and NordVPN configured and use
--proxy us, ausentry in your Basic config wins, because unshackle tries Basic first. To skip ahead to a specific proxy provider, prefix the query (--proxy nordvpn:us).If a proxy provider cannot load, for example because its server list cannot be fetched or its config is not valid, unshackle logs a warning and continues with the other proxy providers. A bare query skips the proxy provider that did not load. A query that names it, such as
--proxy nordvpn:us, fails and shows the reason it did not load.A bare region reaches Control D last
unshackle asks Control D for a bare region such as
--proxy usonly when no other loaded proxy provider has a proxy for it. Hola loads automatically when thehola-proxybinary is on yourPATH, and it comes before Control D. When Control D answers, it changes your Control D account: it points a profile at that country, and it can create anunshackle-usprofile and endpoint. The geofence auto-proxy uses the same order. To use Control D directly, prefix the query (--proxy controld:us).Auto-loading providers
Most providers only load when you configure them under
"},{"location":"guide/proxies-and-vpn/#connection-feedback","title":"Connection feedback","text":"proxy_providers:. Three are special: ExpressVPN and Proton VPN also load automatically once their cache file exists on disk (even with no YAML), and Hola loads automatically whenever thehola-proxybinary is found on yourPATH.After a proxy provider resolves a query, unshackle prints a line so you can see where you came out:
"},{"location":"guide/proxies-and-vpn/#exit-check","title":"Exit check","text":"
- 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 proxy providers log the resolved proxy URL.
Before the service sends a request, unshackle looks up the proxy exit IP through the proxy. The service stops with
Proxy check failedin two cases:
- No IP lookup gets through the proxy. The proxy is down, or it refused the connection.
- The proxy exit IP is the same as your own IP, so traffic does not go through the proxy.
unshackle tries each geolocation API in turn, and a 429 moves on to the next one. If at least one API answers through the proxy but none of them gives an IP, the proxy passes the check without the IP comparison. unshackle does not compare the exit country with the region you asked for, because geolocation data can be out of date.
Over the REST API, the server returns this failure to the client as
"},{"location":"guide/proxies-and-vpn/#basic-static-proxies","title":"Basic (static proxies)","text":"INVALID_PROXY.The
Basicproxy provider is pure static configuration, with no accounts and no network calls. You record proxy URLs under each 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\nQuery forms:
Query MeaningusA proxy for the US. If several are listed, one is chosen at random.us2The 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, unshackle picks one at random. Append an index (
de1,de2) to pin a specific entry.- unshackle normalises each URL (a missing scheme defaults to
http) and validates it, so it rejects a malformed URI up front and does not fail mid-download."},{"location":"guide/proxies-and-vpn/#control-d","title":"Control D","text":"unshackle dl --proxy de2 EXAMPLE 81234567\nControl D is a DNS service, not a proxy. It answers the hostnames it redirects with the IP of a transparent proxy in the region you chose, and that proxy relays onwards by SNI. There is no proxy URI to hand to Requests, so this proxy provider runs a small forwarder on loopback: it resolves each connection through your Control D resolver and relays the bytes. Everything downstream (the service session, the downloads,
--proxy-download) sees an ordinary HTTP proxy.unshackle picks the region itself, by pointing a profile's default rule at it. A profile holds one region at a time, so unshackle gives each region in use a profile of its own, each with its own endpoint and its own forwarder port.
--proxy controld:yyz --proxy-download controld:yultherefore uses two profiles, when two are free: Toronto for the service and Montreal for the downloads. When no second profile is free, the second region moves the first profile (see step 5).Keep both locations in one country. Some CDNs also check the country of the download IP, and refuse a
--proxy-downloadexit in another country with a 403. This applies to every proxy provider."},{"location":"guide/proxies-and-vpn/#what-you-need","title":"What you need","text":"
unshackle search --proxy controld:caworks the same way asdl.unshackle.yaml
- A plan with traffic redirection. On the personal plans that is Full Control; Some Control has no redirection, and every redirect fails. The 14-day trial includes it. See Control D's plans for current prices.
- An API token of type Write. A Read token cannot change a profile's default rule. Create it in the dashboard under API. If you set Allowed IPs on the token, include the address of each machine that runs unshackle with it.
proxy_providers:\n controld:\n token: api.xxxxxxxxxxxx # a Write API token, from the Control D dashboard\nunshackle dl --proxy controld:ca EXAMPLE 81234567\nThe token is the only required key. You do not need to create a profile or an endpoint, or to give a
profileandresolver: unshackle creates anunshackle-<region>profile and endpoint on your account the first time it needs one, and uses it again on the next run. Giveprofileandresolveronly to use a profile that you made yourself (see How unshackle chooses a profile).The returned proxy is HTTP on loopback,
Query Meaninghttp://127.0.0.1:{port}. The locations themselves are read from Control D at startup (107 of them across 68 countries, at the time of writing), so there is nothing to list:caOne of Control D's Canadian locations, at random.yulThat exact location, by its code.res_yyzA residential location, by its code. A country pick never uses a residential location, so name it this way.The configuration reference lists every
"},{"location":"guide/proxies-and-vpn/#how-unshackle-chooses-a-profile","title":"How unshackle chooses a profile","text":"controldconfig key.Each region query gets a profile and an endpoint named after it:
--proxy controld:causesunshackle-ca. For each region, unshackle uses the first match in this list:
- A profile that this process already pointed at that region.
- The endpoint named
unshackle-<region>on your account, made by an earlier run.A profile from your config that this process does not use yet. These are optional:
unshackle.yamlproxy_providers:\n controld:\n token: api.xxxxxxxxxxxx\n profile: abcd123 # a profile kept for unshackle, and nothing else\n resolver: efgh456 # the resolver ID of an endpoint using that profile\n profiles: # more pairs, in the same shape\n - profile: ijkl789\n resolver: mnop012\n max_profiles: 4 # the most profiles unshackle uses, yours included (default 4)\nThe resolver ID is the last part of the endpoint's DoH URL,
https://dns.controld.com/<resolver>. You can also give the full URL.A new profile and endpoint named
unshackle-<region>, while your config pairs plus theunshackle-*endpoints on the account are fewer thanmax_profiles. unshackle keeps them for the next run.max_profileslimits only this step: unshackle uses every pair in your config, even more thanmax_profiles.- When no profile is free, a profile moves to the new region, with everything that still uses it. unshackle first moves a profile that this process has not pointed at a region, which another process can still use. Next, it moves the profile that this process pointed at a region first. An
unshackle-*profile that moves, and its endpoint, get the new region's name. unshackle logsEvery Control D profile is in use; moving profile \u2026 to \u2026, and connections already open keep the old exit until they close.Because the names live on the account, every unshackle process agrees on them: two runs, or two jobs on a
serveinstance, that ask forcaboth useunshackle-ca. Two processes can still move a profile under each other when they run out of profiles (step 5).unshackle changes only the profiles in your config and the profiles of
unshackle-*endpoints. It skips anunshackle-*endpoint whose profile an endpoint without that name also uses, so unshackle never redirects a profile that serves your own devices. Leave theunshackle-*profiles empty: no services, no custom rules. unshackle sets their default rule toREDIRECT, and the Services and Custom Rules tabs stay at zero by design. To remove them, delete the endpoints and profiles in the dashboard. unshackle creates new ones when it next needs them.Business accounts pay per endpoint
Personal plans allow an unlimited number of profiles and endpoints. Business plans bill each endpoint every month, so each
unshackle-<country>endpoint adds to the bill. On a business account, configure your own pairs, as many as the countries you use at the same time, and setmax_profilesto that number. unshackle then never creates a profile.Everything goes through the proxy, including the segments
With the default rule redirecting, the whole download leaves through Control D's transparent proxy. Control D serves it on a best-effort basis. Pair it with
--no-proxy-downloadto keep the geo-check on the manifest and the licence while pulling the files over your own connection.The switch is not instant
unshackle drops its own DNS cache when it changes region, but Control D's edge may still serve the previous answer for the remainder of the record's TTL.
Same machine, same IP, over IPv4
Control D authorises its proxies by source IP: the address that queried DNS is the one allowed to use the proxy. The forwarder resolves and connects from the same host, so this holds. Control D also answers a redirected name only to a query over IPv4, so the forwarder asks over IPv4 even on a host with IPv6, and ignores
HTTPS_PROXYfor these queries.The forwarder refuses a connection to an IP address with a
502, because an IP address skips Control D's DNS and connects from your own address. A request that names a host by IP address fails with this proxy provider.With
--remote, the server resolvesunshackle does not send your API token to a server. For
--proxy controld:cawith--remote, unshackle pointsunshackle-caat Canada with your token, then sends the server only that endpoint's resolver ID, ascontrold://<resolver>@dns.controld.com. The server runs its own forwarder with it, from its own address, and needs no Control D configuration. The resolver ID lets anyone who has it use that endpoint's proxy, so treat it as a secret: unshackle masks it in logs and in stored job parameters.The server must run a version of unshackle that knows
"},{"location":"guide/proxies-and-vpn/#troubleshooting-control-d","title":"Troubleshooting Control D","text":"Symptom Cause and fixcontrold://. An older server rejects it withINVALID_PROXY:Proxy provider 'controld' not foundwhen your API key hasserver_proxy, else a message that asks for a full proxy URI. A bare region, such as--proxy ca, skips Control D with--remote, because the proxy it gives is on your machine. A service whose accounts the server manages does not accept a proxy from the client, and that includes a Control D resolver.Proxy check failed, because the exit IP is your own IP. The profile does not redirect. Make sure that the plan includes traffic redirection (Full Control, not Some Control). After a region change, wait for the TTL of the previous answer to end. A502 Bad Gatewayfor one host. With-d, the log showsControl D returned no A record for \u2026. Control D gave no IPv4 address for that host. A502 Bad Gatewayfor one host only. With-d, the log showsRefused \u2026, an address that Control D cannot redirect. The request names the host by IP address. The forwarder refuses it. See the note on IPv4 above. An HTTP401or403fromapi.controld.com. The token is a Read token, or its Allowed IPs do not include this machine. Use a Write token.Control D needs both a profile and a resolverorControl D needs max_profiles of 1 or moreat startup. Thecontroldconfig is not valid. Giveprofileandresolvertogether, and amax_profilesof 1 or more. Every run fails until you correct it, also a run that does not use Control D.Could not read Control D's locations. unshackle could not get the location list. It tries again at the next Control D query. A403for the segments only, with--proxy-downloadin another country. The CDN checks the country of the download IP. Keep--proxy-downloadin the same country as--proxy."},{"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/gluetunimage. That container establishes a WireGuard or OpenVPN tunnel to your chosen VPN provider and exposes a plain HTTP proxy onlocalhost, 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.
Not available with
--remoteGluetun listens on your machine, so a server on another machine cannot reach it. With
--remote, unshackle stops with an error for--proxy gluetun:..., and for any proxy that resolves tolocalhostor a loopback address, such as a region query that Gluetun answers. Give--remotea proxy the server can reach, or run the service locally. A server on this machine (aremote_servicesURL on127.0.0.1) can reach a local proxy, so it is exempt.Docker is required
Gluetun needs a working Docker installation on your
"},{"location":"guide/proxies-and-vpn/#query-format","title":"Query format","text":"PATH. If Docker is not found, the proxy provider fails to load with an error linking to the install docs. The container also needs theNET_ADMINcapability and access to/dev/net/tunto bring up the tunnel. unshackle requests both automatically.Gluetun uses a nested selector: the proxy provider prefix picks Gluetun, and the rest names the VPN provider and the region.
A Windscribe US exit via Gluetungluetun:<vpn-provider>:<region>\nA NordVPN Germany exit via Gluetununshackle dl --proxy gluetun:windscribe:us EXAMPLE 81234567\nunshackle dl --proxy gluetun:nordvpn:de EXAMPLE 81234567\nAfter unshackle strips the leading
"},{"location":"guide/proxies-and-vpn/#configuration","title":"Configuration","text":"gluetun:, the proxy provider receives exactlyprovider:region. It must be two colon-separated parts, and anything else is an error. The<vpn-provider>must be one you have configured undergluetun.providers, and unshackle normalises friendly names to Gluetun's identifiers (for examplepiaandprivateinternetaccessboth map toprivate internet access).Each VPN provider you want to use gets an entry under
unshackle.yamlproviders:, carrying its VPN type, credentials, and how your region aliases map onto Gluetun's server selectors.proxy_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\nPer-provider keys:
Key Purposevpn_typewireguard(default) oropenvpn.credentialsThe secrets for the tunnel; see the credential rules below.server_countriesMaps your region alias (e.g.us) to Gluetun's country value (e.g.US).server_citiesOptional. Maps an alias to a Gluetun city.server_hostnamesOptional. Maps an alias directly to a specific server hostname.extra_envOptional. Raw environment variables merged into the container last, for any Gluetun setting not covered above.Credential rules by VPN type:
WireGuardOpenVPN
private_keyis always required.surfshark,mullvad, andivpnalso requireaddresses.windscribeadditionally requires bothaddressesandpreshared_key(preshared_keymay be an empty string, but the config key must be present).- NordVPN and Proton VPN need only
private_key.
- Requires
usernameandpassword.Which
vpn_typeshould 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
addressesand apreshared_key). OpenVPN is the simplest to configure (only a username and password) at the cost of some speed. If you start out, or if you have credential trouble, OpenVPN is the path of least resistance. Switch to WireGuard when you want the extra throughput.Global keys:
Key Default Purposebase_port8888The first local port to expose proxies on. Additional containers take the next free ports.auto_cleanuptrueRemove containers on exit. Iffalse, containers are only stopped, not deleted.container_prefixunshackle-gluetunPrefix for Docker container names.auth_user/auth_passwordnullOptional HTTP-proxy credentials. If set, the local proxy requires auth and the credentials are embedded in the proxy URL.verify_iptrueAfter 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, orserver_countriesfor that alias always wins.- unshackle changes a
us1239-style query (country code + number) with no explicit mapping into a provider-specific server hostname (for exampleus1239.nordvpn.comfor NordVPN). Surfshark has no numbered servers, so map a Surfshark region to a server name such asus-dal.prod.surfshark.cominserver_hostnames.- unshackle expands a bare two-letter code to the full country name Gluetun expects.
A few providers (Windscribe, VyprVPN, VPN Secure) select by region rather than country, and unshackle converts Windscribe region names automatically. For instance
"},{"location":"guide/proxies-and-vpn/#container-lifecycle","title":"Container lifecycle","text":"usbecomesUS Eastanduk/gbbecomeUnited Kingdom.Understanding what Gluetun does behind the scenes helps when something goes wrong:
- Image check. unshackle pulls
qmcgaw/gluetun:latestif it is not already present.- Reuse. If a container for this exact
provider:regionalready runs (from a concurrent unshackle process), unshackle adopts it and does not start a new one. It removes stopped or dead containers with the same name 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. unshackle chooses a free local port, starting from
base_port.- Start. unshackle launches the container bound to
127.0.0.1:{port}. It passes credentials through a temporary,0600-permission env-file (never as-eflags), and it overwrites and deletes that file immediately after.- Readiness wait. unshackle polls the container logs (up to 60 seconds) until the HTTP proxy listens and the VPN reports a completed connection. Fatal errors like
authentication failedorinvalid credentialsabort early, and unshackle attaches recent logs to the error to help you diagnose it.- IP verification. When
verify_ipis 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
unshackle cleans up containers with an
"},{"location":"guide/proxies-and-vpn/#troubleshooting","title":"Troubleshooting","text":"atexithandler on normal exit. Gluetun deliberately does not install its own signal handlers, soCtrl+Ccontinues to work as you expect. If something kills a run hard (for examplekill -9), a container can stay behind. Withauto_cleanup: truethe next run that adopts that name still works, or you can remove it manually withdocker rm -f.Common Gluetun issues
"},{"location":"guide/proxies-and-vpn/#expressvpn","title":"ExpressVPN","text":"
RuntimeErrorabout Docker not found. Install Docker and make sure thatdockeris on yourPATH.invalid credentials/authentication failedin the readiness wait. Your WireGuard/OpenVPN credentials are wrong or in the wrong fields. Double-checkprivate_key,addresses, and (for Windscribe)preshared_key.- Region mismatch error. The VPN connected but exited in a different country than requested. Examine your
server_countriesmapping, or setverify_ip: falseif you intentionally use a nearby region.- Timeout after 60 seconds. The tunnel never came up. Inspect the container logs (
docker logs <container>) for the underlying VPN error.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 you signed in headlessly.
Enable it and do a download interactively once:
unshackle.yamlproxy_providers:\n expressvpn:\n enable: true\nWith
unshackle.yaml (optional)enable: trueand an interactive terminal, unshackle prints a code and a URL to enter it at. Once you approve it, unshackle caches the tokens and reuses them automatically, so later runs need no terminal. ExpressVPN also auto-loads once its token cache exists, so you can dropenableafter the first login, but you can keep a YAML block to pin regions or servers: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\nDefault file location:
File Default path Token cache{cache}/vpn/expressvpn_tokens.jsonWhere
{cache}is your configured cache directory.Query forms:
Query MeaningusA smart, random US location.us-nyUS, New York.us-ny-2/us-ny2US, New York, the 2nd server.usny-newyork-2A full location slug.hostname.expressprovider.comA 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
unshackle writes ExpressVPN's token cache with
"},{"location":"guide/proxies-and-vpn/#nordvpn","title":"NordVPN","text":"0600permissions, so the refresh token is never briefly world-readable.NordVPN uses your Service Credentials, not your normal NordVPN login. Make 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\nRequired 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_mapis optional and maps a region (orcode:city) to a pinned NordVPN server ID. Its main use is to tune performance: for a given streaming service one specific server often performs noticeably better than NordVPN's recommended pick. So you can hard-code that server for a day or two as a workaround, until it degrades and you swap in another.Use
gb, notuk, for the United KingdomQuery the UK as
gb. unshackle uses the Alpha-2 country codegbdeliberately to stay consistent with its other regional/country-code systems and avoid the language-vs- country-code confusion thatukinvites.Query forms:
Query MeaningusA recommended US server.us1234The specific serverus1234.nordvpn:us:seattleA recommended server in Seattle.228A NordVPN numeric country ID.The returned proxy is HTTPS on port 89 (NordVPN disabled its plain-HTTP proxies on port 80 in 2021).
"},{"location":"guide/proxies-and-vpn/#proton-vpn","title":"Proton VPN","text":"unshackle dl --proxy nordvpn:us:seattle EXAMPLE 81234567\nProton VPN supports two authentication methods, and unshackle can work with either. Note that free Proton accounts work, but Proton limits them to its free-tier exit countries.
TV login (recommended)Exported cookiesProton's TV device-login flow gives a self-sustaining, refreshable sign-in. This is the only method that can refresh itself headlessly, so it keeps working without re-exporting anything. Enable it and do a download interactively once:
unshackle.yamlproxy_providers:\n protonvpn:\n enable: true\nWith
enable: trueand an interactive terminal, unshackle prints a code and asks you to enter it at account.proton.me/vpn/tv/code. Once you approve it, unshackle caches the sign-in and reuses it automatically.Export your
account.proton.mecookies (theAUTH-<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 HttpOnlyThis 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 \"does not 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. That is why the sign-in lapses at Proton's ~24h TTL and you must export it again. This is exactly why TV login is the better method for set-and-forget use.
Default file locations:
File Default path Cookies{cookies}/vpn/protonvpn.txtSession cache{cache}/vpn/protonvpn.jsonProton VPN auto-loads if its cookie file exists. Like ExpressVPN, unshackle writes its cache file with
0600permissions.Cache vs cookie precedence
If both a TV-login cache and a cookie file exist, unshackle prefers the cache unless the cookie file is newer. unshackle reads cookies fresh every run and never writes them back to the cache. So to deliberately force use of the cookie file, re-save (re-export) it so its modification time is newer than the cache.
Query forms (after the
Query Meaningprotonvpn:prefix):usA random US server (paid tiers preferred over free).us12Proton US server #12.us:nyA server in New York.The returned proxy is HTTPS on port 4443 (or port 443 for Secure Core servers). unshackle excludes Secure Core and Tor servers 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
"},{"location":"guide/proxies-and-vpn/#hola","title":"Hola","text":"protonvpn:deNN, do not guess a low number. A country may have non-contiguous IDs like#203,#813, and so on. Copy the number from a previous run's log line (e.g.DE#203) instead.Hola requires no configuration at all. It uses the
hola-proxybinary, and auto-loads whenever that binary is found on yourPATH. If you query Hola but the binary is missing, the proxy provider raises an error that tells you to install it.The
Build hola-proxy, then query a countryhola-proxyGitHub repository is no longer online, so build the binary with Go. The Go module proxy still has the source for version 1.18.2.go installputs the binary in$(go env GOPATH)/bin, which must be on yourPATH.go install github.com/Snawoot/hola-proxy@v1.18.2\nunshackle dl --proxy hola:us EXAMPLE 81234567\nQuery forms: a two-letter country code (e.g.
us,gb). unshackle askshola-proxyfor 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. While a ban is active,
"},{"location":"guide/proxies-and-vpn/#surfshark","title":"Surfshark","text":"hola-proxydoes not stop, so unshackle waits 30 seconds and then reports the ban. Hola currently uses only datacenter proxies.Surfshark, like NordVPN, uses Service Credentials rather than your login. Make 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: region -> server name\n us: us-dal\n us:seattle: us-sea\nRequired keys:
username,password, validated with the same 48-character rule as NordVPN (combined username+password must be 48 alphanumeric characters, case-insensitive, no@).server_mapoptionally pins a server per region. Give the server name from Surfshark's server list, for exampleus-dal. A full hostname such asus-dal.prod.surfshark.comalso works. Surfshark does not use numeric server IDs. unshackle ignores a numeric or empty value and logs a warning. Aus:seattleentry applies only to that city query, and it does not fall back to theusentry.Query forms:
Query Meaningsurfsharkvpn:usA random US server.ukandgbboth give a UK server.surfsharkvpn:us-bosA specific server from Surfshark's server list.surfsharkvpn:us:seattleA random server in Seattle.unshackle ignores case, spaces, hyphens and accents in a city name, so
us:new-yorkmatches New York. A city also matches the start of a longer name:de:frankfurtmatches Frankfurt am Main. A bareusalso reaches Surfshark. A bareus-boscan stop at a proxy provider earlier in the order, and a bareus:seattledoes not work, so use thesurfsharkvpn:prefix for those forms.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. unshackle only makes sure that a username and a password are present (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\nRequired keys:
username,password.server_mapoptionally maps a region (orregion:city) directly to a hostname.Query forms:
Query MeaningusA random US server.us150/sg007A specific numbered server.windscribevpn:us:seattleA random server in Seattle.The returned proxy is HTTPS on port 443.
Windscribe is not loaded by the REST API resolver
The
"},{"location":"guide/proxies-and-vpn/#developer-notes","title":"Developer notes","text":"dlcommand loads the standalone Windscribe proxy provider (and Gluetun), but the proxy resolver that the REST API and the remote-download client use does not. If you drive downloads through the API and need Windscribe or Gluetun exits, get the proxy on the CLI side, or use a different proxy provider for API jobs. See the developer note below.Two resolution paths (developers)
unshackle has two proxy-resolution code paths that are intentionally not identical:
unshackle/commands/dl.pyinlines its own logic and loads all nine 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, a bare country query tried against providers in order, or acontrold://resolver), but the set of available providers differs. Keep this in mind when you add a proxy provider: an entry indl.pydoes not make it available over the API, and the opposite is also true.The remote-service client resolves
--proxywith its ownresolve_remote_proxy_arg(). It changes acontrold:query to acontrold://resolver for the server withget_remote_proxy(), and it removes Control D from a bare query, because a loopback forwarder is of no use to a server on another machine.Implementing a new provider (developers)
Every proxy provider subclasses
unshackle.core.proxies.proxy.Proxyand has three methods:
__init__(self, **kwargs)receives itsproxy_providerssub-dict splatted as keyword arguments, so YAML keys map 1:1 to constructor parameters. Do authentication and catalogue pre-fetching here.__repr__(self)is a human summary, conventionally\"{n} Countries ({m} Servers)\".get_proxy(self, query)returns a Requests-compatible proxy URI, orNoneonly when the query was valid but no proxy exists. Raise on genuine errors or bad queries.Optionally add
"},{"location":"guide/proxies-and-vpn/#see-also","title":"See also","text":"get_connection_info(query)(as Gluetun does) orlast_connection_display()(as ExpressVPN and Proton VPN do) to makedlprint a friendly connection line instead of the raw proxy URL."},{"location":"guide/subtitles/","title":"Subtitles","text":"
- Downloading: the full
dlcommand guide, including where proxy flags sit in the download flow.- Configuration file: the
unshackle.yamlreference, including directories for cookies and cache used by the cookie-based providers.- Configuration reference: the developer-facing configuration API, including the
proxy_providersstructure.Subtitles are first-class tracks in unshackle. When you download a title, unshackle parses the subtitle tracks from the manifest alongside video and audio. It downloads them, cleans them up, and optionally converts them to the format you want. It then muxes them into the final Matroska file, or writes them out as sidecar files next to it. The sections below cover the supported formats, how unshackle normalizes tracks after download, how to select and filter subtitles, and every subtitle-related configuration option.
A handful of
"},{"location":"guide/subtitles/#supported-formats","title":"Supported formats","text":"dlflags and a smallsubtitleblock in your configuration file control most of this. See Downloading for how subtitle flags fit into a full download command.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 NotesSubRipSRTsrtSubRip, the most widely compatible plain-text formatSubStationAlphaSSAssaSubStation Alpha (styled)SubStationAlphav4ASSassAdvanced SubStation Alpha (styled)TimedTextMarkupLangTTMLttmlTimed Text Markup Language (IMSC/DFXP)WebVTTVTTvttWebVTTSAMISMIsmiSynchronized Accessible Media InterchangeMicroDVDSUBsubMicroDVDMPL2MPL2mpl2MPL2TMPTMPtmpTMPfTTMLSTPPstppMPEG-DASH box-encapsulated TTML (IMSC1)fVTTWVTTwvttMPEG-DASH box-encapsulated WebVTTThe two boxed formats convert automatically
"},{"location":"guide/subtitles/#what-happens-to-a-subtitle-after-download","title":"What happens to a subtitle after download","text":"
fTTML(STPP) andfVTT(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 (fTTMLbecomes TTML andfVTTbecomes 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 write them as an output target, so they can never be a conversion destination.A subtitle download does more than save a file. Once the bytes are on disk, unshackle runs format-appropriate clean-up to make a single, valid file:
- Encoding: unshackle forces non-boxed subtitles to UTF-8, and unescapes stray
‎/‏bidirectional entities.- Segment merging: when a WebVTT or boxed subtitle arrives as many DASH/HLS segments, unshackle merges the segments 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 (likeQ0,Q1) that confuse downstream parsers, and merges multi-line cues that a service split into separate overlapping cues. By default unshackle keeps the original styling and layout (seepreserve_formatting).- Cue markup normalisation: unshackle removes the WebVTT-only parts of a cue payload, so no player draws them as dialogue. See Cue markup.
- TTML for muxing: Matroska cannot carry TTML. If you do not request a specific output format, unshackle automatically converts any plain TTML track to WebVTT before muxing (the closest lossless equivalent). Boxed
fTTMLis likewise unwrapped to TTML first.You normally do not need to think about any of this. It gives a clean file.
"},{"location":"guide/subtitles/#selecting-and-filtering-subtitles","title":"Selecting and filtering subtitles","text":"Subtitle selection happens through
Flag Purposedlflags. The most important is language selection.-sl,--s-langLanguage(s) wanted for subtitles. Defaults toall. A-prefix excludes, e.g.all,-es.--require-subsRequire these languages to exist; if present, download all subtitles. Cannot be combined with--s-lang.-fs,--forced-subsInclude forced subtitle tracks (excluded by default).-fsl,--forced-s-langLanguage(s) wanted for forced subtitles; implies-fs. A-prefix excludes.--exact-langExact language matching, with no regional variants.-S,--subs-onlyDownload only subtitle tracks.-ns,--no-subsDo not download subtitle tracks at all.--sub-formatOutput subtitle format / conversion target (see conversion).--skip-subtitle-errorsSkip a subtitle that fails to download instead of aborting the whole title."},{"location":"guide/subtitles/#choosing-languages","title":"Choosing languages","text":"By default (
English and Spanish subtitles only--s-lang all) unshackle keeps every subtitle language the service offers. To narrow it down, pass a comma-separated list of language tags:unshackle dl --s-lang en,es EXAMPLE 81234567\nLanguage matching is fuzzy by default:
enmatchesen-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
Only Latin-American Spanish, no other Spanish variantses-419matches onlyes-419and neveres-ES, add--exact-lang:"},{"location":"guide/subtitles/#requiring-languages-before-downloading","title":"Requiring languages before downloading","text":"unshackle dl --s-lang es-419 --exact-lang EXAMPLE 'https://www.example.com/...'\nFail unless both English and French subs exist, then grab everything
--require-subsis a gate rather than a filter. It downloads all available subtitles, but only if every language you name is present. If any is missing, the title errors out. This is useful for batch jobs where you want a title without your must-have language to fail loudly, and not to silently give a partial result.unshackle dl --require-subs en,fr EXAMPLE B0ABCDEFGH\n
--require-subsand--s-langare mutually exclusiveBecause
"},{"location":"guide/subtitles/#forced-subtitles","title":"Forced subtitles","text":"--require-subsforces downloading of all subtitle languages, it cannot be combined with--s-lang. Choose one or the other.Forced tracks carry only the important on-screen signs and foreign-dialogue lines meant to display even when you watch in your own language. They are excluded by default. Add
Include forced tracks alongside full subtitles-fs/--forced-substo include them:unshackle dl --s-lang en --forced-subs EXAMPLE 81234567\nAll full subtitles, but only the English forced track
-fskeeps forced tracks in every selected language. If you only want forced tracks in certain languages, use-fsl/--forced-s-langinstead. It implies-fs, so you do not need both:unshackle dl --s-lang all --forced-s-lang en EXAMPLE 81234567\nIf you pass both flags,
"},{"location":"guide/subtitles/#track-types-forced-sdh-and-cc","title":"Track types: forced, SDH, and CC","text":"-fslwins and keeps only forced tracks in its languages. It also understandsorig(the title's original language) andall(every forced track, same as plain-fs).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 it 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 hearing-impaired handling, unshackle groups SDH and CC tracks 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_bytolanguage. Usesubtitle.type_priorityto 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 carry the CC or SDH flag. unshackle rejects these combinations 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 track. After unshackle downloads and decrypts a video track, it probes the track for these captions and, when it finds them, extracts them into a SubRip (
"},{"location":"guide/subtitles/#automatic-sdh-stripping","title":"Automatic SDH stripping","text":"SRT) subtitle flagged as CC. This runs automatically unless you disabled subtitles (--no-subs) or video (--no-video/--video-onlysemantics), and it requires the CCExtractor tool on yourPATH.When a language has an SDH track but no plain (non-SDH, non-forced) track, unshackle can make 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_sdhconfig key.The check is deliberately loose: if a close-enough plain track already exists (for example an
en-GBnon-SDH track when the SDH track isen-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
sdh_methodconfig key selects the stripping engine itself. The default (auto) prefers thesubbycleaner for SRT, falls back to SubtitleEdit when you install it, and otherwise uses the built-infilter-subsapproach.Every engine removes the same three things: bracketed sound effects (
[MUSIC PLAYING],(door creaks)), speaker labels (MAN:), and font tags. Every engine keeps plain dialogue and inline<i>styling. They differ on music and lyric lines:sdh_methodEngine that runs Lyric lines (\u266a ... \u266a)autosubbyfor an SRT track. SubtitleEdit for any other format when you install it, otherwisefilter-subs. Kept, unless the fallback reachesfilter-subs.subbysubby, but only for a track that is already SRT. Any other format falls through tofilter-subs. Kept for SRT, removed otherwise.subtitleeditSubtitleEdit when you install it, otherwisefilter-subs. Kept with SubtitleEdit, removed without it.filter-subsfilter-subsalways. Removed.Pick
filter-subswhen you want lyrics gone as well. Picksubbyor install SubtitleEdit when you want to read along with the songs.Why
convert_before_stripexists"},{"location":"guide/subtitles/#converting-subtitle-formats","title":"Converting subtitle formats","text":"
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 forconvert_before_strip: non-SubtitleEdit engines need the subtitle converted to SRT first, or the hearing-impaired cues stay in the file.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
Convert every subtitle to SRT--sub-format:unshackle dl --s-lang en,es --sub-format srt EXAMPLE 81234567\nKeep whatever the service delivered
--sub-formataccepts the common format names and aliases, includingsrt,vtt,ssa,ass, andttml. Passoriginalto explicitly keep the source format and skip all conversion:unshackle dl --sub-format original EXAMPLE 'https://www.example.com/...'\nConversion only runs when the source format differs from the target, so
--sub-format srton 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 with
--sub-format. If you want to preserve styling, convert to a format that supports it (or keeporiginal).Note that this automatic protection guards only the default muxed track: there unshackle skips the conversion and keeps the styled original. It does not cover sidecars. A
"},{"location":"guide/subtitles/#how-the-conversion-backend-is-chosen","title":"How the conversion backend is chosen","text":"sidecar_format: srtwill still lossily flatten styled subtitles, dropping positioning, colours, and styling, and a per-download--sub-format srtforces the muxed track to convert as well. To keep raw styled sidecars, setsidecar_format: original.Under the hood unshackle picks the highest-fidelity converter that supports the source\u2192target pair. Which converter runs depends on the
Backend Best at Requiresconversion_methodconfig key (defaultauto) and the formats involved:subtitleeditHighest fidelity; preserves positioning and italics SubtitleEdit installedsubbyNative SRT output with clean-up -pysubs2Best fidelity for styled SSA/ASS - (pure Python, always available)pycaptionLast-resort fallback; flattens styling -With
"},{"location":"guide/subtitles/#what-each-backend-preserves","title":"What each backend preserves","text":"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 convert the pair.The table below comes from round-tripping a subtitle that carries italics, bold, underline, positioning, and colour through every backend. Each cell shows the styling that survives.
Conversion\u2014means that the backend cannot convert that pair.subtitleeditpysubs2subbypycaptionWebVTT \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 \u2014Reading the table:
- Keep the original: leaving
--sub-formatunset (or set tooriginal) 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 it is best for \u2192 SRT, where it uniquely converts WebVTT cue settings into{\\an8}positioning. ItsCommonIssuesFixermay also drop near-duplicate cues.pycaption: removes all styling. Last-resort fallback only.What
autopicks"},{"location":"guide/subtitles/#cue-markup-and-html-entities","title":"Cue markup and HTML entities","text":"
autopreferssubtitleeditfirst when you install it, then falls back tosubbyfor \u2192 SRT andpysubs2otherwise. The one exception comes from the table above: for ASS/SSA \u2192 TTML or WebVTT it pickspysubs2even when SubtitleEdit is installed, since SubtitleEdit flattens inline styling on those pairs. Most installs do not ship SubtitleEdit, so in practiceautomeanssubby(\u2192 SRT) orpysubs2. Install SubtitleEdit /seconvif you need colour or the highest \u2192 SRT fidelity.Styling is not the only thing a conversion has to handle. WebVTT carries markup that no other format understands, and a player that does not know a tag draws it as dialogue. unshackle removes that markup before any backend runs, so the result is the same for every
WebVTT markup Resultconversion_method:<c.bg_transparent>text</c>Tags removed, text kept. A class only selects a CSS::cuerule that no player ever receives, so it carries no styling.<i.loud>,<b.x>,<u.y>Class list removed, the styling tag kept.<v Bob>,<lang ja>,<ruby>,<rt>Tags removed, text kept.<00:00:02.000>Removed. These are karaoke timings that no subtitle player renders.&, ,'Decoded to real characters for SRT, SSA, and ASS output. Kept escaped for WebVTT and TTML, which need them.A class list breaks more than you expect
FFmpeg reads SubRip markup as a tag only while the tag name stays inside
[0-9a-zA-Z_/]. The dot in<i.loud>fails that test, so FFmpeg prints the opening tag on screen and then closes an italic run that never opened. The FFmpeg WebVTT reader matchesi,b, anduexactly, so there the same class silently loses the styling. mpv, VLC, Plex, and Jellyfin all use one of those two readers.Config wins over a service's preference
A service may set a
"},{"location":"guide/subtitles/#muxed-vs-sidecar-output","title":"Muxed vs. sidecar output","text":"preferred_conversion_methodon its own tracks (for example when it ships subtitles that a particular backend handles best). An explicitconversion_methodinunshackle.yamlalways overrides that per-track preference, so your config takes precedence. This only matters when a service ships a non-default preference. Leaveconversion_methodatautoto let the service's hint stand.By default unshackle muxes subtitles into the final
mux (default)sidecarboth unshackle.yaml.mkv. You can instead (or additionally) write them as separate sidecar files next to the video, controlled by theoutput_modeconfig key:subtitle:\n output_mode: mux\nunshackle embeds the subtitles in the Matroska file. It writes nothing separately.
unshackle.yamlsubtitle:\n output_mode: sidecar\n sidecar_format: srt\nunshackle writes the subtitles as standalone files and does not mux them into the video (when there is a video or audio track to sit beside).
unshackle.yamlsubtitle:\n output_mode: both\n sidecar_format: srt\nunshackle muxes the subtitles into the video and writes them as sidecar files.
unshackle names sidecar files 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\nThe pattern is
"},{"location":"guide/subtitles/#fonts-for-styled-subtitles","title":"Fonts for styled subtitles","text":"{base}.{language}[.forced][.sdh].{extension}. Thesidecar_formatconfig key sets the sidecar file format. Use a format name likesrtorvtt, ororiginalto keep each subtitle in its downloaded format.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":"You can set defaults for all of the behaviour above in the
unshackle.yamlsubtitleblock of your configuration file:Key Default Values Purposesubtitle:\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\nstrip_sdhtruetrue/falseAuto-generate a stripped non-SDH copy when a language has only an SDH track.preserve_formattingtruetrue/falseKeep original WebVTT styling and layout during sanitization. Whenfalse, WebVTT is re-encoded more aggressively (merging identical cues, dropping empty ones).conversion_methodautoauto,subby,pysubs2,subtitleedit,pycaptionWhich conversion backend to prefer.autoranks them per conversion.sdh_methodautoauto,subby,subtitleedit,filter-subsEngine used to strip hearing-impaired cues.convert_before_striptruetrue/falseConvert a subtitle to SRT before stripping SDH (for non-SubtitleEdit engines).output_modemuxmux,sidecar,bothWhether subtitles are embedded, written as sidecars, or both.sidecar_formatsrta format name (e.g.srt,vtt) ororiginalFormat used for sidecar files.group_bytypetype,languageWhether 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."},{"location":"guide/subtitles/#which-settings-to-change","title":"Which settings to change","text":"The defaults give a clean, player-safe file, so most people change nothing. Start here only when you want something the defaults do not do:
If you want Set Every original style kept, with no re-encode at all--sub-format original, andsidecar_format: originalfor sidecars The highest conversion fidelity, including colour Install SubtitleEdit.conversion_methodstaysautoMusic and lyric lines stripped along with the sound effectssdh_method: filter-subsLyric lines keptsdh_method: subby, or install SubtitleEdit No stripped copies made at allstrip_sdh: falseOne backend for every conversion, whatever you installconversion_method: <backend>Two keys read as if they control markup, and do not:
preserve_formattingdecides whether unshackle re-encodes a WebVTT file after the repair pipeline. Lefttrue, the cue text passes through as written. Set tofalse, unshackle re-encodes the file through pycaption, which merges identical cues and drops empty ones but also flattens styling. Neither setting leaves WebVTT-only markup in the file: cue markup normalisation runs either way.convert_before_stripapplies to SDH stripping, not to your output format. It converts the working copy to SRT so a non-SubtitleEdit engine can read it. Turning it off makessubbyandfilter-subsno-ops on anything that is not already SRT.SubtitleEdit is optional but recommended
Several high-fidelity paths (the best-quality conversions and the SubtitleEdit SDH stripper) only activate when you install the SubtitleEdit CLI and put it on your
"},{"location":"guide/subtitles/#developer-reference","title":"Developer reference","text":"PATH. See Installation for how to add it. Without it, unshackle falls back to its built-in Python backends.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 bothccandsdh, and you cannot combineforcedwith either. Both raiseValueErrorat construction.convert(codec, *, forced=False): converts the downloaded file in place through the backend chain insubtitle_convert.run_conversion.forced=True(what--sub-formatsets) is the only way to permit lossy styled down-converts such as ASS\u2192SRT. RaisesNotImplementedErrorif no backend supports the pair andRuntimeErrorif all attempts fail.strip_hearing_impaired(): removes SDH cues using the engine selected byconfig.subtitle[\"sdh_method\"].reverse_rtl(): fixes right-to-left sentence-ending positioning with SubtitleEdit (requires the binary).parse(data, codec): the central parser, returning apycaption.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 (
"},{"location":"guide/troubleshooting/","title":"Troubleshooting & Debugging","text":"SubtitleEditBackend,SubbyBackend,Pysubs2Backend,PycaptionBackend) each have a small protocol (is_available(),can_convert(),rank(),convert()), and unshackle tries them in rank order, lowest rank first. See the Tracks API reference for the full model.When a download fails, a service misbehaves, or unshackle cannot get a content key, 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: make sure that unshackle finds the external tools it depends on, and see exactly which directories and config file are in use.- Targeted toggles:
debug_keysanddebug_requestsfor 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 give, 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/--debugflagThedebugconfig keyThe root-level flag turns on verbose logging for a single invocation:
unshackle -d dl SERVICE TITLE_ID\nIt must come before the subcommand, because it is an option on the root
unshacklecommand, not ondl.
-ddoes 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
-deach time, set it in yourunshackle.yaml:debug: true\nor with the
cfgcommand:unshackle cfg debug true\n
debug: trueis not identical to-dThe
debugconfig key enables the JSON debug log file, but it does not raise the console toDEBUGlevel and does not add source-path annotations. Those only happen when you pass the-dflag. Use the flag when you want to watch the verbose output live. Use the config key when you want only the log file written every time. Passing-dis 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. unshackle automatically redacts secrets, URLs, and local paths (see Redaction), but you should still read a log before you share 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. You can filter, examine, and analyse this file with standard tooling (
"},{"location":"guide/troubleshooting/#where-it-is-written","title":"Where it is written","text":"jq,grep, a text editor). It is safe to attach to a bug report, because unshackle redacts it.unshackle makes the debug log during a
dlrun, and duringimport, which wrapsdl. The path is:<logs directory>/unshackle_debug_<service>_<timestamp>.jsonl\nFor 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
"},{"location":"guide/troubleshooting/#what-a-log-entry-looks-like","title":"What a log entry looks like","text":"logsfolder inside the package data directory, but you can move it with thedirectories.logsconfig key. To see the exact resolved path on your machine, useunshackle env infoand read the Directories table.Every line is a self-contained JSON object. The first line of each log file 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\"}}\nQuote
unshackle_code_hashin a bug report. It fingerprints the framework source you operate, so it tells us the exact code state even when you did not install from git. A hash that matches no release means that someone edited the source locally.Subsequent lines record the operations: service calls, DRM licence requests, vault lookups, downloader progress, and errors. Common fields include:
Field MeaningtimestampUTC ISO-8601 time of the entry.session_idShort random ID shared by every entry in one run, handy for correlating lines.levelDEBUG,INFO,WARNING, orERROR.operationThe action being logged, e.g.service_call,download_init,drm_get_widevine_license,vault_get_key,session_end.messageHuman-readable description (redacted).serviceService tag the entry belongs to, when applicable.contextArbitrary structured detail for the operation.request/responseHTTP method/URL and response metadata for network operations.duration_msHow long a timed operation took.successWhether the operation succeeded.errorOn failures: the exceptiontype,message, and a fulltracebackarray (all redacted)."},{"location":"guide/troubleshooting/#reading-and-filtering-the-log","title":"Reading and filtering the log","text":"Because each line is independent JSON,
jqis 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\nunshackle logs the binary-tool versions (shaka-packager, mp4decrypt, mkvmerge, FFmpeg, FFprobe) near the start of a
"},{"location":"guide/troubleshooting/#debugging-drm-keys-with-debug_keys","title":"Debugging DRM keys withdlrun. Often that is the fastest way to make sure that a muxing or decryption failure is really a wrong-version problem.debug_keys","text":"By default, unshackle redacts the content keys in the JSON debug log. It writes any field that looks like a content key as
[REDACTED]. This keeps logs shareable. When you specifically debug a key-retrieval or decryption problem and need to see the actual KID/KEY values unshackle fetched, enabledebug_keys:debug_keys: true\nunshackle cfg debug_keys true\nWith
debug_keys: true, the debug log stops masking content key fields. You can then see which content keys came back from a licence server or vault, and compare them against what decryption expected.Keys are sensitive, turn this back off
debug_keyswrites real content keys into the log file in cleartext. Only enable it while you diagnose a content key problem. Never share a log written with it on, and set it back tofalsewhen you are done. Note thatdebug_keysonly affects the JSON debug log file. It does not change what the console prints.Fields that are always redacted regardless of
"},{"location":"guide/troubleshooting/#debugging-http-traffic-with-debug_requests","title":"Debugging HTTP traffic withdebug_keys(anything whose name containspassword,token,secret,auth, orcookie) stay masked, so credentials never leak even in a keys-on log.debug_requests","text":"When you pass
-d, unshackle raises its own modules toDEBUGbut deliberately silences the noisy low-level HTTP libraries (urllib3,requests,rnet,httpx,httpcore,hpack,h2) back down toWARNING. Without this, a single download would bury the useful output under thousands of connection-pool and HTTP/2 frame messages.Sometimes a request fails at the TLS or connection layer, and you need the underlying library's own logs. If you need that low-level traffic, enable
debug_requests:debug_requests: true\nunshackle cfg debug_requests true\nWith
debug_requests: true, unshackle leaves those libraries atDEBUGlevel when you operate it with-d, so their request/response chatter appears in the console.
debug_requestsneeds-dto have any effectThe
"},{"location":"guide/troubleshooting/#what-gets-redacted","title":"What gets redacted","text":"debug_requeststoggle only decides whether unshackle silences the HTTP libraries when the-dflag is active. If you do not operate unshackle with-d(verbose console logging), it changes nothing. Combine them:unshackle -d dl ...withdebug_requests: truein config.unshackle redacts logged strings so that debug output is safe to share. It applies three passes to every logged message, error, and structured value:
- Secrets: unshackle replaces passwords, tokens, API secrets, auth values, and cookies with
[REDACTED].- URLs: unshackle collapses every
http(s)URL toredacted. It keeps only the file extension, so a manifest still shows asredacted.mpdand a segment asredacted.m4s. This hides the CDN, manifest, segment, and licence-server locations, but keeps the type of resource.- Local paths: unshackle replaces install-root, virtualenv, and home-directory prefixes with tokens, so your username and machine layout do not appear in logs.
The
redact_pathsconfig key controls path redaction, and it is on by default:redact_paths: true # default - mask local base directories in logged paths\nSet
"},{"location":"guide/troubleshooting/#checking-your-environment","title":"Checking your environment","text":"redact_paths: falseif you debug a path problem locally and want to see full, unmasked paths in the console and logs. unshackle always applies secret and URL redaction in the JSON debug log, and this toggle does not change that.Many \"download failed\" problems are really missing external tools or a config file that is not where you think it is. The
"},{"location":"guide/troubleshooting/#env-check","title":"unshackle envcommand group answers both questions before you dig into logs.env check","text":"
unshackle env checkverifies every external binary unshackle can use and prints a table of what's installed:unshackle env check\nIt reports each tool's status (
\u2713installed /\u2717missing), whether it is required or optional, and what it is 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 decryptionOptional 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), andgit(service repositories) ordocker(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 will not process, use
"},{"location":"guide/troubleshooting/#env-info","title":"unshackle env checkfirst. A red\u2717next to the relevant tool is the answer far more often than a bug in unshackle.env info","text":"
unshackle env infoshows where unshackle reads configuration from and where every working directory lives:unshackle env info\n
- If unshackle found a config file, it prints the path it was loaded from. If it found none, it shows every location it would accept one (see Config file discovery below). You can then make the file in the right place.
- It then prints a Directories table for every configured path:
downloads,temp,cache,cookies,logs,exports,wvds,prds, theservicessearch paths, and more.This is the authoritative way to answer \"where do my logs, downloads and cookies go?\" The defaults are relative to the installed package. The table also shows any
"},{"location":"guide/troubleshooting/#env-clear","title":"directories:overrides from your config.env clear","text":"Stale cache or leftover temp files can cause confusing behaviour (an old cached title list, a half-written temp file).
env clearwipes 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\nEach 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
"},{"location":"guide/troubleshooting/#common-failure-modes-and-how-to-inspect-them","title":"Common failure modes and how to inspect them","text":"unshackle env clear cache <SERVICE>. See Vaults for the separate, persistent key-vault store, whichenv cleardoes not touch.A required tool is missing
Symptom: decryption, muxing, or analysis fails early, sometimes with a \"binary not found\" style error.
Inspect: use
unshackle env checkand look for a red\u2717on a required row. Rununshackle setupto install it, or install it by hand, and re-run.Decryption produces garbage or fails
Symptom: the download completes but the file will not play, or the decrypt step errors.
Inspect: enable
debug_keysand operate unshackle with-d. Then examine the JSON debug log'sdrm_*andvault_*operations to see which content keys unshackle fetched, and from where. Also make sure that shaka-packager (ormp4decrypt) shows a sane version in thedownload_initbinary-versions entry. See DRM & CDM.A vault served a wrong content key
Symptom: a warning names a content key and the vault it came from, either
was bad, trying other vaults(the decode check failed) oris flagged bad, skipping(an earlier run flagged it). The download stops withThe content key from the vault did not decrypt the track; run again,was bad and no other source has the key, orNo vault or CDM produced a content key that decrypts the trackwhen no source is left to try.Inspect: while it decrypts a track whose content key came from a vault, unshackle keeps the ciphertext as a
.encsibling of the track file in the temp directory, and removes it afterwards; a leftover one is from a killed run and is safe to delete. Read thebad_keystable of your local SQLite vault to see which pairs are flagged and which vault each came from. See Wrong keys from a poisoned vault.A network request fails at the connection or TLS layer
Symptom: timeouts, connection resets, or handshake errors, and unshackle's own logs do not give the full cause.
Inspect: set
debug_requests: trueand operate unshackle with-dto show the underlyingurllib3/rnet/httpxlogs. In the JSON debug log, filter forservice_calloperations to see which request failed. If you use a proxy, also read 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 thedlno-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: use
"},{"location":"guide/troubleshooting/#why-is-my-config-not-being-picked-up","title":"Why is my config not being picked up?","text":"unshackle env infoto see which config file unshackle loaded, andunshackle cfg --listto dump the effective values. It is common to edit a config file in a location unshackle does not read from. See below.unshackle loads the first
unshackle.yamlit finds, in this order:
unshackle.yamlinside the package's namespace directory.unshackle.yamlin the parent of that directory.unshackle.yamlin 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 unshackle silently ignores your edits to a later file.
unshackle env infoprints the path it loaded (or, if none exists, shows all three candidate locations so you can make the file correctly). See Configuration for editing values with thecfgcommand.Developer note: structured logging in code
Service and command code sends entries through the shared
"},{"location":"guide/troubleshooting/#logging-conventions-when-adding-entries","title":"Logging conventions when adding entries","text":"log_event(...)helper (and thetimed_operation(...)context manager for timing a block) fromunshackle.core.utilities. These are no-ops when you disable debug logging, so they are always safe to leave in place. When you add a new feature, calllog_event(\"my_feature_event\", message=\"...\", context={...}). Do not write the \"is the logger enabled?\" guard again. unshackle does the redaction and the JSON-serialisation for you, and redacts any field whose name looks like a content key, a secret, or a token. Content keys only appear when the run hasdebug_keysenabled.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. The
log_eventsignature does not enforce them, so they are easy to miss and worth stating explicitly.Log levels are a flow skeleton, not just severity
Treat
levelas structure, not decoration. Send exactly oneINFOmilestone per pipeline stage so thatjq 'select(.level==\"INFO\")'reads back the whole end-to-end flow of a run. Everything internal goes toDEBUG. Failures go toERROR. A sparseINFOis 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\nNo raw dumps
Never log a full
Tracksobject, an MPD/manifest body, or a response payload. Log only counts, ids, sizes, andsafe_display_url(url). This keeps the log low-noise and prevents leaking title 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 registryOperation names are plain lowercase strings that follow an
<area>_<event>convention:manifest_dash_parse,drm_decrypt,vault_get_key,tool_run. We write them inline at each call site on purpose. There is no central registry or enum. Do not look for one, and do not make one. The absence is a deliberate design choice, not an omission.Message reads alone; data lives in fields
Give every entry a single-sentence
messagethat stands on its own. Put all structured data (context, counts, ids,duration_ms) in fields, not in the prose. This separation lets the log be both human-scannable and machine-filterable. A message like\"Parsed DASH manifest\"withcontext={\"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,
"},{"location":"guide/vaults/","title":"Key Vaults","text":"dovi_tool, and friends), route throughrun_step/ffprobe, which auto-emit atool_runentry capturing the binary's version, return code, and duration consistently. Only fall back to callinglog_tool_runmanually for a directsubprocess.runyou genuinely cannot route throughrun_step. Auto-logging is the preferred path precisely because it captures those fields the same way every time.A key vault is a store of DRM content keys that unshackle remembers between downloads. Once unshackle has recovered a title's keys, it 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. The same pair frequently covers several resolutions or bitrates of the same title. Once unshackle has recovered a key it writes it to your vaults immediately. The moment the next track presents a KID it has already seen, the key comes from the vault instead of 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":"A vault stores every content key as a KID \u2192 KEY pair, grouped by service:
- KID (Key ID): a 16-byte identifier for a specific encrypted track, stored as 32 lowercase hex characters with no dashes.
- KEY: the 16-byte content key that decrypts that track, also 32 hex characters.
- Service: the canonical service tag (for example
EXAMPLE, notExampleService) 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 content key made entirely of zeroes (
"},{"location":"guide/vaults/#how-lookups-and-caching-work","title":"How lookups and caching work","text":"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.When unshackle needs a content key during a download it asks your vaults in the order they appear 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, unshackle skips it and continues the search with the next one.
When unshackle recovers new keys from a license, it pushes them to all configured vaults, so every vault gradually converges on the same set of keys. Two rules apply to pushing:
- unshackle skips keys that already exist in a vault (it never overwrites or deletes existing data).
- A vault marked
no_push: truereceives lookups, but unshackle never writes to it. This is useful for read-only or shared upstream vaults you do not own.unshackle pushes keys from every source, not only a local CDM. A run against a remote server that licenses with its own CDM (
"},{"location":"guide/vaults/#wrong-keys-from-a-poisoned-vault","title":"Wrong keys from a poisoned vault","text":"--remote) writes the keys the server returns, andunshackle importwrites the keys it reads from an export file.A public vault can return the right KID with a wrong content key. The decrypters accept a wrong content key and write noise. unshackle therefore trusts a content key from a vault only after FFmpeg decodes the decrypted track with strict error detection. For a fragmented MP4, unshackle reads from the encrypted file which fragments each KID encrypts, and FFmpeg decodes up to three windows of each KID: from its first, middle and last fragment on through the next fragments of that KID, for up to 4 seconds. A window never runs into a fragment of another KID or a clear fragment. A clear lead or a lead under another KID therefore cannot hide a wrong content key, and only the KID that fails is flagged. A KID that is not in this map, and every KID of any other file, gets one check of the whole file. FFmpeg decodes the first seconds and the last seconds. A failure there flags each of those KIDs. When a mapped KID fails, the KIDs that are not in the map get no verdict from that pass; the retry with the replaced key judges them. Content keys from a licence skip this check. A file this FFmpeg build cannot judge counts as a pass, never as a wrong content key: a codec it has no decoder for, or a header it cannot read. Without FFmpeg there is no check at all.
When the decode fails, unshackle:
- Flags the KID:KEY in the
bad_keystable of every local SQLite vault, with the service and the name of the vault it came from. The pair also goes out of the service table, except in a vault markedno_push, where only the flag is written. A flagged pair cannot go back into a local vault from another vault, a later run, orkv copy. A licence that returns the pair clears the flag and stores it. AnAPIvault that served the pair also gets a report;HTTPandMySQLvaults have no report route. A pair from a local vault goes to no remote vault, even when a remote vault holds the same pair.- Restores the ciphertext from a hard link it made before decryption, then asks for the content key again. Every vault lookup skips a flagged pair, so the request reaches the remaining vaults and then the CDM. A remote answer that arrived after the winning one is reused instead of queried again.
- Removes the flag when the CDM returns the same content key, because then the decode check was wrong, not the content key. A content key from another vault is checked in its turn, and flagged as well if it also fails.
The download stops with an error instead of writing noise when no ciphertext is left to retry with, or when every vault and the CDM are used up. Without a local SQLite vault nothing remembers a flag, so the same vault serves the same pair again and the download stops there.
Per-segment decryption gives up the retry. With
decrypt_segmentson, and always with MonaLisa, the plaintext replaces each segment as it arrives, so no ciphertext is left. A failed decode then flags the pair and stops the download. The next run skips the flagged pair.unshackle copies a content key from one vault to the others only after this check passes, so a wrong content key does not spread. Without FFmpeg, a content key from a vault is not copied at all. To see or clear the flags, open the SQLite file and read or delete rows in
bad_keys.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":"Declare vaults under the
unshackle.yamlkey_vaultslist in your unshackle config file. Each entry is a mapping with two required fields,type(which backend to use) andname(a label you choose), plus whatever settings that backend needs.key_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\"\nThe
typevalue is matched to a vault backend by name. The four backends that ship with unshackle areSQLite,MySQL,HTTP, andAPI, 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.
"},{"location":"guide/vaults/#vault_timeout","title":"MySQL,HTTP, andAPIvaults are loaded leniently: if one fails to load it is logged and skipped, and the download continues with the remaining vaults.vault_timeout","text":"Network-backed vaults (
unshackle.yamlHTTPandAPI) accept an optional per-vaulttimeoutin seconds. If you omit it, unshackle injects the global default:vault_timeout: 10.0 # default, in seconds\nA
timeoutset inline on an individual vault always wins over this global value. TheSQLiteandMySQLbackends do not take atimeoutfield.MySQLusesvault_timeoutas itsconnect_timeoutinstead, and falls back to the pymysql default of 10 seconds whenvault_timeoutis0. To use a different connect timeout, setconnect_timeouton the vault.unshackle pushes content keys to all vaults at the same time, so one slow or unreachable vault costs only its own timeout and does not delay the others.
"},{"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.yamlField Required Descriptionkey_vaults:\n - type: SQLite\n name: local\n path: ~/.local/share/unshackle/vaults/local.db\ntypeyes Must beSQLite.nameyes A label for this vault.pathyes Path to the database file.~is expanded. The file (and its tables) are created automatically on first use.no_pushno Iftrue, keys are read but never written. Defaults tofalse.Each service gets its own table (named after the service tag), created on demand with
kidandkey_columns. Connections use WAL journaling with a 30-second busy timeout, so multiple threads can safely use the same database file.One more table,
bad_keys, is created on demand and holds every KID:KEY that failed the decode check described in Wrong keys from a poisoned vault, with the service, the vault it came from, and the time it was flagged. It is not a service table, and every command that walks the service tables skips it.Moving the .db file silently forks your vault
Because the file is created on demand, if you relocate an existing
.dbwithout updatingpathto match, unshackle will create a fresh empty database at the old location. That leaves 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
"},{"location":"guide/vaults/#mysql-backend","title":"MySQL backend","text":"MySQLvault. 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.A remotely-accessed MySQL/MariaDB database, ideal for sharing keys across several machines. It connects via
pymysql. Prefer MariaDB over MySQL where you have the choice.The vault connects directly to the database server
A
unshackle.yamlMySQLvault (likeSQLite) 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. A database on shared hosting is therefore 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.Field Required Descriptionkey_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\ntypeyes Must beMySQL.nameyes A label for this vault.hostyes Database server hostname or IP.databaseyes Database (schema) name.usernameyes Login user.no_pushno Iftrue, keys are read but never written.Any additional fields you provide (such as
password,port, orssl) are passed straight through topymysql.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
rootaccount, 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 theunshackledatabase, and grant normal users onlySELECTandINSERT. Reserve theCREATEgrant for a single trusted user who bootstraps the service tables (seekv 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
"},{"location":"guide/vaults/#http-backend","title":"HTTP backend","text":"SHOW GRANTS. It requires at leastSELECTto load at all (a missingSELECTgrant makes the vault fail to load). Writing keys additionally needsINSERT, and creating a new service table needsCREATE. A read-only account still works fine as a lookup source. It cannot push new keys. BecauseMySQLis loaded leniently during downloads, a permission problem will not abort the run.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_modeTransport Authentication Notesquery(default)GETwith query parametersusername+passwordFull read and write.jsonPOSTwith a JSON payloadpasswordused as a token Read and write per content key; cannot bulk-enumerate keys.decrypt_labsPOSTin DecryptLabs formatapi_keysent as a header Read-only.no_pushis 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\"\nQuery mode requires
unshackle.yamlusername. Both the username and password are sent as query parameters on each request.key_vaults:\n - type: HTTP\n name: jsonvault\n host: https://vault.example.com/rpc\n api_mode: json\n password: \"your-api-token\"\nRequests are
unshackle.yamlPOSTed as{\"method\": ..., \"params\": ..., \"token\": ...}using methods likeGetKey,InsertKey, andGetServices. Bulk writes (kv copy,kv add) first tryInsertKeyswithparams: {\"keys\": {kid: key, ...}, \"service\": ..., \"title\": ...}(up to 500 content keys per call) and expect{\"inserted\": <count>}back. A server that answers HTTP 404 orUnknown JSON-RPC methodtoInsertKeysgets oneInsertKeycall per content key for the rest of the run instead. This mode cannot list all keys in bulk, so akv copyfrom a json vault relies on the remote server's own duplicate handling.key_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\"\nA read-only lookup source. The API key is sent in a
Field Required Descriptiondecrypt-labs-api-keyheader and the vault refuses all writes.typeyes Must beHTTP.nameyes A label for this vault.hostyes Base URL of the vault API.api_modenoquery(default),json, ordecrypt_labs.usernameforqueryRequired in query mode; ignored otherwise.passwordone of these Password (query mode) or token (json mode).api_keyone of these Alternative topassword; used fordecrypt_labs. If you give both,api_keywins.no_pushno Read-only whentrue(alwaystruefordecrypt_labs).timeoutno Request timeout in seconds; defaults tovault_timeout.You must supply either
"},{"location":"guide/vaults/#api-backend","title":"API backend","text":"passwordorapi_key, or the vault will fail to load.A simple RESTful HTTP vault with a fixed, well-defined protocol. Authentication is a bearer token.
unshackle.yamlField Required Descriptionkey_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\"\ntypeyes Must beAPI.nameyes A label for this vault.uriyes Base URI of the vault API (a trailing slash is trimmed).tokenyes Bearer token, sent asAuthorization: Bearer <token>.no_pushno Iftrue, keys are read but never written.timeoutno Request timeout in seconds; defaults tovault_timeout.headersno Extra HTTPheaderssent with every request, for example the Cloudflare Access service-token headersCF-Access-Client-IdandCF-Access-Client-Secret.The backend talks to endpoints under
uri(for exampleGET {uri}/{service}/{kid}to look up a single key). It interprets a numericcodefield 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
APIbackend 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 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 theHTTPbackend 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
"},{"location":"guide/vaults/#managing-vaults-with-unshackle-kv","title":"Managing vaults withAPIvault whosenamecontainsdecrypt_labswill have itstokenfilled in automatically from a globaldecrypt_labs_api_keyin your config if you leavetokenunset. This lets you keep the API key in one place.unshackle kv","text":"The
kvcommand group manages the contents of your configured vaults. Every subcommand refers to vaults by thenameyou gave them inkey_vaults; an unknown name is an error."},{"location":"guide/vaults/#kv-add-import-keys-from-a-file","title":"$ unshackle kv --help\nkv add: import keys from a file","text":"Add
KID:KEYpairs to one or more vaults for a given service. The file must contain one pair per line asHEX:HEX(32 hex characters each); lines that do not match are ignored.keys.txt$ unshackle kv add keys.txt EXAMPLE local shared\n9a04f07998404286ab92e65be0885f95:b1c2d3e4f5a6978012345678deadbeef\n0e7cc1a4e2f9411db0f24a3fbb8f8b21:00112233445566778899aabbccddeeff\nThe service argument (
"},{"location":"guide/vaults/#kv-search-find-a-content-key-by-kid","title":"EXAMPLEabove) is normalized to its canonical tag automatically. Keys that already exist in a vault are reported as skipped.kv search: find a content key by KID","text":"Search your vaults for a KID and print any matching content key.
$ unshackle kv search 9a04f07998404286ab92e65be0885f95\nThe KID must be 32 hex characters (dashes are stripped for you). By default the search scans every configured vault and every service table; narrow it with options:
Option Description-s,--serviceLimit the search to a single service tag.-v,--vaultLimit the search to one configured vault by name.Note
Remote vaults that cannot enumerate their service tables (for example an
"},{"location":"guide/vaults/#kv-copy-copy-keys-between-vaults","title":"HTTPvault injsonordecrypt_labsmode) can only be searched when you also pass--service. Without it, they are skipped.kv copy: copy keys between vaults","text":"Copy every content key from one or more source vaults into a single destination vault.
kv copynever alters existing rows; it adds only genuinely new KIDs and skips null keys.$ unshackle kv copy shared local\nThe first argument is the destination; the rest are sources. You can supply several sources at once:
Option Description$ unshackle kv copy local shared community\n-s,--serviceOnly copy the given service.-l,--local-onlyOnly copy services you have installed locally, skipping tables for unknown services.
--serviceand--local-onlyare mutually exclusive.A pair the destination vault has flagged in
"},{"location":"guide/vaults/#kv-sync-mirror-vaults-both-ways","title":"bad_keysis left behind, so a copy does not undo a flag.bad_keysis never treated as a service, so its rows are never copied into a service table.kv sync: mirror vaults both ways","text":"Make each of two or more vaults hold every content key the others have. This is effectively a two-way
copybetween each pair, so afterwards all listed vaults hold the same set of keys.$ unshackle kv sync local shared\nIt accepts the same
"},{"location":"guide/vaults/#kv-prepare-pre-create-service-tables","title":"--serviceand--local-onlyoptions ascopy, and requires at least two vaults. It runscopybetween each pair, so it skips flagged pairs and thebad_keystable in the same way.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-backedHTTPandAPIbackends) are skipped.$ unshackle kv prepare shared\nThis is mainly useful with a
"},{"location":"guide/vaults/#see-also","title":"See also","text":"MySQLvault where you want the tables created once by an account that has theCREATEgrant."},{"location":"reference/api/config/","title":"Config API","text":"
- Downloading: how keys are recovered and used during a download.
Auto-generated reference for the
"},{"location":"reference/api/config/#unshackle.core.config.Config","title":"Config","text":"Configobject that models theunshackleconfiguration file. For a human-friendly walkthrough of every config key, see the Configuration Reference."},{"location":"reference/api/config/#unshackle.core.config.Config.validate_output_templates","title":"validate_output_templates","text":"Config(**kwargs)\nvalidate_output_templates()\nValidate output template configurations and warn about potential issues.
"},{"location":"reference/api/config/#unshackle.core.config.Config.get_folder_template","title":"get_folder_template","text":"get_folder_template(kind)\nFind 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')\nGet 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 Defaulttemplate_typestrOne of \"movies\", \"series\", or \"songs\".
'movies'"},{"location":"reference/api/config/#unshackle.core.config.restore_bool_languages","title":"restore_bool_languages","text":"restore_bool_languages(data)\nPut back the language tags
"},{"location":"reference/api/config/#unshackle.core.config.resolve_decryption","title":"resolve_decryption","text":"noandyes, which YAML reads as booleans.resolve_decryption(decryption_map, default, service)\nPick the decryption backend 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)\nFind a service's top-level CDM entry (case-insensitive), with default fallback.
"},{"location":"reference/api/config/#unshackle.core.config.cdm_entry_names_device","title":"cdm_entry_names_device","text":"cdm_entry_names_device(entry, profile=None)\nWhether a per-service
cdmentry names a device when no quality is known.An entry that selects only by quality, or by a different profile, names none.
"},{"location":"reference/api/config/#unshackle.core.config.get_config_path","title":"get_config_path","text":"get_config_path()\nGet 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)\nAES-128 ClearKey (HLS) DRM system.
Give the IV where possible. If you do not give an IV, unshackle sets it to \u0000 of the same bit-size as the content key.
"},{"location":"reference/api/drm/#unshackle.core.drm.ClearKey.warn_clear","title":"warn_clear","text":"warn_clear(reason)\nWarn once per track that unshackle passed a segment through undecrypted.
"},{"location":"reference/api/drm/#unshackle.core.drm.ClearKey.decrypt","title":"decrypt","text":"decrypt(path)\nDecrypt a Track with AES-128 ClearKey (HLS) DRM.
"},{"location":"reference/api/drm/#unshackle.core.drm.ClearKey.from_m3u_key","title":"from_m3u_keyclassmethod","text":"from_m3u_key(m3u_key, session=None)\nLoad a ClearKey from an M3U(8) Playlist's EXT-X-KEY.
Parameters:
Name Type Description Defaultm3u_keyKeyA
requiredKeyobject parsed from a m3u(8) playlist using them3u8library.sessionOptional[Session]Optional HTTP 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)\nW3C EME ClearKey (org.w3.clearkey) DRM System over MPEG-CENC tracks.
Distinct from the HLS AES-128
"},{"location":"reference/api/drm/#unshackle.core.drm.ClearKeyCENC.kid","title":"kidClearKeyclass: a license server gives the keys here as a JWK Set keyed by KID, and the media is standard CENC (decrypted with shaka-packager/mp4decrypt KID:KEY pairs, same as Widevine).property","text":"kid\nGet 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:
ExceptionKID (Encryption Key ID) was not found.
"},{"location":"reference/api/drm/#unshackle.core.drm.ClearKeyCENC.Exceptions.CEKNotFound","title":"CEKNotFound","text":"Bases:
ExceptionCEK (Content Encryption Key) for KID was not found in License.
"},{"location":"reference/api/drm/#unshackle.core.drm.ClearKeyCENC.Exceptions.EmptyLicense","title":"EmptyLicense","text":"Bases:
ExceptionLicense returned no Content Encryption Keys.
"},{"location":"reference/api/drm/#unshackle.core.drm.ClearKeyCENC.to_dict","title":"to_dict","text":"to_dict()\nSerialise this DRM instance for export/import (KIDs + license URL).
unshackle stores the content keys once at the export's track level, and does not duplicate them here.
"},{"location":"reference/api/drm/#unshackle.core.drm.ClearKeyCENC.get_license_challenge","title":"get_license_challenge","text":"get_license_challenge()\nAssemble 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)\nGet Content Keys for this DRM Instance from a ClearKey license server.
The licence param is a function. unshackle gives it the W3C JSON license request as
"},{"location":"reference/api/drm/#unshackle.core.drm.ClearKeyCENC.decrypt","title":"decrypt","text":"challenge. It may return the JWK Set license as a dict, JSON str, or bytes. If it returns None and the manifest gave a Laurl, unshackle POSTs the challenge there directly instead.decrypt(path)\nDecrypt 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 unshackle has not yet downloaded the track. SubprocessError if the decryption process returned a non-zero exit code.
"},{"location":"reference/api/drm/#unshackle.core.drm.ClearKeyCENC.mp4decrypt_key_args","title":"mp4decrypt_key_args","text":"mp4decrypt_key_args()\nBuild the mp4decrypt --key arguments for every content key.
"},{"location":"reference/api/drm/#unshackle.core.drm.ClearKeyCENC.decrypt_with_mp4decrypt","title":"decrypt_with_mp4decrypt","text":"decrypt_with_mp4decrypt(path)\nDecrypt 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)\nDecrypt with shaka-packager
"},{"location":"reference/api/drm/#unshackle.core.drm.MonaLisa","title":"MonaLisa","text":"MonaLisa(ticket, device_path, tool_path=None, **kwargs)\nMonaLisa DRM System.
Unlike Widevine/PlayReady, MonaLisa does not use a challenge/response flow with a license server. Instead, the service API gives the PSSH value (ticket) directly, and a WASM module extracts the content keys locally.
Initialise MonaLisa DRM.
Parameters:
Name Type Description DefaultticketUnion[str, bytes]PSSH value from service API (base64 string or raw bytes).
requireddevice_pathPathPath to the CDM device file (.mld).
requiredtool_pathOptional[Path]Optional path to an external decryption binary.
None**kwargsAnyAdditional metadata stored in self.data.
{}Raises:
Type DescriptionTicketNotFoundIf ticket/PSSH is empty.
KeyExtractionFailedIf content key extraction fails.
"},{"location":"reference/api/drm/#unshackle.core.drm.MonaLisa.kid","title":"kidproperty","text":"kid\nGet the Key ID.
"},{"location":"reference/api/drm/#unshackle.core.drm.MonaLisa.key","title":"keyproperty","text":"key\nGet the content key as hex string.
"},{"location":"reference/api/drm/#unshackle.core.drm.MonaLisa.pssh","title":"psshproperty","text":"pssh\nGet the raw PSSH/ticket value as a string.
Returns:
Type DescriptionstrThe raw PSSH value as a base64 string.
"},{"location":"reference/api/drm/#unshackle.core.drm.MonaLisa.content_id","title":"content_idproperty","text":"content_id\nExtract 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 DescriptionOptional[str]The Content ID string if extractable, None otherwise.
"},{"location":"reference/api/drm/#unshackle.core.drm.MonaLisa.content_keys","title":"content_keysproperty","text":"content_keys\nGet content keys in the same format as Widevine/PlayReady.
Returns:
Type Descriptiondict[UUID, str]Dictionary mapping KID to the content key hex string.
"},{"location":"reference/api/drm/#unshackle.core.drm.MonaLisa.key_pair","title":"key_pairproperty","text":"key_pair\nThe formatted
"},{"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":"KID:KEYfor CLI decryptors (32hex:32hex).Bases:
ExceptionRaised when the caller gives no PSSH/ticket data.
"},{"location":"reference/api/drm/#unshackle.core.drm.MonaLisa.Exceptions.KeyExtractionFailed","title":"KeyExtractionFailed","text":"Bases:
ExceptionRaised when content key extraction from the ticket fails.
"},{"location":"reference/api/drm/#unshackle.core.drm.MonaLisa.extract_keys","title":"extract_keys","text":"extract_keys()\nExtract keys from the ticket using the MonaLisa CDM.
"},{"location":"reference/api/drm/#unshackle.core.drm.MonaLisa.from_ticket","title":"from_ticketclassmethod","text":"from_ticket(ticket, device_path, tool_path=None)\nMake a MonaLisa DRM instance from a PSSH/ticket.
Parameters:
Name Type Description DefaultticketUnion[str, bytes]PSSH value from service API.
requireddevice_pathPathPath to the CDM device file (.mld).
requiredtool_pathOptional[Path]Optional path to an external decryption binary.
NoneReturns:
Type DescriptionMonaLisaMonaLisa DRM instance with extracted keys.
"},{"location":"reference/api/drm/#unshackle.core.drm.MonaLisa.decrypt_segment","title":"decrypt_segment","text":"decrypt_segment(segment_path, output_path=None)\nDecrypt an individual segment file using the configured CLI tool via subprocess.
Parameters:
Name Type Description Defaultsegment_pathPathPath to the segment file to decrypt.
requiredoutput_pathOptional[Path]Optional path for the decrypted output segment.
None"},{"location":"reference/api/drm/#unshackle.core.drm.MonaLisa.decrypt","title":"decrypt","text":"decrypt(path, output_path=None, *, is_segment=False)\nDecrypt a media file (segment or track) using the configured CLI tool via subprocess.
Parameters:
Name Type Description DefaultpathPathPath to the target file to decrypt.
requiredoutput_pathOptional[Path]Optional path for the decrypted output file.
Noneis_segmentboolSet True if this is an individual segment rather than a full track.
False"},{"location":"reference/api/drm/#unshackle.core.drm.PlayReady","title":"PlayReady","text":"PlayReady(pssh, kid=None, pssh_b64=None, **kwargs)\nPlayReady DRM System.
"},{"location":"reference/api/drm/#unshackle.core.drm.PlayReady.extract_kids_from_pssh_b64","title":"extract_kids_from_pssh_b64","text":"extract_kids_from_pssh_b64(pssh_b64)\nExtract all KIDs from base64-encoded PSSH data.
"},{"location":"reference/api/drm/#unshackle.core.drm.PlayReady.to_dict","title":"to_dict","text":"to_dict()\nSerialise this DRM instance for export/import (PSSH + KIDs).
unshackle stores the content keys once at the export's track level, and does not duplicate them here.
"},{"location":"reference/api/drm/#unshackle.core.drm.PlayReady.header_kids","title":"header_kidsstaticmethod","text":"header_kids(header)\nReturn the KIDs one WRM header names, in header order.
"},{"location":"reference/api/drm/#unshackle.core.drm.PlayReady.absorb","title":"absorb","text":"absorb(*others)\nFold sibling PlayReady objects into this one so their headers and KIDs get licensed too.
A manifest can attach one PlayReady object per WRM header to the same track (ISM shares its Protection list across tracks; HLS emits one per EXT-X-KEY). The caller keeps this object's identity, which the decrypt path and drm_lock depend on.
"},{"location":"reference/api/drm/#unshackle.core.drm.PlayReady.distinct_headers","title":"distinct_headers","text":"distinct_headers()\nReturn the WRM headers to license, deduplicated by the KID set each one names.
Two headers naming the same KIDs (a v4.0 header beside its v4.3 twin, or one PRH per rendition for one content key) cost one challenge. A header with no readable KID is kept: it may still be the only route to a content key nothing else names.
"},{"location":"reference/api/drm/#unshackle.core.drm.PlayReady.pro_b64_for","title":"pro_b64_forstaticmethod","text":"pro_b64_for(header)\nBuild a base64 one-record PlayReady Object holding only this header.
Remote CDMs send the value given to set_pssh_b64 rather than the header passed to get_license_challenge, so a per-header challenge needs a per-header PRO.
"},{"location":"reference/api/drm/#unshackle.core.drm.PlayReady.extract_keys_from_cdm","title":"extract_keys_from_cdm","text":"extract_keys_from_cdm(cdm, session_id)\nExtract keys from CDM session with cross-library compatibility.
Parameters:
Name Type Description DefaultcdmCdmCDM instance
requiredsession_idbytesSession identifier
requiredReturns:
Type DescriptiondictDictionary mapping KID UUIDs to hex keys
"},{"location":"reference/api/drm/#unshackle.core.drm.PlayReady.get_content_keys","title":"get_content_keys","text":"get_content_keys(cdm, certificate, licence)\nLicense every distinct WRM header whose KIDs still lack a content key.
A v4.2+ header may list several KIDs and one response may stack several licences, so one challenge often covers everything. Content that ships several headers (ISM with one ProtectionHeader per track type, HLS with one PRH per playlist) gets one challenge per header that is not yet covered. Headers are sent as-is: Microsoft lets the server ignore the header, so a merged header could ask for keys it never grants.
A header that a round refused, or that yielded nothing, is not sent again by this object: every track of a title runs this method, and a repeat would only repeat the refusal.
"},{"location":"reference/api/drm/#unshackle.core.drm.PlayReady.detect_revocation","title":"detect_revocationstaticmethod","text":"detect_revocation(text)\nReturn the decoded error string if a revocation HRESULT is present, else None.
Reads the code from the raw SOAP body (0x8004C065) or from an exception message that carries it, so this method covers any service.
"},{"location":"reference/api/drm/#unshackle.core.drm.PlayReady.decrypt","title":"decrypt","text":"decrypt(path)\nDecrypt 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 unshackle has not yet downloaded the track. SubprocessError if the decryption process returned a non-zero exit code.
"},{"location":"reference/api/drm/#unshackle.core.drm.PlayReady.own_kids","title":"own_kids","text":"own_kids()\nReturn the KIDs that identify this track's own content key.
The KID given to the constructor (from the manifest or the init segment) names the track exactly. A PSSH can name the KIDs of other renditions too.
"},{"location":"reference/api/drm/#unshackle.core.drm.PlayReady.mp4decrypt_key_args","title":"mp4decrypt_key_args","text":"mp4decrypt_key_args()\nBuild the mp4decrypt --key arguments for every content key and the zero-KID fallback.
"},{"location":"reference/api/drm/#unshackle.core.drm.PlayReady.decrypt_with_mp4decrypt","title":"decrypt_with_mp4decrypt","text":"decrypt_with_mp4decrypt(path)\nDecrypt using mp4decrypt
"},{"location":"reference/api/drm/#unshackle.core.drm.PlayReady.decrypt_with_shaka_packager","title":"decrypt_with_shaka_packager","text":"decrypt_with_shaka_packager(path)\nDecrypt with shaka-packager (original method)
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine","title":"Widevine","text":"Widevine(pssh, kid=None, **kwargs)\nWidevine DRM System.
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine.pssh","title":"psshproperty","text":"pssh\nGet Protection System Specific Header Box.
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine.kid","title":"kidproperty","text":"kid\nGet first Key ID, if any.
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine.kids","title":"kidsproperty","text":"kids\nGet 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:
ExceptionPSSH (Protection System Specific Header) was not found.
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine.Exceptions.KIDNotFound","title":"KIDNotFound","text":"Bases:
ExceptionKID (Encryption Key ID) was not found.
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine.Exceptions.CEKNotFound","title":"CEKNotFound","text":"Bases:
ExceptionCEK (Content Encryption Key) for KID was not found in License.
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine.Exceptions.EmptyLicense","title":"EmptyLicense","text":"Bases:
ExceptionLicense returned no Content Encryption Keys.
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine.from_track","title":"from_trackclassmethod","text":"from_track(track, session=None)\nGet 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.
Make a Widevine DRM System object from a track's information. Use this method only when a PSSH cannot be given directly. It is rare to need to use this.
You can give your own
requests.Sessionto use custom headers and more.Raises:
Type DescriptionPSSHNotFoundIf the PSSH was not found within the data.
KIDNotFoundIf the KID was not found within the data or PSSH.
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine.from_init_data","title":"from_init_dataclassmethod","text":"from_init_data(init_data)\nGet PSSH and KID from within Initialization Segment Data.
Use this method only when a PSSH cannot be given directly. It is rare to need to use this.
Raises:
Type DescriptionPSSHNotFoundIf the PSSH was not found within the data.
KIDNotFoundIf 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()\nSerialise this DRM instance for export/import (PSSH + KIDs).
unshackle stores the content keys once at the export's track level, and does not duplicate them here.
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine.get_content_keys","title":"get_content_keys","text":"get_content_keys(cdm, certificate, licence)\nMake a CDM Session and get Content Keys for this DRM Instance. The certificate and license params are functions. unshackle gives them the challenge and the session ID.
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine.decrypt","title":"decrypt","text":"decrypt(path)\nDecrypt 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 unshackle has not yet downloaded the track. SubprocessError if the decryption process returned a non-zero exit code.
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine.own_kids","title":"own_kids","text":"own_kids()\nReturn the KIDs that identify this track's own content key.
The KID given to the constructor (from the manifest or the init segment) names the track exactly. A PSSH can name the KIDs of other renditions too.
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine.mp4decrypt_key_args","title":"mp4decrypt_key_args","text":"mp4decrypt_key_args()\nBuild the mp4decrypt --key arguments for every content key and the zero-KID fallback.
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine.decrypt_with_mp4decrypt","title":"decrypt_with_mp4decrypt","text":"decrypt_with_mp4decrypt(path)\nDecrypt using mp4decrypt
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine.decrypt_with_shaka_packager","title":"decrypt_with_shaka_packager","text":"decrypt_with_shaka_packager(path)\nDecrypt with shaka-packager (original method)
"},{"location":"reference/api/drm/#unshackle.core.drm.drm_from_dict","title":"drm_from_dict","text":"drm_from_dict(data)\nReconstruct 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/drm/#unshackle.core.drm.real_kids","title":"real_kids","text":"real_kids(drm)\nReturn the KIDs a DRM object names, without the all-zero and test-pattern placeholders.
"},{"location":"reference/api/drm/#unshackle.core.drm.own_kids","title":"own_kids","text":"own_kids(drm)\nReturn the real KIDs that identify the track's own content key, as the DRM object knows them.
"},{"location":"reference/api/service/","title":"Service API","text":"Auto-generated reference for the
"},{"location":"reference/api/service/#unshackle.core.service.TimeoutSession","title":"TimeoutSession","text":"Servicebase class that every service plugin subclasses. See Creating a Service for a guided walkthrough and Service Architecture for the wider object model.Bases:
Sessionrequests.Session applying DEFAULT_TIMEOUT when the caller passes none.
requests has no native default timeout. Without one, a stalled connect or read hangs forever. RnetSession bounds every request through its client's connect_timeout/read_timeout, so this mirrors that on the requests path. A per-request non-None
"},{"location":"reference/api/service/#unshackle.core.service.TimeoutHTTPAdapter","title":"TimeoutHTTPAdapter","text":"timeout=wins. :class:TimeoutHTTPAdapteron the mounted adapters still replaces an explicittimeout=Nonewith the default, so there is no unbounded read, as with RnetSession where the client-level timeouts always apply. Pass a large timeout instead.TimeoutHTTPAdapter(\n *args, timeout=DEFAULT_TIMEOUT, **kwargs\n)\nBases:
HTTPAdapterHTTPAdapter applying DEFAULT_TIMEOUT when the caller passes none.
Backstops :class:
Source code inTimeoutSessionfor thesession.send(prepared)path, which bypassesSession.request. RnetSession bounds those too through its client, so this keeps parity. A per-request non-Nonetimeout=wins.None(unset, or explicitly passed) gets the default, because the adapter cannot distinguish the two, and rnet has no unbounded mode either.unshackle/core/service.py"},{"location":"reference/api/service/#unshackle.core.service.TrackRequest","title":"TrackRequestdef __init__(self, *args: Any, timeout: Any = DEFAULT_TIMEOUT, **kwargs: Any) -> None:\n self.default_timeout = timeout\n super().__init__(*args, **kwargs)\ndataclass","text":"TrackRequest(\n codecs=list(),\n ranges=(lambda: [SDR])(),\n best_available=False,\n)\nHolds 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 Descriptioncodecslist[Codec]Requested codecs from CLI. Empty list means no filter (accept any).
rangeslist[Range]Requested ranges from CLI. Defaults to [SDR].
"},{"location":"reference/api/service/#unshackle.core.service.Service","title":"Service","text":"Service(ctx)\nThe Service Base Class.
A Service must define the abstract methods. The rest are optional overrides that fall back to the base implementation when a Service does not define them. The main flow operates the HTTP session and authentication methods first, then titles, then tracks and chapters. The license callbacks operate later still, during track download.
Source code inunshackle/core/service.py"},{"location":"reference/api/service/#unshackle.core.service.Service.is_region_allowed","title":"is_region_alloweddef __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 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 whose = \"the server's\" if ctx.parent and ctx.parent.params.get(\"served\") else \"your\"\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 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 if proxy_provider_name:\n full_proxy_key = f\"{proxy_provider_name}:{proxy_query}\"\n else:\n full_proxy_key = proxy_query\n\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 if proxy_provider_name:\n # Specific provider requested\n try:\n proxy_provider = find_provider(ctx.obj.proxy_providers, proxy_provider_name)\n except ValueError:\n proxy_provider = None\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__}: \"\n f\"{sanitize_proxy_for_log(proxy, mask_host=isinstance(proxy_provider, Basic))}\"\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 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__}: \"\n f\"{sanitize_proxy_for_log(proxy, mask_host=isinstance(proxy_provider, Basic))}\"\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 or self.GEOBLOCK:\n try:\n current_region = (get_ip_info(self.session) or {}).get(\"country\", \"\").lower()\n if not current_region:\n self.log.warning(f\"Could not find {whose} region, so the region check does not run\")\n elif self.is_region_allowed(current_region):\n self.log.info(\"Service is not Geoblocked in your region\")\n elif not (fence_targets := [x for x in self.GEOFENCE if self.is_region_allowed(x)]):\n raise click.ClickException(\n f\"Service is not available in {whose} region ({current_region.upper()}). \"\n \"Pass --proxy with a proxy outside the blocked regions.\"\n )\n else:\n requested_proxy = fence_targets[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 if not proxy:\n self.log.warning(\n f\"No proxy available for {requested_proxy}. \"\n f\"Pass --proxy with a proxy in {requested_proxy}, or the request can fail.\"\n )\n except click.ClickException:\n raise\n except Exception as e:\n self.log.warning(f\"Failed to check geofence: {e}\")\n else:\n self.log.info(\"Service has no region restrictions\")\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 # Verify the proxy IP every time, because a proxy can change its exit node. A dead\n # proxy fails here, not after every service request has used up its retries.\n exit_region = verify_proxy_exit(self.session).get(\"country\")\n self.current_region = exit_region\n # Only GEOBLOCK applies here: an explicit --proxy outside GEOFENCE stays the user's call.\n if exit_region and any(x.lower() == exit_region.lower() for x in self.GEOBLOCK):\n raise click.ClickException(\n f\"Service is not available in {whose} proxy's region ({exit_region.upper()}). \"\n \"Pass --proxy with a proxy outside the blocked regions.\"\n )\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\nclassmethod","text":"is_region_allowed(region)\nTrue when an exit IP in
region(two-letter region code, any case) can use the service.An unknown region (None or empty) is allowed, because there is nothing to compare.
Source code inunshackle/core/service.py"},{"location":"reference/api/service/#unshackle.core.service.Service.get_tracks_for_variants","title":"get_tracks_for_variants","text":"@classmethod\ndef is_region_allowed(cls, region: Optional[str]) -> bool:\n \"\"\"True when an exit IP in `region` (two-letter region code, any case) can use the service.\n\n An unknown region (None or empty) is allowed, because there is nothing to compare.\n \"\"\"\n if not region:\n return True\n region = region.lower()\n if any(x.lower() == region for x in cls.GEOBLOCK):\n return False\n return not cls.GEOFENCE or any(x.lower() == region for x in cls.GEOFENCE)\nget_tracks_for_variants(title, fetch_fn)\nCall fetch_fn for each codec/range combo in track_request, merge results.
Services that need separate API calls per codec/range combo can use this helper from their get_tracks() implementation.
The fetch_fn signature should be: (title, codec, range_) -> Tracks
For HYBRID range, this helper calls fetch_fn with HDR10 and DV separately, then merges the DV video tracks into the HDR10 result.
Parameters:
Name Type Description DefaulttitleTitle_TThe title to process.
requiredfetch_fnCallable[..., Tracks]A callable that fetches tracks for a specific codec/range.
required Source code inunshackle/core/service.py"},{"location":"reference/api/service/#unshackle.core.service.Service.get_session","title":"get_sessiondef get_tracks_for_variants(\n self,\n title: Title_T,\n fetch_fn: Callable[..., Tracks],\n) -> Tracks:\n \"\"\"Call fetch_fn for each codec/range combo in track_request, merge results.\n\n Services that need separate API calls per codec/range combo can use this\n helper from their get_tracks() implementation.\n\n The fetch_fn signature should be: (title, codec, range_) -> Tracks\n\n For HYBRID range, this helper calls fetch_fn with HDR10 and DV separately,\n then merges the DV video tracks into the HDR10 result.\n\n Args:\n title: The title to process.\n fetch_fn: A callable that fetches tracks for a specific codec/range.\n \"\"\"\n all_tracks = Tracks()\n first = True\n\n codecs = self.track_request.codecs or [None]\n ranges = self.track_request.ranges or [Video.Range.SDR]\n\n for range_val in ranges:\n if range_val == Video.Range.HYBRID:\n # HYBRID: fetch HDR10 first (full tracks), then DV (video only)\n for codec_val in codecs:\n try:\n hdr_tracks = fetch_fn(title, codec=codec_val, range_=Video.Range.HDR10)\n except (ValueError, SystemExit) as e:\n if self.track_request.best_available:\n self.log.warning(f\" - HDR10 not available for HYBRID, skipping ({e})\")\n continue\n raise\n if first:\n all_tracks.add(hdr_tracks, warn_only=True)\n if hdr_tracks.manifest_url and not all_tracks.manifest_url:\n all_tracks.manifest_url = hdr_tracks.manifest_url\n first = False\n else:\n for video in hdr_tracks.videos:\n all_tracks.add(video, warn_only=True)\n\n try:\n dv_tracks = fetch_fn(title, codec=codec_val, range_=Video.Range.DV)\n for video in dv_tracks.videos:\n all_tracks.add(video, warn_only=True)\n except (ValueError, SystemExit):\n self.log.info(\" - No DolbyVision manifest available for HYBRID\")\n else:\n for codec_val in codecs:\n try:\n tracks = fetch_fn(title, codec=codec_val, range_=range_val)\n except (ValueError, SystemExit) as e:\n if self.track_request.best_available:\n codec_name = codec_val.name if codec_val else \"default\"\n self.log.warning(f\" - {range_val.name}/{codec_name} not available, skipping ({e})\")\n continue\n raise\n if first:\n all_tracks.add(tracks, warn_only=True)\n if tracks.manifest_url and not all_tracks.manifest_url:\n all_tracks.manifest_url = tracks.manifest_url\n first = False\n else:\n for video in tracks.videos:\n all_tracks.add(video, warn_only=True)\n\n return all_tracks\nstaticmethod","text":"get_session()\nCreates a Python-requests HTTP session, adds common headers from config, cookies, retry handler, and a proxy if available. :returns: Prepared Python-requests HTTP session
Source code inunshackle/core/service.py"},{"location":"reference/api/service/#unshackle.core.service.Service.get_binaries","title":"get_binaries@staticmethod\ndef get_session() -> requests.Session:\n \"\"\"\n Creates a Python-requests HTTP session, adds common headers\n from config, cookies, retry handler, and a proxy if available.\n :returns: Prepared Python-requests HTTP session\n \"\"\"\n session = TimeoutSession()\n session.headers.update(config.headers)\n # Retry / pool policy mirrors RnetSession via the shared constants in session.py:\n # same total, forcelist, backoff cap, allowed_methods (incl. POST, so license\n # requests retry on both paths), and pool size. Accepted divergence: urllib3's\n # backoff_jitter adds an absolute 0..N seconds whereas rnet jitters \u00b110% of the\n # backoff, the only intentional difference, not worth hand-rolling a retry loop.\n session.mount(\n \"https://\",\n TimeoutHTTPAdapter(\n max_retries=Retry(\n total=MAX_RETRIES,\n backoff_factor=BACKOFF_FACTOR,\n backoff_max=MAX_BACKOFF,\n backoff_jitter=0.2,\n status_forcelist=STATUS_FORCELIST,\n allowed_methods=RETRY_METHODS,\n respect_retry_after_header=True,\n ),\n pool_connections=POOL_MAX_SIZE,\n pool_maxsize=POOL_MAX_SIZE,\n pool_block=True,\n ),\n )\n session.mount(\"http://\", session.adapters[\"https://\"])\n return session\nstaticmethod","text":"get_binaries()\nDeclare custom binary dependencies required by this service. :returns: List of dicts, each with a
Source code innameand optionalcandidatesanddesc.unshackle/core/service.py"},{"location":"reference/api/service/#unshackle.core.service.Service.authenticate","title":"authenticate","text":"@staticmethod\ndef get_binaries() -> list[dict]:\n \"\"\"\n Declare custom binary dependencies required by this service.\n :returns: List of dicts, each with a ``name`` and optional ``candidates`` and ``desc``.\n \"\"\"\n return []\nauthenticate(cookies=None, credential=None)\nAuthenticate 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. unshackle operates this method 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.
Do not store the cookies outside this function either. However, you can load the cookies into the service HTTP session.
Source code inunshackle/core/service.py"},{"location":"reference/api/service/#unshackle.core.service.Service.request_input","title":"request_input","text":"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. unshackle operates this method before\n any of the 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 Do not store the cookies outside this function either. However, you can load\n the cookies into the service HTTP 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\nrequest_input(prompt)\nRequest interactive input from the user.
Locally, prompts through the shared rich console so the prompt renders alongside Live progress. A remote-dl session relays it through its :class:
Source code inInputBridge, and a REST download job relays it throughprompt_userto the job'sinput_prompt.unshackle/core/service.py"},{"location":"reference/api/service/#unshackle.core.service.Service.search","title":"search","text":"def request_input(self, prompt: str) -> str:\n \"\"\"Request interactive input from the user.\n\n Locally, prompts through the shared rich console so the prompt renders alongside\n Live progress. A remote-dl session relays it through its :class:`InputBridge`, and a\n REST download job relays it through ``prompt_user`` to the job's ``input_prompt``.\n \"\"\"\n if self._input_bridge is not None:\n return self._input_bridge.request_input(prompt)\n return prompt_user(prompt)\nsearch()\nFind titles from the Service by query.
The Service class must take the query as a CLI argument. Ideally re-use the title ID argument (that is, self.title).
unshackle displays the search results in the order yielded.
Source code inunshackle/core/service.py"},{"location":"reference/api/service/#unshackle.core.service.Service.get_widevine_service_certificate","title":"get_widevine_service_certificate","text":"def search(self) -> Generator[SearchResult, None, None]:\n \"\"\"\n Find titles from the Service by query.\n\n The Service class must take the query as a CLI argument.\n Ideally re-use the title ID argument (that is, self.title).\n\n unshackle displays the search results in the order yielded.\n \"\"\"\n raise NotImplementedError(f\"Search functionality has not been implemented by {self.__class__.__name__}\")\nget_widevine_service_certificate(\n *, challenge, title, track\n)\nGet 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
Source code inTitlefrom get_titles that unshackle processes now. unshackle gives this in case it holds data you need, for example for an HTTP request. :param track: The currentTrackneeding decryption. Provided for same reason astitle. :return: The Service Privacy Certificate as Bytes or a Base64 string. Do not Base64 Encode or Decode the data, return as is to reduce unnecessary computations.unshackle/core/service.py"},{"location":"reference/api/service/#unshackle.core.service.Service.get_widevine_license","title":"get_widevine_license","text":"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 unshackle processes now. unshackle\n gives this in case it holds data you need, for example for an 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. Do not Base64 Encode or\n Decode the data, return as is to reduce unnecessary computations.\n \"\"\"\nget_widevine_license(*, challenge, title, track)\nGet a Widevine License message by sending a License Request (challenge).
This License message contains the encrypted content 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 the service to detect your CDM device. The service can then ban, revoke, disable, or downgrade that device.
:param challenge: The license challenge from the Widevine CDM. :param title: The current
Source code inTitlefrom get_titles that unshackle processes now. unshackle gives this in case it holds data you need, for example for an HTTP request. :param track: The currentTrackneeding decryption. Provided for same reason astitle. :return: The License response as Bytes or a Base64 string. Do not Base64 Encode or Decode the data, return as is to reduce unnecessary computations.unshackle/core/service.py"},{"location":"reference/api/service/#unshackle.core.service.Service.get_playready_license","title":"get_playready_license","text":"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 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 the service to detect your CDM device.\n The service can then ban, revoke, disable, or downgrade that device.\n\n :param challenge: The license challenge from the Widevine CDM.\n :param title: The current `Title` from get_titles that unshackle processes now. unshackle\n gives this in case it holds data you need, for example for an 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. Do not Base64 Encode or\n Decode the data, return as is to reduce unnecessary computations.\n \"\"\"\nget_playready_license(*, challenge, title, track)\nGet a PlayReady License message by sending a License Request (challenge).
This License message contains the encrypted content 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 the service to detect your CDM device. The service can then ban, revoke, disable, or downgrade that device.
:param challenge: The license challenge from the PlayReady CDM. :param title: The current
Source code inTitlefrom get_titles that unshackle processes now. unshackle gives this in case it holds data you need, for example for an HTTP request. :param track: The currentTrackneeding decryption. Provided for same reason astitle. :return: The License response as Bytes or a Base64 string. Do not Base64 Encode or Decode the data, return as is to reduce unnecessary computations.unshackle/core/service.py"},{"location":"reference/api/service/#unshackle.core.service.Service.get_clearkey_license","title":"get_clearkey_license","text":"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 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 the service to detect your CDM device.\n The service can then ban, revoke, disable, or downgrade that device.\n\n :param challenge: The license challenge from the PlayReady CDM.\n :param title: The current `Title` from get_titles that unshackle processes now. unshackle\n gives this in case it holds data you need, for example for an 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. Do not Base64 Encode or\n Decode the data, return as is to reduce unnecessary computations.\n \"\"\"\n # A service that implements Widevine licensing only still gets PlayReady, with the\n # arguments its own signature declares.\n return self.get_widevine_license(\n **declared_kwargs(self.get_widevine_license, {\"challenge\": challenge, \"title\": title, \"track\": track})\n )\nget_clearkey_license(*, challenge, title, track)\nGet a W3C ClearKey License (JWK Set) by sending a License Request (challenge).
Used for DASH
org.w3.clearkeytracks. unshackle uses no CDM here: 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
Source code inTitlefrom get_titles that unshackle processes now. unshackle gives this in case it holds data you need, for example for an HTTP request. :param track: The currentTrackneeding decryption. Provided for same reason astitle. :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'scontent_keysin get_tracks.unshackle/core/service.py"},{"location":"reference/api/service/#unshackle.core.service.Service.get_titles","title":"get_titlesdef 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` tracks. unshackle uses no CDM here: 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 unshackle processes now. unshackle\n gives this in case it holds data you need, for example for an 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\nabstractmethod","text":"get_titles()\nGet 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.
You must return at least one object. If you do not, unshackle presumes an invalid Title ID.
You can use the
Source code indatadictionary 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 you need later, like any sub-asset IDs, or such.unshackle/core/service.py"},{"location":"reference/api/service/#unshackle.core.service.Service.get_titles_cached","title":"get_titles_cached","text":"@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 You must return at least one object. If you do not, unshackle presumes an invalid\n Title ID.\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 you need later, like any sub-asset IDs, or such.\n \"\"\"\nget_titles_cached(title_id=None)\nCached 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 Defaulttitle_idstrOptional title ID for cache key generation. If not provided, will try to extract from service instance.
NoneReturns:
Type DescriptionTitles_TTitles object (Movies, Series, or Album)
Source code inunshackle/core/service.py"},{"location":"reference/api/service/#unshackle.core.service.Service.apply_title_map","title":"apply_title_map","text":"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 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 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 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)\napply_title_map(titles)\nRewrite service-provided titles using the per-service
title_mapconfig.Source code in
title_maplives underservices.<TAG>in unshackle.yaml. Applied after the title cache so config edits take effect without a cache reset, and before any--enrichoverride so enrich wins. Seeremap_titlesfor the match rules.unshackle/core/service.py"},{"location":"reference/api/service/#unshackle.core.service.Service.get_tracks","title":"get_tracksdef 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 {})\nabstractmethod","text":"get_tracks(title)\nGet 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 track, Representation, or Adaptation, or a Subtitle file.
While one Track should only hold information for one downloadable track, try to get as many unique Track objects per track type so track selection by the root code can give you more options in terms of Resolution, Bitrate, Codecs, Language, and such.
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 have encryption, and which DRM System they use, with the
drmproperty.If you can get the Track's KID (Key ID) as a 32 char (16 bit) HEX string, give it to the Track's
kidvariable, as it will speed up the decryption process later on. The service decides whether it is necessary. Generally if you can give it, without downloading any of the Track's media data, then do.:param title: The current
Source code inTitlefrom get_titles that unshackle processes now. :return: Tracks object containing Video, Audio, Subtitles, and Chapters, if available.unshackle/core/service.py"},{"location":"reference/api/service/#unshackle.core.service.Service.get_chapters","title":"get_chapters@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 track, Representation, or\n Adaptation, or a Subtitle file.\n\n While one Track should only hold information for one downloadable track, try to get as many\n unique Track objects per track type so track selection by the root code can give you more\n options in terms of Resolution, Bitrate, Codecs, Language, and such.\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 have encryption, and which DRM System they\n use, with the `drm` property.\n\n If you can get the Track's KID (Key ID) as a 32 char (16 bit) HEX string, give it to the\n Track's `kid` variable, as it will speed up the decryption process later on. The service\n decides whether it is necessary. Generally if you can give it, without downloading any of\n the Track's media data, then do.\n\n :param title: The current `Title` from get_titles that unshackle processes now.\n :return: Tracks object containing Video, Audio, Subtitles, and Chapters, if available.\n \"\"\"\nabstractmethod","text":"get_chapters(title)\nGet Chapters for the Title.
Parameters:
Name Type Description DefaulttitleTitle_TThe current Title from
requiredget_titlesthat unshackle processes now.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 is 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
Source code inchapter_fallback_name. For example,\"Chapter {i:02}\"for \"Chapter 01\".unshackle/core/service.py"},{"location":"reference/api/service/#unshackle.core.service.Service.on_segment_downloaded","title":"on_segment_downloaded","text":"@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 unshackle processes now.\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 is 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 \"\"\"\non_segment_downloaded(track, segment)\nCalled when one of a Track's Segments has finished downloading.
Parameters:
Name Type Description DefaulttrackAnyTrackThe Track object that had a Segment downloaded.
requiredsegmentPathThe Path to the downloaded Segment.
required Source code inunshackle/core/service.py"},{"location":"reference/api/service/#unshackle.core.service.Service.on_track_downloaded","title":"on_track_downloaded","text":"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 downloaded Segment.\n \"\"\"\non_track_downloaded(track)\nCalled when a Track has finished downloading.
Parameters:
Name Type Description DefaulttrackAnyTrackThe downloaded Track object.
required Source code inunshackle/core/service.py"},{"location":"reference/api/service/#unshackle.core.service.Service.on_track_decrypted","title":"on_track_decrypted","text":"def on_track_downloaded(self, track: AnyTrack) -> None:\n \"\"\"\n Called when a Track has finished downloading.\n\n Parameters:\n track: The downloaded Track object.\n \"\"\"\non_track_decrypted(track, drm, segment=None)\nCalled when a Track has finished decrypting.
Parameters:
Name Type Description DefaulttrackAnyTrackThe decrypted Track object.
requireddrmDRM_TThe DRM object it decrypted with.
requiredsegmentOptional[Segment]The decrypted HLS segment information. None for DASH and ISM tracks and for an HLS track that merged during the download (merge_segments).
NoneSource code inunshackle/core/service.py"},{"location":"reference/api/service/#unshackle.core.service.Service.on_track_repacked","title":"on_track_repacked","text":"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 decrypted Track object.\n drm: The DRM object it decrypted with.\n segment: The decrypted HLS segment information. None for DASH and ISM tracks and\n for an HLS track that merged during the download (merge_segments).\n \"\"\"\non_track_repacked(track)\nCalled when a Track has finished repacking.
Parameters:
Name Type Description DefaulttrackAnyTrackThe repacked Track object.
required Source code inunshackle/core/service.py"},{"location":"reference/api/service/#unshackle.core.service.Service.on_track_multiplex","title":"on_track_multiplex","text":"def on_track_repacked(self, track: AnyTrack) -> None:\n \"\"\"\n Called when a Track has finished repacking.\n\n Parameters:\n track: The repacked Track object.\n \"\"\"\non_track_multiplex(track)\nCalled immediately before unshackle multiplexes a Track into a Container.
Note: Right now unshackle multiplexes only MKV containers. In the future unshackle can also call this when it multiplexes to other containers like MP4 with FFmpeg/mp4box.
Parameters:
Name Type Description DefaulttrackAnyTrackThe repacked Track object.
required Source code inunshackle/core/service.py"},{"location":"reference/api/service/#unshackle.core.service.grow_session_pool","title":"grow_session_pool","text":"def on_track_multiplex(self, track: AnyTrack) -> None:\n \"\"\"\n Called immediately before unshackle multiplexes a Track into a Container.\n\n Note: Right now unshackle multiplexes only MKV containers. In the future\n unshackle can also call this when it multiplexes to other containers like\n MP4 with FFmpeg/mp4box.\n\n Parameters:\n track: The repacked Track object.\n \"\"\"\ngrow_session_pool(session, size)\nGrow a shared requests session's connection pool to
sizebefore track downloads start.The worker threads of every track draw on this one pool, because the downloader never remounts an HTTP session the caller passes in (see downloaders/requests.py). The pool must hold
downloads * workersconnections, or threads queue for a slot instead of reading. Call this before any download thread exists: the rebuild races with other threads that callget_adapter. RnetSession does not block on its idle-pool cap, so this function skips it.Every mounted adapter grows in place, through its own
Source code ininit_poolmanager. A service can mount an :class:HTTPAdaptersubclass, such asSSLCiphers, on any prefix. Mounting a new adapter over it would drop that subclass state, and its TLS context with it.unshackle/core/service.py"},{"location":"reference/api/service/#unshackle.core.service.sanitize_proxy_for_log","title":"sanitize_proxy_for_log","text":"def grow_session_pool(session: Any, size: int) -> None:\n \"\"\"Grow a shared requests session's connection pool to ``size`` before track downloads start.\n\n The worker threads of every track draw on this one pool, because the downloader never\n remounts an HTTP session the caller passes in (see downloaders/requests.py). The pool must hold\n ``downloads * workers`` connections, or threads queue for a slot instead of reading.\n Call this before any download thread exists: the rebuild races with other threads that call\n ``get_adapter``. RnetSession does not block on its idle-pool cap, so this function skips it.\n\n Every mounted adapter grows in place, through its own ``init_poolmanager``. A service can\n mount an :class:`HTTPAdapter` subclass, such as ``SSLCiphers``, on any prefix. Mounting a new\n adapter over it would drop that subclass state, and its TLS context with it.\n \"\"\"\n if not isinstance(session, requests.Session):\n return\n for adapter in {id(a): a for a in session.adapters.values()}.values():\n if not isinstance(adapter, HTTPAdapter) or getattr(adapter, \"_pool_maxsize\", 0) >= size:\n continue\n adapter.init_poolmanager(size, size, block=True)\n adapter.proxy_manager.clear()\nsanitize_proxy_for_log(uri, mask_host=False)\nSanitise a proxy URI for logs by masking any embedded userinfo (username/password).
Source code in
servesends these log lines to the client of a remote session, so the mask is unconditional and debug mode never lifts it.mask_hosthides the hostname as well, for a proxy that came from the user-suppliedBasicproxy provider.unshackle/core/service.py"},{"location":"reference/api/titles/","title":"Titles API","text":"def sanitize_proxy_for_log(uri: Optional[str], mask_host: bool = False) -> Optional[str]:\n \"\"\"\n Sanitise a proxy URI for logs by masking any embedded userinfo (username/password).\n\n ``serve`` sends these log lines to the client of a remote session, so the mask is\n unconditional and debug mode never lifts it. ``mask_host`` hides the hostname as\n well, for a proxy that came from the user-supplied ``Basic`` proxy provider.\n \"\"\"\n if uri is None:\n return None\n if not isinstance(uri, str):\n return str(uri)\n return mask_proxy(uri, mask_host=mask_host, allow_debug=False)\nAuto-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)\nBases:
"},{"location":"reference/api/titles/#unshackle.core.titles.Episode.matches_wanted","title":"matches_wanted","text":"Titlematches_wanted(wanted)\nWhether a parsed
-wkey set selects this episode.A part-ful episode answers to both its base key and its part key, so
"},{"location":"reference/api/titles/#unshackle.core.titles.Episode.part_suffix","title":"part_suffix","text":"-w s1e1takes every part. A dated episode also answers to its ISO air date.!keys are part-qualified exclusions resolved here.part_suffix()\n"},{"location":"reference/api/titles/#unshackle.core.titles.Episode.build_template_context","title":"build_template_context","text":"
.Part.2/Part 2in the series template's own separator style.build_template_context(\n media_info, show_service=True, include_part=True\n)\nBuild template context dictionary from MediaInfo.
"},{"location":"reference/api/titles/#unshackle.core.titles.Episode.air_date_display","title":"air_date_display","text":"air_date_display()\nRender air_date using the series template's own separator (dots or spaces).
"},{"location":"reference/api/titles/#unshackle.core.titles.Episode.folder_season","title":"folder_season","text":"folder_season()\nSeason folder label: air year when the air date parsed to a date, else SxxExx-style season.
"},{"location":"reference/api/titles/#unshackle.core.titles.Episode.part_label","title":"part_label","text":"part_label()\n"},{"location":"reference/api/titles/#unshackle.core.titles.Movie","title":"Movie","text":"
.2selection-syntax suffix for console output, empty when part-less.Movie(\n id_,\n service,\n name,\n year=None,\n language=None,\n data=None,\n description=None,\n)\nBases:
"},{"location":"reference/api/titles/#unshackle.core.titles.Movie.build_template_context","title":"build_template_context","text":"Titlebuild_template_context(media_info, show_service=True)\nBuild template context dictionary from MediaInfo.
"},{"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)\nBases:
MusicBackward-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)\nBases:
SortedKeyList,ABCA 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=1,\n year=None,\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)\nBases:
"},{"location":"reference/api/titles/#unshackle.core.titles.Song.matches_wanted","title":"matches_wanted","text":"Titlematches_wanted(wanted)\nWhether a parsed
-wkey set selects this song.A song shares the episode key space, its disc reading as the season and its track as the episode, so
"},{"location":"reference/api/titles/#unshackle.core.titles.Song.build_template_context","title":"build_template_context","text":"-w 1-5takes tracks 1 to 5 and-w 2x3takes disc 2 track 3. A song has no parts and no air date, so it answers to its disc-and-track key only.build_template_context(media_info, show_service=True)\nBuild template context dictionary from MediaInfo.
"},{"location":"reference/api/titles/#unshackle.core.titles.remap_titles","title":"remap_titles","text":"remap_titles(titles, title_map)\nRewrite 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.
"},{"location":"reference/api/tracks/","title":"Tracks API","text":"title_mapmaps a source title string to the desired output title. Episodes are matched on theirtitle(the show name), Movies and Songs on theirname. Returns the same collection for convenient chaining.Auto-generated reference for the track model:
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Attachment","title":"Attachment","text":"Tracks,Track, and the media-specific subclasses. See Service Architecture for how these fit together.Attachment(\n path=None,\n url=None,\n name=None,\n mime_type=None,\n description=None,\n session=None,\n)\nMake a new Attachment.
If providing a path, the file must already exist. If providing a URL, download() fetches the file during the download phase. You must give either path or url.
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 DefaultpathUnion[Path, str, None]Path to an existing file.
NoneurlOptional[str]URL to download the attachment from.
NonenameOptional[str]Name of the attachment.
Nonemime_typeOptional[str]MIME type of the attachment.
NonedescriptionOptional[str]Description of the attachment.
NonesessionOptional[AnySession]Optional requests HTTP session to download the file with.
None"},{"location":"reference/api/tracks/#unshackle.core.tracks.Attachment.id","title":"idproperty","text":"id\nCompute an ID from the attachment data.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Attachment.download","title":"download","text":"download(\n session=None,\n *,\n no_proxy_download=False,\n proxy_download=None,\n)\nDownload a URL-backed attachment to the temp directory.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Attachment.to_dict","title":"to_dict","text":"to_dict()\nSerialise a URL-backed attachment for export/import.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Attachment.from_url","title":"from_urlclassmethod","text":"from_url(\n url,\n name=None,\n mime_type=None,\n description=None,\n session=None,\n)\nMake an attachment from a URL.
Parameters:
Name Type Description DefaulturlstrURL to download the attachment from.
requirednameOptional[str]Name of the attachment.
Nonemime_typeOptional[str]MIME type of the attachment.
NonedescriptionOptional[str]Description of the attachment.
NonesessionOptional[AnySession]Optional requests HTTP session to download the file with.
NoneReturns:
Name Type DescriptionAttachment'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)\nBases:
TrackMake a new Audio track object.
Parameters:
Name Type Description DefaultcodecOptional[Codec]An Audio.Codec enum representing the audio codec. If not specified, unshackle uses MediaInfo to get the codec after it downloads the track.
NonebitrateOptional[Union[str, int, float]]A number or float representing the average bandwidth in bits/s. unshackle rounds float values up to the nearest integer.
NonechannelsOptional[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.
NonejocOptional[int]The number of Joint-Object-Coding Channels/Objects in the audio track.
NonedescriptiveUnion[bool, int]Mark this audio as being descriptive audio for the blind.
FalseNote: 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":"atmosproperty","text":"atmos\nReturn True if the audio track contains Atmos.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Audio.parse_channels","title":"parse_channelsstaticmethod","text":"parse_channels(channels)\nConverts a Channel string to a float representing the audio channel layout. E.g. \"3\" -> \"3.0\", \"2.1\" -> \"2.1\", \".1\" -> \"0.1\".
An immersive layout names its height channels in a third figure, e.g. \"5.1.4\", which no float can hold, so it stays the string it came as. Use channel_total for arithmetic on either form.
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.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Audio.channel_total","title":"channel_totalstaticmethod","text":"channel_total(channels)\nTotal number of channels in either channel form, so a caller can compare both.
A float layout keeps its own value, because a caller compares those by rounding the sub channel up, e.g. 5.1 and 6.0 both match. An immersive layout has no such float, so this adds its figures: \"5.1.4\" -> 10.0.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Audio.to_music_container","title":"to_music_container","text":"to_music_container()\nRemux the track into the container of its codec, as a standalone audio file.
Music titles never reach the muxer, so the downloaded file is the delivered file, and after decryption that is still a fragmented MP4. Its header states a length of zero, so a player stops after the first fragment. FLAC inside an MP4 is also not a FLAC stream. A rename cannot correct either fault. Returns True once the new file replaces the downloaded one; every failure raises.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Audio.get_track_name","title":"get_track_name","text":"get_track_name()\nReturn the base Track Name.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Chapter","title":"Chapter","text":"Chapter(timestamp, name=None)\nMake 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.,90323120is25:05:23.120. - a timecode float in seconds, e.g.,90323.12is25:05:23.120.If you have a timecode integer in seconds, multiply it by 1000. If you have a timecode float in milliseconds (no decimal value), convert it to an integer.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Chapter.id","title":"idproperty","text":"id\nCompute an ID from the Chapter data.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Chapter.named","title":"namedproperty","text":"named\nTrue if the Chapter has a name.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Chapters","title":"Chapters","text":"Chapters(iterable=None)\nBases:
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Chapters.id","title":"idSortedKeyList,ABCproperty","text":"id\nCompute an ID from the Chapter data.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Chapters.loads","title":"loadsclassmethod","text":"loads(data)\nLoad chapter data from a string.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Chapters.load","title":"loadclassmethod","text":"load(path)\nLoad chapter data from a file.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Chapters.dumps","title":"dumps","text":"dumps(fallback_name='')\nReturn chapter data in OGM-based Simple Chapter format. https://mkvtoolnix.download/doc/mkvmerge.html#mkvmerge.chapters.simple
Parameters:
Name Type Description Defaultfallback_namestrName 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\".unshackle formats these with f-strings, so format directives work. For example,
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Chapters.dump","title":"dump","text":"\"Chapter {i:02}\"will result in\"Chapter 01\".dump(path, *args, **kwargs)\nWrite chapter data in OGM-based Simple Chapter format to a file.
Parameters:
Name Type Description DefaultpathUnion[Path, str]The file path to write the Chapter data to, overwriting any existing data.
requiredSee
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Hybrid","title":"Hybrid","text":"Chapters.dumpsfor more parameter documentation."},{"location":"reference/api/tracks/#unshackle.core.tracks.Hybrid.log","title":"logHybrid(videos, source, output_name=None)\ninstance-attribute","text":"log = logging.getLogger('hybrid')\nTakes 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)\nMake Level 5 active area metadata through crop detection on the HDR10 track.
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_l6staticmethod","text":"sanitize_l6(max_mdl, min_mdl, max_cll, max_fall)\nClamp 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 this method keeps it as-is.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Hybrid.level_6","title":"level_6","text":"level_6()\nEdit 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)\nExtract HDR10+ metadata from the video track
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Hybrid.probe_hdr_metadata","title":"probe_hdr_metadata","text":"probe_hdr_metadata()\nExtract mastering display and Content Light Level metadata from the HDR10 track with FFprobe.
Returns (max_mdl, min_mdl, max_cll, max_fall) in dovi_tool level6 units: - max_mdl: nits (integer) - min_mdl: 0.0001 nit units (integer) - max_cll / max_fall: nits (integer)
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Hybrid.convert_hdr10plus_to_dv","title":"convert_hdr10plus_to_dv","text":"convert_hdr10plus_to_dv()\nConvert 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)\nBases:
TrackMake a new Subtitle track object.
Parameters:
Name Type Description DefaultcodecOptional[Codec]A Subtitle.Codec enum representing the subtitle format. If not specified, unshackle uses MediaInfo to get the format after it downloads the track.
NoneccboolClosed Caption. - Intended as if you could not hear the audio at all. - Can have Sound as well as Dialogue, but does not have to. - Original source would be from EIA-CC encoded video. Typically all upper-case characters. Indicators of it being CC without knowing original source: - Extracted with CCExtractor, or - >>> (or similar) at the start of some or all lines, or - All text is uppercase or at least the majority, or - Subtitles use the scrolling-text style (one line appears, oldest line then disappears). The fact that you downloaded it as a SRT or VTT or such does not mean it is not from an EIA-CC stream. And I would not take the streaming services (CC) as gospel either as they tend to get it wrong too.
FalsesdhboolDeaf or Hard-of-Hearing. Also known as HOH in the UK (EU?). - Intended as if you could not hear the audio at all. - MUST have Sound as well as Dialogue to count as SDH. - It has no \"syntax\" or \"format\", but it is not transmitted in archaic forms like EIA-CC streams. It is intended for transmission through SubRip (SRT), WebVTT (VTT), TTML, and the other modern subtitle formats. If you can see important audio/sound transcriptions and not only dialogue and it does not have the indicators of CC, then it is most likely SDH. If it does not have important audio/sounds transcriptions it might only be regular subtitling (you would not mark as CC or SDH). This would be the case for most translation subtitles. Like Anime for example.
FalseforcedboolTypically used if there is important information at some point in time like watching a Dubbed title and an important Sign or Letter is shown or someone talking in a different language. The Matroska Spec recommends that the player plays Forced tracks if the player's current playback audio language matches a subtitle marked as \"forced\". However, that does not 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. Know what to expect with \"forced\" subtitles.
FalseNote: If codec is not specified, unshackle can skip some checks or assume a value. Give as much information as possible.
Information on Subtitle Typeshttps://bit.ly/2Oe4fLC (3PlayMedia Blog on SUB vs CC vs SDH). However, I would not pay much attention to the claims about SDH needing to be in the original language. It is logically not true.
CC == Closed Captions. Source: Every site. SDH = Subtitles for the Deaf or Hard-of-Hearing. Source: 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()\nReturn the base Track Name.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.strip_webvtt_cue_classes","title":"strip_webvtt_cue_classesstaticmethod","text":"strip_webvtt_cue_classes(text)\nReduce a cue payload to the markup every target format shares:
<i>,<b>and<u>without their class list. It removes class spans (<c.foo>),<v>,<lang>,<ruby>/<rt>and karaoke timestamps (<00:00:01.000>).A class only selects a CSS
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.strip_webvtt_timestamp_map","title":"strip_webvtt_timestamp_map::cuerule that no player receives, so it never carries styling, but it does break rendering. FFmpeg's SubRip reader treats a tag name as a tag only while it matches[0-9a-zA-Z_/]. The dot fails that test, so FFmpeg prints<i.loud>as cue text and then emits the closing italic-off with nothing opened. FFmpeg's WebVTT reader matchesi/b/uexactly and drops the rest, so there a class silently loses the styling instead. The tags this method removes outright are the ones both readers already discard, and that libass would draw as text if a conversion carried them into ASS.staticmethod","text":"strip_webvtt_timestamp_map(text)\nRemove 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_timestampsstaticmethod","text":"sanitize_webvtt_timestamps(text)\nFix invalid timestamps in WebVTT files, particularly negative timestamps.
Parameters:
Name Type Description DefaulttextstrThe WebVTT text as string
requiredReturns:
Type DescriptionstrSanitized WebVTT text
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.has_webvtt_cue_identifiers","title":"has_webvtt_cue_identifiersstaticmethod","text":"has_webvtt_cue_identifiers(text)\nExamine WebVTT text for cue identifiers that need removal.
Parameters:
Name Type Description DefaulttextstrThe WebVTT text as string
requiredReturns:
Type DescriptionboolTrue if the text has cue identifiers, False otherwise
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.sanitize_webvtt_cue_identifiers","title":"sanitize_webvtt_cue_identifiersstaticmethod","text":"sanitize_webvtt_cue_identifiers(text)\nRemove WebVTT cue identifiers that can confuse subtitle parsers.
Some services use cue identifiers such as \"Q0\" and \"Q1\" that appear on their own line before the timing line. These can be incorrectly parsed as part of the previous cue's text by some parsers (like pysubs2).
Parameters:
Name Type Description DefaulttextstrThe WebVTT text as string
requiredReturns:
Type DescriptionstrSanitized WebVTT text with cue identifiers removed
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.parse_vtt_time","title":"parse_vtt_timestaticmethod","text":"parse_vtt_time(t)\nParse WebVTT timestamp to milliseconds. Returns 0 for malformed input.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.has_overlapping_webvtt_cues","title":"has_overlapping_webvtt_cuesstaticmethod","text":"has_overlapping_webvtt_cues(text)\nExamine WebVTT text for 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 DefaulttextstrThe WebVTT text as string
requiredReturns:
Type DescriptionboolTrue if the text has overlapping cues, False otherwise
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.merge_overlapping_webvtt_cues","title":"merge_overlapping_webvtt_cuesstaticmethod","text":"merge_overlapping_webvtt_cues(text)\nMerge 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 DefaulttextstrThe WebVTT text as string
requiredReturns:
Type DescriptionstrWebVTT text with overlapping cues merged
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.sanitize_webvtt","title":"sanitize_webvttstaticmethod","text":"sanitize_webvtt(text)\nMore thorough sanitization of WebVTT files that corrects multiple potential issues.
This is lossy, so use it only as a fallback once normal parsing has failed. This method discards everything before the WEBVTT header line and reduces the header itself to a bare \"WEBVTT\" line. Negative timestamps become 00:00:00.000, and it pads timestamps that have no hours field to HH:MM:SS.mmm.
Parameters:
Name Type Description DefaulttextstrThe WebVTT text as string
requiredReturns:
Type DescriptionstrSanitized WebVTT text
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.convert","title":"convert","text":"convert(codec, *, forced=False)\nConvert this Subtitle to another format.
Backend selection is data-driven (see
tracks/subtitle_convert.py): unshackle uses the best available backend that can convert source->target, and falls back through the capability chain on failure. Theconversion_methodconfig key (auto|subby|pysubs2|subtitleedit|pycaption) pins the backend, andpreferred_conversion_methodnudges it per-service. An explicit config value always wins."},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.extract_fonts","title":"extract_fonts
forcedmarks an explicit user request (--sub-format). unshackle skips lossy downconverts of styled formats (SSA/ASS -> SRT) unlessforcedis True.staticmethod","text":"extract_fonts(text)\nFont names referenced by an ASS/SSA subtitle.
Covers both sources that need attaching for correct rendering: - the
Fontnamecolumn of everyStyle:line in[V4+ Styles]/[V4 Styles](column located from the section'sFormat:line, not assumed by index), and - inline\\fnfont overrides insideDialogueoverride blocks.This method removes a leading
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.sanitize_broken_webvtt","title":"sanitize_broken_webvtt@(vertical-writing prefix), de-duplicates the names case-insensitively, and prefers a mixed-case spelling over an all-lowercase one.staticmethod","text":"sanitize_broken_webvtt(text)\nRemove or fix corrupted WebVTT lines, particularly those with invalid timestamps.
Parameters:
Name Type Description DefaulttextstrThe WebVTT text as string
requiredReturns:
Type DescriptionstrSanitized WebVTT text with corrupted lines removed
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.space_webvtt_headers","title":"space_webvtt_headersstaticmethod","text":"space_webvtt_headers(data)\nSpace 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 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_cuesstaticmethod","text":"merge_same_cues(caption_set)\nMerge 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_cuesstaticmethod","text":"filter_unwanted_cues(caption_set)\nFilter out subtitle cues containing only \u00a0 or whitespace.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.merge_segmented_wvtt","title":"merge_segmented_wvttstaticmethod","text":"merge_segmented_wvtt(data, period_start=0.0)\nConvert Segmented DASH WebVTT cues into a pycaption Caption List. Also returns an ISO 639-2 alpha-3 language tag if available.
Code ported originally by xhlove to Python from shaka-player. rlaphoenix has since improved it with pymp4 and pycaption functions.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.strip_hearing_impaired","title":"strip_hearing_impaired","text":"strip_hearing_impaired()\nRemove the SDH captions.
The 'sdh_method' setting in config gives the SDH stripping method: - '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()\nReverse RTL (Right to Left) Start/End on Captions. Use this to fix the positioning of sentence-ending characters.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.DownloadContext","title":"DownloadContextdataclass","text":"DownloadContext(\n save_path,\n save_dir,\n progress,\n session=None,\n proxy=None,\n max_workers=None,\n adaptive_workers=False,\n download_processes=1,\n license_widevine=None,\n cdm=None,\n)\nShared arguments passed to each manifest's
"},{"location":"reference/api/tracks/#unshackle.core.tracks.DownloadContext.ensure_session","title":"ensure_session","text":"download_track.ensure_session()\nReturn the HTTP session, or a new
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Track","title":"Track","text":"Sessionif none was set."},{"location":"reference/api/tracks/#unshackle.core.tracks.Track.data","title":"dataTrack(\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)\npropertywritable","text":"data\nArbitrary track data dictionary.
This uses a defaultdict 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 note that these values can change or be removed at any point.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Track.drm_preference","title":"drm_preferencepropertywritable","text":"drm_preference\nDRM system this track must license with, one of the names in DRM_PREFERENCE_TYPES.
None (the default) lets the loaded CDM choose. Set it when the manifest advertises more than one DRM system but only one of them licenses this track.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Track.prefers_playready","title":"prefers_playready","text":"prefers_playready(cdm)\nWhether to try PlayReady before Widevine. The track's preference wins over the loaded CDM.
"},{"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 proxy_download=None,\n adaptive_workers=False,\n download_processes=1,\n)\nDownload and optionally Decrypt this Track.
For a URL-descriptor Video or Audio track with no
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Track.move","title":"move","text":"drmset, unshackle probes the DRM from the track's init data and stores it on the track, so a service need not declaredrmitself.move(target)\nMove the Track's file from current location, to target location. This will overwrite anything at the target path.
Raises:
Type DescriptionTypeErrorIf the target argument is not the expected type.
ValueErrorIf track has no file to move, or the target does not exist.
OSErrorIf 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()\nSerialise 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_dictstaticmethod","text":"base_kwargs_from_dict(data)\nAssemble the shared Track constructor kwargs from a
to_dict()payload.DRM is not reconstructed here:
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Track.from_dict","title":"from_dictto_dictdoes not serialise it, and the import flow attaches the licensed DRM + content keys separately.classmethod","text":"from_dict(data)\nReconstruct the correct Track subclass from a
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Track.get_track_name","title":"get_track_name","text":"to_dict()payload.get_track_name()\nGet 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)\nReturn 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)\nProbe the DRM encryption Key ID (KID) for this specific track.
It can find the Key ID by probing the track with FFprobe for
enc_key_iddata, as well as for mp4tenc(Track Encryption) boxes.It explicitly ignores PSSH information like the
PSSHbox, 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_segmentwith the positional and keyword arguments. This method then uses the return value ofget_init_segmentto find the Key ID.Returns:
Type DescriptionOptional[UUID]The Key ID as a UUID object, or None if unshackle cannot find the Key ID.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Track.load_drm_if_needed","title":"load_drm_if_needed","text":"load_drm_if_needed(service=None)\nLoad DRM information for this track if the parser deferred it.
Parameters:
Name Type Description DefaultserviceService | NoneService instance that can fetch track-specific DRM info
NoneReturns:
Type DescriptionboolTrue if the DRM loaded or is already present, False if the load failed
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Track.load_drm_from_playlist","title":"load_drm_from_playlist","text":"load_drm_from_playlist()\nFallback 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)\nGet the Track's initial segment data.
HLS and DASH tracks must explicitly give a URL to the init segment or file. Give the byte-range for the init segment where possible.
If
byte_rangeis not set, it will make a HEAD request and examine the size of the file. If it cannot find the size, it will download up to the first 20KB only, which should contain the entirety of the init segment. You may override this by changing themaximum_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 Defaultmaximum_sizeintSize to assume as the response body length if byte-range is not used, if unshackle cannot find the body size, or if the body size is larger than it. Use a value of 20000 (20KB) or higher.
20000urlOptional[str]Explicit init map or file URL to probe from.
Nonebyte_rangeOptional[str]Range of bytes to download from the explicit or implicit URL.
NonesessionOptional[Session]HTTP session context, for example authorization and headers.
None"},{"location":"reference/api/tracks/#unshackle.core.tracks.Track.repackage","title":"repackage","text":"repackage(bsf_v=None)\nRemux the track with FFmpeg
-c copy.A given
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Tracks","title":"Tracks","text":"bsf_vgoes into the same pass as-bsf:v, which normalises video VUI colour metadata without a second full-file remux. Repackaging is mandatory. The bitstream filter is best-effort: if the combined pass fails, and it is not the AAC-retry case, unshackle tries it again once withoutbsf_vso the remux still succeeds. Returns True if unshackle applied the requestedbsf_v(always False whenbsf_vis None, because the caller requested nothing).Tracks(*args, manifest_url=None)\nVideo, 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)\nExamine whether a track already exists, by various methods.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Tracks.add","title":"add","text":"add(tracks, warn_only=False)\nAdd a provided track to its appropriate array.
A track whose ID is already in the collection raises ValueError. With
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Tracks.sort_videos","title":"sort_videos","text":"warn_onlyset, this method skips such a track and logs how many it skipped.sort_videos(by_language=None, exact_match=False)\nSort 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)\nSort 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)\nSort subtitle tracks by various track attributes to a common P2P standard. You may optionally give a sequence of languages to prioritise to the top.
Section OrderType Order
- by_language groups prioritized to top, and ascending alphabetically
- then rest ascending alphabetically after the prioritized groups (Each section ascending alphabetically, but separated)
- 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 sort order. \"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)\nReturn a new Tracks with tracks filtered by predicate, preserving metadata.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Tracks.merge_video_selections","title":"merge_video_selectionsstaticmethod","text":"merge_video_selections(*groups)\nConcatenate video selections, dropping duplicates (by track id, order-preserving).
A caller can choose a DV track as both the hybrid ingredient (lowest) and an explicit deliverable. Without dedup, unshackle would mux and download the same track twice.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Tracks.partition_hybrid_videos","title":"partition_hybrid_videosstaticmethod","text":"partition_hybrid_videos(videos, non_hybrid_ranges)\nSplit videos into hybrid-ingredient candidates and the standalone-deliverable pool.
HDR10/HDR10+/DV tracks are hybrid ingredients. They only enter the standalone pool when the user explicitly requested their range alongside HYBRID, so for example
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Tracks.flag_hybrid_ingredients","title":"flag_hybrid_ingredients-r HYBRIDmuxes only the hybrid while-r HYBRID,HDR10Palso delivers HDR10+.staticmethod","text":"flag_hybrid_ingredients(\n hybrid_selected, non_hybrid_selected\n)\nMark tracks selected only as hybrid ingredients so the standalone mux loop skips them.
A track that the caller also selected as an explicit deliverable (same track in both selections) stays unflagged, and the standalone mux loop muxes it 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)\nMultiplex all the Tracks into a Matroska Container file.
A failed mux does not raise. mkvmerge's exit code and error lines come back alongside the output path, and the caller must examine them.
Parameters:
Name Type Description DefaulttitlestrSet the Matroska Container file title. Usually displayed in players instead of the filename if set.
requireddeleteboolDelete all track files after multiplexing.
TrueprogressOptional[partial]Update a rich progress bar with
completed=.... This must be the progress object's update() func, pre-set with task id by functools.partial.Noneaudio_expectedboolWhether the output must have audio. unshackle uses this to decide if it adds embedded audio metadata.
Truetitle_languageOptional[Language]The title's intended language. Used to select the best video track for audio metadata when multiple video tracks exist.
Noneskip_subtitlesboolSkip muxing subtitle tracks into the container.
Falseoutput_pathOptional[Path]Explicit destination for the muxed container. When None (default) unshackle derives the path 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)\nBases:
TrackMake a new Video track object.
Parameters:
Name Type Description DefaultcodecOptional[Codec]A Video.Codec enum representing the video codec. If not specified, unshackle uses MediaInfo to get the codec after it downloads the track.
Nonerange_Optional[Range]A Video.Range enum representing the video colour range. Defaults to SDR if not specified.
NonebitrateOptional[Union[str, int, float]]A number or float representing the average bandwidth in bytes/s. unshackle rounds float values up to the nearest integer.
NonewidthOptional[int]The horizontal resolution of the video.
NoneheightOptional[int]The vertical resolution of the video.
NonefpsOptional[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.
NoneNote: If codec, bitrate, width, height, or fps is not specified, unshackle can skip some checks or assume a value. Give as much information as possible.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Video.Range","title":"Range","text":"Bases:
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Video.Range.from_cicp","title":"from_cicpstr,Enumstaticmethod","text":"from_cicp(primaries, transfer, matrix)\nConvert CICP (Coding-Independent Code Points) values to Video Range.
ITU-T H.273 and ISO/IEC 23091-2 define CICP for signalling video colour 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 - Coding-independent code points - 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 after user feedback (GitHub issue) and matches 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_)\nChange the Video's Colour Range to Limited (0) or Full (1).
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Video.vui_bsf","title":"vui_bsf","text":"vui_bsf()\nReturn the
-bsf:vvalue that rewrites SPS VUI colour metadata to matchself.range.Returns None when a rewrite is not necessary: SDR/DV/HYBRID ranges, non-AVC/HEVC codecs, a missing file, or when the bitstream already ships the correct colour metadata. Otherwise returns a
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Video.normalize_vui","title":"normalize_vui","text":"{h264,hevc}_metadata=...string suitable for FFmpeg-bsf:v.normalize_vui()\nRewrite 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 unshackle rewrote the bitstream.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Video.ccextractor","title":"ccextractor","text":"ccextractor(track_id, out_path, language, original=False)\nReturn 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()\nExtract Apple-Style c608 box (CEA-608) subtitle using CCExtractor.
This is not much more than a wrapper to the track.ccextractor function. All this does is examine if a c608 box exists, and only then does it call CCExtractor.
Even though there is a possibility of more than one c608 box, unshackle can extract only one. It is also 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, before unshackle canextract more than one CEA-608 track.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Video.remove_eia_cc","title":"remove_eia_cc","text":"remove_eia_cc()\nRemove 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 only the SEI data). unshackle can do this only for bitstreams with x264 encoding information, because of the obscurity on the MDAT mp4 box structure. Therefore, we need to use hacky regex.
"},{"location":"reference/configuration/","title":"Configuration Reference","text":"Every config 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 config key is optional except
output_template, whichdlrequires. Without it,dlaborts with an error. If you do not write any other config key, unshackle uses the built-in default listed here. There is no schema validation: unshackle silently ignores unknown config keys (they never become settings), and reads the file withyaml.safe_load. An empty or missing file means every other config key falls back to its default.Two keys behave specially on load
curl_impersonateis a deprecated alias ofnetworkand emits aDeprecationWarning.scene_naminghas been removed. If it is set to any value, unshackle exits immediately with an error telling you to useoutput_templateinstead.See Deprecated & removed keys.
"},{"location":"reference/configuration/#quick-index","title":"Quick index","text":"Area Keys Directoriesdirectories,filenames,services_repo_forceServices & authenticationservices,credentials,firefox_cookies,remote_services,serveDownload & processingdl,continue_downloads,merge_segments,subtitle,audio,muxing,language_tags,dual_multi_modeOutput & namingoutput_template,tag,tag_rules,tag_group_name,tag_imdb_tmdb,chapter_fallback_name,unicode_filenamesDRM & CDMcdm,remote_cdm,decryptionNetwork & proxynetwork,headers,proxy_providersKey vaultskey_vaults,vault_timeoutPost-scriptspost_scriptsExternal API keysomdb_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_keyCaching & updatestitle_cache_enabled,title_cache_time,title_cache_max_retention,update_checks,update_check_intervalAppearancethemeLogging, privacy & debugredact_paths,debug,debug_keys,debug_requests,set_terminal_bgDeprecated & removedcurl_impersonate,downloader,scene_naming"},{"location":"reference/configuration/directories/","title":"Directories","text":"The
unshackle.yamldirectoriesconfig key is a dict mapping directory names to filesystem paths. unshackle honours only the names listed below. A handful are protected and you cannot move them, and unshackle silently ignores an override of one. unshackle computes all defaults relative to the installed package, and passes every user-settable path throughPath(...).expanduser(), so~works.Key Type Default Overridable Purposedirectories:\n downloads: ~/Media/unshackle\n temp: /mnt/fast-scratch/unshackle-temp\n wvds: ~/.unshackle/WVDs\n prds: ~/.unshackle/PRDs\ndownloadspath<repo>/downloadsYes Default output directory for finished files.temppath<repo>/tempYes Temporary working files during download/decrypt/mux.cachepath<data>/cacheYes Generic cache, title cache, and the update-check store.cookiespath<data>/cookiesYes Per-service cookie files (and VPN cookie files).logspath<data>/logsYes Log files.exportspath<data>/exportsYes Export JSON files.wvdspath<data>/WVDsYes Widevine device files (.wvd).prdspath<data>/PRDsYes PlayReady device files (.prd).dcslpath<data>/DCSLYes DCSL data.commandspathunshackle/commandsYes CLI command modules.serviceslist | path[unshackle/services]Yes Service search paths and/or remote repo specs (see below).vaultspathunshackle/vaultsYes Vault backend modules.fontspathunshackle/fontsYes Bundled fonts.user_configspathunshackle/No protected Whereunshackle.yamllives.datapathunshackle/No protected Base for the data subdirectories above.core_dirpathunshackle/coreNo protected Package core.namespace_dirpathunshackle/No protected Package root.app_dirs-AppDirs(\"unshackle\", False)No protected Internal AppDirs instance.The
servicesdirectory is special
servicesmay 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) orowner/reposhorthand. unshackle clones and updates repo specs automatically, and uses plain paths as they are. 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\nHow git-backed service repos are handled
unshackle's use of git is read-only on the remote: it only ever does a shallow
clone,fetch,pull, and a localreset. unshackle never pushes anything. Your existing git credential helper authenticates private repos, and unshackle stores no tokens of its own. Clones live under<first-local-services-dir>/_repos/<host>__<owner>__<repo>/(or the bundledunshackle/services), and unshackle writes nothing 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/searchrun 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 manualunshackle util refresh-servicescommand instead hard-resets the clone to upstream, discarding any local edits.Set
services_repo_force: trueto make the automatic 24 h refresh behave likerefresh-services. When a refresh is due, the run fetches and hard-resets every clone, and discards any local edits in_repos/. Runs between refreshes do not touch the network. Use it on unattended hosts where nobody edits the clones by hand.services_repo_force: true\nRefresh inside
serveA running server does not restart to pick up a refresh. Both the periodic refresh (
serve.services_refresh_interval, see serve) and the manualPOST /api/maintenance/refresh-servicesendpoint hard-reset the clones and then re-import only the services that changed. A service with a running or queued download job keeps its current code until that job finishes, then swaps;GET /api/servicesmarks it withpending_updatein the meantime.Read-only installs and reinstalls
If the services directory lives inside the installed package, a reinstall can delete the
"},{"location":"reference/configuration/directories/#filenames","title":"Filenames","text":"_reposclones. They are re-cloned on next use. On read-only installs you must pointservicesat a writable path, or cloning will fail.The
Key Type Default Notesfilenamesconfig key is a dict of templated file/name patterns. unshackle uses each value verbatim (no path processing). It fills in braced fields like{time}and{service}at runtime.logstr\"unshackle_{name}_{time}.log\"Written underdirectories.logs.debug_logstr\"unshackle_debug_{service}_{time}.jsonl\"Structured debug log, underdirectories.logs.configstr\"config.yaml\"Per-service config file, under that service's directory.root_configstr\"unshackle.yaml\"The main config filename itself.chaptersstr\"Chapters_{title}_{random}.txt\"Underdirectories.temp.subtitlestr\"Subtitle_{id}_{language}.srt\"Underdirectories.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
dlcommand. unshackle uses the block as Click'sdefault_map, so you can give anydlflag a default here. See Downloading and the CLI Reference for what each flag does.Config key naming. A config key is the flag's long name with dashes replaced by underscores (
Flag Config key--best-available\u2192best_available,--sub-format\u2192sub_format). A few config keys use the flag's internal destination and not its visible name. Set these exact config keys:-r/--rangerange--listlist--tmdbtmdb_id--imdbimdb_id--anilistanilist_id-naa/--noatmosno_atmos-o/--outputoutput_dir--cdm-only/--vaults-onlycdm_onlyPrecedence (highest first): an explicit CLI flag \u2192 a per-service
services.<TAG>.dloverride \u2192 this globaldl:block \u2192 the built-in default. In other words, config only fills in options you did not set on the command line.Common config keys. This is a useful subset, and every
Key Type Default Setsdlflag works:langlist[\"orig\"]Video/audio language(s);orig= original language. A-prefix excludes, e.g.[all, -es].a_lang/v_langlist[]Audio- / video-only language override. A-prefix excludes.s_langlist[\"all\"]Subtitle language(s). A-prefix excludes, e.g.[all, -es].qualitylist[](best) Resolution(s), e.g.[1080].vcodeclist[](any) Video codec(s), e.g.[H265].acodeclist[](any) Audio codec(s), e.g.[EC3].rangelist[\"SDR\"]Colour range(s):SDR,HLG,HDR10,HDR10P,DV,HYBRID.channelsfloat (unset) Audio channels, e.g.6for 5.1.sub_formatstr (unset) Convert subtitles to this format (srt,vtt,original, ...).forced_subsboolfalseInclude forced subtitle tracks.forced_s_langlist[]Forced subtitle language(s); impliesforced_subs. A-prefix excludes.no_subs/no_audio/no_chapters/no_attachmentsboolfalseSkip that track type.no_attachmentsalso skips attaching subtitle fonts.downloadsint1Tracks downloaded concurrently.workersint (downloader default) Threads per track.speed_limitstr (unlimited) Total download speed cap across all downloads combined, e.g.500k,5M,1.5Gor plain bytes/sec. Values are bytes, not bits (5M= 5.0 MB/s).slowstr (unset) Inter-title delay, e.g.\"20-40\".best_availableboolfalseWarn instead of failing when a requested resolution, range, or language is missing. The CLI flag is--best-available, also spelled--warn-only.proxystr (unset) Default proxy URI or 2-letter country.no_folderboolfalseDo not create a per-title folder.
noandyesare language tagsYAML reads a bare
Global download defaultsnoasfalseand a bareyesastrue. unshackle puts both back to the tagsno(Norwegian) andyes(Nyankpa) in every config key that takes a language:lang,v_lang,a_lang,s_lang,forced_s_lang,require_audio,require_video,require_subs,language_priority,muxing.default_languageand thelanguage_tagsrules. Quotes (lang: \"no\") work as well.dl:\n lang: [en]\n quality: [1080]\n range: [HDR10]\n vcodec: [H265]\n sub_format: srt\n downloads: 2\nLanguage 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\"\nSee Excluding languages for the rules.
Per-service overrides
A
dlblock nested under a service inservicestakes 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
serveuses these same keysThe
"},{"location":"reference/configuration/download/#continue_downloads","title":"serveblock accepts most of the samedloption keys at its top level to set server-wide download defaults for every request it handles. The API ignores the config keys it does not know there,speed_limitfor example.servehas its ownglobal_speed_limit.continue_downloads","text":"
- Type:
bool\u00a0\u00b7\u00a0 Default:falseKeep a failed download's completed segment files so the next download resumes from where it stopped instead of restarting from byte zero.
unshackle reuses only whole, fully downloaded segments. It always discards partially written segments, and no partial-byte (
Range) resume across runs takes place. Reuse is possible only with proof that the segmentation did not change. The manifest URL, segment count, and byte ranges must all match the previous download, or the download silently restarts clean.What can and cannot resume
- Segmented DASH, ISM, and HLS tracks resume.
- Single-file (non-segmented) downloads and DASH tracks collapsed to a single ranged download never make segment files, so this option does not apply to them (the downloader has its own in-flight partial-file handling for those).
- HLS resumes Widevine/PlayReady and unencrypted titles only. An AES-128 title always restarts. An HLS failure during the post-download merge step also restarts.
- A track downloaded with
merge_segmentsordecrypt_segmentsnever resumes. Both options consume or rewrite the segment files during the download.For a one-off resume without changing config, pass
Resume failed downloadsdl --continue-downloadson the command line (the flag can only enable resume, never disable it)."},{"location":"reference/configuration/download/#merge_segments","title":"continue_downloads: true\nmerge_segments","text":"
- Type:
bool\u00a0\u00b7\u00a0 Default:falseAppends each segment to the output file as soon as it and every segment before it have arrived, instead of joining all the segment files after the last one lands. The join runs during the download, so the \"Merging\" step after the last segment goes away. The output file is the same either way.
Segments arrive out of order because the workers run in parallel. unshackle keeps a cursor on the next segment it needs and appends every segment that is ready in order, then deletes the segment file. With
decrypt_segmentsalso on, each segment is appended after its decryption completes, never before.This option is off by default because it disables resume, and because its cost during the download is not measured. The append reads each segment straight after a worker wrote it, so the read usually comes from the page cache. On a hard disk, the append to one file while the workers write many segment files can still make the head seek. If a download gets slower with the option on, turn it off.
This applies to video and audio tracks from DASH, ISM, and HLS. An HLS track qualifies only when every segment shares one init segment and one
EXT-X-KEY, with noEXT-X-DISCONTINUITY, and the DRM is Widevine, PlayReady, or none. AES-128 tracks, tracks with content key rotation or discontinuities, and subtitle tracks keep the merge after the download.No resume
A track that merges segment by segment always downloads from the start.
continue_downloadscannot reuse its segments, because unshackle deletes each segment after it appends it."},{"location":"reference/configuration/download/#subtitle","title":"merge_segments: true\nsubtitle","text":"
- Type:
dict\u00a0\u00b7\u00a0 Default:{}Subtitle handling: SDH stripping, format conversion, and whether unshackle muxes the subtitles in or writes them as sidecar files. See Subtitles for the full guide.
Sub-key Type Default Descriptionstrip_sdhbooltrueStrip SDH/CC cues (hearing-impaired annotations) into a clean track.sdh_methodstr\"auto\"SDH-stripping backend to use.preserve_formattingbooltruePass WebVTT cue text through as written.falsere-encodes it through pycaption, which merges identical cues but flattens styling. unshackle removes WebVTT-only cue markup either way.convert_before_stripbooltrueConvert to the working format before stripping SDH.conversion_methodstr\"auto\"Subtitle conversion backend.output_modestr\"mux\"muxembeds subtitles in the MKV;sidecarwrites separate files;bothwrites sidecars and still muxes.sidecar_formatstr\"srt\"Format for sidecar files whenoutput_modeissidecarorboth.type_prioritylist (unset) Ordered ranking of subtitle types (forced,normal,sdh; CC counts as SDH). Types you leave out fall to the end.group_bystr\"type\"Which key groups the subtitle tracks.typeputs all forced tracks together, then all normal, then all SDH.languagekeeps each language next to its own variants.language_prioritylist (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_bytrack ordergroup_byResulttypeDanish, Finnish, French, \u2026 Danish SDH, Finnish SDH, French SDHlanguageDanish, Danish SDH, Finnish, Finnish SDH, French, French SDH
type_prioritystill sets the order of the variants, so withgroup_by: languageit controls the order inside each language.
language_prioritysorts languages, it does not select themResult for an English title Default English, Arabic, Bulgarian, Spanish, French, Japanese With the list above English, Spanish, French, Arabic, Bulgarian, Japanesesubtitle:\n group_by: language\n language_priority: [en, es, fr]\nunshackle removes nothing, unlike
-sl, which drops every subtitle language it does not name.Use
origin the list for the title's original language. A base tag also covers its regional forms, soensortsen-USanden-GB, butzhdoes not coverzh-Hant, which you must name. The sort ignores entries that match no track, and it ignores entries that are not valid language tags.
--exact-langapplies here too: with it,ensorts onlyen, anden-USfalls to the alphabetical tail with the rest. Name each tag you want when you use that flag.
type_prioritypicks the default subtitle only withmuxing.default_languageAt mux time the
defaultflag goes to every forced subtitle whose language matches the first audio track, so a full dialogue track never becomes default on its own.type_priorityalone does not change that. Name a subtitle language inmuxing.default_languageand the first track in that language gets the flag instead, which is wheretype_prioritydecides which track that is:"},{"location":"reference/configuration/download/#audio","title":"muxing:\n default_language:\n subtitle: en\nsubtitle:\n type_priority: [normal, sdh, forced]\naudio","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_priorityis 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_priorityis a soft priority: nothing is dropped"},{"location":"reference/configuration/download/#muxing","title":"
codec_priorityoutranks 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 overcodec_priority, and audio-description tracks are always forced to the end regardless of it. Whencodec_priorityis unset, tracks sort by bitrate alone, with those same Atmos/descriptive rules still applied.muxing","text":"
- Type:
dict\u00a0\u00b7\u00a0 Default:{}Matroska (MKV) muxing options.
Sub-key Type Default Descriptionset_titlebooltrueWrite a human-readable title into the MKV container.default_languagedict{}Force which language is flagged default per track type, e.g.{audio: en, subtitle: en}.merge_audiobooltrueMerge all audio into one file.--split-audioon the CLI flips this off.merge_videoboolfalseMerge video tracks that share height, range, and codec into one file, so only language varies inside a file.--merge-videoon the CLI flips this on.concurrencyint4How many mkvmerge processes run at the same time when a title muxes more than one output file (one per video variant or audio codec). Each process uses one CPU core, so this shortens the mux step on an SSD. Set it to1on a hard disk, where parallel writes are slower."},{"location":"reference/configuration/download/#language_tags","title":"muxing:\n set_title: true\n concurrency: 4\n default_language:\n audio: en\n subtitle: en\nlanguage_tags","text":"
- Type:
dict\u00a0\u00b7\u00a0 Default:{}Language-tag remapping and the rule engine behind the
Condition Meaning{lang_tag}filename variable. When aruleslist is present, unshackle evaluates each rule in order and uses the first match'stag. A rule can examine:audioAt least one audio language matches.subs_containAt least one subtitle language matches.subs_contain_allEvery listed language must be present.dual/multi/dubbedtruematches when that filename variable is set,falsewhen it is not.Each language condition takes one language tag or a list of them. A list matches when any entry matches, except
subs_contain_all, which needs them all. An entry that is not a valid language tag (for examplejapanese) never matches.dual,multianddubbedtake a baretrueorfalse, not a quoted string. unshackle skips a rule with a condition name it does not know, or with any other value in a boolean condition. It logs a warning in each of these cases and tries the next rule.
dual,multianddubbedcarry the same meaning as the filename variables of those names. Pair one with a language to name a release yourself, in place of the fixedDUAL,MULTiandDUBBEDtext. A rule that namesdubbed: trueand a language tags a localized dub without tagging a native release the same way.language_tags:\n rules:\n - audio: [ja]\n subs_contain: [en]\n tag: \"SUBBED\"\n - audio: th\n dubbed: true\n tag: \"THAIDUB\"\n - audio: th\n dual: true\n tag: \"THAIDUAL\"\nPut
{lang_tag}where you want the nameThese rules fill
"},{"location":"reference/configuration/download/#dual_multi_mode","title":"{lang_tag}. To use your own name in place of the fixed text, keep{lang_tag?}in the template and leave out{dual?},{multi?}and{dubbed?}.dual_multi_mode","text":"
- Type:
str\u00a0\u00b7\u00a0 Default:strictRule 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\nDialects count as one language
Regional variants of the same base language (e.g.
"},{"location":"reference/configuration/drm/","title":"DRM & CDM","text":"en-USanden-GB) collapse to a single language in both modes, so they never trigger{dual}on their own.These are the config keys. For concepts, device provisioning, and the
"},{"location":"reference/configuration/drm/#cdm","title":"wvd/prdcommands see the DRM & CDM guide. Device files themselves live indirectories.wvds(.wvd) anddirectories.prds(.prd).cdm","text":"
- Type:
dict\u00a0\u00b7\u00a0 Default:{}Maps a service tag to the CDM (Widevine/PlayReady device) to use, with a
defaultfallback. Resolution is case-insensitive: an override (dl --cdm <name>, or thecdmfield of an API job) wins, then the per-service entry, thendefault. An override pins one device for the whole run and skips any quality or Widevine/PlayReady sub-entries.Over
--remote, an override also makes your device license the remote session, not the server CDM. A per-service entry does the same when it names a device without a quality: a device name, awidevineorplayreadysub-entry, or a profile ordefaultsub-entry. A per-service entry that selects only by quality, or only for a different profile, loads no device when the run starts. unshackle then shows a warning, and the server decides who licenses the remote session. With only the top-leveldefaultentry, the server CDM licenses when the API key has it. Seeremote_services.cdm:\n default: chromecdm_l3\n EXAMPLE2: android_l1\nA 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\nQuality keys can hold
1080,>=1080,>720,<=576,<480style comparisons. DRM keys arewidevine/playready. unshackle matches the quality keys first, but only when a video height is known. If the value is still a dict after that, unshackle useswidevine/playreadywhen present, or else the credential profile name, thendefault.unshackle finds the resolved name in
"},{"location":"reference/configuration/drm/#remote_cdm","title":"remote_cdmbynamefirst. If no entry matches, unshackle loads it as a local device file, in this order:<name>.prdindirectories.prds,<name>.prdindirectories.wvds, then<name>.wvdindirectories.wvds.remote_cdm","text":"
- Type:
list[dict]\u00a0\u00b7\u00a0 Default:[]A list of remote CDM definitions. unshackle matches each entry by its
name(referenced fromcdm), and itstypeselects the backend:typeBackend Key fieldsdecrypt_labsDecrypt Labs KeyXtractorhost,device_name,secret(fields)custom_apiFully YAML-configurable remote APIhost(required),device,auth,endpoints,request_mapping,response_mapping,caching,timeout(fields) (none),Device Type: PLAYREADYpyplayreadyRemoteCdmhost,secret,device_name,security_level(default 3000) (none), otherwise pywidevineRemoteCdmhost,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\nFor 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.Serve APIs behind a proxy
unshackle does not send upstream's
HEADrequest or examine theServerresponse header, so the server can operate behind a reverse proxy or a CDN that removes or changes that header. Thus ahostthat is not a serve API fails at the first licence request, not when unshackle loads the CDM. On a redirect to a different host or port, unshackle removes thesecretfrom the request.PlayReady
hostneeds a/playreadysuffixpyplayready's
"},{"location":"reference/configuration/drm/#remote-cdm-decrypt-labs","title":"RemoteCdmtreatshostas a base URL and appends its own endpoint paths, so a PlayReady entry'shostmust include the trailing/playreadysegment (for examplehttps://cdm.example.com/playready). Without it the server returns404rather than a configuration error.decrypt_labsfields","text":"Field Default Noteshosthttps://keyxtractor.decryptlabs.comdevice_nameChromeCDMChromeCDM,L1,L2(Widevine) orSL2,SL3(PlayReady)secretfromdecrypt_labs_api_keySent as thedecrypt-labs-api-keyheader. An error is raised if neither is set.system_id26830(Widevine),0(PlayReady)security_levelWidevine3; PlayReady2000forSL2, else3000unshackle uses PlayReady mode when
"},{"location":"reference/configuration/drm/#remote-cdm-custom-api","title":"device_typeisPLAYREADYordevice_namestarts withSL.custom_apifields","text":"Field Type Noteshoststr Required. Base URL of the API.devicedictname,type(CHROME,ANDROID,PLAYREADY),system_id,security_levelauthdicttype(defaultheader):headerusesheader_name(defaultAuthorization) andkey;bearerusesbearer_tokenorkey;basicusesusernameandpassword.custom_headersis merged in for any type.endpointsdictget_requestanddecrypt_response, each{path, method, timeout}request_mappingdict Per endpoint:param_names,static_params,conditional_params,transforms,nested_params,exclude_paramsresponse_mappingdict Per endpoint:fields,transforms,response_types,success_conditions,error_fields,key_fieldscachingdictenabled,use_vaults,check_cached_firstlegacydict Legacy mode options.timeoutint Default30. Request timeout in seconds.Transforms cover base64/hex/JSON encoding and
kid:keyparsing. unshackle uses PlayReady mode whendevice.typeisPLAYREADYor the device name isSL2/SL3."},{"location":"reference/configuration/drm/#decryption","title":"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\ndecryption","text":"
- Type:
strordict\u00a0\u00b7\u00a0 Default:\"shaka\"Selects the tool that physically decrypts CENC-encrypted tracks. unshackle compares the value case-insensitively.
\"mp4decrypt\"selects Bento4's mp4decrypt, and anything else (including\"shaka\") selects shaka-packager. When the value is a dict, unshackle compares the service tag case-insensitively and uses thedefaultentry as the fallback.unshackle decrypts HLS AES-128 ClearKey in-process and ignores this setting.
GlobalPer-servicedecryption: shaka\n"},{"location":"reference/configuration/drm/#decrypt_segments","title":"decryption:\n default: shaka\n EXAMPLE1: mp4decrypt\ndecrypt_segments","text":"
- Type:
bool\u00a0\u00b7\u00a0 Default:falseDecrypts each fragmented MP4 segment as it arrives, instead of decrypting the whole track after the download ends. The decryption runs during the download, so the wait after the last segment goes away. The output file is the same either way.
This works with
mp4decryptonly. Setdecryptiontomp4decryptfor the service (or as the default); withshakathe option has no effect, because shaka-packager cannot decrypt a segment without its init segment. It applies to DASH tracks and to HLS tracks that use one init segment, oneEXT-X-KEYand noEXT-X-DISCONTINUITY. Every other track keeps the whole-file decryption.No resume
A track that decrypts segment by segment always downloads from the start.
continue_downloadscannot reuse its segments, because a reused segment is already decrypted."},{"location":"reference/configuration/example/","title":"A complete annotated example","text":"decrypt_segments: true\nThe following config sets a handful of common config keys. Every config 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\nFor 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 answer with, see What each metadata provider supplies.
Key Type Default Descriptionomdb_api_keystr\"\"OMDb API key for IMDb metadata lookups; a fallback for when the keylessimdbprovider has no answer. Free keys are available on the OMDb site.tmdb_api_keystr\"\"TMDB API key for metadata enrichment and external-ID tags.simkl_client_idstr\"\"SIMKL client ID for metadata lookups; an alternative/fallback source to TMDB.tvdb_api_keystr\"\"TheTVDB v4 API key; a fallback source to TMDB, strongest on TV series. Also the source--enrich --tvdbreads. Free keys are available on the TVDB site.tvdb_pinstr\"\"Subscriber PIN, only needed for a user-supported TVDB key. Leave empty for a normal project key.tvdb_orderstr\"\"Default for--tvdb-order: renumber episodes to a TVDB season order (official,dvd,absolute,alternate,regional). Empty keeps the numbering the service gives.metadata_providerslist or map (see below) Metadata providers to use, in the order they are tried.anilist_title_languagestrenglishWhich AniList title variant to use:english,romaji, ornative.disable_metadataboolfalseSet totrueto stop all automatic metadata lookups. An ID you give yourself is still looked up.decrypt_labs_api_keystr\"\"Global Decrypt Labs API key (used by remote CDM / vault).ipinfo_api_keystr\"\"ipinfo.io API key for IP/region lookups.How
metadata_providersis appliedA lookup goes to each metadata provider in turn and stops at the first good match, so the order decides which source wins.
metadata_providerssets that order:metadata_providers: [tvdb, tmdb, simkl]\nNames 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]\nWhatever the order, unshackle skips a metadata provider that has no API key.
imdbandanilistneed no API key, so they are always available. Available names areimdb,omdb,simkl,tmdb,tvdb, andanilist. The old nameimdbapistill works and is read asimdb.
anilistis 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_languagepicksAniList 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\nA 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_metadataturns offWith
disable_metadata: true, unshackle never contacts a metadata provider on its own. The title search is dead, and that includes the keylessimdbmetadata provider. Titles, years and languages then come only from the service.disable_metadata: true\nAn ID you give with
--tmdb,--imdb,--tvdb, or--anilistis your permission to use that ID. That lookup still works. unshackle looks the ID up directly, cross references the other IDs from it, and--enrichstill reads that source. unshackle stops only the lookups you did not ask for.The
imdbprovider needs no keyIt replaces the earlier
imdbapimetadata provider, which read a third-party mirror (api.tiffara.com) and needed theimdb_api_enabledswitch. 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,
tvandmovie, and this example sets both of them and names every metadata provider, so nothing is left to the default:metadata_providers:\n tv: [tvdb, tmdb, simkl, imdb, omdb]\n movie: [tmdb, imdb, omdb, simkl, tvdb]\nEach list is independent: it both orders and filters that kind on its own, so unshackle still uses for
moviea metadata provider you leave out oftv. Here unshackle tries TVDB 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
tvcovers every episode andmoviecovers every film. unshackle only looks up metadata for those two kinds, so unshackle ignores any other config key, such asanimeormusic.
ipinfo_api_keynever touches your service sessionsunshackle sends the token only to
"},{"location":"reference/configuration/misc/#caching-updates","title":"Caching & updates","text":""},{"location":"reference/configuration/misc/#title-cache","title":"Title cache","text":"api.ipinfo.io, as a per-requestAuthorizationheader. It never goes on your service HTTP session, so it cannot leak to a streaming service. 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 \u2192ip-api.inas a last resort.unshackle caches fetched title metadata (region- and account-aware) to avoid repeat API calls.
Key Type Default Descriptiontitle_cache_enabledbooltrueMaster switch for the title cache.title_cache_timeint (seconds)1800(30 min) Lifetime of fresh cached titles.title_cache_max_retentionint (seconds)86400(24 h) Total retention window for a cached title, counted from when it was cached. Pasttitle_cache_timethe 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 Descriptionupdate_checksbooltrueWhether to check for new unshackle releases.update_check_intervalint (hours)24Minimum hours between update checks.Note
Update checks query the GitHub releases API with a fixed 5-second timeout and cache the result in
"},{"location":"reference/configuration/misc/#appearance","title":"Appearance","text":"Key Type Default Descriptiondirectories.cache/update_check.json.themestrcatppuccin-mochaNamed colour theme for all CLI output: banner, logs, progress bars, and help screens. One ofcatppuccin-mocha(aliasesdefault,catppuccin,mocha),dracula,nord,gruvbox,one-dark(aliasonedark),tokyo-night(aliastokyonight),monokai,flexoki,solarized-dark(aliassolarized). Case-insensitive; underscores work in place of hyphens. Unknown names fall back to the default with a warning. Preview them all withunshackle env theme."},{"location":"reference/configuration/misc/#logging-privacy-debug","title":"Logging, privacy & debug","text":"Key Type Default Descriptionredact_pathsbooltrueMask install-root / venv / home prefixes in logged paths (<unshackle>,<venv>,~). Setfalseto show full paths.debugboolfalseGlobal debug mode.debug_keysboolfalseLog decryption keys.debug_requestsboolfalseLog HTTP requests.set_terminal_bgboolfalseAppend the theme's background colour to output styling.Effect of
set_terminal_bgWhen on, unshackle appends the theme's background colour 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_keyson, unshackle writes the content keys to the structured debug log at INFO level during the DRM handshake. Treatdebug,debug_keys, and theunshackle_debug_*.jsonlfiles as sensitive, and keepredact_pathsenabled when sharing logs."},{"location":"reference/configuration/misc/#deprecated-removed-keys","title":"Deprecated & removed keys","text":"Key Status Behaviour
debug_keysaffects only content-encryption keys (thecontent_key/keyfields). Passwords, tokens, cookies, and session tokens are always redacted regardless of this setting, and KIDs, content key counts, and other metadata are always logged either way.curl_impersonateDeprecated \u2192 usenetworkEmits aDeprecationWarning; still honoured only ifnetworkis absent.downloaderDeprecated Any value other than\"requests\"emits aDeprecationWarning; the value is otherwise ignored (the unified requests downloader is always used).scene_namingRemoved If set to any non-null value, unshackle exits with an error directing you to configureoutput_templateinstead.imdb_api_enabledRemoved Ignored. It gated the oldimdbapiprovider, which the keylessimdbprovider 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 HTTP session.
Sub-key Type Default Descriptionbrowserstr\"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_onlybool (unset) Force HTTP/1.1.http2_onlybool (unset) Force HTTP/2.connect_timeoutint10Connect timeout in seconds.read_timeoutint30Read timeout in seconds (max gap between response chunks).timeoutint (unset) Total request timeout in seconds.allow_redirectsbool (unset) Follow redirects automatically (rnet client default when unset).pool_idle_timeoutint55Seconds an idle pooled connection is kept (default stays under typical ~60s CDN idle kills).pool_max_idle_per_hostint64Max idle connections kept per host.pool_max_sizeint (unset) Max total connections in the pool.tcp_keepaliveint30TCP keepalive time in seconds.tcp_keepalive_intervalint (unset) TCP keepalive probe interval in seconds.tcp_keepalive_retriesint (unset) TCP keepalive probe retry count.tcp_nodelaybool (unset) Disable Nagle's algorithm.network:\n browser: Firefox135\n http2_only: true\nMeasured effect of
http1_onlyBoth
http1_onlyandhttp2_onlyare 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_impersonateThe old
"},{"location":"reference/configuration/network/#headers","title":"curl_impersonatesection is a deprecated alias. If you still use it, unshackle honours it (only whennetworkis absent) but emits aDeprecationWarning. Rename it tonetwork.headers","text":"
- Type:
dict\u00a0\u00b7\u00a0 Default:{}Default HTTP headers merged into every HTTP session unshackle makes.
headers:\n Accept-Language: en-US,en;q=0.9\nDon't set
Accept-Encoding(and similar) hereCompatibility headers such as
"},{"location":"reference/configuration/network/#proxy_providers","title":"Accept-Encodingare set by the rnet HTTP backend as part of its browser-impersonation profile. If you override them, you break the impersonation fingerprint. This block is for cross-service defaults only (for exampleAccept-Language,User-Agent). Per-service headers belong in that service's own config.proxy_providers","text":"
- Type:
dict\u00a0\u00b7\u00a0 Default:{}Proxy/VPN provider configuration. Each sub-key names a proxy provider, and unshackle passes its block straight to that provider's constructor. See Proxies & VPN for the full proxy provider guide. Recognised proxy providers and their exit ports:
Provider Config key Credentials Proxy scheme/port Basic (static)basiccountry \u2192 URI(s) as specified Control DcontroldWrite API token (profile/resolver pairs optional)http://127.0.0.1:{port}(local forwarder) NordVPNnordvpnservice credentials (48 chars combined)https://...:89Surfsharksurfsharkvpnservice credentials (48 chars combined)https://...:443Windscribewindscribevpnservice credentialshttps://...:443ExpressVPNexpressvpndevice login (enable: true) / token cachehttps://cat:...@...:443ProtonVPNprotonvpnTV login or exported cookieshttps://...:4443(or:443Secure Core) Gluetungluetunper-VPN keys/credshttp://localhost:{port}(local Docker) Hola (none, auto) nonehttp://...:{peer}"},{"location":"reference/configuration/network/#controld","title":"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>\ncontrold","text":"Config keys of the Control D proxy provider. See Control D for how unshackle uses them.
Key Type Default Meaningtokenstrrequired A Control D API token of type Write.profilestrnone Optional. The ID of a profile you made for unshackle. Give it together withresolver. Without it, unshackle createsunshackle-<region>profiles itself.resolverstrnone Optional. The resolver ID of an endpoint that usesprofile, or its DoH URLhttps://dns.controld.com/<resolver>.profileslist[]Optional. More pairs, each a mapping withprofileandresolver.max_profilesint4The most profiles unshackle uses: your pairs plus theunshackle-*endpoints on the account. It must be 1 or more. unshackle creates anunshackle-<region>profile and endpoint only while the total is lower.Provider loading differs between CLI and REST server
The
"},{"location":"reference/configuration/output/","title":"Output & naming","text":"dlCLI loads all providers, includinggluetun. The REST API / remote-client path does not loadgluetun, because it starts a local Docker container. ExpressVPN and ProtonVPN also auto-load when their cached session exists, and Hola auto-loads whenever thehola-proxybinary is present.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;dlexits with an error if this is missing)Filename templates keyed by media kind, using
unshackle.yaml{variable}placeholders. Recognised kinds aremovies,series, andsongs(andalbumsfor folder templates). A trailing?on a variable (e.g.{quality?}) marks it optional: if empty, it and one adjacent separator are dropped.output_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})\"\nThe nested
foldersub-key controls output subfolders, and unshackle pops it out ofoutput_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 (
All valid template variables< > : \" / \\ | ? *), empty templates, and unknown folder-kind keys.
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}
partholds 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 shippedseriestemplate 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"},{"location":"reference/configuration/output/#tagging-naming-keys","title":"Tagging & naming keys","text":"Key Type Default Description
absoluteholds the absolute episode number, zero-padded to 3 digits (007), and is empty on titles that have none. You add it to the name yourself, and it never replaces{season}or{episode}. Any series with a TVDB absolute order can use it, anime most often. Services can supply it, and--enrichfills it in from TVDB. Use{absolute?}so the name stays clean when it is unknown.tagstr\"\"Release-group tag. Fills the{tag}template variable and is written into MKV/audio tags.tag_group_namebooltrueInclude the group name (tag) in the MKVGrouptag.tag_imdb_tmdbbooltrueWrite IMDb/TMDB/TVDB external-ID tags into the MKV (needs metadata providers).chapter_fallback_namestr\"\"Fallback template for chapter names, e.g.\"Chapter {i:02}\".unicode_filenamesboolfalseAllow Unicode in filenames; whenfalse, names are ASCII-sanitised."},{"location":"reference/configuration/output/#tag_rules","title":"tag_rules","text":"
- Type:
list\u00a0\u00b7\u00a0 Default:[]Conditionally replaces
unshackle.yamltagfor a given download. Each rule pairs awhendict of conditions with thetagto use when they hold. unshackle evaluates the rules in order and the first match wins. If no rule matches, the globaltagstays.tag_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\nRule semantics:
- Every condition in
whenmust hold (they are AND-ed).- A value can be a scalar or a list. A list matches if any entry matches.
- Comparison is case-insensitive string equality.
- The keys of
whenare the filename template variables for the release itself:title_type,quality,resolution,source,video,hdr,hfr,audio,audio_channels,audio_full,atmos,dual,multi,dubbed,edition,repack, andlang_tag. Only these are available. unshackle adds the title's own fields (title,year,season,episode, and so on) after it evaluates the rules, so a condition cannot use them.- An unknown key in
whenis a mistake: unshackle logs a warning that names the key and skips that rule. unshackle also warns about a rule without conditions or without atag, and skips it.Put the more specific rules first, because unshackle stops at the first match.
Match
hdron the exact valueMatching is exact, and
"},{"location":"reference/configuration/output/#operators","title":"Operators","text":"hdris a composite label for Dolby Vision titles: a DV title with an HDR10 base layer givesDV.HDR, and with an HDR10+ base layerDV.HDR10P. PlainDVdoes not match those. Name every variant you want, as the example above does. Note also that HDR10 givesHDR, notHDR10. The full set of values isHDR,HDR10P,DV,DV.HDR,DV.HDR10P, andHLG, as listed in the variable table.A condition value can start with a comparison operator, which unshackle applies 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 of2160p. 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, soquality: >=2160fails with a \"while scanning a block scalar\" error.!starts a tag, sohdr: !=DVfails 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_typeis the media kind of the title:movie,series, ormusic. 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\nThe resolved tag applies to the
{tag}filename variable only. TheGroupmetadata tag written into MKV and music files always usestag.
--tagwinsAn explicit
"},{"location":"reference/configuration/post-scripts/","title":"Post-scripts","text":"dl --tagon the command line disables the rules completely and uses the given tag.Operate your own script when a download finishes, with unshackle's metadata passed in as
"},{"location":"reference/configuration/post-scripts/#post_scripts","title":"{variable}placeholders. Use it to hand a finished file to an uploader, send a notification, or start a library scan.post_scripts","text":"unshackle.yaml
- Type:
list[dict]\u00a0\u00b7\u00a0 Default:[](unshackle operates no scripts)post_scripts:\n - command: \"python /opt/upload.py {filepath} --service={service} --season={season} --episode={episode} --tmdb={tmdb} --subs={sidecars}\"\n event: success # success | failure\n mode: file # file | season | run\n - command: \"python /opt/notify.py --title={title} --episode={episode}\"\nEvery entry that matches the event and mode runs, so one download can feed an uploader and a notifier without a wrapper script. You can also write an entry as a plain string, which is the same as
Key Default Meaning{command: \"...\", event: success, mode: file}.commandrequired The command to run, with{variable}placeholderseventsuccesssuccesswhen a file was written,failurewhen the download failedmodefilefile,season, orrun. See Modeswaitfalsetruemakes unshackle wait for the script to exit before carrying on. See Waiting for a scriptunshackle skips an entry whose
"},{"location":"reference/configuration/post-scripts/#how-the-command-runs","title":"How the command runs","text":"eventormodeis not one of the listed values, and logs a warning that names the bad value, once per command. unshackle also skips an entry whosecommandit cannot split into arguments, such as one with an unbalanced quote, and logs a warning. Neither stops the download or the other entries.unshackle splits the command into arguments first, and then substitutes the variables into those arguments. The process runs without a shell.
This is what keeps a title like
Bob\"; rm -rf ~harmless: a value can never turn into an extra argument or a shell operator, and you never have to think about quoting. It also means a path containing spaces always arrives as a single argument on every OS.Prefix your variables
Titles, episode names and IDs come from the streaming service, not from you. Tokenizing first stops a value becoming a new argument, but a value on its own can still be a whole argument. A bare
{title}whose value is--upload-file=/etc/passwdwould arrive as an option to your script, not as text. Write--title={title}, not a bare{title}, so a service can never forge a flag. unshackle refuses to operate a command when a substituted value would become an option-like token (one that starts with-), and logs a warning instead.No shell features, and name your interpreter
Pipes,
&&,>redirection,~and globs do not work, because unshackle uses no shell. If you want them, make the shell explicit:bash -c \"...\".For the same reason there is no interpreter lookup. Write
python /opt/upload.pyrather than/opt/upload.py. unshackle will not guess whether you meant the system Python or the one in its own virtual environment.On Windows, when a fixed part of an argument contains a space, quote the whole argument (
\"--out=C:\\My Dir\\{filename}\"), not only the value. Substituted values never need quoting on any OS.On Windows, do not target a
.bator.cmdfile directly. Windows runs batch files throughcmd.exe, whose argument quoting differs from every other program and has a history of letting a crafted value break out (CVE-2024-3566). Call the interpreter and pass the script as an argument (python upload.py,pwsh -File upload.ps1) instead.By default, scripts are fire and forget. unshackle starts the process, writes the command to the debug log, and carries on. It does not wait for the script, capture its output, time it out, or change its own exit code when a script fails. A script keeps running if unshackle exits first, and Ctrl+C during a season pack does not kill the scripts already started.
One process per file
In
"},{"location":"reference/configuration/post-scripts/#wait","title":"Waiting for a script","text":"unshackle.yamlfilemode a large season pack starts one process per episode with nothing throttling them. If your script is heavy, setwait: true, queue the work inside the script, or usemode: season.post_scripts:\n - command: \"python /opt/upload.py --file={filepath}\"\n wait: true\nWith
"},{"location":"reference/configuration/post-scripts/#variables","title":"Variables","text":"wait: true, unshackle waits until the script exits, then writes the exit code to the debug log and carries on. Because thefilepost-script runs between titles, unshackle runs one script at a time over a season pack instead of starting them all at once. unshackle only logs the exit code: a failing script does not fail the download or change unshackle's own exit code.--postscriptentries never wait.Metadata comes from the same naming context that produced the file's name, built per output file. So
Variable Value{quality}and{hdr}always give the values for the file unshackle passes in. Downloading-q 1080,2160 -r HDR10,SDRproduces four files, each with its own invocation and its own values.{filepath}Absolute path of the output file{filename}Its filename, with extension{folder}Absolute path of its parent folder{ext}Its extension, including the dot{sidecars}Sidecar subtitle files written for this output, newline separated{title}Show, movie or track name, sanitized{title_raw}Exactly what the service returned, untouched{title_id}The service's ID for the title{service}Service tag{year}{season}{episode}{episode_name}Season and episode are plain numbers (1,5){part}Part number of a split episode as a plain number (1forPart.1). Empty when the episode has no part{season_episode}{absolute}{date}As in the filename template:{season_episode}keeps the part suffix (S01E05.Part.1) while{episode}drops it{quality}{resolution}{hdr}{hfr}{vcodec}{acodec}{edition}{tag}{lang_tag}As in the filename template, for this file{tmdb}{imdb}{tvdb}The tagging IDs in use, otherwise empty. See Tagging IDs{error}Failure message. Empty on successEvery variable from your
output_templateis available here as well, including{video}and{audio}, of which{vcodec}and{acodec}are aliases.Empty variables become an empty string
--tmdb={tmdb}with no ID resolved hands your script--tmdb=, so treat an empty string as absent. Write your script to accept an empty value for every variable.Empty is common, and not only when data is missing:
- No metadata provider found an ID.
{tmdb}is empty when no metadata provider found the title, the matching API key is missing, or the title is a movie you gave no ID for.- The title has no such field. Movies have no
{season},{episode}or{part}, and music has none of them.- The naming context renders it empty on purpose.
{hdr}is empty for an SDR file, because SDR is the absence of an HDR tag in a filename rather than a tag that readsSDR.{edition},{atmos},{multi},{dual}and{hfr}behave the same way. If your script needs the wordSDR, derive it from an empty{hdr}yourself.- The post-script is a
failureone. A failed download has no output file to read metadata from, so everything that comes from the output file is empty. See Events.There is no
{variable?}conditional here, unlikeoutput_template. The token is always passed, so the argument list your script receives keeps the same shape on every run and your argument parser does not have to cope with a flag appearing and disappearing.Season and episode are the plain numbers, not the
"},{"location":"reference/configuration/post-scripts/#music-variables","title":"Music variables","text":"S01E05form used in filenames. The padding and the separator belong to the filename template rather than to the data.A music download builds its variables from the music naming context, so a post-script gets the release fields instead of the season and episode ones.
Variable Value{season},{episode},{part}and{episode_name}are always empty for music.{artist}Track artist{album_artist}Album artist, falling back to the track artist{album}Album name{track_number}{disc}Zero padded.{disc}is empty on a single-disc release{track_total}{disc_total}Zero padded totals, empty when the service did not give them{isrc}{upc}{label}{genre}As the service reported them, empty when absent{release_type}album,single,ep, and so on. A service that reports nothing givesalbum{explicit}The wordExplicitwhen the track is flagged, otherwise emptyIn a
filepost-script{title}is the track name. See Modes for what an album post-script puts in{title}."},{"location":"reference/configuration/post-scripts/#tagging-ids","title":"Tagging IDs","text":"
{year}is empty when the service gives the release no year.{ext}is the container the track arrived in, such as.flac, because a music download is not muxed.
{tmdb},{imdb}and{tvdb}carry the IDs unshackle tags the file with. unshackle reads them at the moment it dispatches the post-script, and not at the start of the run. The title search inside the download loop finds an episode's TMDB ID, so a post-script fires with it already filled in.They hold an ID you passed with
"},{"location":"reference/configuration/post-scripts/#sidecar-files","title":"Sidecar files","text":"--tmdb,--imdbor--tvdb, plus a TMDB ID the episode search resolved. No such search runs for a movie, so a movie post-script gets them empty unless you passed one.
{sidecars}holds the sidecar subtitle files written beside the output, which are the only extra files a download leaves next to it. unshackle muxes attachments such as fonts into the container and deletes temporary artwork, so neither appears. Expect{sidecars}to be empty unlesssubtitle.output_modeissidecarorboth, which is not the default. A music download writes no sidecars at all.Paths are absolute and separated by a newline, because a newline is the only character that cannot appear in a filename on any supported OS. The whole list arrives as a single argument, so your template needs no quoting.
When one download writes several outputs (
Reading {sidecars} in Python-q 1080,720), each output's folder gets its own copy of the sidecar files, and each invocation lists the copies sitting beside its own{filepath}."},{"location":"reference/configuration/post-scripts/#modes","title":"Modes","text":"subs = [p for p in sys.argv[i].removeprefix(\"--subs=\").split(\"\\n\") if p]\nmodeRuns{filepath}{sidecars}fileOnce per output file That file That output's sidecarsseasonOnce per season folder, after that season's last queued title Empty EmptyrunOnce per folder written, at the end of the run Empty Empty
seasonmeans everything this run asked forA
seasonpost-script fires when the last title this run queued for that season finishes. With-w S01-S02the S01 post-script runs while S02 is still downloading. It says nothing about whether the season is complete on disk. Downloading a single episode fires the post-script for that season as soon as the episode lands.A title that fails stops the whole run. unshackle dispatches the
failurepost-script for that title and then returns. It downloads no further title, and noseasonorrunpost-script fires after that point. This applies to every title type. If one episode of S01 fails, the S01 post-script does not fire at all.unshackle attaches the post-script to the folder, not to the season, so one season landing in several folders (
-q 1080,720with a quality-dependent folder template) fires it once per folder, each with its own{folder}. A movie is a group of its own: aseasonpost-script fires for a movie's folder as soon as the movie finishes.In
"},{"location":"reference/configuration/post-scripts/#music","title":"Music","text":"runmode every variable except{folder}is empty, because one run can cover several shows. Only{folder}is meaningful, which makes it useful for starting a library scan once.Music uses the same three modes, with the album standing in for the season.
modeRunsfileOnce per track, after its metadata has been writtenseasonOnce per album folder, after every queued track of that album has landedrunOnce per folder written, at the end of the runIn a
filepost-script{title}and{title_raw}are the track name. In an album post-script they are the album name, and the per-track variables{track_number},{disc}and{isrc}are empty, in the same way that{episode}is empty in a season post-script.An album post-script obeys the same rule as a season post-script. It fires when the last track this run queued for that album lands. This does not mean that the album is complete on disk. A one-track download fires the album post-script as soon as that track lands. If a track fails, the run stops there, so the album post-script never fires, in the same way as an episode and its season.
"},{"location":"reference/configuration/post-scripts/#events","title":"Events","text":"
successruns after the file has moved to its final path.failureruns when the download fails, with{filepath}empty and{error}set. Each track of an album is its own title. A musicfailurepost-script therefore describes the track that failed, not the release.
failureonly fires infilemodeA
failureentry withmode: seasonormode: runnever runs. Leavemodeat its default on a failure entry.A
failurepost-script has no output file to read metadata from, so it carries only what the title object already knew:{title},{title_raw},{title_id},{year},{season},{episode},{part},{season_episode},{absolute},{date},{episode_name}, the music release fields such as{artist}and{album},{service}, the tagging IDs and{error}. Everything that comes from the output file, such as{quality},{hdr},{vcodec}and{acodec}, is empty.A resumed
--continue-downloadsdownload that finishes runssuccessas usual. unshackle sees your script's exit code only when that entry setswait: true, and even then it only writes the code to the debug log. Nothing else reports a failing script. Your script owns its own error handling and logging.A
"},{"location":"reference/configuration/post-scripts/#-postscript","title":"successpost-script always names a file this run wrote. There is no skip-if-exists path indltoday, so nothing fires for a title that was not downloaded.--postscript","text":"
dlaccepts--postscript \"<command>\", repeatable. Every command given this way runs withevent: successandmode: file.$ unshackle dl --postscript \"python /opt/upload.py {filepath}\" SERVICE TITLE_ID\nIt replaces the
"},{"location":"reference/configuration/post-scripts/#-no-postscript","title":"post_scriptsconfig for that run rather than adding to it, so no configured entry runs while it is in use. That includes yourseason,runandfailureentries, which have no--postscriptequivalent.--no-postscript","text":"
dl --no-postscriptoperates the run with no post-script. It silences every configured entry and every--postscriptcommand, for each event and mode. Use it to skip an uploader or a notifier for one run without an edit tounshackle.yaml."},{"location":"reference/configuration/post-scripts/#the-rest-api","title":"The REST API","text":"$ unshackle dl --no-postscript SERVICE TITLE_ID\nThe API never accepts a post-script command.
postscript,post_scriptandpost_scriptsin aPOST /api/downloadbody get400 INVALID_PARAMETERS. A command that arrives from an HTTP caller would be remote code execution.unshackle does operate the scripts defined in
"},{"location":"reference/configuration/post-scripts/#logging","title":"Logging","text":"unshackle.yamlfor API jobs, music jobs included. Only the command itself may not cross the network.unshackle logs the command it operated twice: at
DEBUGlevel on the console, and as apost_script_dispatchentry in the structured JSON debug log when that log is on.unshackle masks both the same way, and honours
redact_paths, which is on by default. Debug logs get shared in issue reports, and a post-script command line is mostly paths. URL credentials andtoken=/api_key=query parameters are also masked.Do not hardcode secrets in the command
unshackle cannot recognise an arbitrary secret in the command line, such as
"},{"location":"reference/configuration/post-scripts/#limitations","title":"Limitations","text":"-H \"Authorization: Bearer <key>\", and logs it verbatim. Read the secret from a file or an environment variable inside your script rather than putting it in thecommand."},{"location":"reference/configuration/services/","title":"Services & authentication","text":""},{"location":"reference/configuration/services/#services","title":"
- No
successpost-script operates under--no-muxor--skip-dl, because neither writes a muxed output. Afailurepost-script still runs under--no-muxif the download itself fails.failurepost-scripts only operate infilemode.- There is no webhook sender. Call
curlfrom a script instead.- Script output is not captured. Redirect it inside your own script if you want a log.
services","text":"
- Type:
dictkeyed 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/--proxyfor this service. A config key is that query, or\"provider:query\"when the flag also named a proxy provider. The value is the query asked of the proxy provider instead. It has no effect when you give-pa 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 globaldlblock.Individual services may read any additional keys they define. unshackle hands the merged result (the service's own
config.yamlwith this block layered on top) to the service asself.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."},{"location":"reference/configuration/services/#credentials","title":"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\"\ncredentials","text":"
- Type:
dictkeyed 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/--profileselects the entry, and unshackle uses thedefaultconfig key when you give no profile, or when the named profile is missing. unshackle parses these intoCredentialobjects. It also uses the credential's SHA-1 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\nCookies vs credentials
unshackle stores cookies as files under
"},{"location":"reference/configuration/services/#firefox_cookies","title":"directories.cookies, not in this config key. A service'sauthenticate()accepts cookies, credentials, or both.firefox_cookies","text":"
- Type:
dictkeyed by service tag \u00a0\u00b7\u00a0 Default:{}Settings for extracting cookies directly from a local Firefox profile. A service block must give
hosts, a list of cookie hostnames. unshackle ignores an entry shorter than 3 characters, to prevent a broad match dumping most of the profile. With an optionallocal_storageboolean, unshackle also pulls matching entries fromwebappsstore.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.sqliteand its WAL file into a0700temp directory, so the copy holds the writes Firefox has not yet flushed to the main DB. 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 (
"},{"location":"reference/configuration/services/#remote_services","title":"cookies/<SERVICE>.txtorcookies/<SERVICE>/<profile>.txt), with no error reported.remote_services","text":"
- Type:
dict\u00a0\u00b7\u00a0 Default:{}Definitions of remote unshackle service servers, used by the
--remotemode. Each entry is named by you (pick it with--server, or do not write that flag when you configure only one) and gives the server'surl(required), an optionalapi_key, an optionalauth_headerslist, an optionalserver_cdmboolean, an optionaltimeoutin seconds, and an optionalservicessub-dict of per-service local overrides such astitle_map.Leave
server_cdmunset and the client follows the server: it uses the server CDM for each service the API key has it on, and the local CDM for the others.trueasks for the server CDM and falls back to the local CDM when the server refuses;falsealways uses the local CDM.dl --cdm <name>on a remote run has the same effect asfalsefor that run: the device you name licenses the remote session. Acdmentry for the service has the same effect for that service when it names the device that unshackle loaded. Both take priority overserver_cdm: true. Remove the entry to use the server CDM for that service. Thedefaultentry does not count.The server has the last word. When it keeps the server CDM for a remote session that your device was to license, unshackle shows a warning. This occurs with a server that does not read your choice, and with a service option that runs the remote session on the server's device.
auth_headerslists extra header names to send the API key in, tried before the defaultsX-Secret-KeyandX-Api-Key, which unshackle always appends as fallbacks. It sends the first name. If the server answers401, it retries the same request with the next name, and keeps the one that works for the rest of the HTTP session. Names you give keep your spelling and are not repeated in the fallbacks, so unshackle triesauth_headers: [\"Authorization\", \"x-secret-key\"]asAuthorization,x-secret-key,X-Api-Key.
timeoutis the time in seconds that the client waits for data from the server. The default is120. The server sends a heartbeat every 30 seconds while it gets titles, tracks or licences, so a slow service does not cause a timeout. Increasetimeoutonly when the server runs an older version that sends no heartbeat. Give a number of seconds. With a value below 30 the client can time out before the first heartbeat.0means the default.In
"},{"location":"reference/configuration/services/#serve","title":"--remotemode unshackle turns the server's service list into synthetic CLI commands that operate against it, falling back to the tags in thatservicessub-dict when unshackle cannot fetch the list. Each synthetic command carries the server-side service's options and documentation, sounshackle dl --remote <TAG> -hshows the same help text as it does on the server. Service names resolve against the server's tags and aliases, not your local services. If the server does not offer a name to your API key, unshackle rejects the name and lists the tags it does offer. See remote sessions for the full setup.serve","text":"
- Type:
dict\u00a0\u00b7\u00a0 Default:{}Configuration for the
Sub-key Type Default Descriptionservecommand (the built-in REST API server). The full server guide is the REST API section. These are the config keys.api_secretstr (unset) Master secret the server accepts in theX-Secret-Keyheader. Required unless you start the server with--no-key.usersdict{}Per-user API keys and their allowlists (see below).tiersdict{}Named settings thatusersentries reference by name (see below).serviceslist (unset) Global service allowlist. Omit to allow all.remote_onlyboolfalseExpose only the remote service session endpoints (health, services, search, session) and disable the rest of the REST API.dashboarddict (unset) Developer dashboard:keyis the API key for the read-only/api/dashboard/endpoints (status, remote sessions, remote session logs, jobs, keys, services, health, logs, SSE events). Unset leaves those routes unregistered. See dashboard endpoints.session_ttlint (s)300Seconds a remote session can stay idle before it expires. A remote client sends keep-alive requests to its remote session during a download, so the remote session does not expire during a long download.max_sessionsint or null100Maximum concurrent remote sessions; at the cap the server evicts the oldest. Set it tonullor0for no limit, which is also what/api/dashboard/statusthen reports, so a dashboard can tell \"no cap\" from a cap that happens to sit at the default.history_limitint100How many finished jobs to retain in history.compression_levelint1gzip level for responses.services_refresh_intervalint (s)0How often the server pulls the git-backed service repositories indirectories.servicesand hot-reloads the services that changed.0turns it off. A service with a running or queued job, or a live remote session, is staged and swaps to the new code as soon as its last job and session finish.services_staged_max_ageint (s)0How long a staged service waits for its jobs and sessions before the server swaps it in anyway.0waits forever. Set it on a server whose popular services never have a quiet moment. A session that is live at the swap keeps the old code until it ends; a running job keeps its old code too.global_speed_limitstr (unlimited) Server-wide download speed cap, e.g.10M,1.5Gor plain bytes/sec (same format asspeed_limit). One shared budget across all concurrent jobs; the server ignores per-job speed limits while it is set.cdm_overrideslist or bool (unset) Allowed per-request CDM overrides: a list of permitted device names, ortruefor any. Unset rejects every override.allow_job_credentialsboolfalsePermit clients to supply credentials per job.server_accountsdict{}Services whose remote sessions use the server's owncredentialsand cookie files instead of the client's (see below).deviceslist (auto) Widevine devices offered; auto-filled fromdirectories.wvds.playready_deviceslist (auto) PlayReady devices; auto-filled fromdirectories.prds.Each entry under
usersuses that user's API key as its name, and can set its ownservices,devices, andplayready_devicesallowlists, narrowing the global ones, plus an optionalusernameused as the log label for that API key (defaults to a truncated form of the API key). A user with noplayready_devicesconfig key gets no PlayReady access at all, not the global list.
server_cdmdecides whether the server runs the CDM licensing for that API key. Set it totrueto enable every service, or to a list of service tags to enable only those. It isfalseunless the entry sets it. For a service the API key does not cover, the server tells a remote client configured withserver_cdm: trueto license with its own local CDM instead, and a client that asks anyway gets aFORBIDDENerror. Because a download job always licenses with the server's CDM, an API key withoutserver_cdmfor that service also cannot submit or retry/api/downloadjobs. Keys that have nousersentry, such asapi_secret, keep server CDM access.A remote client that picks its own device licenses the remote session with that device. It picks with
dl --cdm, with acdmentry for the service, or withserver_cdm: falsein itsremote_servicesentry. The server CDM makes no licence for that remote session, with or without a limit. A service option that runs the remote session on the server's device, such as a server-identity flag, keeps the server CDM.
server_cdm_max_heightlimits the live licences the server's device makes for that API key. Set it to a height in pixels for every service, or to a map of service tag to height with an optionaldefaultentry. A service the map does not name or sets tonullgets thedefault, and with nodefaultit has no limit. The server compares the limit with the lowest-qthat selects the track: the track height, or the 16:9 height from the width when that is lower. The server reads the remote client's-qwhen the remote session starts:
- With no
-q, or a-qat or below the limit, the server CDM licenses the remote session. The server refuses a live licence for a video track taller than the limit, and the client licenses that track with its own local CDM.- With a
-qabove the limit, the client's own local CDM licenses every track of the remote session. The service sees the DRM system and security level of the client's device. A client with no local CDM gets aSERVER_CDM_CAPPEDerror.- A download job must pass a
qualityat or below the limit, and must not setbest_availableor aHYBRIDrange.HYBRIDalways keeps the lowest Dolby Vision track, at any quality. A job with noquality, with a higher one, withbest_available, or withHYBRIDgets aSERVER_CDM_CAPPEDerror. The server checks the values the job runs with, after it applies the service defaults and theserve:download defaults.The limit protects the server's device, not the content keys. For a Widevine track, a server vault still supplies a content key at every height. A PlayReady track never reads the vault, so above the limit it always goes to the client's device. A licence for a lower track can carry the content key of a higher track when the service shares one content key between them. With server accounts enabled, a client that reports a stronger device than it holds makes the server's account request those manifests; the licence server still refuses its challenge.
server_vaultlets a remote session that the client's own device licenses take content keys from the server vault. Set it totruefor every service, or to a list of service tags. It isfalseunless the entry sets it, andserver_cdmfor a service implies it. The server looks up every KID of the track in its vault and makes no live licence. A track that the vault does not hold goes back to the client's device. When the service runs the remote session on the server's own device (a service option, such as a server-identity flag), the client's own vaults, and the server vault with this grant, are the only source, and a missing content key stops the download.
adminis a boolean that lets the API key run the maintenance endpoints (clear-cache, clear-temp, refresh-services). It isfalseunless the entry sets it. Keys that have nousersentry, such asapi_secret, keep that access.A
tiernames an entry underserve.tiers, which holds the settings that several API keys share. Today a tier carriesrate_limit, the requests per hour that API key may make; an API key can also set its ownrate_limit, which wins over its tier's. An API key with neither has no limit. Going over the limit answers429with aRetry-Afterheader. The server keeps a fixed window rather than a sliding one: it opens on the first request and resets an hour later. Atierthat names no entry underserve.tiers, or arate_limitthat is not a positive whole number, stops the server at startup, because the alternative is an API key that silently gets no limit. The rate limit never applies to the dashboard key, and never to/api/health.
server_accountslists, per service, the accounts the server lends to remote sessions. A remote session for a listed service ignores the credentials, cookies, and cache the client sends and uses one of the server's own profiles fromcredentials. For a service that is not listed the server never uses its own accounts: a client that sends nothing authenticates with nothing.credentials:\n EXAMPLE1:\n ca_main: user1@example.com:pw1\n eu_box: user2@example.com:pw2\n shared: user3@example.com:pw3\n\nserve:\n server_accounts:\n EXAMPLE1:\n ca_main: ca # one country\n eu_box: [gb, fr, de] # a list of countries\n shared: global # any region\n EXAMPLE2: true # every profile (or the single credential), any region\nEach entry under a service names a profile from
credentials, or a cookie filecookies/<TAG>/<profile>.txtfor a service that has no credentials. Its value is the region or regions that account works in: a two-letter country code, a list of them, orglobal. Quoteno(Norway), because YAML reads the bare word asfalse.The region of a remote session is the country the client asked for with
--proxy(ca,ca1,provider:ca), the server's own region with--no-proxy, or the client's own region otherwise. A full proxy URI carries no country, so with one only aglobalaccount matches. The server picks the profiles that cover that region (or areglobal) and rotates through them round-robin, one account per remote session. With no matching account it rejects the remote session. These services ignore the client's-p/--profile.Cookies come only from the profile's own file (
cookies/EXAMPLE1/ca_main.txt; the server does not read a barecookies/EXAMPLE1.txt), and each account keeps its own persistent cache undercache/_accounts/EXAMPLE1/<profile>/, so refreshed tokens survive the remote session but never cross accounts.serverefuses to start when a listed profile has neither an entry undercredentialsnor a cookie file./api/search,/api/list-titlesand/api/list-tracksuse the same pool, matched on the client's reported region, and returnFORBIDDENto an API key without the opt-in. Rotation is per process and does not track which accounts are in use, so concurrent remote sessions can share one account.A
usersentry opts in to those accounts withserver_accounts: true(every listed service) or a list of service tags. It is off unless set: an API key without it authenticates with the credentials the client sends, as for any other service, and/api/servicesdoes not advertise the server's accounts to it. API keys with nousersentry, such asapi_secret, keep access.
server_proxydecides whether that API key may use the proxy providers the server has configured. There is no implicit access: only the booleantrueon the API key'susersentry grants it, and an API key without ausersentry has no access either. Without the opt-in the server never spends its own proxy subscriptions on a remote client: not to resolve a country code inproxy, not to match the client's region, and not for a service geofence. This applies to remote sessions, search, title lists, and/api/downloadjobs alike. Such a client must send a full proxy URI, orno_proxyto accept the server's own connection. Setserver_proxy: trueto let that API key send a country code or aprovider:countryvalue, and to let the server pick a proxy for the client's region and for a service geofence. The client reports its own region; a client that reports none is not blocked.serve:\n api_secret: change-me\n remote_only: true\n services: [EXAMPLE1, EXAMPLE2]\n services_refresh_interval: 3600 # pull the service repositories hourly and reload what changed\n # server-wide download defaults (same keys as the `dl:` block)\n downloads: 3\n best_available: true\n tiers:\n bot: { rate_limit: 600 } # requests per hour, for every key on this tier\n users:\n a1b2c3d4: # this user's API key\n services: [EXAMPLE1] # may only use EXAMPLE1\n tier: bot # 600 requests/hour; 429 with Retry-After once over\n e5f6a7b8:\n server_cdm: true # this key may have the server do the licensing\n server_cdm_max_height: 1080 # above 1080p the client licenses with its own device\n c9d0e1f2:\n services: [EXAMPLE1, EXAMPLE2]\n server_cdm: [EXAMPLE1] # the server licenses only EXAMPLE1; EXAMPLE2 needs a local CDM\n server_accounts: [EXAMPLE1] # may authenticate EXAMPLE1 with one of the server's own accounts\n server_proxy: true # this key may also use the server's proxy providers\n f3a4b5c6:\n services: [EXAMPLE1]\n server_vault: [EXAMPLE1] # the client's device licenses; the server vault still serves keys\n
dlkeys insideserveMost
"},{"location":"reference/configuration/vaults/","title":"Key vaults","text":"dlflag keys (downloads,workers,best_available, and so on) can be set directly insideserve, 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.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 the content keys instead of licensing them again. Each entry needs a
type(the backend module name) and aname, plus backend-specific keys.typePurpose Required keys NotesSQLiteLocal SQLite databasename,pathLoaded critically; a failure aborts the run.MySQLRemote MySQL databasename,host,database,usernameExtra keys (e.g.password,port) forwarded to pymysql.connect_timeoutdefaults tovault_timeout.APIRESTful JSON APIname,uri,tokenOptionalheadersmap is sent with every request. Honoursvault_timeout.HTTPHTTP API with modesname,host, one ofpassword/api_key, andusernamein query modeapi_mode:query(default),json,decrypt_labs. Honoursvault_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\nPer-entry options
no_push: truemakes a vault read-only (unshackle fetches content keys from it but never writes to it).- A vault of
type: APIwhosenamecontainsdecrypt_labsauto-fills itstokenfromdecrypt_labs_api_keywhen not set inline. Vaulttypevalues are case-sensitive module names.- unshackle treats an all-zero content key (32 zeros) as \"no key\" everywhere and never stores it.
Bad content keys
A content key from a vault is proven by short FFmpeg decodes of the decrypted output, with windows inside the fragments of each KID in a fragmented MP4. A content key that fails the decode is written to the
"},{"location":"reference/configuration/vaults/#vault_timeout","title":"bad_keystable of everySQLitevault with the name of the vault that supplied it, and every vault lookup skips a flagged pair from then on. ASQLitevault also refuses to store a flagged pair again. Only theSQLitebackend stores flags; anAPIvault that supplied the pair gets a report. Aserveinstance withserver_cdmflags a pair the same way when a remote client reports it, so a server whosekey_vaultshold noSQLiteentry cannot remember a bad content key.vault_timeout","text":"
- Type:
float\u00a0\u00b7\u00a0 Default:10.0Timeout in seconds for vault operations. Injected automatically into any backend whose constructor accepts a
"}]} \ 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":"timeoutparameter (a per-vaulttimeoutstill wins). TheMySQLbackend uses it as the defaultconnect_timeout.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, and a built-in HTTP server.
Use responsibly
The GPL-3.0 licence covers unshackle. Do not use it for content you do not have the rights to. Keep the core free and open, keep your own services 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:
"},{"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, DTS-X, 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 Control D, 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":"
- 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.
- Get the content keys through a Widevine or PlayReady CDM, with optional key vaults that let unshackle reuse a content key instead of licensing it again.
- 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.
A git clone with the install script is the recommended way to install unshackle.
Linux (recommended)Windows (recommended)Advanced: uv toolgit clone https://github.com/unshackle-dl/unshackle.git\ncd unshackle\nsh install.sh\nuv run unshackle env check\ngit clone https://github.com/unshackle-dl/unshackle.git\ncd unshackle\n.\\install.bat\nuv run unshackle env check\nuv tool install git+https://github.com/unshackle-dl/unshackle.git\nunshackle setup\nThis installs unshackle into the tool folder of
uv, not a folder that you choose.unshackle setupthen puts the tools, config, and data in your user data and config directories.The install script installs
uvand unshackle, then runsunshackle setup. In a clone, run every command asuv run unshackle .... The examples on this site use the bareunshacklecommand.External tools
unshackle shells out to FFmpeg, MKVToolNix, shaka-packager, Bento4, and
"},{"location":"#a-first-download","title":"A first download","text":"dovi_toolfor decryption, repacking, muxing, and Dolby Vision work.unshackle setupdownloads them when a portable build exists for your system. See Installation for the full list and recommended versions.Every download is three parts: the
Download a 1080p title with English audiodlcommand, a service tag, and the service's title argument (a URL, ID, or slug).unshackle dl -q 1080 -l en EXAMPLE 81234567\nThe
"},{"location":"#where-to-go-next","title":"Where to go next","text":"dlcommand carries all the quality, language, and output flags. The service tag (EXAMPLEabove) selects which streaming service to talk to, and unshackle passes the value after it to that service. See the Quickstart to go from a fresh install to your first file.
- Installation. Install unshackle and the external tools it depends on.
- Quickstart. Configure
unshackle.yamland do your first download.- Downloading. The full
dlcommand guide: quality, codecs, languages, track selection, and output.- Configuration. Where
unshackle.yamllives, the directory layout, and how to edit values. Full config key list in the Configuration Reference.- REST API. Operate the
serveHTTP server and drive downloads programmatically.Getting help
Operate
"},{"location":"dev/creating-a-service/","title":"Creating a Service","text":"unshackle --helporunshackle dl --helpat any time. The community Discord is the fastest place to ask questions.A service is a plugin that teaches unshackle how to talk to one streaming platform: how to log in, how to look up a title, what video/audio/subtitle tracks exist, and how to license any DRM. The unshackle core does everything else (track selection, downloading, decryption, muxing, naming).
Who this page is for
This is a developer guide. If you only want to use services, see downloading and configuration. You do not need anything here to operate the CLI. The code of a service is deliberately separate from the core, so it can live in its own private repository.
unshackle ships two reference services. EXAMPLE, at
"},{"location":"dev/creating-a-service/#how-unshackle-finds-a-service","title":"How unshackle finds a service","text":"unshackle/services/EXAMPLE/, is a non-runnable showcase of every framework feature in a single file. Read it alongside this page. Most snippets below come from it. MUSIC_EXAMPLE, atunshackle/services/MUSIC_EXAMPLE/, is the same kind of showcase for a music service. If your platform has songs, read that one too.At startup unshackle scans every path in the
directories.servicesconfig key (a list). Each entry is either a local directory of services or a remote repo spec (a git URL orowner/reposhorthand, which unshackle clones for you). The default is the bundledunshackle/servicesdirectory.Within a services directory, every subfolder that contains an
__init__.pyis 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\nTwo rules the loader enforces:
- The class name must match the folder/tag name exactly. Folder
EXAMPLEmust defineclass EXAMPLE. A mismatch raises aRuntimeError.- List order is priority. The first services path to define a given tag wins, and unshackle ignores the later duplicates. Put your own local overrides last to use them as fallbacks, or first to override a repo.
After installation, you call a service as a subcommand of
dl:unshackle dl EXAMPLE 20914\nunshackle dl EX 20914 # via an ALIAS\nConfig lives next to the code
A service's own settings go in
config.yamlinside the service folder (directories.services/<TAG>/config.yaml). This is separate from the globalunshackle.yaml. Access it in code asself.config[...]. Never hardcode URLs, user agents, or certificates. Put them here.
config.yamlis shared: keep secrets out of itTreat
config.yamlas a checked-in file of defaults: it belongs in the service repo, and everyone who runs the service shares it. Per-user secrets (API keys, device IDs, account tokens) must not live here. Those go in the user's ownunshackle.yamlunderservices.<TAG>, which is merged intoself.configat runtime. That checked-in-defaults / per-user-overrides split is exactly why every URL lives inconfig.yamlwith{}/{name}placeholders while the secrets that fill them stay out of it. Both halves read back through the sameself.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\nA service loaded from a remote repo lives outside the
"},{"location":"dev/creating-a-service/#the-service-base-class","title":"The Service base class","text":"unshacklepackage, so Python cannot find the absoluteunshackle.services.<TAG>.*path and the import fails withno known parent package. Relative imports work both locally and from a cloned repo.Every service subclasses
"},{"location":"dev/creating-a-service/#class-variables","title":"Class variables","text":"unshackle.core.service.Service. The minimum is a Clickclientry point plus three abstract methods.Declared at class level to configure framework behaviour:
Variable Type PurposeALIASEStuple[str, ...]Extra tags that resolve to this service (e.g.(\"EX\", \"DOMAIN\")). Case does not matter. Default().GEOFENCEtuple[str, ...]IP region codes the service requires. Empty = no geofence. unshackle treats the first entry as the main region for auto-proxy.GEOBLOCKtuple[str, ...]IP region codes where the service refuses to work. Every other region is allowed. Default().VAULT_TAGOptional[str]Overrides the key-vault namespace so sibling services can share one vault. DefaultNone(use the service's own tag).AUTH_METHODSOptional[tuple[str, ...]]Auth methods accepted (\"cookies\"/\"credentials\"). WhenNone, the REST/servicesendpoint infers them fromauthenticate().NO_SUBTITLESboolSetTrueon a service with no subtitle tracks to skip subtitle handling entirely.ANIMEboolSetTruewhen the catalogue is anime, so metadata lookups prefer AniList. A title's ownanimeflag overrides it.DAILYboolSetTruewhen the catalogue is daily/date-based (talk shows, news, sports), so unshackle names episodes by air date. A title's owndailyflag overrides it.
NO_SUBTITLESis a convention, not a base-class attribute
dl.pyuseshasattrto findNO_SUBTITLES, and theServicebase class deliberately does not declare it, so it only exists if your service defines it. SettingNO_SUBTITLES = Trueis what makes the pipeline skip subtitle work for services that never carry subtitles. Leave it off otherwise.
GEOFENCEdrives 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 toGEOFENCE[0]from your configured proxy providers.
GEOBLOCKis the negative form: the service works everywhere except the listed regions. With noGEOFENCEto name a main region, aGEOBLOCKhit stops the run with a \"not available in your region\" error; pass--proxyyourself."},{"location":"dev/creating-a-service/#what-service__init__-wires-up","title":"Whatclass EXAMPLE(Service):\n ALIASES = (\"EX\", \"DOMAIN\")\n GEOFENCE = (\"US\", \"UK\")\n VAULT_TAG = \"DIFFERENT_NAME\"\n\n\nclass WORLDWIDE(Service):\n GEOBLOCK = (\"GB\",)\nService.__init__wires up","text":"When you call
Attribute What it issuper().__init__(ctx), the base class sets up the attributes you will use throughout the service:self.configThis service'sconfig.yamlcontents (a dict), orNoneif absent.self.logAlogging.Loggernamed after your class.self.sessionA preparedrequests.Sessionwith config headers and a retry adapter (5 retries, backoff 0.2, retries on 429/500/502/503/504).self.cacheACacherfor arbitrary key/value data, for example authentication tokens.self.title_cacheA region/account-awareTitleCacherused byget_titles_cached.self.cache_dirconfig.directories.cache / <ClassName>.self.track_requestATrackRequestbuilt from the CLI--vcodec/--range/--best-availableflags.self.credentialNoneuntilauthenticate()runs.self.current_regionThe two-letter region resolved from proxy/IP.
self.track_requestis 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\nRead the request, don't second-guess the user
Services may narrow
track_requestfor hard technical constraints (e.g. \"this service delivers HDR only as HEVC\"), but must not filter the tracks they return by resolution, bitrate, or language. All selection is done by the core afterget_tracksreturns.Override
get_session()to defeat TLS-fingerprint bot detectionThe base
"},{"location":"dev/creating-a-service/#the-methods-you-write","title":"The methods you write","text":"get_session()returns a plainrequests.Session(config headers + retry adapter) and deliberately does not do TLS impersonation. When a streaming platform blocks you on TLS fingerprint alone, override it to returnsession(\"Chrome131\")(anyrnet.Impersonatepreset).RnetSessionis a drop-inrequests.Sessionreplacement (same.get/.post/cookie-jar interface), so nothing else in the service changes. If instead you need specific SSL cipher behaviour, mountunshackle.core.utils.sslciphers.SSLCiphersonto a plainrequests.Session.unshackle calls the methods in the order shown. Only the three abstract ones are mandatory.
"},{"location":"dev/creating-a-service/#cli-the-click-entry-point","title":"graph LR\n A[cli] --> B[__init__] --> C[authenticate] --> D[get_titles] --> E[get_tracks] --> F[get_chapters]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 commandunshackle chooses the command from the folder/class name, not from the
name=in@click.command. A mismatchedname=still loads and runs. It only produces confusing--help/usage output that names the wrong command. That confusing output is the reason to keep it in sync, not any requirement of the command lookup.@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\nYour docstring becomes the CLI
--helptext (help=__doc__), so document the accepted URL/ID formats and options there.CDM-aware behaviour
The resolved CDM is on
"},{"location":"dev/creating-a-service/#authenticate-optional-login","title":"ctx.obj.cdm(may beNonefor DRM-free runs). Useis_widevine_cdm()/is_playready_cdm()fromunshackle.core.cdm.detectto classify it. These work correctly for local and remote CDMs, so never hand-roll anisinstancecheck. Services often pick a device profile or manifest endpoint based on which DRM the CDM speaks.authenticate: optional login","text":"Override to log in with cookies and/or credentials. Call
super().authenticate()first: the base implementation loads the cookie jar intoself.session.cookiesand storesself.credential. Do all token fetching here. It runs beforeget_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\"]\nCookies come from files under
directories.cookies. Credentials come from thecredentialsmap inunshackle.yaml. For interactive prompts (OTP, captcha) useself.request_input(prompt), never a bareinput(). Underservemode there is no local terminal, so a bareinput()would hang the server waiting on stdin that never arrives.request_inputinstead relays the prompt to the client: a remote-dl session gets it through the attachedInputBridge, and a REST download job shows it asinput_prompt. Locally it routes through the shared Rich console (prompt_user) so the prompt renders correctly alongside progress and log output.Make each token cache entry unique
The cache above uses
tokens_{device}_{profile}on purpose. A token cache must include every dimension that changes the token (device, profile, orcredential.sha1), or two profiles/devices will share one cache entry and stomp each other's tokens. This is not obvious from the caching API, which happily lets you use a single flat name.Use the credential carefully
Use the
"},{"location":"dev/creating-a-service/#get_titles-required","title":"Credentialonly insideauthenticate()to get tokens. Do not stash it or the raw cookie jar elsewhere. The base class already caches identity for you.get_titles: required","text":"Return a titles collection for the given ID. The return type depends on the kind of title:
Return Contains Use forMoviesMovieobjects FilmsSeriesEpisodeobjects Shows (flatten seasons into episodes)AlbumSongobjects MusicEvery title carries a
language(its original recorded language) and an arbitrarydatadict you can use to stash metadata for later methods. You must return at least one title. If you return none, unshackle treats the ID 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 ])\nCommon constructor fields:
MovieEpisodeSongMovie(id_, service, name, year=None, language=None, data=None, description=None)\nEpisode(id_, service, title, season, number, name=None, year=None,\n language=None, data=None, description=None, air_date=None,\n part=None)\nPass
air_datefor daily and sports titles to name by date instead ofSxxExx. Return aSeries([...])of episodes. Passpartonly when the service splits one episode into several videos, see Split episodes.Song(id_, service, name, artist, album, track, disc=1, year=None,\n language=None, data=None, ...)\nReturn an
Album([...])of songs.discdefaults to 1 andyearis optional. Optional metadata keywords such asalbum_artist,genre,isrc,label,lyricsandartwork_urlfeed the tagger and the naming templates. Read the class for the current list.From
get_tracks(), give oneAudiotrack for each codec and bitrate you offer for the song. The framework then selects one with its usual audio options. ASongskips the muxer, so restore the real container suffix on the track path inon_track_downloaded. The MUSIC_EXAMPLE service shows the full music surface.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
"},{"location":"dev/creating-a-service/#split-episodes","title":"Split episodes","text":"self.get_titles_cached()instead ofget_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-servicetitle_mapconfig.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.numberstays anint. The optionalpartcarries the index.Episode(..., season=1, number=1, part=1, name=\"The Reckoning\")\nEpisode(..., season=1, number=1, part=2, name=\"The Reckoning\")\n
partcounts from 1, contiguous. unshackle rejectspart=0, and-wcan only address parts up to 99.- Every part of one episode shares the same
seasonandnumber.- Each part is a separate
Episodewith its own uniqueid_.- Unsplit episode: leave
partunset. Never passpart=1for a whole episode.- Do not put the part in
nameornumber. Pass a cleanname(orNone) and setpart.partis 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.
partaccepts anintor a digit string. A value that is not an integer raisesTypeError. A value of0or less raisesValueError.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
partargument.Clean the part out of the name
Episodediscards anynamethat starts withEpisode <number>, so a raw\"Episode 1 (Part 2): The Reckoning\"leaves you with no episode name at all. Remove the prefix before you pass it, or passNone.Once
partis set, the filename gains a part token (S01E01.Part.2, see Output & naming),-w S01E01.2selects a single part while a bare-w S01E01selects them all, and the--list-titlestree labels the episode01.2. The REST API reports it as an extrapartfield on the serialized title, described in Endpoints.Opt-in and additive
Leaving
"},{"location":"dev/creating-a-service/#get_tracks-required","title":"partunset costs you nothing: filenames, sort order,-wmatching and the API JSON are identical to a plain episode, so a service that does not split episodes has nothing to adopt.get_tracks: required","text":"Given one title, return a
Tracksobject holdingVideo,Audio, andSubtitletracks. In almost all cases you assemble these by parsing a manifest, not by hand.The manifest parsers live in
unshackle.core.manifests:from unshackle.core.manifests import DASH, HLS, ISM\nEach exposes
DASH (.mpd)HLS (.m3u8)ISM (Smooth Streaming)from_url(url, session=...)(orfrom_text(text, url)) and then.to_tracks(language=...):def get_tracks(self, title: Title_T) -> Tracks:\n return DASH.from_url(manifest_url, session=self.session) \\\n .to_tracks(language=title.language)\ndef get_tracks(self, title: Title_T) -> Tracks:\n return HLS.from_url(manifest_url, session=self.session) \\\n .to_tracks(language=title.language)\nThe 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)\nAlways pass
language=Pass the title's original language to
to_tracks(language=...). For DASH and HLS this is required as a fallback. Ifto_trackscannot derive a track's language and you gave no valid fallback, it raisesValueError. It is also what lets the parser flagis_original_langon each track (throughis_close_match), which drives-l best/allselection 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 theTrackRequest, includingHYBRID(fetch HDR10 + DV and merge) and--best-availableskip-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)\nAfter 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 anAttachment(e.g. cover art). Do not filter for resolution/bitrate. Seefetch_dash_manifestin 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 bitstream, 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 is wrong. Only the service author knows the platform embeds HDR10+, so it is on you to correct
video.rangeto HDR10+ here for those services.
get_tracksis your only chance to capture license inputsThe license callbacks (
get_widevine_licenseand the others) fire much later, in the download/decrypt step, long afterget_trackshas returned, and they receive onlychallenge,title, andtrack. Nothing else from the manifest response reaches them. So you must stash anything a license call needs (the license URL,dt-custom-data, a session token) now, duringget_tracks, ontoself.license_dataortitle.data. This is a lifecycle-timing consequence you cannot infer from the callback signatures.Give the KID if you cheaply can
If you can get a track's Key ID (32-char hex) without downloading the track data, set
"},{"location":"dev/creating-a-service/#get_chapters-required","title":"track.kid. It speeds up the decryption and key-lookup path. And be sure encrypted tracks carry the correctdrmso the core licenses them.get_chapters: required","text":"Return a
Chaptersobject of 0 or moreChapters. It sorts by timestamp, numbers the chapters at mux time, and adds a00:00:00.000chapter if none exists. Timestamps take\"HH:MM:SS[.mmm]\", int milliseconds, or float seconds.def get_chapters(self, title: Title_T) -> Chapters:\n chapters = [Chapter(name=c.get(\"name\"), timestamp=c[\"start\"]) for c in title.data.get(\"chapters\", [])]\n return Chapters(sorted(chapters, key=lambda c: c.timestamp))\nSort by timestamp before you build the
Chapters
Chapters.add()adds its own unnamedChapter(0)once you add a chapter that is not at0, and a duplicate timestamp raisesValueError. Sort first, so yours lands before it.You never need to add
Chapter(0). An API chapter at0keeps its name. Drop duplicate timestamps.Close a named range with an unnamed chapter
An unnamed
Chapterends the range before it:Chapter(name=\"Intro\")at the start, an unnamed one where the intro ends.Don't invent chapter names
Never write
"},{"location":"dev/creating-a-service/#handling-drm","title":"Handling DRM","text":"\"Chapter 1\"; leave unnamed chapters unnamed. Users setchapter_fallback_namein their config (for example,\"Chapter {i:02}\").The core drives licensing. Your job is to answer the license challenges the CDM produces. Mark encrypted tracks with the right DRM in
Method For Returnsget_tracks(the manifest parsers do this automatically for PSSH/EXT-X-KEYfound in the manifest), then write the license callbacks you need. Each receives the challenge plus the currenttitleandtrack:get_widevine_service_certificateWidevine privacy mode The service certificate (bytes or base64 str), orNone.get_widevine_licenseWidevine The raw license response (bytes or base64 str), unmodified.get_playready_licensePlayReady The license response. Defaults to delegating toget_widevine_license.get_clearkey_licenseDASHorg.w3.clearkeyThe JWK Set (dict/JSON/bytes), orNoneto 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\nReturn 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 levelsPick the lowest level that works for the platform:
- The manifest carries a
<Laurl>: write nothing. The framework POSTs the challenge to that URL itself.- Your service needs a custom endpoint or extra headers: override
get_clearkey_licenseto do the POST yourself.- Keys arrive obfuscated or through a bespoke channel: fetch and unwrap the content key in
get_tracksand pre-populatedrm.content_keys[kid] = key_hexon the track'sClearKeyCENC. The non-obvious payoff: when every KID is already keyed, the framework skips the license round-trip entirely.Things the service does not do:
- HLS AES-128 (
ClearKey) tracks and DRM-free tracks have no license callback. The content key comes from the manifest, and unshackle applies it directly.- Key vaults cache
KID:KEYpairs 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 overridesearch()to yieldSearchResultobjects forunshackle search."},{"location":"dev/creating-a-service/#minimal-service-skeleton","title":"Minimal service skeleton","text":"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\"))\nA complete, minimal single-manifest service. Save as
services/MYSVC/__init__.pyservices/MYSVC/__init__.py(class nameMYSVC), with an optionalservices/MYSVC/config.yamlbeside it.services/MYSVC/config.yamlfrom __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\nendpoints:\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\nTry it with:
"},{"location":"dev/creating-a-service/#checklist","title":"Checklist","text":"unshackle dl MYSVC https://myservice.com/watch/abc123\n
- Folder name, class name, and Click
name=all match the tag exactly.cliis a@staticmethodthat returns an instance.__init__callssuper().__init__(ctx).get_titles,get_tracksandget_chapterswritten. Titles carry the originallanguage.- Tracks come from a manifest parser with
to_tracks(language=title.language). No filtering by quality or language.- Encrypted tracks carry their DRM. The license callbacks you need return responses unmodified.
- URLs, user agents, and certificates live in
config.yaml, read throughself.config[...].- Multi-file services import their own modules relatively (
from .helpers import x), so they work when loaded from a repo.Read
"},{"location":"dev/service-architecture/","title":"Service Architecture","text":"unshackle/services/EXAMPLE/end to end. It annotates every feature touched above in one place. For a music service, readunshackle/services/MUSIC_EXAMPLE/too.This page is the conceptual map of the internal object model that a service author works against. It ties together the moving parts: the
Servicebase class, theTitlehierarchy a service returns, theTrackmodel those titles carry, the manifest parsers that make tracks, the DRM hooks that license and decrypt them, and the request and HTTP session objects wired up for you at construction time.Who this page is for
This is a developer reference. If you only want to download titles, you never touch these classes directly. See Downloading and the CLI reference instead. If you write or read a service plugin, this page explains what each object is and how the pieces connect. For a guided, step-by-step walkthrough, 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 only answers questions. The
dlcommand 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 (movie / episode)\n \u2502 a Song skips the muxer and keeps its own container\n
get_titles()returns aTitlescollection: the movies, episodes, or songs the platform exposes for the requested ID.get_tracks(title)returns aTracksobject for a single chosen title. In practice this means fetching a manifest and handing it to a parser, which builds theVideo/Audio/Subtitleobjects.get_chapters(title)returns any chapter markers.The core does everything after that (track selection, licensing, decryption, repacking, subtitle conversion, and muxing) with the objects your service returned. Your job is to give accurate
"},{"location":"dev/service-architecture/#the-service-base-class","title":"TheTitleandTrackobjects. The framework does the rest.Servicebase class","text":"Every service subclasses
"},{"location":"dev/service-architecture/#class-variables","title":"Class variables","text":"unshackle.core.service.Service, an abstract base class. Two kinds of members matter: class variables that give the details of the service, and methods that the pipeline calls.Declared at the top of your service class, these are static descriptors:
Class variable Type PurposeALIASEStuple[str, ...]Alternate tags the service answers to, in any case (resolved byServices.get_tag).GEOFENCEtuple[str, ...]ISO region codes the service requires; empty means no geofence. unshackle treats the first entry as the \"main region\" for automatic proxy selection.GEOBLOCKtuple[str, ...]ISO region codes where the service refuses to work; every other region is allowed. Use it for a service that works worldwide except in its home market.VAULT_TAGOptional[str]Overrides the key-vault namespace; defaults to the service's own tag.AUTH_METHODSOptional[tuple[str, ...]]Accepted auth methods (\"cookies\"/\"credentials\"). WhenNone, the REST/servicesendpoint infers them fromauthenticate().ANIMEboolThe catalogue is anime, so metadata lookups prefer AniList. A title's ownanimeflag overrides it.DAILYboolThe catalogue is daily/date-based, so unshackle names episodes by air date. A title's owndailyflag overrides it."},{"location":"dev/service-architecture/#required-methods","title":"Required methods","text":"These are abstract. A service will not load unless it defines all three:
"},{"location":"dev/service-architecture/#optional-methods-and-hooks","title":"Optional methods and hooks","text":"
get_titles() -> Titles_T: find the title collection for the requested ID.get_tracks(title) -> Tracks: assemble the track set for one title.get_chapters(title) -> Chapters: return chapter markers (may be empty)."},{"location":"dev/service-architecture/#what-__init__-wires-up-for-you","title":"What
authenticate(cookies=None, credential=None): the base implementation loads aCookieJarintoself.session.cookiesand storesself.credential. Override it to do a real login handshake.search(): raisesNotImplementedErrorby default. Write it so that thesearchcommand can operate.- DRM license hooks:
get_widevine_service_certificate,get_widevine_license,get_playready_license(delegates to Widevine by default), andget_clearkey_license(returnsNoneby default). See DRM hooks below.request_input(prompt): prompts the user. It goes through the remote-dl input bridge, the REST download job relay, or the rich console.- Event callbacks:
on_segment_downloaded,on_track_downloaded,on_track_decrypted,on_track_repacked,on_track_multiplexlet a service react to pipeline events (for example, MonaLisa decrypts each segment inon_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 theServicebase class at all:dl.pyprobes for it withhasattrand calls it (once, after track selection) only if your service defines it. Use it when your service must fetch or inject keys in bulk before download, separate from the per-track license callbacks below.__init__wires up for you","text":"You rarely override
Attribute What it is__init__. The base constructor takes the Click context and sets up the objects your methods rely on:self.configThe per-service config slice (see Request and HTTP session objects).self.logAlogging.Loggernamed after your class.self.sessionA ready-to-use HTTP session (headers, retries, and proxy applied).self.cacheA generic key/valueCachernamespaced to the service.self.title_cacheA region- and account-awareTitleCacher.self.cache_dirconfig.directories.cache / <ServiceName>.self.credentialThe activeCredential, orNoneuntil authenticated.self.track_requestATrackRequestdescribing the requested codecs, ranges, and best-available flag (see below).The constructor also resolves proxies: it reads
--proxyand theproxy_queryandproxy_providercontext params thatdlderives from it, consults the per-serviceproxy_map, and, ifGEOFENCEis set and you did not give an explicit proxy, does a live IP check and auto-fetches a proxy toGEOFENCE[0]when it detects a geoblock. AGEOBLOCKhit with noGEOFENCEstops the run with a \"not available in your region\" error, because there is no main region to fetch a proxy for; pass--proxyyourself. A proxy whose exit is in aGEOBLOCKregion stops the run the same way. See Proxies and VPN.
TrackRequest"},{"location":"dev/service-architecture/#titles-and-the-title-hierarchy","title":"Titles and the
self.track_requestis a small dataclass built from the CLI:codecs(from--vcodec, empty means \"accept any\"),ranges(from--range, default[Video.Range.SDR]), andbest_available. Use it inget_tracksto decide which variants to fetch.Titlehierarchy","text":"Media kind Item (
get_titles()returns a title collection, one of theTitles_Ttypes. Each collection holds one or moreTitleobjects (Title_T). There are three parallel families, one per media kind:Title_T) Collection (Titles_T) MoviesMovieMoviesTVEpisodeSeriesMusicSongAlbum, andMusic(the base collection)All items subclass
Title, which carries the shared identity: a uniqueid, the originatingserviceclass, an optional originallanguage, arbitrarydata, and a freshly-constructedself.tracks = Tracks()thatget_trackswill fill. Collections use sorted lists, so titles stay in a stable order.The unique ID is validated
"},{"location":"dev/service-architecture/#constructors-at-a-glance","title":"Constructors at a glance","text":"
Title.__init__rejects a falsyid_, 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.Each subclass adds its own required fields on top of
MovieEpisodeSongid_andservice:Movie(id_, service, name, year=None, language=None, data=None, description=None)\nYou must give
name.yearis optional, but it must be a positive integer if you give it.Episode(id_, service, title, season, number,\n name=None, year=None, language=None, data=None,\n description=None, air_date=None)\n
titleis the show name. You must giveseasonandnumber, and0is a valid value. unshackle converts anair_dateto adateso the{date}naming token stays clean, and it discards a placeholder episodenamelike\"Episode 4\"automatically.Song(id_, service, name, artist, album, track, disc=1, year=None,\n language=None, data=None, ...)\nYou must give
name,artist,album, andtrack, and unshackle validates them.discdefaults to 1, andyearis optional. A single therefore needs nodisc, and an undated release needs noyear.A
Songalso takes optional metadata keywords that the tagger and the naming templates read:album_artist,release_type,total_tracks,total_discs,genre,explicit,isrc,upc,copyright,label,lyrics, andartwork_url. Read the class for the current list.The tagger also falls back to
Song.datafor fields the constructor did not get, such as the composer or a full release date. It reads the top level of that dict and a nestedmetadatasub-dict, with the nested keys winning, so either shape works.From
get_tracks(), a music service gives oneAudiotrack for each codec and bitrate it offers for aSong. The framework then selects one with its usual audio options,-a/--acodecand--abitrate, and its usual audio sorting. A movie or an episode uses the same options.-q/--qualitysets the video height only and does not apply to music.Audiohas nobit_depthorsample_ratefield. Thereforebitrateseparates a hi-res FLAC from a CD FLAC, and the audio sort already ranks it.A
Songis an ordinaryTitleon the one generic download path. unshackle has no music-specific service API and no separate music download loop. TheGROUP_AUDIO_DOWNLOADSflag,get_music_track_optionsandget_music_collection_labelno longer exist, and a music service does not need them.Restore the container in
on_track_downloadedEvery track downloads to a temp file named
Audio_<id>.mp4whatever it holds, and the final move keeps that suffix. ASongskips the muxer, so its download is the delivered file, and the tagger picks its metadata format from the suffix. Rename the track path to the true container inon_track_downloaded, or a FLAC lands as an untaggable.mp4. The event fires twice, once before decryption and once at the end, so wait untiltrack.drmis clear before you rename.The MUSIC_EXAMPLE reference service, at
"},{"location":"dev/service-architecture/#title-remapping","title":"Title remapping","text":"unshackle/services/MUSIC_EXAMPLE/, shows the full music surface.After the service fetches the titles (and after any cache), the framework applies
remap_titles(titles, title_map)with the per-servicetitle_mapconfig. This does an exact-string rename (it comparesEpisodes on their showtitle, andMovies andSongs on theirname), so you can change a platform's odd naming to what your library expects.get_titles_cached()also cache-wraps the titles, and it is region- and account-aware. See the TitleCacher notes.The ordering here is deliberate on both sides. The framework applies
"},{"location":"dev/service-architecture/#tracks-and-the-track-model","title":"Tracks and thetitle_mapafter 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.Trackmodel","text":"A
Tracksobject is the containerget_tracksreturns. It has typed buckets (videos,audio,subtitles,chapters, andattachments) plus amanifest_url. Iterating aTracksyields 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
"},{"location":"dev/service-architecture/#the-base-track","title":"The base
Tracks.addrefuses 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 assemble tracks by hand, keep IDs deterministic oraddwill raise.Track","text":"Descriptor Delivery
Trackis the base forVideo,Audio, andSubtitle. (Chapter,Chapters, andAttachmentare standalone classes, notTracksubclasses.) Every track records its delivery method inTrack.Descriptor, which tells unshackle how to download it:URLA single direct URL.HLSHTTP Live Streaming.DASHDynamic Adaptive Streaming over HTTP.ISMSmooth Streaming.The main attributes include
"},{"location":"dev/service-architecture/#video","title":"url(a string or list of strings),language(alangcodes.Language),drm(an optional iterable of DRM objects),needs_repack, and descriptor-specific state stashed intrack.data[\"hls\"]/track.data[\"dash\"]. If you do not give aname, unshackle derives a human-readable name from the language.Video","text":"
Videoaddscodec,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), andHYBRID.HYBRIDis special: it tells the pipeline to select both an HDR10(+) base and a DV track and merge the DV RPU onto the HDR10 track withdovi_tool(see Dolby Vision hybrid).Bitrate units
Both
"},{"location":"dev/service-architecture/#audio","title":"VideoandAudiostorebitratein bits per second (rounded up). The string form displays kb/s, and--vbitrateand--abitratecompare in kb/s.Audio","text":""},{"location":"dev/service-architecture/#subtitle","title":"
Audioaddsbitrate,channels,joc(Joint Object Coding, the Atmos indicator), anddescriptive.Audio.CodeccoversAAC,AC3(DD),EC3(DD+),AC4,OPUS,OGG(Vorbis),DTS,DTSX,ALAC, andFLAC. Theatmosproperty isTruewhenjocis set (or whenextraflags it).Subtitle","text":""},{"location":"dev/service-architecture/#chapters-and-attachments","title":"Chapters and attachments","text":"
Subtitleadds three mutually-constrained flags:cc(closed captions),sdh(subtitles for the deaf and hard-of-hearing), andforced. A track cannot be bothccandsdh, andforcedcannot combine with either.Subtitle.CodecincludesSubRip(SRT),WebVTT(VTT),SubStationAlpha/SubStationAlphav4(SSA/ASS),TimedTextMarkupLang(TTML),SAMI, and the box-encapsulated DASH formatsfTTML(STPP) andfVTT(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/#selection-sorting-and-muxing","title":"Selection, sorting, and muxing","text":"
Chapter(timestamp, name=None): a single marker. The timestamp acceptsHH:MM:SS[.mmm], integer milliseconds, or float seconds.Chapters: a sorted collection that auto-inserts a00:00:00.000chapter and reads/writes OGM Simple format.Attachment: a font or image attached to the mux (for example, fonts an ASS subtitle references), built from a localpathor aurl."},{"location":"dev/service-architecture/#manifest-parsers","title":"Manifest parsers","text":"
Tracksis also the selection engine thedlcommand uses. It offerssort_videos/sort_audio/sort_subtitles(P2P-standard ordering), predicate-basedselect_*filters,by_resolutions, andby_language. When downloads finish, the same object multiplexes everything:Tracks.mux(title, ...)builds and runs anmkvmergecommand, applying language, default-track, forced, and hearing-impaired flags, attaching chapters and fonts, and writing a.muxed.mkv(or.mka/.mksfor audio- or subtitle-only sets). As a service author you never callmuxyourself. You only return correct tracks.Most services do not assemble tracks by hand. They give a manifest to one of three parsers in
Format Class Notes MPEG-DASH (unshackle.core.manifests. Each parser has the same three-part contract:.mpd)DASHParses<MPD>; enumerates tracks from the first content period. HLS (.m3u8)HLSRequires a variant (master) playlist, not a media playlist. Smooth Streaming (.ism)ISMSynthesizes init segments fromCodecPrivateData."},{"location":"dev/service-architecture/#the-parser-contract","title":"The parser contract","text":"
- Construct:
DASH.from_url(url, session=...)orDASH.from_text(text, url)(and likewise forHLS/ISM). Always pass aurl, even tofrom_text, because the parser needs it to make relative segment paths absolute.to_tracks(...): parses the manifest into aTrackscontainer ofVideo,Audio, andSubtitleobjects, each carrying descriptor-specific state.download_track(...): a staticmethod the core calls to fetch, decrypt, and merge segments. You do not call it. You only return the tracks it will operate on.A typical
get_trackstherefore looks like fetching the manifest text withself.sessionand returningDASH.from_text(mpd, url).to_tracks(language=title.language).Pass the original language
DASH and HLS
"},{"location":"dev/service-architecture/#segment-filtering-hooks","title":"Segment-filtering hooks","text":"to_tracksraiseValueErrorif they cannot derive a track's language and you gave no valid fallback. Always pass the title's original recorded language. For HLS,to_tracksuses this fallback directly for the primary track, because HLS variants carry no language."},{"location":"dev/service-architecture/#format-gotchas-worth-knowing","title":"Format gotchas worth knowing","text":"
- HLS: set
track.OnSegmentFilter(segment)to drop unwanted segments (ads/bumpers). A dropped segment is invisible to the whole download: unshackle ignores itsEXT-X-KEYand itsEXT-X-DISCONTINUITY, so a clear ad break inside an encrypted track does not disturb decryption.- DASH: pass a
period_filtercallable toto_tracksto drop whole periods. unshackle automatically drops the caching-breadth periods that some services send, and the non-contentsegment types."},{"location":"dev/service-architecture/#drm-hooks","title":"DRM hooks","text":"
- DASH enumerates tracks from only the first content period, but downloads segments from all content periods matching the representation. The parser reads
SegmentTemplate,SegmentList, andSegmentBaseaddressing, and$Number%0Nd$padding.- HLS re-fetches the media playlist at download time, recovers
SUPPLEMENTAL-CODECSfrom raw text (python-m3u8 drops it) to detect Dolby Vision composite streams, and uses the media-sequence-number IV for AES-128 keys that have no explicit IV.- ISM has no list of segments and no init segments: the parser synthesizes segment URLs from the
<c>fragment timeline, and rebuilds amoov/ftypinit box fromCodecPrivateData(supported for AVC, HEVC, DV-over-HEVC, AAC, EC-3, and TTML). A track id covers the manifest URL, so a title you build from one ISM manifest per codec or range keeps every variant:Tracks.addno longer reads the second manifest's tracks as duplicates of the first.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 flowWidevineWidevine CDM challenge \u2192 license server \u2192 content keys (.wvddevice)PlayReadyPlayReady CDM challenge (WRM header) \u2192 license server \u2192 content keys (.prddevice)ClearKeyCENCW3Corg.w3.clearkeyover CENC JSON KID request \u2192 JWK set; no CDMClearKeyHLS AES-128 content key fetched directly from a URI; no CDM, no challengeMonaLisaProprietary WASM DRM keys extracted locally; no license server
ClearKeyvsClearKeyCENCDespite the shared prefix these are entirely different systems.
"},{"location":"dev/service-architecture/#the-service-side-hooks","title":"The service-side hooks","text":"ClearKeyis HLS AES-128, decrypted in-process.ClearKeyCENCis the W3C EME JWK flow over MPEG-CENC, decrypted with shaka-packager or mp4decrypt like Widevine.The framework supplies the CDM and the decryption backend. Your service gives the network calls that must carry the CDM's challenge. Override the hooks that your platform needs:
Hook Return Purposeget_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 /NoneForClearKeyCENC; returnNoneto let the framework POST to the manifestLaurl.You do not open CDM sessions or operate shaka-packager yourself. The
"},{"location":"dev/service-architecture/#how-unshackle-chooses-a-cdm-and-finds-keys","title":"How unshackle chooses a CDM and finds keys","text":"Widevine/PlayReadyclasses drive the CDM handshake and call the decryption backend. Your hook is only the \"send this challenge, give me back the license\" step.CDM selection is config-driven: the
"},{"location":"dev/service-architecture/#dolby-vision-and-hybrid","title":"Dolby Vision and hybrid","text":"cdmmap resolves a service tag (and optionally the track quality or DRM type) to a device name, whichload_cdminstantiates as a local.wvd/.prdor a remote CDM. Before ever requesting a license, the framework tries an in-process cache of content keys and the configured key vaults. Only unresolved KIDs trigger an actual license request, and the framework writes newly licensed keys back to every vault. This is why the same content key is rarely re-licensed twice. Users configure all of this in DRM and CDM and Key vaults. The DRM API reference gives the API surface.Two DV mechanisms sit on top of the track model. DV fixup re-exposes DV signalling on HLS streams that carry a valid DV RPU but advertise plain HEVC (
"},{"location":"dev/service-architecture/#request-and-http-session-objects","title":"Request and HTTP session objects","text":"dv_compatible_bitstream). Hybrid processing (Video.Range.HYBRID) selects an HDR10(+) base and a DV donor, extracts the RPU withdovi_tool, and injects it onto the base layer, to make a single track that carries both HDR10 metadata and the DV RPU. Both are automatic once the pipeline selects the right tracks. A service only needs to give accurate ranges.Everything a service does over the network flows through objects the base constructor prepared, all derived from the Click context (
ctx) thatdlpasses in.
self.config: your service's slice ofunshackle.yaml, i.e.config.services[<ServiceName>]. This is a plain dict. Use it for per-service settings like base URLs, an API key, or atitle_map. See Configuration.self.session: arequests.Sessionwithconfig.headersapplied, a retry policy (total=5, backoff, retry on 429/5xx), and any resolved proxy already mounted. Pass this HTTP session into the manifest parsers and DRM calls so headers, cookies, and proxies flow everywhere. (unshackle also ships an rnet-based TLS-fingerprinting HTTP session that the newersession()factory uses and that thenetworkconfig key configures.)self.credential: the activeCredential(username:password[:extra]), populated byauthenticate. unshackle uses itssha1as the account hash for cache keys.self.cache: a genericCacher: JSON key/value storage under the service's cache dir, with CRC32 integrity checks, versioning, and optional expiry (auto-derived from a JWTexpclaim when present). Good for auth tokens and API responses.self.title_cache: aTitleCacherthat wrapsget_titlesresults, keyed by title ID, region, and account hash. It serves fresh data withintitle_cache_time, falls back to stale data withintitle_cache_max_retentionwhen an API call fails, and--no-cachebypasses it.self.track_request: theTrackRequestdescribing what the user asked for (codecs, ranges, best-available), for use insideget_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\nThe directory name (
"},{"location":"dev/service-architecture/#where-to-go-next","title":"Where to go next","text":"EXMP) must match the class name exactly. That stem is the service tag. Put your service in one of thedirectories.servicespaths."},{"location":"dev/rest-api/","title":"REST API Overview","text":"
- Creating a Service: a guided walkthrough that builds on this model.
- Service API: exact signatures for the
Servicebase 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.
unshackle ships with a built-in HTTP server. Alongside the command-line tool, you can operate
unshackle serveand 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 for people who make 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
dlcommand, but as discrete HTTP calls you can script against:
- Discovery: show the services available on the server, use 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, examine 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 job 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
Start the REST API on the default host and portservecommand:unshackle serve\nBy default the server binds to
127.0.0.1:8786and 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
"},{"location":"dev/rest-api/#common-options","title":"Common options","text":"--no-key, the server needs an API secret configured under theservesection of your config. If none is set,unshackle serverefuses to start. See Authentication for how to setapi_secretand how theX-Secret-Keyheader works.The
Option Default Effectservecommand accepts the following flags:-h,--host127.0.0.1Address to bind to. Use0.0.0.0to accept connections from other machines.-p,--port8786Port to bind to.--api-onlyoff Serve only the REST API, without the Widevine/PlayReady CDM HTTP endpoints.--no-widevineoff Disable the Widevine CDM endpoints. Cannot be combined with--api-only.--no-playreadyoff Disable the PlayReady CDM endpoints. Cannot be combined with--api-only.--no-keyoff Disable API-key authentication entirely; every request is allowed.--remote-onlyoff Expose only the remote-session subset of routes. Implies--api-only.--debug-apioff Include tracebacks and worker stderr in error responses.--debugoff Enable debug-level logging.--caddyoff Also launch Caddy as a reverse proxy, using theCaddyfilein your user-config directory. Serve the REST API only, on all interfacesunshackle serve --api-only --host 0.0.0.0 --port 8786\nBinding to a public interface
"},{"location":"dev/rest-api/#server-modes","title":"Server modes","text":"
--no-keydisables authentication completely. Anyone who can reach the port can find titles, download them, and read your configuration. Only combine it with a host like127.0.0.1, or keep the default key-based auth when binding to0.0.0.0. Put a TLS-terminating reverse proxy (Caddy, nginx) in front for anything internet-facing.The routes the server mounts depend on how you start the server:
Default (CDM + REST)--api-only--remote-onlyThe server mounts both the pywidevine/pyplayready CDM endpoints and the full REST API. This is the mode you get when you operate
unshackle servewith no mode flags.The server mounts only the REST API. It does not mount the CDM HTTP endpoints. Authentication uses the single configured API key (or none, with
--no-key).The server mounts only the remote-session subset of routes (health, services, search, and everything under
"},{"location":"dev/rest-api/#base-url","title":"Base URL","text":"/api/session/*). This mode implies--api-only, and the interactive Swagger UI is not mounted.All REST endpoints live under the
/apiprefix. With the default bind address the base URL is:http://127.0.0.1:8786/api\nEvery 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/. The server generates it from the same route table it serves, so it always matches the version you operate. Note that in--api-onlymode the docs page is itself behind the API key. Only/api/healthis exempt from authentication.A quick smoke test that needs no API key:
Confirm the server is upResponsecurl http://127.0.0.1:8786/api/health\n"},{"location":"dev/rest-api/#endpoint-map","title":"Endpoint map","text":"{\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}\nEvery REST path uses JSON request and response bodies and lives under
"},{"location":"dev/rest-api/#discovery","title":"Discovery","text":"Method Path Purpose/api. The table below is a map of the surface. The Endpoints page gives each request and response in full.GET/api/healthLiveness and version check. Never requires an API key.GET/api/servicesList the services available on this server.POST/api/searchRun a service's search for a query.POST/api/list-titlesList the titles behind a service title ID.POST/api/list-tracksList the video, audio, and subtitle tracks for a title."},{"location":"dev/rest-api/#downloads-and-jobs","title":"Downloads and jobs","text":"Method Path PurposePOST/api/downloadQueue a download; returns ajob_id.GET/api/download/jobsList jobs, with filtering and sorting.POST/api/download/jobs/clear-finishedRemove all finished jobs.GET/api/download/jobs/{job_id}Full detail for one job.GET/api/download/jobs/{job_id}/eventsStream 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}/retryRe-queue a finished job's parameters as a new job.POST/api/download/jobs/{job_id}/priorityMove a queued job to the front.POST/api/download/jobs/{job_id}/inputAnswer the prompt that a running job waits on."},{"location":"dev/rest-api/#history-and-server-info","title":"History and server info","text":"Method Path PurposeGET/api/historyRead the persisted log of finished jobs.DELETE/api/history/{job_id}Delete a history entry.GET/api/profilesNamed credential profiles per service.GET/api/configThe server's effective, redacted configuration.GET/api/env/checkPresence and versions of external tools."},{"location":"dev/rest-api/#maintenance","title":"Maintenance","text":"Method Path PurposePOST/api/maintenance/clear-cacheClear the cache directory.POST/api/maintenance/clear-tempClear the temp directory.POST/api/maintenance/refresh-servicesgit pulleach configured service repository and re-import the services that changed."},{"location":"dev/rest-api/#remote-download-sessions","title":"Remote-download sessions","text":"Method Path PurposePOST/api/session/createStart a server-side, authenticated service session.GET/api/session/{session_id}Remote session info and time-to-live.DELETE/api/session/{session_id}Tear down a session.GET/api/session/{session_id}/titlesTitles for the session's title ID.POST/api/session/{session_id}/tracksTracks (with URLs) for a chosen title.POST/api/session/{session_id}/segmentsPer-track segment/download details.POST/api/session/{session_id}/segment_filterServer-side HLS segment filter results for one track.POST/api/session/{session_id}/licenseProxy or server-side DRM licensing.POST/api/session/{session_id}/keys/badReport a server-vault content key that did not decrypt.GET/api/session/{session_id}/logsDrain the service's server-side log output.GET/api/session/{session_id}/promptPoll for interactive auth status/prompt.POST/api/session/{session_id}/promptSubmit an answer to an auth prompt.Remote-only routes
The health, services, search, and
"},{"location":"dev/rest-api/#authentication-in-one-line","title":"Authentication in one line","text":"/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-onlymodes.With the default configuration, send your API secret in the
An authenticated requestX-Secret-Keyheader on every request except/api/health:curl -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\"}'\nA missing or unknown API key returns
"},{"location":"dev/rest-api/#where-to-go-next","title":"Where to go next","text":"401. Authentication gives the full model, including per-key service allowlists."},{"location":"dev/rest-api/authentication/","title":"REST API Authentication","text":"
- Quick Start: start the server, set an API key, and operate your first end-to-end download over the API.
- Authentication: the
X-Secret-Keyheader,--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
queuedto 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.
The unshackle REST API protects every endpoint (with one exception) behind a single API key. Clients present that API key in a request header, and the server examines it against the API keys in your configuration. This page explains how that check works, how to configure API keys, and how clients send them. It also explains the extra controls on top of the API key: per-key service allowlists, per-key CDM device access, and remote session IP binding.
You start the API with the
unshackle servecommand. 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 servecan operate in two shapes, and each uses a slightly different piece of code to examine your API 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 the developer notes below give them.
"},{"location":"dev/rest-api/authentication/#the-short-version","title":"The short version","text":"A minimal authenticated request
- Add an
api_secretto theservesection of yourunshackle.yaml.- Start the server with
unshackle serve.- Send that secret in the
X-Secret-Keyheader on every request exceptGET /api/health."},{"location":"dev/rest-api/authentication/#configuring-authentication","title":"Configuring authentication","text":"curl -H \"X-Secret-Key: your-secret-key-here\" \\\n http://127.0.0.1:8786/api/services\nAll authentication settings live under the
"},{"location":"dev/rest-api/authentication/#a-single-api-key","title":"A single API key","text":"servesection of your unshackle config file (unshackle.yaml).The simplest setup defines one shared secret with
unshackle.yamlapi_secret:serve:\n api_secret: \"a-long-random-secret-string\"\nThis value becomes the one and only API key that the server accepts. The server allows requests that present it in the
X-Secret-Keyheader. The server rejects everything else.The server will not start without a key
If you start
unshackle servewith noapi_secretconfigured, 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.\nChoose a long, unpredictable value. The API 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 withusers","text":"For finer control you can define named users, each with their own API key. A user entry can also grant CDM device access and restrict which services that API 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\"]\nThe config key of each entry under
users(for examplea-secret-key-for-alice) is the secret that client sends inX-Secret-Key. Theusernameis a human-readable label used in logs and in job/history metadata.How
api_secretanduserscombine (integrated server)On the integrated server, the server automatically adds the configured
api_secretas an extra accepted API key alongside everyone inusers. It gives that API key access to all loaded Widevine and PlayReady devices, and labels itapi_user. Soapi_secretbehaves as an admin-level API key in addition to any named users you define.On the API-only server, the server accepts only
"},{"location":"dev/rest-api/authentication/#cdm-device-access-per-api-key","title":"CDM device access per API key","text":"api_secret: the single-key model. (See the developer notes for the exact difference.)Under the integrated server, an API key can do DRM licensing only through a CDM device that its user entry lists:
Config key Grants access todevicesWidevine.wvddevices, by nameplayready_devicesPlayReady.prddevices, by namePlayReady access is opt-in per user
If a user entry omits
playready_devices, the server gives that API key no PlayReady access, and logs a warning at startup. You must name the PlayReady devices explicitly for any API key that needs them. Widevine and PlayReady device files themselves are auto-loaded from your WVDs and PRDs directories.Grants decide whether the server returns content keys
The devices on an API key decide which CDM the server can use. Two grants in the same user entry decide whether the server returns content keys (
"},{"location":"dev/rest-api/authentication/#disabling-authentication","title":"Disabling authentication","text":"KID:KEY) for a remote session.server_cdmlets the server license with those devices.server_vaultlets a remote session that the client's own device licenses read the server vault, and the server then makes no live licence. Without either grant, the server can only proxy CDM challenges, so the client must operate its own CDM. Seeservefor both grants.The
--no-keyflag turns authentication off entirely. The server allows every request, ignoresX-Secret-Key, and needs no API key in your configuration.unshackle serve --no-key\nOnly use
--no-keyon a trusted, isolated networkWith
"},{"location":"dev/rest-api/authentication/#how-clients-present-credentials","title":"How clients present credentials","text":"--no-key, any client that can reach the port can show your services, start downloads, and use your CDM devices. Never put a--no-keyserver on an untrusted network.Clients authenticate by sending the secret in a single HTTP request header on every call:
X-Secret-Key: <your-secret>\nThe value must match one of the configured API keys exactly (
api_secret, or one of the keys underusers). There is no login step, no HTTP session cookie, and no token exchange for API access. The client sends the same static API key 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 API key in asecret_keyquery parameter. This is for browsers: theEventSourceAPI 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}`);\nThe header always wins. If the request has an
X-Secret-Keyheader, the server examines that header and ignores the query parameter. The server rejects a wrong header even when the query parameter holds a valid API key.An API key in a URL is easier to leak than an API 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\nimport 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())\nconst 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());\nThe reference client sets it for you
unshackle's own remote download client (
"},{"location":"dev/rest-api/authentication/#the-health-check-is-always-open","title":"The health check is always open","text":"RemoteService) sendsX-Secret-Keyautomatically, reading the API key from yourremote_servicesconfig. It also sends aUser-Agentofunshackle/<version>. You only deal with the header directly when writing your own client.One endpoint is exempt from authentication:
No key neededGET /api/health. It never requires an API key, so you can use it for liveness and version probes without embedding a secret in your monitoring:Responsecurl http://127.0.0.1:8786/api/health\n{\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}\nEvery other endpoint (including the Swagger UI at
"},{"location":"dev/rest-api/authentication/#authentication-failures","title":"Authentication failures","text":"/api/docs/on the API-only server) requires a valid API key.When the server rejects a request for authentication reasons, the response has an HTTP
Missing headerWrong key401 Unauthorizedstatus and a small JSON body.Sending a request with no
X-Secret-Keyheader:{\n \"status\": 401,\n \"message\": \"Secret Key is Empty.\"\n}\nSending an API key that is not in your config:
{\n \"status\": 401,\n \"message\": \"Secret Key is Invalid.\"\n}\nAuth 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\": \"...\"}, wherestatusis an integer, and there is noerror_codeortimestamp.In your client, treat any
"},{"location":"dev/rest-api/authentication/#rate-limit-rejections","title":"Rate limit rejections","text":"401as an authentication problem and parse both shapes, because the standard error body is not always present.An API key that carries a rate limit gets
429 Too Many Requestsonce it goes over, from the same middleware and in the same shape as the401bodies above, with aRetry-Afterheader in seconds:{\n \"status\": 429,\n \"message\": \"Rate limit exceeded.\"\n}\nA limit comes from
serve.tiers.<name>.rate_limitor from the API key's ownserve.users.<key>.rate_limit, in requests per hour. An API key with neither has no limit, andGET /api/healthis never limited. See dashboard rate limits for the config and the counters, and services configuration for the keys themselves.
429is not theRATE_LIMITEDerror body"},{"location":"dev/rest-api/authentication/#access-controls-on-top-of-the-api-key","title":"Access controls on top of the API key","text":"
RATE_LIMITEDin Errors is a service rate limit that a handler raises, and it arrives in the standard envelope with anerror_code. The429here comes from the middleware before any handler runs, and has the integer-statusshape shown above. A client that readserror_codeto classify a429must cope with it being absent.After the server authenticates a request, several additional controls decide what that specific API key can do.
"},{"location":"dev/rest-api/authentication/#per-key-service-allowlists","title":"Per-key service allowlists","text":"You can restrict which services an API key may use. Two layers combine:
- Global allowlist:
serve.services, applied to every API key.- Per-key allowlist:
servicesinside that key'susersentry.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\nEndpoints that name a service (listing services, searching, listing titles/tracks, downloading, profiles, history, and remote sessions) all filter against this allowlist. The server rejects a request for a service outside the API key's allowlist as an invalid service. The server matches service tags after normalisation, so casing and aliases give the same result.
"},{"location":"dev/rest-api/authentication/#cdm-overrides-and-per-job-credentials","title":"CDM overrides and per-job credentials","text":"The server config gates two download-time capabilities. Unless you enable them, the server refuses them with
Config key Default Controls403 Forbidden, even for an authenticated API key:serve.cdm_overridesoff Whether a request may pick a specific CDM device via thecdmfield. Set totrueto allow any, or to a list/set of device names to allow only those.serve.allow_job_credentialsoff Whether a request may pass its owncredential/credentialslogin 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\nWhen to enable these gates
The two gates are fully independent, and you can combine them. For a locked-down or public-facing deployment, leave both unset. They then default off and reject with
403, so client requests can never steer the server's CDM or feed it their own logins. Enable them only 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 you enable
allow_job_credentialsand a download job supplies its owncredential/credentials, the server gives each distinct credential its own isolated token cache. The server never shares or merges client-supplied logins with its configured-credential caches. They cannot cross-contaminate or read the server's own token state, and they do not clobber each other.See Downloads for how the download endpoint uses these fields.
"},{"location":"dev/rest-api/authentication/#session-ip-binding-remote-sessions","title":"Session IP binding (remote sessions)","text":"The remote-download session endpoints (
"},{"location":"dev/rest-api/authentication/#transport-security-https","title":"Transport security (HTTPS)","text":"/api/session/*) add an extra check on top of the API key. When the server creates a remote session, it records the client's IP address. Any later request for that remote session must come from the same IP, or the server rejects it with403 Forbidden. Thus the server binds a remote session both to the API key that created it and to the address it came from.The API server speaks plain HTTP. Because the client sends your API key on every request, do not use it over an unencrypted connection on an untrusted network. To serve over HTTPS, operate a reverse proxy in front of unshackle. The
servecommand can launch Caddy for you:unshackle serve --caddy\nWith
"},{"location":"dev/rest-api/authentication/#quick-reference","title":"Quick reference","text":"Item Value Auth header--caddy, unshackle also startscaddy runusing theCaddyfilelocated next to your unshackle config, letting Caddy end the TLS connection and reverse-proxy to the API. Configuring Caddy itself (certificates, domains, upstream) is done in thatCaddyfile.X-Secret-KeyQuery-parameter alternative?secret_key=onGET /api/download/jobs/{job_id}/eventsonly Default bind address127.0.0.1:8786Exempt endpointGET /api/healthFailure status401 Unauthorized, or429 Too Many Requestsfor a key over its rate limit Missing-header body{\"status\": 401, \"message\": \"Secret Key is Empty.\"}Invalid-key body{\"status\": 401, \"message\": \"Secret Key is Invalid.\"}Over-limit body{\"status\": 429, \"message\": \"Rate limit exceeded.\"}, plus aRetry-Afterheader Rate limit configserve.tiers.<name>.rate_limit, overridden byserve.users.<key>.rate_limitDisable auth--no-key(all requests allowed) Config locationserve.api_secretandserve.usersinunshackle.yaml"},{"location":"dev/rest-api/authentication/#developer-notes-how-the-server-does-the-check","title":"Developer notes: how the server does the check","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-onlywhich forces API-only). Anapi_key_authenticationmiddleware runs on every request. It letsGET /api/healththrough unconditionally, then readsX-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 yourapi_secret(labelledapi_user, with no devices) plus every key underserve.users. A per-user key therefore authenticates here exactly as it does on the integrated server, and that user'sservicesallowlist applies as usual. With--no-key, this middleware is not installed and the users map is empty.Integrated server (default). A
serve_authenticationmiddleware 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 fullserveconfig:api_secret(auto-added with access to all loaded devices) plus every entry underusers, each with its declareddevices/playready_devices.Because the API-only middleware guards all non-health paths, the Swagger UI at
/api/docs/is also behindX-Secret-Keythere. In--remote-onlymode the Swagger UI is not mounted at all.Both middlewares read the caller's API key through the shared
"},{"location":"dev/rest-api/authentication/#cors-and-the-authorization-header","title":"CORS and therequest_secret_key()helper inunshackle/core/api/handlers.py. That helper returns theX-Secret-Keyheader, and falls back to thesecret_keyquery parameter only when the matched route is the job event stream. The integrated server needs one extra branch for this. pywidevine'sauthenticationis header-only. Soserve_authenticationcompares the API key from the query parameter itself when the header is absent, and delegates to pywidevine in every other case. That is why both modes reject a present-but-wrong header.Authorizationheader","text":"A
cors_middlewareruns on every response in both modes (and answersOPTIONSpreflight 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\nThe REST API authenticates with
"},{"location":"dev/rest-api/authentication/#where-device-access-is-read","title":"Where device access is read","text":"X-Secret-Key. The middleware advertisesAuthorizationbecause the co-hosted pywidevine / pyplayready CDM endpoints use it.PUTappears in the allowed-methods list, but no REST route uses it. OnlyGET,POST, andDELETEhave routes.The map of accepted API keys lives at
"},{"location":"dev/rest-api/dashboard/","title":"Developer Dashboard","text":"request.app[\"config\"][\"users\"]. For DRM operations, an API key's permitted Widevine devices come from itsdeviceslist, and PlayReady devices fromplayready_devices. The server uses the first configured device when a request does not (or may not) override it. A server-wideconfig.cdm[<service>]mapping, if present, takes precedence over the API key's device list when the server chooses which device to use.The dashboard endpoints show the operator of a
serveinstance what the server is doing: version and uptime, request counts, live remote sessions, download jobs, and the recent log lines. They exist so you can watch a headless server (serve --quiet) from a web page instead of a terminal.The dashboard endpoints are for the person who runs the server, not for API users. They have their own key: a user key or a tier key never opens them.
"},{"location":"dev/rest-api/dashboard/#enable","title":"Enable","text":"Set a dashboard key under
serve.dashboardinunshackle.yaml:serve:\n api_secret: ...\n dashboard:\n key: a-long-random-string\nWithout
dashboard.keythe server does not register the routes, and they answer404.serve --no-keyturns off user authentication only; the dashboard key stays required. Once the dashboard key is set, every/api/dashboard/request must carry it inX-Secret-Key. Every other endpoint refuses the dashboard key, and the dashboard refuses user keys. The routes are available in--remote-onlymode too.Run the server headless with
--quiet: no banner, no Rich output, one plain log line per event on stderr, andapi.*loggers stay atINFOso remote session activity reaches the log ring. Add--debugto raise the level toDEBUGand also write the full log todirectories.logsasunshackle_serve_<time>.log. Nothing else the dashboard shows reaches the disk: stats, the log ring and the event stream live in memory and reset on restart, so a dashboard UI that wants history must keep its own copy."},{"location":"dev/rest-api/dashboard/#endpoints","title":"Endpoints","text":"unshackle serve --remote-only --quiet\nAll are
Path ReturnsGET./api/dashboard/statusVersion,code_hash, bind host and port,mode(full,api_only,remote_only), uptime,requests_total,requests_rejected(401s),requests_by_key(label \u2192 count), configured service count (the same services as/api/dashboard/services), session count,max_sessions(nullwhen the operator configured no limit),session_ttl, job counts by status. The service count includes services that failed to import - use/api/dashboard/servicesfor the number that loaded./api/dashboard/sessionsEvery live remote session:id,owner(username or masked key),creator_ip,service,title_id,titles,tracks,title(the display name of the title the client is on: the last one it asked tracks for, else the first resolved title),auth_status,auth_error,server_account(the server profile lent to the remote session, elsenull),server_cdm(falsewhen the client's own device licenses the remote session),server_cdm_max_height(the tallest video the server CDM licenses live, elsenull),client,actions,created_atandlast_accessed(ISO 8601) withcreated_tsandlast_accessed_ts(Unix epoch, the same clock as logts),age_seconds,idle_seconds,log_seq.clientis whatever the remote client sent asclientin its session create request. The CLI sendsversion,code_hash(the commit the client runs,nullwhen its source cannot be read),platformandargv.argvis the command line the user ran: proxy and URL userinfo, secret query parameters and credential values become***, home and install paths shorten as in the logs, and the line is cut at 3000 characters. An older client sends less or nothing and the field is{}.log_seqis the last sequence number in the remote session's service log - poll/api/dashboard/sessions/{id}/logswhen it changes.actionsis the session's request log, newest last, capped at 500:ts,method,action(titles,tracks,segments,license,prompt, \u2026),query,status,ms,bytes_in,bytes_out./api/dashboard/jobsEvery download job with full detail, regardless of owner. Empty in--remote-onlymode./api/dashboard/logs{\"seq\": N, \"records\": [...]}: the last 1000 log records.?since=<seq>returns only newer records;?level=WARNINGsets the minimum level;?logger=servekeeps one logger and its children (aiohttp.accessis the noisiest).tsis a Unix epoch in seconds./api/dashboard/sessions/{id}/logsOne remote session's service log:{\"session_id\", \"last_seq\", \"records\": [...]}. Each record hasseq,ts,level,message.?since=<seq>returns only newer records. 404 when the remote session is unknown./api/dashboard/keysEvery configured API key: what it may do and what it has done. One row per key inserve.users, plusserve.api_secretwhen it is set./api/dashboard/servicesEvery service configured on this server and its load state, including the ones that failed to import. See Services for what configured means./api/dashboard/healthPreflight: whether this instance could finish a download. Cached 30 seconds./api/dashboard/eventsServer-Sent Events stream. With?since=<seq>it returns{\"seq\", \"stats\", \"events\": [...]}instead: every event afterseq(the server keeps the last 20000, log records included, so a poll every few seconds is safe even under a busy access log) as one JSON burst, so a UI that polls every few seconds still sees a remote session that opened and closed in between."},{"location":"dev/rest-api/dashboard/#event-stream","title":"Event stream","text":"
requests_rejectedcounts 401 responses; those never appear inrequests_by_key. The label is theusernamefrom theusersentry when one is set, else the first four characters of the API key.Event Data
/api/dashboard/eventssends astatsevent (the same body as/status) on connect and then every 5 seconds, so the connection stays alive and the counters stay current. Between those it sends:logOne log record:seq,ts,level,logger,msg.sessionaction(create,updateon every request the session makes and when titles or tracks load, ordelete) plus the full remote session summary, so each event is a complete snapshot. Adeletealso carriesreason:closed(the client ended it),expired(TTL sweep) orevicted(max_sessionsreached). A session opened by--skip-dlcan live only a few seconds, so a UI that wants history must keep its own record of these events.jobevent(queued,progress,status, or the terminal status) plus the full job object.serviceaction(staged,appliedorfailed) andtags, the services it applies to. Afailedaction carrieserrorsand an emptytags. Refetch/api/dashboard/serviceswhen one arrives, instead of polling for a reload.Every frame carries an
id:(the event seq). After a dropped connection anEventSourcereconnects withLast-Event-IDand the server replays the events it missed. The connection never closes on its own.A browser
EventSourcecannot set headers, so this route also accepts the dashboard key as?secret_key=. The dashboard key then appears in proxy and access logs and in browser history, so use the query parameter only for/eventsand send the header everywhere else.const es = new EventSource(`${server}/api/dashboard/events?secret_key=${key}`);\nes.addEventListener(\"log\", (e) => console.log(JSON.parse(e.data).msg));\nCORS is open (
"},{"location":"dev/rest-api/dashboard/#remote-session-logs","title":"Remote session logs","text":"Access-Control-Allow-Origin: *), so a dashboard page served from another origin works without a proxy.A remote session mirrors the service's own
self.loginto a 500-record buffer atINFO, whatever level the server prints at. The remote session summary carries only a truncatedauth_error; the buffer holds the real reason a login failed.GET /api/dashboard/sessions/{session_id}/logs?since=12\n{\"session_id\": \"...\", \"last_seq\": 14,\n \"records\": [{\"seq\": 13, \"ts\": 1756909254.1, \"level\": \"ERROR\", \"message\": \"login rejected: MFA required\"}]}\nThe field name is
records, the same one/api/dashboard/logsuses, so one dashboard reads both logs the same way. Each record has the shape the client route/api/session/{id}/logsreturns.last_seqis the buffer's own last sequence number, so an empty page still reports the true value.Reading is a cursor read, not a drain: the client draining the same buffer still gets every record. It also does not refresh the remote session's idle timer, so polling a remote session here never keeps it alive or makes it look active.
log_seqis a poll trigger, not a pushA remote session summary reaches the dashboard only when something publishes a
"},{"location":"dev/rest-api/dashboard/#keys","title":"Keys","text":"sessionevent - a request the remote session makes, or an auth transition. A log line on its own publishes nothing. Poll this route whenlog_seqchanges, and on a timer while a drawer is open.GET /api/dashboard/keys\n[{\"id\": \"3ed054be9371\", \"role\": \"user\", \"label\": \"tier1\", \"services\": [\"EXAMPLE\", \"DEMO\"],\n \"server_cdm\": false, \"server_cdm_max_height\": null, \"server_accounts\": false, \"server_proxy\": false,\n \"tier\": \"bot\", \"rate_limit\": 600, \"window_used\": 412,\n \"requests\": 41300, \"rejected\": 4, \"bytes_out\": 103079215104, \"last_seen\": 1756909188.2},\n {\"id\": \"7c1d0f52a884\", \"role\": \"dashboard\", \"label\": \"dash\u2026\", \"services\": [],\n \"server_cdm\": false, \"server_cdm_max_height\": null, \"server_accounts\": false, \"server_proxy\": false,\n \"tier\": null, \"rate_limit\": null, \"window_used\": 0,\n \"requests\": 8140, \"rejected\": 0, \"bytes_out\": 41200311, \"last_seen\": 1756909350.9}]\nField Meaning
idis a hash of the API key, so it stays stable across restarts and carries no key material. Two API keys with nousernamemerge into one label inrequests_by_keybut stay separate here, so useidas the identity andlabelonly for display.roleWhich key this is:user(aserve.usersentry),admin(serve.api_secret) ordashboard(serve.dashboard.key). Identity, not capability - read the grant fields for what the API key may do.servicesThe effective allowlist, the global list intersected with the API key's.nullmeans unrestricted;[]means the API key reaches no service route at all.server_cdm,server_accountsfalse,true, or the list of service tags the grant covers.server_cdm_max_heightThe configured limit on live server CDM licences: a height, a map of service tag to height, ornullfor no limit.server_proxyBoolean. Only a literaltruegrants it, and an API key with nousersentry does not get it.rate_limitRequests per hour, from the API key's own value or its tier's.nullmeans no limit.window_usedRequests counted in the current hourly window.rejectedResponses of 400 or worse that the server attributes to this API key, 429s included. The globalrequests_rejectedstays 401-only.bytes_outResponse bodies only. An event stream reports no body length, so it counts as zero.last_seennulluntil the API key makes its first request.Only a configured API key gets a row of counters. A request on an open route such as
/api/healthwith an unknown key counts asanonymous, so an unauthenticated caller cannot grow the table.Every API key the server counts has a row here, the dashboard key included, so a
requests_by_keybucket other thananonymousalways matches one. A \"traffic by key\" panel reads the rows here, which are unambiguous, and takesanonymousfromrequests_by_key. Readrolebefore you draw the bars: thedashboardrow is the panel measuring its own polling.The dashboard key gets
"},{"location":"dev/rest-api/dashboard/#rate-limits","title":"Rate limits","text":"services: []and no grants unless the operator also gave it aserve.usersentry, because it reaches nothing outside/api/dashboard/. An API key listed in more than one place still gets exactly one row.An API key over its limit gets
429with aRetry-Afterheader:serve:\n tiers:\n bot: { rate_limit: 600 } # requests per hour\n users:\n \"<api key>\":\n username: bot-relay\n tier: bot\n # rate_limit: 900 # optional, wins over the tier\nThe server keeps a fixed window, not a sliding one: it opens on the first request and resets an hour later. An API key with no tier and no override has no limit. A
"},{"location":"dev/rest-api/dashboard/#services","title":"Services","text":"tierthat names no entry underserve.tiers, or arate_limitthat is not a positive whole number, stops the server at startup, because the alternative is an API key that silently gets no limit. The rate limit never applies to the dashboard key, and never to/api/health.GET /api/dashboard/services\n[{\"tag\": \"EXAMPLE\", \"state\": \"staged\", \"error\": null,\n \"commit\": \"9cc230c98f13b83f6c6fe831595b9702d3ba2786\", \"staged_commit\": \"5f0aa17...\",\n \"staged_since\": 1756908900.0, \"sessions\": 1, \"jobs\": 1,\n \"aliases\": [\"EXMPL\"], \"geofence\": [\"US\"], \"geoblock\": []}]\nThe list holds only the services configured on this server:
- The global
serve.serviceslist, when it is set.- Else, the union of the
serviceslists of all keys inserve.users.- Else, every installed service.
The union does not show what every API key can reach
An API key with no
serviceslist can use every service, but it does not add to the union. Theapi_secretAPI key is the same. So withk1: {services: [A]}and ak2with no list, the list shows onlyA. To list every service, remove theserviceslists or setserve.services.
GET /api/dashboard/services?all=1lists every installed service./statusalways counts only the configured services.A configured tag that names no installed service does not show. Name a service by its tag: an alias resolves only for a service that imported.
stateisloaded,stagedorfailed. A service that failed to import keeps its error here instead of disappearing, and/statusstill counts it in the service total - count thestatevalues here for the number that loaded.A
stagedservice has an update on disk that a busy service blocks from the import: the repo pull already happened, socommit(what runs now) andstaged_commit(what waits) differ. It swaps in when the last job and the last remote session for that tag finish. Both commits arenullfor a service from a plain local directory rather than a git repo.Watch the
"},{"location":"dev/rest-api/dashboard/#health","title":"Health","text":"serviceevent rather than polling for the swap.GET /api/dashboard/health\n{\"generated_at\": 1756909350.4, \"status\": \"ok\",\n \"checks\": [{\"id\": \"ffmpeg\", \"label\": \"ffmpeg\", \"status\": \"ok\",\n \"detail\": \"7.1 \u00b7 /usr/bin/ffmpeg\", \"ms\": 4.1}]}\nAn instance can answer every request and still fail every download, because
mp4decryptis missing or a vault's credentials expired. This finds that before a job does.
statusisok,degraded(an optional dependency is missing) orfailing(a required one is). Player binaries are not checked: a headless server never has one and it plays nothing. Each check isok,warnorfail. Check ids are the binary names,cdm,vault:<name>(the vault's configuredname, so two vaults of the same type stay apart),bad_keysandproxies.bad_keyswarns when the vaults hold noSQLiteentry, because only that backend stores the flag for a content key a remote client proved wrong; without it the server serves the same bad pair again.The server caches the result for 30 seconds and keeps every probe shallow: a vault answers one lookup that should miss, and no probe allocates anything. A failing probe's
detailhas the vault's password or token masked. This is a panel, not a liveness probe - do not poll it faster than the cache: every refresh that the cache does not answer costs one real lookup against each configured vault, which a vault that meters or rate limits its callers will count.An unreachable vault costs a slow, partial answer
The probes run one after another, so a cold read takes as long as the probes that finish inside the deadline. The endpoint gives up after 15 seconds and answers with the checks that did complete, plus one
probecheck with the statusfailnaming how many finished. A database vault that accepts the connection and then stalls is what this deadline is for: its driver applies no timeout of its own, so without one the request would never answer.The probe that timed out keeps running in its thread until its driver returns, and the server caches the partial answer for the full 30 seconds, so callers cannot stack up more of them. A second caller waits behind the first, because the server shares one refresh rather than running it twice.
Proxy providers are only checked at construction
The proxy check builds each proxy provider but never asks it for a proxy, because for some proxy providers that allocates a connection. A proxy provider that only fails on its first real use still reads
"},{"location":"dev/rest-api/dashboard/#counters-reset-on-restart","title":"Counters reset on restart","text":"ok.Stats, the log ring and the event history are all in memory. A restart drops every number to zero.
"},{"location":"dev/rest-api/endpoints/","title":"REST API Endpoints","text":"started_atin thestatsframe (withversionandcode_hash) is the signal: it arrives in the first frame after a reconnect, so a dashboard can tell a restart from a bug without any extra field.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 give. This page groups the routes into general, download jobs, server management, and remote-dl sessions.
Start the server with
unshackle serve. By default it binds to127.0.0.1:8786, and every route lives under the/api/prefix. The Authentication page gives authentication, server modes, and theX-Secret-Keyheader in detail. 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 so that they match your ownunshackle serve -h <host> -p <port>invocation.Interactive docs
The server mounts a live Swagger UI at
"},{"location":"dev/rest-api/endpoints/#conventions","title":"Conventions","text":"/api/docs/whenever it does not operate in--remote-onlymode. Its title is \"Unshackle REST API\" and its version tracks the installed package version. In API-only mode with an API key configured, the Swagger UI itself sits behindX-Secret-Key. Only/api/healthis exempt.
- Authentication. Every request except
GET /api/healthrequires theX-Secret-Keyheader when you configure an API key.--no-keydisables the check entirely. See Authentication.- Content type. Request bodies are JSON (
Content-Type: application/json). All success responses are JSON unless this page documents a204 No Content.- CORS. Every response carries permissive CORS headers, and the server answers
OPTIONSpreflight requests automatically.- Compression. JSON responses of 256 bytes or more are gzip-compressed when the client sends
Accept-Encoding: gzip. A slow response is not compressed.- Service allowlist. The effective allowlist for your API key filters the service-facing endpoints (the intersection of the global
serve.serviceslist and your per-key list). The server treats services you cannot use as unknown.Error responses have two shapes
Structured application errors (raised inside a handler) return
"},{"location":"dev/rest-api/endpoints/#general-endpoints","title":"General endpoints","text":"{\"status\": \"error\", \"error_code\": \"...\", \"message\": \"...\", \"timestamp\": \"...\"}. The authentication middleware, however, returns a different shape:{\"status\": 401, \"message\": \"...\"}with an integerstatusand noerror_code. The same middleware answers429in that shape when an API key goes over its rate limit. Clients must accept both shapes. The Error responses section at the bottom of this page gives the full error model.These endpoints report server state and enumerate what is available.
"},{"location":"dev/rest-api/endpoints/#get-apihealth","title":"GET /api/health,GET /api/services, andPOST /api/searchare also exposed in--remote-onlymode.GET /api/health","text":"Health check. This is the only route exempt from authentication: you can call it without an
RequestResponseX-Secret-Keyheader. It reports the running version and a best-effort update check.200curl 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_hashfingerprints the framework source the server operates, so it names 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. It changes when you edit anything underunshackle/core,unshackle/commands,unshackle/utils, orunshackle/vaults. Service modules are not included. unshackle sets the value tonullonly when it cannot read the source files.If the update check fails (for example, no network),
Status Meaningupdate_availableandlatest_versionarenullwhilecurrent_versionstill reports the installed version.200Server is healthy."},{"location":"dev/rest-api/endpoints/#get-apiservices","title":"GET /api/services","text":"Show the streaming services available on this server, filtered by your allowlist. Each entry gives the service's tag, matching rules, capability flags, and its CLI parameters (useful when you make a UI that accepts service-specific options).
RequestResponseload_errorslists the services the server skipped because they failed to import, at startup or after a service repository refresh; the server also writes each one to its log. An entry markedpending_updatehas newer code staged: it keeps its current code until its running or queued jobs and its live remote sessions finish.200curl -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 \"geoblock\": [],\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 \"load_errors\": []\n}\nField notes:
Field DescriptiontagCanonical service tag; use this as theservicevalue on other endpoints.aliasesAlternate tags the service also answers to.geofenceRegion codes the service is geofenced to (may be empty).geoblockRegion codes where the service refuses to work (may be empty).title_regexURL/ID pattern the service matches, as a string or list of strings (ornull).urlService URL, taken from the service's CLI short help.helpThe service module's docstring.cli_paramsArray 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, achoiceslist.needs_authWhether the service overridesauthenticate().has_searchWhether the service supportsPOST /api/search.has_drmWhether the service overrides a Widevine or PlayReady license hook.pending_updateSet totruewhile a service repository refresh waits for this service's jobs to finish before it swaps in the new code. Absent otherwise.server_accountsPresent when the server lends its own accounts for this service:{regions: [\"ca\", \"gb\"], global: bool}, the regions those accounts cover. A client sends no credentials, cookies, or cache for such a service.auth_methodsDeclared or inferred auth methods, e.g.[\"cookies\"],[\"credentials\"]. Status Meaning200Service list returned.500Unexpected server error (INTERNAL_ERROR)."},{"location":"dev/rest-api/endpoints/#post-apisearch","title":"POST /api/search","text":"Find titles in a service by query string. The service must have a
search()method (seehas_searchabove).Request body
Field Type Required Default Descriptionservicestring yes - Service tag.querystring yes - Search query.profilestring nonullCredential/cookie profile to use.proxystring nonullFull proxy URI, a Control D resolver ascontrold://<resolver>@dns.controld.com, or a country code when the API key hasserver_proxy.no_proxyboolean nofalseForce-disable all proxy use.credentials,cookies,cache- nonullYour own login material, on a--remote-onlyserver only. See Client cache on a remote-only server. RequestResponse200curl -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\"}'\nStatus Error code Meaning{\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}\n200- Search completed.400INVALID_PARAMETERSMissingquery.400INVALID_SERVICEUnknown or disallowed service.400INVALID_PROXYProxy could not be resolved.502SERVICE_ERRORService failed to initialise, or does not support search."},{"location":"dev/rest-api/endpoints/#post-apilist-titles","title":"POST /api/list-titles","text":"Get the list of titles behind a title ID, for example episodes or a movie, without downloading anything.
Request body
Field Type Required Descriptionservicestring yes Service tag.title_idstring yes Title identifier.profilestring no Credential/cookie profile.proxystring no Full proxy URI, a Control D resolver ascontrold://<resolver>@dns.controld.com, or a country code when the API key hasserver_proxy.no_proxyboolean no Force-disable proxy.cdm_typestring no Preferred CDM type.credentials,cookies,cache- no Your own login material, on a--remote-onlyserver only. See Client cache on a remote-only server.You can also pass service-specific CLI options as additional keys.
"},{"location":"dev/rest-api/endpoints/#client-cache-on-a-remote-only-server","title":"Client cache on a remote-only server","text":"A
--remote-onlyserver keeps nothing that a client sends. When the request does not use a server account, the server runssearch,list-titlesandlist-trackson a new service cache directory for that request only, and removes the directory when the request ends, on success and on error. A--remote-onlyserver exposes onlysearchof these three endpoints, so the rule applies tolist-titlesandlist-tracksonly if a later release exposes them in that mode.
credentials({username, password, extra?}) andcookies(base64 of a zlib-compressed Netscape cookie file) are the same as onPOST /api/session/create.cacheis a map of cache files in the same form: each cache key is the file path relative to the service cache directory, with/separators and no.jsonsuffix, and each value is base64 of the zlib-compressed file. The server writes these files into the request cache directory before the service authenticates.When you send
RequestResponsecredentials,cookiesorcache, a successful response also has acachefield in the same form, with the updated files (for example a refreshed token), but notitles_files. Save them to send with the next request. A request that uses a server account, or a request to a full-mode server, uses the server's own cache and returns nocachefield.200curl -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}\nEach serialized title carries
type(\"episode\",\"movie\", or\"other\"),name,id,language,description,date, andcover_url. Episodes and movies addyear. Episodes also addseries_title,season, andnumber.A song serializes as
\"other\"A song has no
typeof its own. The server sends it as\"other\", and the response carries none of its music fields: no artist, no album, no track or disc number, and noyear.nameholds the song name. To read the disc and track numbers of a release, operate the CLI with--list-titles. You can still select songs over the API, through thewantedparameter.These keys are sent only when the title carries them, so a title without them serializes exactly as before:
Key Type On Meaningpartinteger episode Part index of a split episode, counting from 1.air_datestring episode ISO air date of daily and date-based content. unshackle names the episode by date instead ofSxxExx.absoluteinteger episode Absolute episode number across all seasons.dailyboolean episode The episode is daily/date-based. Set from the title, or from the service'sDAILYclass attribute.animeboolean episode, movie The title is anime, so metadata lookups prefer AniList. Set from the title, or from the service'sANIMEclass attribute.
parton a split episodeA service that splits one episode into several separately playable videos reports each one as its own episode. Those titles share a
Status Error code Meaningseasonandnumberand add apartinteger counting from 1. No other title carries thepartfield, so a client can ignore it safely. See Split episodes.200- Titles returned.400INVALID_INPUTMissingserviceortitle_id.400INVALID_SERVICEUnknown or disallowed service.401AUTH_FAILEDAuthentication with the service failed.404NOT_FOUNDTitle not found.500INTERNAL_ERRORUnexpected error."},{"location":"dev/rest-api/endpoints/#post-apilist-tracks","title":"POST /api/list-tracks","text":"Show 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 defaultlangoforigselects. unshackle finds it with the same language matcher the downloader uses. Thus it stays correct for regional variants a client cannot separate from tag text alone (ptmarkspt-BRrather thanpt-PT). Services need not report a title's language, so every track can legitimately come backfalse.Request body
Field Type Required Descriptionservicestring yes Service tag.title_idstring yes Title identifier.wantedstring / string[] no Episode/season range (e.g.\"S01E01-S01E03\"), or a song selector (e.g.\"1-5\",\"1,3,7\", or\"2x3\"for disc 2 track 3). A list of selectors is accepted too.seasonint/string no Season number (combined withepisode).episodeint/string no Episode number (combined withseason).partint/string no Part index of a split episode (combined withseasonandepisode).profile,proxy,no_proxy,cdm_type- no As onlist-titles.credentials,cookies,cache- no As onlist-titles.dl_paramsobject no Thedltrack selection that the service reads fromctx.parent.params:lang,v_lang,a_lang,acodecandforced_subs, as onPOST /api/session/create. Some services select the manifests they fetch from these values.When you give both
Single title / movie: Responseseasonandepisode, the server combines them into a\"{season}x{episode}\"selector.partnarrows that to\"{season}x{episode}.{part}\", one part of a split episode. Withoutpartyou get every part of the episode. The server readspartonly when you give bothseasonandepisode, andwantedtakes precedence over all three. To scope a part by range, put it inwantedinstead (\"S01E01.2\").200Multiple episodes: Response200{\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, \"bitrate_bps\": 8000000, \"width\": 3840, \"height\": 2160,\n \"resolution\": \"2160p\", \"fps\": \"24\", \"range\": \"HDR10\",\n \"range_display\": \"HDR10\", \"scan_type\": null, \"closed_captions\": [],\n \"dv_compatible_bitstream\": false, \"language\": \"en\", \"is_original_lang\": true,\n \"name\": null, \"needs_repack\": false, \"edition\": [], \"data\": null,\n \"drm\": [], \"descriptor\": \"DASH\"\n }\n ],\n \"audio\": [\n {\n \"id\": \"a-1\", \"codec\": \"EC3\", \"codec_display\": \"DD+\",\n \"bitrate\": 640, \"bitrate_bps\": 640000, \"channels\": \"5.1\", \"language\": \"en\",\n \"is_original\": true, \"is_original_lang\": true, \"atmos\": false, \"joc\": 0,\n \"descriptive\": false, \"name\": null, \"needs_repack\": false, \"edition\": [],\n \"data\": null, \"drm\": [], \"descriptor\": \"DASH\"\n }\n ],\n \"subtitles\": [\n { \"id\": \"s-1\", \"codec\": \"vtt\", \"language\": \"en\",\n \"forced\": false, \"sdh\": false, \"cc\": false, \"is_original_lang\": false,\n \"name\": null, \"needs_repack\": false, \"edition\": [], \"data\": null,\n \"drm\": [], \"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}\nThe server sorts tracks by bitrate, descending. Episodes the server cannot get go into
unavailable_episodes, rather than failing the whole request. Track objects here do not include a downloadurl. Only the remote session endpoints give segment URLs.The
drmarray on a track is a list of{type, pssh?, kids?, content_keys?, license_url?}objects.Every track carries the same fields as a local track:
Status Error code Meaningis_original_lang,name,needs_repack,edition, anddata.bitrateis in kb/s, andbitrate_bpsgives the exact bits/s.dataholds the part of the service-settrack.datathat fits in JSON, without thehls,dash, andismparser keys. Audio keepsis_originalandatmosfor clients that read them, and adds the exactjocobject count.200- Tracks returned.400INVALID_INPUTMissingserviceortitle_id.400INVALID_PARAMETERSInvalidwantedrange.400INVALID_SERVICEUnknown or disallowed service.404NO_CONTENTNo matching episodes, or all matches failed."},{"location":"dev/rest-api/endpoints/#download-jobs","title":"Download jobs","text":"Downloads are asynchronous.
POST /api/downloadenqueues a job and returns immediately with ajob_id. The server runs each job in a background job worker subprocess. You then poll the job endpoints for progress and results.Job states
A job moves through
"},{"location":"dev/rest-api/endpoints/#post-apidownload","title":"queued\u2192downloading\u2192 one of the terminal statescompleted,failed, orcancelled. You can prioritise only queued jobs. You can retry or remove only terminal jobs.POST /api/download","text":"Make a download job. It requires
RequestResponseserviceandtitle_id. Every other field is an optional download parameter with a server-side default. On success this returns202 Accepted, not200.202curl -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\": \"HEVC\",\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}\nCommon parameters (all optional). These mirror the
Field Type Default Notesdlcommand flags:profilestringnullCredential/cookie profile.qualityint[][]Target resolution(s); best available if empty.vcodecstring/string[]nullVideo codec filter.acodecstring/string[]nullAudio codec filter.vbitrate/abitrateintnullVideo / audio bitrate in kbps (positive).vbitrate_range/abitrate_rangestringnull\"MIN-MAX\"bitrate window.rangestring[][\"SDR\"]Dynamic range(s).channelsnumbernullAudio channel layout.no_atmosbooleanfalseExclude Atmos tracks.wantedstring / string[][]Episode/season selectors, as a list or as one comma-separated string. Accepts the part form,\"S01E01.2\", and the air-date form,\"2026-08-11\"or\"2026-08-01:2026-08-31\". For a music release, a selector is a track number,\"1-5\"or\"1,3,7\", or\"{disc}x{track}\"such as\"2x3\".latest_episodebooleanfalseOnly the newest episode.latest_episodesintegernullOnly the N newest episodes. A positive integer. When set, it overrideslatest_episode.lang/v_lang/a_lang/s_langstring[][\"orig\"]/[]/[]/[\"all\"]Language filters.require_audiostring[][]Audio languages that must exist. The job fails if one is missing, even withbest_available.require_videostring[][]Video languages that must exist. The job fails if one is missing, even withbest_available.require_subsstring[][]Subtitle languages that must exist. The job fails if one is missing.s_langstill decides what to keep.forced_subsbooleanfalseInclude forced subtitles.forced_s_langstring[][]Forced subtitle language(s); impliesforced_subs.exact_langbooleanfalseExact language matching.sub_formatstringnullOutput subtitle format.video_only/audio_only/subs_only/chapters_onlybooleanfalseTrack-type restriction (at most one).no_subs/no_audio/no_chapters/no_video/no_attachmentsbooleanfalseSkip a track type.audio_descriptionbooleanfalseDownload audio description.slowboolean/stringnullRandomized delay between downloads.split_audiobooleannullSeparate files per audio codec.skip_dlbooleanfalseOnly fetch keys, do not download.exportbooleanfalseExport manifest/keys/subs to JSON.cdm_onlybooleannullForce CDM-only (true) or vault-only (false) key retrieval.proxy/no_proxy/no_proxy_download/proxy_downloadstring / bool / bool / stringnull/false/false/nullProxy controls.proxyandproxy_downloadmust be full proxy URIs unless the API key hasserver_proxy. A Control D resolver,controld://<resolver>@dns.controld.com, counts as a full proxy URI: the server runs a forwarder for it.no_folder/no_source/no_muxbooleanfalseOutput/mux controls.workersintnullThreads per track.adaptive_workersbooleanfalseScale segment workers to measured CDN throughput, up to theworkerscap.download_processesint1Split a track's segments across this many processes. Only engages for large batches.downloadsint1Concurrent tracks.worstbooleanfalseLowest bitrate within quality.best_availablebooleanfalseFall back to best available.repackbooleanfalseAdd REPACK tag.tagstringnullRelease group tag.tmdb_id/imdb_id/tvdb_id/anilist_id-nullExternal ID overrides. Each resolves its metadata directly instead of by a title search, and is used for tagging. Setenrichto also take the title, year and original language. Give at most one oftmdb_id,imdb_idandtvdb_id, since unshackle resolves the others from it. Sending two returns400.anilist_idstill combines with one of them.tmdb_idandtvdb_idmust be positive integers,imdb_idmust look likett1375666, andanilist_idmust be a positive integer or a string likemal:12345, or the request returns400. An ID whose provider is unconfigured, such astmdb_idwith notmdb_api_key, fails the job rather than returning400.anilist_idneeds no API key.tvdb_orderofficial,dvd,absolute,alternate,regionalnullRenumber episodes to a TVDB season order. Falls back to thetvdb_orderconfig option.enrichbooleanfalseOverwrite title, year and original language with the external source's. Needs one oftmdb_id,imdb_id,tvdb_idoranilist_id. Without one the job fails instead of returning400.dailybooleanfalseTreat the title as daily/date-based content and fill missing episode air dates from TVDB. The fill needsenrichand a TVDB ID. An air date the service already set is kept.output_dirstringnullOutput directory, relative to the server's downloads directory. The server rejects a path that resolves outside it with400.no_cache/reset_cachebooleanfalseTitle cache controls.Validation. Invalid values return
400 INVALID_PARAMETERS.vcodec,acodecandsub_formattake the same spellings as the CLI options of the same name (unshackle dl --helpprints each list). The API and the CLI read one list, so they cannot disagree.rangemust be one of SDR/HDR10/HDR10P/DV/HLG/HYBRID, andHDR10+is also valid. The bitrate, download worker, and download counts must be positive integers. You may set at most one of the*_onlyflags. You cannot combineno_subswithsubs_only, orno_audiowithaudio_only.Service options. A service's own CLI options travel in the same body. Send them in a nested
Requestservice_paramsobject, or as flat top-level keys.service_paramswins over a flat key of the same name. An option you leave out takes the default declared on the service'sclicommand, so the job matches the equivalentdlrun.profileat the top level always means the credential profile, never a service's own--profileoption.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 \"service_params\": {\"device\": \"chrome\", \"movie\": true}\n }'\nRejected parameters
postscript,post_scriptandpost_scriptsare never accepted. A body containing any of them gets400 INVALID_PARAMETERS, because a command string that arrives from an HTTP caller would be remote code execution. You configure post-scripts only inunshackle.yaml, where they still operate for API jobs. See Post-scripts.Gated parameters (developer)
The server accepts a few keys, but permits them only when the server config opts in. Otherwise the server rejects the request with
403 FORBIDDEN:
cdm(per-request CDM device name): allowed only ifserve.cdm_overridesistrueor a list containing that device name.credential(\"user:pass\") andcredentials(a map): allowed only ifserve.allow_job_credentialsis truthy (off by default).On the server, unshackle merges the effective parameters in this order: defaults \u2192
Status Error code Meaningserve:config overrides \u2192 service-specific defaults \u2192 your request body.202- Job queued.400INVALID_INPUTMissingservice/title_idor malformed JSON.400INVALID_PARAMETERSA parameter failed validation.400INVALID_SERVICEUnknown or disallowed service.403FORBIDDENA gated parameter is not permitted.403SERVER_CDM_CAPPEDThe API key has aserver_cdm_max_heightfor the service, and the job has noqualityat or under it, setsbest_available, or hasHYBRIDinrange."},{"location":"dev/rest-api/endpoints/#get-apidownloadjobs","title":"GET /api/download/jobs","text":"Show download jobs, with optional filtering and sorting.
Query parameters
Param Values Default Descriptionstatusqueued,downloading,completed,failed,cancelled- Filter by status.serviceservice tag - Filter by service.sort_bycreated_time,started_time,completed_time,progress,status,servicecreated_timeSort field.sort_orderasc,descdescSort direction.fulltrue,falsefalseInclude full per-job details. RequestResponse200curl -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 \"input_prompt\": null\n }\n ]\n}\nWith
Status Error code Meaningfull=true, each job additionally includesparameters(with secrets redacted),started_time,completed_time,output_files,error_message,error_details, anderror_code. When you start the server with--debug-api, each job also includeserror_tracebackandworker_stderr.200- Jobs returned.400INVALID_PARAMETERSInvalidsort_byorsort_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
Status Error code Meaningfull=truelist entry).200- Job returned.404JOB_NOT_FOUNDNo 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>\nThe
Event When it is sentdatafield of every event is the same job object thatGET /api/download/jobs/{job_id}returns.snapshotImmediately, as the first event. Gives the job's current state.statusThe job leaves the queue and starts to download.progressThe 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.completedThe job finished. The server then closes the event stream.failedThe job failed. The server then closes the event stream.cancelledThe job was cancelled. The server then closes the event stream.The server sends a
: keep-alivecomment 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
snapshotevent, then the terminal event, and closes the event stream immediately.Authentication from a browser
EventSourcecannot set headers. For this endpoint only, the server also accepts the API key in thesecret_keyquery 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());\nThe header always wins. Send the API key in the query parameter or in the header, not both. If an
RequestResponseX-Secret-Keyheader is present, the server examines that header and ignores the query parameter. See Authentication.200curl -N -H \"X-Secret-Key: $KEY\" \\\n \"http://127.0.0.1:8786/api/download/jobs/b0f7c8e2-.../events\"\nStatus Error code Meaningevent: 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,...}\n200- Event stream started.404JOB_NOT_FOUNDNo 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 Contentwith an empty body.- Queued or downloading job: cancelled, returning
200{\"status\": \"success\", \"message\": \"Job cancelled\"}.Warning
A
Status Error code Meaning204response has no body. Do not try to parse JSON from it.200- Job cancelled.204- Terminal job removed (empty body).400INVALID_PARAMETERSJob is in a state that cannot be cancelled.404JOB_NOT_FOUNDNo 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.
Response200"},{"location":"dev/rest-api/endpoints/#post-apidownloadjobsjob_idretry","title":"{ \"removed\": 3 }\nPOST /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
202with the new job's ID.Retry reuses the original, unredacted parameters
A retry replays the finished job's stored parameters exactly as the original client submitted them, including any secrets such as
Responsecredential/credentials. It does not use the redactedparametersview thatGET /api/download/jobsandGET /api/historygive. This is deliberate: it lets a retried job reproduce a credentialed download that the redacted job record alone never could. The API masks those credentials everywhere else. But anyone who can retry a job can operate it again with the original credentials.202Status Error code Meaning{\n \"job_id\": \"9c1e2f3a-...\",\n \"status\": \"queued\",\n \"created_time\": \"2026-07-03T12:05:00+00:00\"\n}\n202- New job queued.404JOB_NOT_FOUNDNo such job.409CONFLICTJob is not in a terminal state.400INVALID_SERVICEService is no longer allowed.403FORBIDDENA gated parameter is no longer permitted.403SERVER_CDM_CAPPEDThe job is no longer inside theserver_cdm_max_heightof the API key."},{"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.
Response200Status Error code Meaning{ \"job_id\": \"b0f7c8e2-...\", \"position\": \"front\" }\n200- Job moved to front.404JOB_NOT_FOUNDNo such job.409CONFLICTJob is not queued."},{"location":"dev/rest-api/endpoints/#post-apidownloadjobsjob_idinput","title":"POST /api/download/jobs/{job_id}/input","text":"Answer the prompt that a running job waits on. A service can ask for input during a job, for example an OTP code, a PIN, or a device-code confirmation. The job then shows the prompt in its
RequestResponseinput_promptfield and in itsprogressevents. Show the prompt to the user and send the answer here.200{ \"response\": \"123456\" }\nStatus Error code Meaning{ \"status\": \"accepted\" }\n200- Answer sent to the job. Theinput_promptfield goes back tonull.400INVALID_INPUTThe body is not JSON, or it has noresponse.404JOB_NOT_FOUNDNo such job.409CONFLICTThe job does not wait on a prompt.Warning
The job keeps its download slot while it waits. It fails with
"},{"location":"dev/rest-api/endpoints/#server-management","title":"Server management","text":"AUTH_FAILEDif nobody answers the prompt within 10 minutes.Endpoints for profiles, effective configuration, download history, environment checks, and maintenance actions.
"},{"location":"dev/rest-api/endpoints/#get-apiprofiles","title":"GET /api/profiles","text":"Show the named credential profiles configured per service (usable as the
Responseprofileparameter elsewhere). The response holds only services whose credentials are a mapping of profile name to credential. It leaves out services with a single unnamed credential. Your allowlist filters the response.200"},{"location":"dev/rest-api/endpoints/#get-apiconfig","title":"{\n \"profiles\": {\n \"EXAMPLE\": [\"default\", \"premium\"],\n \"OTHER\": [\"us-account\"]\n }\n}\nGET /api/config","text":"Return a read-only, redacted view of the effective server configuration, suitable for a settings page in a UI. The server never sends secrets: it masks every config key whose name looks like a secret (
Responsesecret,password,passwd,token,api_key,credential,auth,cookie,bearer,private) and any userinfo in proxy URLs. Thedirectoriesblock holds the server's absolute paths and reaches only an admin key (admin: trueor theapi_secret); a plain key gets the response without it.200"},{"location":"dev/rest-api/endpoints/#get-apihistory","title":"{\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}\nGET /api/history","text":"Read the persisted history of jobs that reached a terminal state, newest first. Each API key sees only the jobs it submitted, plus entries written before the history recorded an owner. The same rule applies to
DELETE /api/history/{job_id}andPOST /api/download/jobs/clear-finished. unshackle stores the history inapi_history.jsonlin the cache directory. It skips corrupt lines, and a missing file yields an empty list.Query parameters
Param Type Default Descriptionlimitint (\u2265 1)100Maximum entries to return.servicestring - Case-insensitive service filter. Response200Status Error code Meaning{\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}\n200- History returned.400INVALID_PARAMETERSInvalidlimit."},{"location":"dev/rest-api/endpoints/#delete-apihistoryjob_id","title":"DELETE /api/history/{job_id}","text":"Remove a single persisted history entry. Returns
Status Error code Meaning204 No Content(empty body) on success.204- Entry removed (empty body).404NOT_FOUNDNo such entry (or outside your allowlist)."},{"location":"dev/rest-api/endpoints/#post-apimaintenanceclear-cache-and-post-apimaintenanceclear-temp","title":"POST /api/maintenance/clear-cacheandPOST /api/maintenance/clear-temp","text":"Delete and recreate the cache or temp directory. Both, and refresh-services, need
admin: trueon the API key or theapi_secretitself; any other key gets403 FORBIDDEN. Neither takes a request body. The server blocks both with409 CONFLICTif any job is currentlydownloading(thedetailsinclude the offendingactive_jobs).The
409guard 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. The removal of either directory mid-download would corrupt those jobs, so the endpoints refuse while a download is in progress.clear-cache also wipes download history
The persistent history read by
ResponseGET /api/historylives inapi_history.jsonlinside the cache directory. Because clear-cache deletes and recreates that whole directory, calling it also erases your entire download history, not only cached title data. If you want to keep history, do not clear the cache. Remove individual entries withDELETE /api/history/{job_id}instead.200Status Error code Meaning{ \"cleared\": true, \"freed_bytes\": 1048576 }\n200- Directory cleared.409CONFLICTA 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
Responsedirectories.servicesand re-import the services that changed, without a restart.refreshedbecomestruewhen all repos updated successfully, or when you configure no repository.deferredlists the services that have a running or queued job: they keep their current code until that job finishes, then swap.load_errorslists the services whose new code failed to import; the server drops them until the next successful refresh.200"},{"location":"dev/rest-api/endpoints/#get-apienvcheck","title":"{\n \"refreshed\": true,\n \"repos\": [\n {\n \"spec\": \"github.com/example/services\",\n \"updated\": true,\n \"changes\": [\"~EXAMPLE\", \"+EXAMPLE2\"],\n \"deferred\": [\"EXAMPLE\"],\n \"load_errors\": []\n }\n ]\n}\nGET /api/env/check","text":"Report the install status of the external binaries unshackle depends on (the same set
Responseunshackle env checkinspects), with best-effort version detection.200"},{"location":"dev/rest-api/endpoints/#remote-dl-sessions","title":"Remote-dl sessions","text":"{\n \"checks\": [\n { \"name\": \"ffmpeg\", \"installed\": true, \"version\": \"6.1.1\", \"required\": true },\n { \"name\": \"ccextractor\", \"installed\": false, \"version\": null, \"required\": false }\n ]\n}\nDeveloper feature
The
/api/session/*endpoints power unshackle's remote download mode. A thin local client authenticates once against a remote server, then fetches titles, tracks, and segments, and proxies DRM licensing back through it. The server mounts these endpoints in--remote-onlymode, and they are what the built-inRemoteServiceclient drives. Most end users never call them directly.The server holds remote sessions in memory. They expire after
"},{"location":"dev/rest-api/endpoints/#remote-session-lifecycle-overview","title":"Remote session lifecycle overview","text":"serve.session_ttl(default 300s) without a request, except while awaiting interactive authentication input, which uses a 600s window. A remote session binds to the IP that made it, so the server rejects a request from a different IP with403 FORBIDDEN."},{"location":"dev/rest-api/endpoints/#slow-responses","title":"Slow responses","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}/segment_filter \u2192 HLS segments the service drops (ads, bumpers)\nPOST /api/session/{id}/license \u2192 proxy or server-CDM DRM licensing\nPOST /api/session/{id}/keys/bad \u2192 report a server-vault content key that did not decrypt\nDELETE /api/session/{id} \u2192 tear down, harvest updated cache\nThe
titles,tracksandlicenseroutes can take minutes on a large catalogue. When the answer is not ready after 30 seconds, the route sends status200and the headers. It then sends a newline every 30 seconds until the JSON body is ready. JSON parsers ignore the leading newlines. This keeps the connection open through a reverse proxy that closes an idle request.A late failure has status
200The server sent the status before the work ended. A failure after the first 30 seconds arrives as the standard error body with status
200. Checkstatusin the body, not only the HTTP status. The body has no HTTP status field. Useerror_codeandretryableto decide what to do.A slow response is not gzip-compressed. Gzip output cannot start before the body is ready, and the newlines must go out at once.
"},{"location":"dev/rest-api/endpoints/#post-apisessioncreate","title":"POST /api/session/create","text":"Make a remote 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 acceptsserviceandtitle_id(both required). It also accepts a broad set of optional keys, because the body allowsadditionalProperties. These arecredentials({username, password, extra?}),cookies(base64 of zlib-compressed Netscape cookie file),proxy,no_proxy,profile,cache(map of forwarded cache files, keyed by the file path relative to the service cache directory with/separators and no.jsonsuffix, so a nested key such assession_web/<sha1>lands in a subdirectory),client_region,proxy_region,cdm_type,range_,vcodec,quality,best_available,dl_params,client, and any service CLI options.proxy_regionis the country code the client resolvedproxyfrom; the server matches it against its own accounts.clientis a freeform object the dashboard shows as sent (the CLI putsversion,code_hash,platformand a redactedargvin it); the server ignores it above 4096 bytes of JSON.
dl_paramsis an object with thedltrack selection:lang,v_langanda_lang(arrays of language strings),acodec(an array of audio codec names ordl -aaliases such asddp) andforced_subs(boolean). The service reads these values fromctx.parent.params. The server uses thedldefault for each absent or malformed value, and drops an unknown codec name.Service CLI options also travel in a nested
service_paramsobject, which wins over a flat key with the same name.profileat the top level always means the credentials profile, never a service's own--profileoption.The
Responseproxyvalue must be a full proxy URI, unless the operator gives your API keyserver_proxyin theserve.usersconfig. Withoutserver_proxy, the server does not resolve country codes with its own proxy providers. It rejects the request withINVALID_PROXYwhen noproxyis set and the reportedclient_regiondiffers from the server's region. A request that reports noclient_regionis not blocked. Passproxywith your own proxy, orno_proxyto accept the server's own connection. Withserver_proxy, the server resolves a country code and picks a proxy for yourclient_regionitself. A Control D resolver,controld://<resolver>@dns.controld.com, counts as a full proxy URI: the server runs a forwarder for it and needs no Control D configuration of its own.200{\n \"session_id\": \"d4e5f6a7-...\",\n \"service\": \"EXAMPLE\",\n \"status\": \"authenticating\",\n \"server_account\": false\n}\nStatus Error code Meaning
server_accountistruewhen the server authenticated with one of its own accounts (serve.server_accounts). Otherwise the server uses only what the client sent, and never falls back to its own credentials. Only a client that sent its own cookies, credentials, or cache files gets cookies, auth headers, and the remote session cache back. A client that answered a login prompt (a device code or an OTP) gets them too. This applies only when the login then succeeds and the remote session does not use a server account. For any other remote session (a server account, or an anonymous login through the server's proxy) the tracks and segments responses carry no cookies and no auth headers. The server also drops secret-looking keys from track data, and the cache stays on the server. A server-account service also refuses a client proxy URI, because the server's login would pass through it.200- Session created; authentication in progress.400INVALID_INPUTMissingserviceortitle_id.400INVALID_SERVICEUnknown 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\" }\nStatus Error code Meaning{ \"status\": \"failed\", \"error\": \"Invalid credentials\" }\n200- Status returned.403FORBIDDENRequest IP differs from the remote session creator.404SESSION_NOT_FOUNDNo 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{ \"response\": \"...\" }(required).200Status Error code Meaning{ \"status\": \"accepted\" }\n200- Response accepted.400INVALID_INPUTMissingresponse, or no prompt is pending.404SESSION_NOT_FOUNDNo such session."},{"location":"dev/rest-api/endpoints/#get-apisessionsession_idlogs","title":"GET /api/session/{session_id}/logs","text":"Drain the service's server-side log output for this remote session. The server mirrors every log call the service makes (
Responseself.log.info, warnings, errors) into a bounded per-session buffer, so a remote client can see why an auth, title, or license step failed. Passsince=<seq>to get only records newer than the last drained sequence number. Works in every auth state, includingfailed.200Status Error code Meaning{\n \"session_id\": \"a1b2c3\",\n \"logs\": [\n { \"seq\": 1, \"level\": \"INFO\", \"message\": \"Signed in as profile 'Main'\", \"ts\": 1724990000.1 },\n { \"seq\": 2, \"level\": \"WARNING\", \"message\": \" - ThePlatform returned no streams\", \"ts\": 1724990002.7 }\n ],\n \"last_seq\": 2\n}\n200- Records returned (empty list when nothing new).404SESSION_NOT_FOUNDNo such session.The buffer keeps the newest 500 records. Only the service instance's own logger is mirrored; core pipeline loggers stay on the server. A server-account session gets no log relay at all - auth-time service output can carry the operator account's identity, so it stays server-side.
"},{"location":"dev/rest-api/endpoints/#get-apisessionsession_idtitles","title":"GET /api/session/{session_id}/titles","text":"Fetch the titles for an authenticated remote session. A slow answer arrives as a slow response.
Response200{\n \"session_id\": \"d4e5f6a7-...\",\n \"titles\": [ { \"type\": \"episode\", \"name\": \"Pilot\", \"id\": \"ep-1\" } ]\n}\nRequires an authenticated remote session. Otherwise it returns
"},{"location":"dev/rest-api/endpoints/#post-apisessionsession_idtracks","title":"404 SESSION_NOT_FOUND.POST /api/session/{session_id}/tracks","text":"Get tracks and chapters for a specific title within the remote session. Body:
{ \"title_id\": \"...\" }(required). Unlikelist-tracks, track objects here include download URLs, along with session headers/cookies and manifest data the client needs to download directly. A slow answer arrives as a slow response.Response
manifestsholds each served DASH/ISM manifest once. A service can build an AdaptationSet of its own that the served manifest does not contain.track_manifestsholds a one-AdaptationSet MPD for each such track, and the client re-parses that in place of the whole manifest.200Status Error code Meaning{\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 \"track_manifests\": [ { \"track_id\": \"s-1\", \"type\": \"dash\", \"url\": \"https://...\", \"data\": \"<base64 zlib xml>\" } ],\n \"session_headers\": { \"User-Agent\": \"...\" },\n \"session_cookies\": { \"sid\": \"...\" },\n \"server_cdm_type\": \"widevine\"\n}\n200- Tracks returned.400INVALID_INPUTMissing or unknowntitle_id.404SESSION_NOT_FOUNDNo such session."},{"location":"dev/rest-api/endpoints/#post-apisessionsession_idsegments","title":"POST /api/session/{session_id}/segments","text":"Get the download URL, DRM info, headers, cookies, and raw track data for the selected tracks. Body:
Response{ \"track_ids\": [\"v-1\", \"a-1\"] }(required).200Status Error code Meaning{\n \"tracks\": {\n \"v-1\": { \"descriptor\": \"DASH\", \"url\": \"https://...\", \"drm\": [ ... ], \"headers\": { ... }, \"cookies\": { ... }, \"data\": { ... } }\n }\n}\n200- Segment info returned.400INVALID_INPUTMissingtrack_ids.404TRACK_NOT_FOUNDAn unknown track ID was requested."},{"location":"dev/rest-api/endpoints/#post-apisessionsession_idsegment_filter","title":"POST /api/session/{session_id}/segment_filter","text":"Get the HLS segments a service drops (ads, bumpers, dub cards). The server runs the service's own segment filter over the track's media playlist and returns only the URIs, so the client drops the same segments the service does. Body:
Response{ \"track_id\": \"v-1\" }(required).unwantedisnullwhen the track has no filter. The client keeps every segment when the server does not have this route.200Status Error code Meaning{ \"unwanted\": [\"https://cdn.example/hls/ad1.ts\"] }\n200- Unwanted segment URIs, ornull.400INVALID_INPUTMissingtrack_id.404TRACK_NOT_FOUNDAn unknown track ID was requested.500SERVICE_ERRORThe playlist request or the filter failed."},{"location":"dev/rest-api/endpoints/#post-apisessionsession_idlicense","title":"POST /api/session/{session_id}/license","text":"Get the content keys for the DRM. The
modefield selects one of two modes. A slow answer arrives as a slow response.Proxy mode (
RequestResponsemode: \"proxy\", the default) forwards the client's CDMchallengeto the service's license endpoint and returns the raw license bytes:200{ \"track_id\": \"v-1\", \"challenge\": \"<base64 CDM challenge>\", \"drm_type\": \"widevine\" }\n{ \"license\": \"<base64 license bytes>\" }\nServer-CDM mode (
Single: Responsemode: \"server_cdm\") runs the full CDM flow on the server using the device configured for your API key, returning content keys directly. Usetrack_idfor a single track ortrack_idsfor a batch:200Batch: Response200{ \"keys\": { \"<kid_hex>\": \"<key_hex>\" }, \"vault_keys\": [\"<kid_hex>\"], \"drm_type\": \"widevine\" }\nField Description{ \"keys\": { \"v-1\": { \"<kid_hex>\": \"<key_hex>\" } }, \"vault_keys\": [\"<kid_hex>\"], \"drm_type\": \"widevine\" }\ntrack_id/track_idsSingle track, or a batch of tracks.challengeBase64 CDM challenge (proxy mode).psshBase64 PSSH (server-CDM mode).drm_typewidevine(default) orplayready. In server-CDM mode the server'scdmmapping for the service decides the DRM system and the responsedrm_typenames the system that licensed. The client's value decides only when the server has no mapping for the service.modeproxy(default) orserver_cdm.clear_tracks(response) Requested track ids that carry no DRM, so they have no keys. Absent when every requested track is encrypted.vault_keys(response) KIDs whose content key came from a server vault, not the CDM. The client proves such a content key with a decode before it trusts it; the field is absent when every content key came from the CDM. Status Error code Meaning200- License/keys returned.400INVALID_INPUTMissing challenge/PSSH, or no CDM device configured for the API key.400INVALID_PARAMETERSUnsupporteddrm_type.404TRACK_NOT_FOUNDUnknown track ID.404NO_CONTENTServer CDM produced no keys.502SERVICE_ERRORThe service aborted during licensing."},{"location":"dev/rest-api/endpoints/#post-apisessionsession_idkeysbad","title":"POST /api/session/{session_id}/keys/bad","text":"Report a content key from
vault_keysthat did not decrypt the track. The server flags the pair in its local vaults and reports it to the vault that served it, and the next licence for that KID reaches the CDM. The client never learns the server vault names.Status Error code Meaning{ \"kid\": \"<kid_hex>\", \"key\": \"<key_hex>\" }\n200- The pair is flagged.400INVALID_INPUTThis remote session was not served that pair.404SESSION_NOT_FOUNDUnknown remote session."},{"location":"dev/rest-api/endpoints/#get-apisessionsession_id","title":"GET /api/session/{session_id}","text":"Return remote session metadata and validity.
Response200{\n \"session_id\": \"d4e5f6a7-...\",\n \"service\": \"EXAMPLE\",\n \"valid\": true,\n \"expires_in\": 300,\n \"track_count\": 4,\n \"title_count\": 10\n}\n"},{"location":"dev/rest-api/endpoints/#delete-apisessionsession_id","title":"
404 SESSION_NOT_FOUNDif the remote session does not exist or has expired.DELETE /api/session/{session_id}","text":"Tear down a remote session. The server cancels its input bridge, harvests any updated cache files, and deletes the remote session and its cache directory.
Response200{\n \"status\": \"ok\",\n \"cache\": { \"tokens\": \"<base64 zlib bytes>\", \"session_web/<sha1>\": \"<base64 zlib bytes>\" }\n}\nThe
"},{"location":"dev/rest-api/endpoints/#error-responses","title":"Error responses","text":"cachefield is present only if the remote session produced cache files and the login belongs to the client. Each cache key is the file path relative to the session cache directory, with/separators and no.jsonsuffix, so a file in a subdirectory keeps its place on the client. The server rejects a cache key with a drive letter, a root, a..segment, or an empty segment, and so does the client.404 SESSION_NOT_FOUNDif the remote session does not exist.The server serialises every structured error raised inside a handler 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
detailsappears only when non-empty.retryableappears only when the error is retryable (e.g. network errors, rate limiting).debug_infoappears only when you start the server with--debug-api.Authentication errors differ
Errors raised by the API-key middleware use a distinct shape (
"},{"location":"dev/rest-api/endpoints/#error-codes-and-default-status","title":"Error codes and default status","text":"Error code HTTP status{\"status\": 401, \"message\": \"Secret Key is Empty.\"},{\"status\": 401, \"message\": \"Secret Key is Invalid.\"}, or{\"status\": 429, \"message\": \"Rate limit exceeded.\"}with aRetry-Afterheader) with an integerstatusand noerror_code,timestamp, ordetails. A429from the middleware therefore carries noerror_code, unlike theRATE_LIMITEDcode below, which a handler raises for a rate limit the service imposed. A malformed JSON body on any POST, by contrast, returns the standard envelope witherror_code: \"INVALID_INPUT\".INVALID_INPUT400INVALID_SERVICE400INVALID_PROXY400INVALID_PARAMETERS400AUTH_FAILED401FORBIDDEN403SERVER_CDM_CAPPED403GEOFENCE403NOT_FOUND404NO_CONTENT404JOB_NOT_FOUND404SESSION_NOT_FOUND404TRACK_NOT_FOUND404CONFLICT409RATE_LIMITED429INTERNAL_ERROR500DOWNLOAD_ERROR500WORKER_ERROR500SERVICE_ERROR502DRM_ERROR502NETWORK_ERROR503SERVICE_UNAVAILABLE503When 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
"},{"location":"dev/rest-api/endpoints/#see-also","title":"See also","text":"NETWORK_ERROR(503, retryable), \"rate limit\" or \"429\" becomeRATE_LIMITED(429, retryable), and anything unrecognized collapses toINTERNAL_ERROR(500)."},{"location":"dev/rest-api/errors/","title":"Error Responses","text":"
- REST API overview. What the server is and when to use it.
- Authentication. Keys, server modes, and the
X-Secret-Keyheader.- Quickstart. A first end-to-end request walkthrough.
- Downloading. The
dlflags mirrored byPOST /api/download.Every endpoint of the unshackle REST API reports failures in a single, predictable shape. The cause can be a missing field, a service that refuses to authenticate, or a download worker that crashed. In each case 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 send.
If you write a client against the API, read this page alongside the endpoint reference. Each endpoint lists the specific codes it raises, and this page gives the meanings of those codes.
Where this comes from
"},{"location":"dev/rest-api/errors/#the-standard-error-shape","title":"The standard error shape","text":"
unshackle/core/api/errors.pydefines the error format, and the wrapper that turns raised errors into responses lives inunshackle/core/api/routes.py. Nothing on this page is invented. It reflects the actual server behaviour.A structured error is always a JSON object with
Error response bodystatusset to the literal string\"error\". The full set of fields:Field Type Always present? Meaning{\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}\nstatusstring Yes Always the literal\"error\". Lets a client distinguish an error body from a success body at a glance.error_codestring Yes A stable, machine-readable code from the catalogue below. Match on this, not onmessage.messagestring Yes A human-readable description of what went wrong. Wording may change between versions, so do not parse it.timestampstring Yes ISO 8601 timestamp (UTC) of when the error response was built.detailsobject 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).retryableboolean Only whentruePresent and set totruewhen retrying the same request may succeed later (network blips, rate limiting, transient outages). Absent otherwise.debug_infoobject Only with--debug-apiTechnical diagnostics; see Debug information.Match on
error_code, branch on HTTP statusTreat
"},{"location":"dev/rest-api/errors/#the-details-object","title":"Theerror_codeas 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.detailsobject","text":"When present,
detailscarries extra context specific to the error. It is only included when it is non-empty, so a client must treat a missingdetailsas \"no extra information\". Some concrete examples you may encounter:"},{"location":"dev/rest-api/errors/#the-retryable-hint","title":"The
- A validation failure may include the offending field or the list of valid values.
- A \"title not found\" error on a remote session includes
available_titles.- A maintenance conflict includes
active_jobs, the jobs blocking the operation.- Categorised exceptions add a
reasontag, such as\"network_connectivity\"or\"geofence_restriction\".retryablehint","text":"The
retryablefield appears only when it istrue. It is set for conditions the server believes are transient: network errors, rate limiting, and temporary service unavailability. When you see it, you can back off and try the request again. When it is absent, assume the request will keep failing until you change something.A retryable network error
"},{"location":"dev/rest-api/errors/#error-code-catalogue","title":"Error code catalogue","text":"{\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}\nEvery code below is a member of the
"},{"location":"dev/rest-api/errors/#client-errors-4xx","title":"Client errors (4xx)","text":"Code Status MeaningAPIErrorCodeenumeration. 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 to503), but the defaults hold for the overwhelming majority of responses.INVALID_INPUT400 A required field is missing, or the request body could not be parsed. Also used as the generic \"your input was malformed\" code.INVALID_SERVICE400 The named service is unknown, or is not permitted for your API key.INVALID_PROXY400 The supplied proxy specification could not be resolved or is malformed, or the proxy failed the exit check.INVALID_PARAMETERS400 One or more download or query parameters failed validation (bad codec, bitrate, sort field, and so on).AUTH_FAILED401 Authentication with the streaming service failed (bad credentials or cookies).FORBIDDEN403 The action is not allowed. Raised by server-side gates (per-key restrictions on CDM or credential overrides) and by remote session IP binding.SERVER_CDM_CAPPED403 The server CDM does not license live above theserver_cdm_max_heightof the API key.details.reasonisserver_cdm_max_height(withdetails.max_height),client_device, for a remote session where the client's own device licenses, orserver_device, for a remote session that runs on the server's device. A remote client licenses the track with its own device, except forserver_device: then only its own vaults can supply the key.GEOFENCE403 The title is not available in the applicable region.NOT_FOUND404 A requested resource (title, history entry, and so on) does not exist.NO_CONTENT404 The request was valid but produced nothing: no matching titles, tracks, episodes, or keys.JOB_NOT_FOUND404 The referenced download job does not exist.SESSION_NOT_FOUND404 The remote-dl session does not exist or has expired.TRACK_NOT_FOUND404 A referenced track ID is not present in the remote session.CONFLICT409 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_LIMITED429 The streaming service is rate-limiting requests. Retryable.Not every
429isRATE_LIMITEDThe server has a rate limit of its own. An API key over
"},{"location":"dev/rest-api/errors/#server-errors-5xx","title":"Server errors (5xx)","text":"Code Status Meaningserve.tiers.<name>.rate_limitorserve.users.<key>.rate_limitgets a429from the authentication middleware, before any handler runs, so it carries noerror_codeand is not theRATE_LIMITEDcode above. It is the shape{\"status\": 429, \"message\": \"Rate limit exceeded.\"}with aRetry-Afterheader. ReadRetry-Afterand wait it out rather than retrying on a backoff of your own: the window is fixed, so an early retry only spends the next window. See Authentication.INTERNAL_ERROR500 An unexpected server-side error. This is the fallback for any exception the server could not categorise.DOWNLOAD_ERROR500 The download process itself failed.WORKER_ERROR500 The download worker subprocess errored out.SERVICE_ERROR502 The streaming service's API returned an error, or the service does not support the requested operation.DRM_ERROR502 DRM or license acquisition failed. Retryable.NETWORK_ERROR503 A network connectivity problem (connection, timeout, DNS, TLS). Retryable.SERVICE_UNAVAILABLE503 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
"},{"location":"dev/rest-api/errors/#how-the-server-produces-errors","title":"How the server produces errors","text":"500. In practice every code above has an explicit mapping.Understanding where an error comes from helps interpret it.
Explicitly raised errors. Most endpoints raise a structured
APIErrordirectly. For example, a missingservicefield yieldsINVALID_INPUT, and a disallowed service yieldsINVALID_SERVICE. These carry a precise code, message, and often a populateddetailsobject.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 Retryableauth,login,credential,unauthorized,forbidden,tokenAUTH_FAILED(401) noconnection,timeout,network,unreachable,socket,dns,resolve(orConnectionError,TimeoutError,URLError,SSLError)NETWORK_ERROR(503) yesgeofence,geoblock,region,not available in,territoryGEOFENCE(403) nonot found,404,does not exist,invalid idNOT_FOUND(404) norate limit,too many requests,429,throttleRATE_LIMITED(429) yesdrm,license,widevine,playready,decryptDRM_ERROR(502) noservice unavailable,503,maintenance,temporarily unavailableSERVICE_UNAVAILABLE(503) yesinvalid,malformed,validation(orValueError,ValidationError)INVALID_INPUT(400) no anything elseINTERNAL_ERROR(500) noCategorised errors include a
reasontag insidedetails(such as\"network_connectivity\"or\"drm_failure\") so a client can react without parsing the message text.Invalid JSON bodies. If a
Malformed request bodyPOSTendpoint receives a body that is not valid JSON, the server returnsINVALID_INPUT(400) with the parse error indetails.error:"},{"location":"dev/rest-api/errors/#the-authentication-error-exception","title":"The authentication error exception","text":"{\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}\nThere 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
Missing keyInvalid keyX-Secret-Keyheader, the middleware short-circuits with a legacy body that uses an integerstatusand noerror_codeortimestamp.{ \"status\": 401, \"message\": \"Secret Key is Empty.\" }\n{ \"status\": 401, \"message\": \"Secret Key is Invalid.\" }\nHandle both shapes
A client must expect two error formats: the standard structured error (string
"},{"location":"dev/rest-api/errors/#late-failures-with-status-200","title":"Late failures with statusstatusof\"error\", witherror_code), and this authentication error (integerstatus, noerror_code). TheGET /api/healthendpoint is exempt from authentication, so it never returns the key-error shape.200","text":"The remote session
titles,tracksandlicenseroutes send status200and the headers when the work takes longer than 30 seconds. They then send a newline every 30 seconds to keep the connection open. See Slow responses. A failure after that time arrives as the standard error body, but with status200. The body has no field for the HTTP status that the error would otherwise have.error_codeandretryablestay in the body.Check the body on these routes
On these routes a
"},{"location":"dev/rest-api/errors/#responses-with-no-body","title":"Responses with no body","text":"200does not always mean success. Parse the body and treat\"status\": \"error\"with anerror_codeas a failure.Not every failure or completion has a JSON body. Some endpoints answer with
204 No Contentand an empty body, notably deleting a terminal download job and deleting a history entry.Do not parse
204bodiesA
"},{"location":"dev/rest-api/errors/#debug-information","title":"Debug information","text":"204response has no body. Clients must examine the status code before they decode JSON, or the decode will fail on an empty string.By default, error responses never include tracebacks or internal diagnostics. If you start the server with the
Error with --debug-api enabled--debug-apiflag, every structured error gains adebug_infoobject:{\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}\nThe
debug_infoobject always carriesexception_typeand, for exception-derived errors, a formattedtraceback. Some endpoints add extra diagnostics such as captured job worker stderr.Developer use only
"},{"location":"dev/rest-api/errors/#client-handling-recommendations","title":"Client handling recommendations","text":"
--debug-apiexposes internal tracebacks in responses. Enable it only for local debugging, never on a server reachable by untrusted clients.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 it lets you find the
204/no-body and authentication-error cases before you read the body.- Branch on
error_code. For structured errors, this is the stable identifier to switch on. Do not usemessageas the identifier.- Respect
retryable. When it istrue, back off and try the request again. The reference client retries429,500,502,503, and504with exponential backoff. When absent, fix the request rather than repeating it.- Fall back gracefully. For an unrecognised code, show the
messageand the HTTP status.Reference consumer
unshackle's own remote-download client treats any status of
"},{"location":"dev/rest-api/job-lifecycle/","title":"Download Job Lifecycle","text":"400or above as fatal, and a200body with\"status\": \"error\"and anerror_codetoo: it logsServer error [<error_code>]: <message>and exits. It also wraps requests in a retry adapter (five attempts, backing off on429,500,502,503, and504). This is a reasonable template for your own client's error handling.When you submit a download over the REST API, the server does not block the request until it writes the file. 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 the server reports live progress, and how to cancel or retry. Clearly marked developer sections tell you how the job worker and the manager behind the queue operate 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 give the internal job worker and manager model, for contributors and for people who debug 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 the server reports errors, 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 Meaningstatusfield:queuedCreated and waiting for a free job worker. No download has started yet.downloadingA job worker has picked the job up and the download is in progress.completedThe download finished successfully;output_fileslists the results.failedThe download stopped with an error;error_messageanderror_codeexplain why.cancelledThe job was cancelled before it could finish, from either the queued or downloading state.The last three (
"},{"location":"dev/rest-api/job-lifecycle/#how-a-job-moves-between-states","title":"How a job moves between states","text":"completed,failed, andcancelled) are terminal: once a job reaches one of them it never changes state again. You can remove, retry, or read terminal jobs from history, but they will not resume.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\u2192downloadinghappens 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\u2192completedis the happy path: the download engine wrote all the requested files and reported success.downloading\u2192failedis 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\u2192cancelledhappens when you send aDELETEfor 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
"},{"location":"dev/rest-api/job-lifecycle/#creating-a-job","title":"Creating a job","text":"queueduntil a job worker picks it up. It is not a state change.You queue a download with
Queue a downloadPOST /api/download. The request needs at least aserviceand atitle_id, plus any download options you want to set. On success the server replies with202 Acceptedand the new job's identity:202 Acceptedcurl -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{\n \"job_id\": \"b1e6c7de-6d0a-4f2f-9d1e-3a2b4c5d6e7f\",\n \"status\": \"queued\",\n \"created_time\": \"2026-07-03T12:00:00.000000\"\n}\nThe job starts in
queued. The job workers auto-start on the first job you submit, so you switch nothing else on. Keep thejob_id. Every later call uses it.The full parameter set
"},{"location":"dev/rest-api/job-lifecycle/#reading-a-jobs-status-and-progress","title":"Reading a job's status and progress","text":"
POST /api/downloadaccepts the same download options as thedlcommand (quality, codecs, language selection,wantedepisode ranges, and many more), with server-side defaults filling in anything you do not write. The Endpoints page gives the complete list, their defaults, and the validation rules.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/jobslists every job the manager currently holds. By default each entry is a summary. Passfull=trueto 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\"\nYou can filter the list by
"},{"location":"dev/rest-api/job-lifecycle/#the-progress-fields","title":"The progress fields","text":"statusandservice, and order it withsort_by(created_time,started_time,completed_time,progress,status, orservice) andsort_order(asc/desc). See Endpoints for the query-parameter reference.While a job is
Field Type Meaningdownloading, 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:progressnumber0to100Overall percent complete.phasestring Human-readable phase, e.g.downloading video 1080p HDR10, audio en 5.1.titlestring Title being processed.current_titlestring The specific episode/movie in progress (for a batch).completed_tracksint Number of tracks finished so far.total_tracksint Total tracks selected for the download.active_trackslist of strings Labels of the tracks downloading right now.track_progresslist Per-active-track{label, progress, speed}entries.segments_donenumber Segments downloaded for the track in progress.segments_totalnumber Total segments for the track in progress.speedstring Transfer speed of the track in progress, e.g.4.20 MB/s.skipped_subtitleslist Subtitles that were skipped as non-fatal failures (whenskip_subtitle_errorsis set).input_promptstring ornullThe prompt the service waits on. Answer it withPOST /api/download/jobs/{job_id}/input.The full detail adds
started_time,completed_time,output_files, the redactedparameters, and, for failed jobs,error_message,error_details, anderror_code. With--debug-api, it also addserror_tracebackandworker_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\nOr subscribe instead of polling
GET /api/download/jobs/{job_id}/eventspushes the same job object as Server-Sent Events, and closes the event stream when the job ends. See Endpoints.What the percentage means
progressclimbs from0toward90while tracks download, then the final stretch to100covers post-download work such as decryption cleanup, repackaging, and muxing. A job that has finished downloading but is still muxing can sit near90for a moment before completing. On success the manager sets it to exactly100.For a batch,
progressis per-episode, not whole-batchA multi-episode request (a
"},{"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":"wantedrange) is a single job that processes its episodes sequentially. As each new episode begins,progressand 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 growingoutput_filesarray againstparameters.wantedrather than trustingprogress.A
"},{"location":"dev/rest-api/job-lifecycle/#failed","title":"Failed","text":"completedjob has itsoutput_filespopulated with the paths of the muxed results, andprogressis100. This is the state you want.A
failedjob carries the error.error_messageis the short human message,error_codeis one of the API's structured error codes (for exampleNETWORK_ERROR,DRM_ERROR,AUTH_FAILED, orDOWNLOAD_ERROR), anderror_detailsgives more context. When you start the server with--debug-api,error_tracebackandworker_stderrare also filled in, to help you find the cause.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, andworker_stderrbefore 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":"The server stopped a
"},{"location":"dev/rest-api/job-lifecycle/#cancelling-a-job","title":"Cancelling a job","text":"cancelledjob on request, before it finished. It may have partial output, but it is not considered complete. See below.Send
Job state when you DELETE What happens ResponseDELETE /api/download/jobs/{job_id}. The behaviour depends on the job's current state, and this is important because the response differs:queuedordownloadingThe job is cancelled. A running download's worker process is terminated.200with{\"status\": \"success\", \"message\": \"Job cancelled\"}completed,failed, orcancelled(terminal) The job is removed from the manager entirely.204 No Content, empty body Not found -404with error codeJOB_NOT_FOUNDIn other words,
DELETEon a live job cancels it, whileDELETEon 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
Cancel a running job204 No Contentwith an empty body. A client must not try to decode JSON from it. Examine the status code first.curl -X DELETE http://127.0.0.1:8786/api/download/jobs/b1e6c7de-6d0a-4f2f-9d1e-3a2b4c5d6e7f \\\n -H \"X-Secret-Key: your-api-secret\"\nTo remove all terminal jobs at once, use
"},{"location":"dev/rest-api/job-lifecycle/#retrying-a-job","title":"Retrying a job","text":"POST /api/download/jobs/clear-finished, which returns{\"removed\": <count>}.
POST /api/download/jobs/{job_id}/retryre-runs a finished job by creating a new job with the same service, title, and parameters. The retry does not change the original job. It stays in its terminal state, and the response gives you the new job's ID with202.202 Accepted, a fresh job
- You can retry only terminal jobs. A retry of a
queuedordownloadingjob returns409with error codeCONFLICT.- A missing job returns
404(JOB_NOT_FOUND).- The server examines the service allowlist and any download gates again for the new job, exactly as for the original submission. Thus the server can reject a retry with
INVALID_SERVICEorFORBIDDENif permissions changed."},{"location":"dev/rest-api/job-lifecycle/#prioritising-a-job","title":"Prioritising a job","text":"{\n \"job_id\": \"c2f7d8ef-7e1b-5a3a-ae2f-4b3c5d6e7f80\",\n \"status\": \"queued\",\n \"created_time\": \"2026-07-03T12:30:00.000000\"\n}\n
POST /api/download/jobs/{job_id}/prioritymoves a queued job to the front of the line, so it will be the next one a free job worker picks up.
- The job must be
queued. Prioritising a job in any other state returns409(CONFLICT). A missing job returns404.- On success the response is
{\"job_id\": \"<id>\", \"position\": \"front\"}.This only reorders the queue. The job's status stays
"},{"location":"dev/rest-api/job-lifecycle/#history-and-retention","title":"History and retention","text":"queueduntil a job worker starts it.The server also writes every job that reaches a terminal state once to a persistent history log. The job survives there even after the server removes it from the live list.
- Read it with
GET /api/history(newest first, withlimitandservicefilters).- Delete a single entry with
DELETE /api/history/{job_id}(returns204).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
unshackle puts
"},{"location":"dev/rest-api/job-lifecycle/#under-the-hood-the-job-worker-and-manager-model","title":"Under the hood: the job worker and manager model","text":"api_history.jsonlunder 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.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 the jobs it bumped to the front. On the first job, it starts a fixed pool of job worker tasks plus one cleanup task. The start is idempotent, so later jobs reuse the same pool.
- Concurrency:
max_concurrent_downloads(default 2) caps it. That many jobs can be indownloadingat once. The rest wait inqueued.- Retention:
job_retention_hours(default 24) controls it. 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_downloadscaps how many separate download jobs operate at once. The separateserve.downloadssetting 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_downloadsandconfig.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.GET /api/configgives the live values. The relatedserve.history_limit(default 100) instead controls how many entries the on-disk history file keeps.Each job worker loop drains the priority deque first, then the FIFO queue. The loop skips the stale FIFO copy of a promoted job, so it does not do the job twice. When a job worker takes a job it flips it to
"},{"location":"dev/rest-api/job-lifecycle/#each-job-runs-in-its-own-subprocess","title":"Each job runs in its own subprocess","text":"downloading, stampsstarted_time, does the download, and on finish stampscompleted_timeand records history.The manager does not operate 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>\nThree temporary files bridge the parent and the job worker:
payload.json: the manager writes it before launch. It contains the job ID, service, title ID, and merged parameters.progress.json: the job worker writes it continuously as the download proceeds.result.json: the job worker writes it at the end.The job worker has no terminal. When a service asks the user for input, the job worker writes the prompt to
progress.jsonasinput_promptand waits. The answer fromPOST /api/download/jobs/{job_id}/inputgoes to the job worker on its stdin.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 job worker's exit codes are
"},{"location":"dev/rest-api/job-lifecycle/#how-progress-reaches-the-api","title":"How progress reaches the API","text":"0for success,1for a download error, and2for bad arguments. Itsresult.jsonis 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 jobfailedand adopts the job worker'serror_code(or derives one by categorising the exception).While the job worker operates, the manager polls
progress.jsonevery 0.5 s and copies the fields it finds onto the liveDownloadJob. That is what theGETendpoints return.The job worker aggregates progress across all selected tracks. It weights each track's completion fraction 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 job worker scales the download portion to fill
"},{"location":"dev/rest-api/job-lifecycle/#how-cancellation-works","title":"How cancellation works","text":"0to90. The download engine drives the remaining90to100as it repackages and muxes. A track counts as done when it reaches a terminal state such asDownloaded,Decrypted, or a skipped subtitle.Each job carries a cancellation event. When you
DELETEa live job:"},{"location":"dev/rest-api/job-lifecycle/#secret-handling-in-serialized-jobs","title":"Secret handling in serialized jobs","text":"
- If it is
queued, it flips straight tocancelled, the manager records its history immediately (it never reaches a job worker), and the job 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 tocancelled. The polling loop notices the event,terminate()s the job worker subprocess (escalating tokill()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.Job parameters may carry secrets: a raw
"},{"location":"dev/rest-api/job-lifecycle/#see-also","title":"See also","text":"user:passcredential, 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. The manager also scrubs free-text fields that a service might have leaked a secret into (error_message,error_details,error_traceback,worker_stderr, and job worker stdout/stderr in the logs). It removes the job's known secret values, longest-match first, before it returns or logs them."},{"location":"dev/rest-api/quickstart/","title":"REST API Quick Start","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-Keyheader and per-key service allowlists that gate job creation and retry.- Downloading: the equivalent options on the
dlcommand line.Unshackle ships with a built-in REST API that exposes the same download engine you drive from the command line. The
unshackle servecommand serves it 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. The
curlcommands here come 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
"},{"location":"dev/rest-api/quickstart/#before-you-begin","title":"Before you begin","text":"curl. If you only want to download something, the command line is simpler. The REST API is meant for building tools, UIs, and automation on top of unshackle.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 dlworks for you, the API will too, since it runs the identical download pipeline.If you have no install yet, follow Installation. Then check your tools:
From a cloneInstalled as a uv tooluv run unshackle env check\nunshackle env check\nThis page uses the bare
"},{"location":"dev/rest-api/quickstart/#step-1-configure-an-api-secret","title":"Step 1: Configure an API secret","text":"unshacklecommand. From a git clone, the recommended install, run each command asuv run unshackle ....The server authenticates every request (except the health check) with an API key that you send in the
X-Secret-Keyheader. The server reads this API key from theservesection of yourunshackle.yamlconfig.At minimum you must set
unshackle.yamlapi_secret:serve:\n api_secret: \"choose-a-long-random-secret\"\nIf you want the API to license and download DRM-protected titles, you must also grant CDM devices to the API key. Configure that under
unshackle.yamlserve.users, keyed by the secret:serve:\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\nThe secret must exist
If
api_secretis not set and you do not pass--no-key,unshackle serverefuses 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
"},{"location":"dev/rest-api/quickstart/#step-2-start-the-server","title":"Step 2: Start the server","text":"
--no-keyturns authentication off entirely: the server allows every request and ignores theX-Secret-Keyheader. This is convenient for a throwaway local test, but never put a--no-keyserver on a network you do not fully trust.Start the REST API with the
servecommand:unshackle serve --api-only\nBy default the server binds to
127.0.0.1:8786. The--api-onlyflag serves only the REST API and omits the Widevine/PlayReady CDM HTTP endpoints. Drop it when you also want those endpoints.Common options:
Option Default Effect-h, --host127.0.0.1Address to bind to-p, --port8786Port to bind to--api-onlyoff Serve only the REST API, no CDM endpoints--no-keyoff Disable API-key authentication (allow all requests)--debug-apioff Include tracebacks and stderr in error responses--debugoff Enable DEBUG-level logging--remote-onlyoff Expose only the remote-session subset of routes--caddyoff 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/\nSwagger UI is also behind auth
In
--api-onlymode (without--no-key), the Swagger UI at/api/docs/requires theX-Secret-Keyheader, the same as every other route. Only/api/healthis exempt from authentication.For the rest of this page we will assume the server is reachable at
http://127.0.0.1:8786and store the secret in an environment variable:"},{"location":"dev/rest-api/quickstart/#step-3-make-sure-that-the-server-is-alive","title":"Step 3: Make sure that the server is alive","text":"export SECRET=\"choose-a-long-random-secret\"\nexport BASE=\"http://127.0.0.1:8786\"\nThe health endpoint is the one route that never requires an API key. Use it to make sure that the server operates, and to read its version:
200 OKcurl \"$BASE/api/health\"\n{\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}\nThe
"},{"location":"dev/rest-api/quickstart/#step-4-authenticate-a-real-request","title":"Step 4: Authenticate a real request","text":"update_checkblock is best effort. If unshackle cannot reach the update source,update_availableandlatest_versioncome back asnullrather than failing the request.Every other endpoint needs the
X-Secret-Keyheader. A quick way to prove your API key works is to show the services the server can see:200 OK (truncated)curl \"$BASE/api/services\" \\\n -H \"X-Secret-Key: $SECRET\"\n{\n \"services\": [\n {\n \"tag\": \"EXAMPLE\",\n \"aliases\": [\"EX\"],\n \"geofence\": [],\n \"geoblock\": [],\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}\nIf the API key is missing or wrong, you get a
401 Unauthorized401with a plain body:{ \"status\": 401, \"message\": \"Secret Key is Invalid.\" }\nTwo 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. Parse both shapes in your client.The
"},{"location":"dev/rest-api/quickstart/#step-5-submit-a-download-job","title":"Step 5: Submit a download job","text":"tagvalue for the service you want is the identifier you pass asservicein the calls below.Downloads are asynchronous. You
POSTa job to/api/download, the server immediately hands back ajob_id, and the actual download runs in the background so your request never blocks.The only required fields are
serviceandtitle_id. Everything else mirrors the flags of thedlcommand 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 }'\nA 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\": \"HEVC\",\n \"range\": [\"SDR\"],\n \"wanted\": [\"S01E01\", \"S01E02\"]\n }'\nOn success the server responds with
202 Accepted202 Acceptedand the identifier you will poll:{\n \"job_id\": \"3f9c1e0a-8d2b-4e6f-9a1c-77b0f2a1c4d5\",\n \"status\": \"queued\",\n \"created_time\": \"2026-07-03T12:00:00.000000+00:00\"\n}\nCapture the
job_idfor the next step:"},{"location":"dev/rest-api/quickstart/#frequently-used-download-fields","title":"Frequently used download fields","text":"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'])\")\nThe request body accepts the full download parameter set. A few of the most useful:
Field Default Meaningservice- Service tag (required)title_id- Title identifier (required)profilenullNamed credential/cookie profile to usequality[]Resolution(s), e.g.[1080]; empty means best availablevcodecnullVideo codec, e.g.\"H265\"acodecnullAudio codec, e.g.\"EC3\"range[\"SDR\"]Dynamic range:SDR,HDR10,HDR10+,DV,HLG,HYBRIDwanted[]Episodes to fetch, e.g.[\"S01E01\"], or music tracks, e.g.[\"1-5\"]. Empty means alllang[\"orig\"]Video/audio language(s)s_lang[\"all\"]Subtitle language(s)downloads1Number of tracks to download concurrentlyproxynullProxy URI or country codeValidation is strict: invalid codecs, subtitle formats, dynamic-range names, non-positive counts, or mutually exclusive flags (for example both
video_onlyandaudio_only) come back as400with error codeINVALID_PARAMETERS. See the full reference on the download reference page for every accepted field.Gated fields
The server refuses a per-request CDM (
"},{"location":"dev/rest-api/quickstart/#step-6-poll-the-job-to-completion","title":"Step 6: Poll the job to completion","text":"cdm) or inline credentials (credential/credentials) with403 FORBIDDEN, unless the server operator has explicitly opted in withserve.cdm_overridesandserve.allow_job_credentials. Both are off by default.A job moves through these states:
queued \u2192 downloading \u2192 completed | failed | cancelled\nThe 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\"\nWhile the download runs you will see
200 OK (in progress)status: \"downloading\"and aprogressvalue climbing from0toward100:{\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}\nWhen it finishes you will see
200 OK (completed)status: \"completed\"and the produced files underoutput_files:{\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}\nA 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\nPolling 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}/eventsstreams the same job record as Server-Sent Events, and closes the event 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\"\nIf a job ends in
"},{"location":"dev/rest-api/quickstart/#step-7-clean-up","title":"Step 7: Clean up","text":"failed, the full record includeserror_message,error_code, and (when you start the server with--debug-api)error_tracebackandworker_stderrto help you find the cause. The server scrubs sensitive values in these fields before it returns them.Once you have collected the output you can remove the finished job.
DELETEon 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), the manager removes it. You get
204 No Contentwith an empty body.- If the job is still queued or downloading, the manager cancels it instead. You get
200with{\"status\": \"success\", \"message\": \"Job cancelled\"}.Do not parse JSON on 204
A
204response has no body. This applies to terminal-job removal here and to deleting history entries. Examine the status code before you decode JSON.To clear every finished job at once instead of one at a time:
200 OKcurl -X POST \"$BASE/api/download/jobs/clear-finished\" \\\n -H \"X-Secret-Key: $SECRET\"\n"},{"location":"dev/rest-api/quickstart/#managing-the-queue","title":"Managing the queue","text":"{ \"removed\": 3 }\nA handful of endpoints round out job control. All require the
Method Path PurposeX-Secret-Keyheader.GET/api/download/jobsList all jobs, with optional filtering and sortingGET/api/download/jobs/{job_id}Full detail for one jobDELETE/api/download/jobs/{job_id}Cancel a running job, or remove a terminal onePOST/api/download/jobs/{job_id}/retryRe-queue a new job from a terminal onePOST/api/download/jobs/{job_id}/priorityMove a queued job to the front of the queuePOST/api/download/jobs/clear-finishedRemove all terminal jobsListing supports query parameters for filtering by
statusorservice, sorting withsort_by(created_time,started_time,completed_time,progress,status,service) andsort_order(asc/desc), and requesting full per-job detail withfull=true:200 OKcurl \"$BASE/api/download/jobs?status=downloading&sort_by=progress&sort_order=desc\" \\\n -H \"X-Secret-Key: $SECRET\"\n{\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}\nA retry reuses the original job's service, title, and parameters to enqueue a brand new job, and returns
"},{"location":"dev/rest-api/quickstart/#beyond-a-single-download","title":"Beyond a single download","text":"202with a freshjob_id. A retry works only on terminal jobs, and returns409 CONFLICTotherwise. A priority change works only on jobs still in thequeuedstate.The download loop above is the core of the API, but the same server exposes more:
- Discovery:
POST /api/searchfinds titles by query, andPOST /api/list-titles/POST /api/list-tracksinspect what a title offers before you commit to downloading it. See titles and tracks.- History:
GET /api/historyreads a persisted log of jobs that reached a terminal state (newest first), andDELETE /api/history/{job_id}removes a single entry. See history.- Introspection:
GET /api/profileslists configured credential profiles per service,GET /api/configreturns a redacted view of the effective server config, andGET /api/env/checkreports which external binaries the machine has.- Maintenance:
POST /api/maintenance/clear-cache,.../clear-temp, and.../refresh-servicesdo housekeeping (the server blocks the cache and temp clears with409while 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
"},{"location":"dev/rest-api/quickstart/#error-handling","title":"Error handling","text":"/api/docs/documents every route and lets you try them in the browser (send yourX-Secret-Keythere too).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
detailsappears only when the server has more to add.retryable: trueappears only for transient failures. The server includes adebug_infoblock (exception type and traceback) only when it runs with--debug-api.The
Error code HTTP Typical causeerror_codemaps to an HTTP status. The ones you will meet most often:INVALID_INPUT400 Malformed JSON body or missing required fieldINVALID_SERVICE400 Unknown or disallowed service tagINVALID_PARAMETERS400 A download field failed validationAUTH_FAILED401 Service login/credential failureFORBIDDEN403 A gated field was used without server opt-inNOT_FOUND404 Title or resource does not existJOB_NOT_FOUND404 No job with thatjob_idCONFLICT409 Illegal state transition (e.g. retry a running job)RATE_LIMITED429 Upstream throttling (retryable)INTERNAL_ERROR500 Unexpected server-side failureSERVICE_ERROR502 The service raised an errorNETWORK_ERROR503 Connection/timeout reaching the service (retryable)Retry politely
Codes marked retryable (
"},{"location":"dev/rest-api/remote-sessions/","title":"Remote Service Sessions","text":"NETWORK_ERROR,RATE_LIMITED,SERVICE_UNAVAILABLE) reflect transient conditions. Back off and retry rather than hammering the server.A remote 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 unshackleserveinstance 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 remote session.Why you'd use this
"},{"location":"dev/rest-api/remote-sessions/#the-big-picture","title":"The big picture","text":"
- The server has service accounts, cookies, or a CDM you do not want to copy to every machine.
- You want to do downloads on a fast/local machine but keep credentials and licensing centralised.
- A service is region-locked to where the server lives, and the client is not.
\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\nThe client implements the same interface
dlexpects 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_dlis goneThe
RemoteServiceadapter lives inside the ordinarydlcommand and replaces the old standaloneremote_dlcommand. If you have prior scripts or docs that invokeremote_dl, usedlwith a configured remote server instead. The functionality now rides ondlthroughRemoteService.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
unshackle.yaml (client)remote_servicesblock to yourunshackle.yaml. Each entry names a server and provides its URL and API key.Key Required Meaningremote_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)\"\nurlyes Base URL of theunshackle serveinstance (no trailing slash needed)api_keyno Sent as theX-Secret-Keyheader on every requestserver_cdmno Whentrue, DRM keys are resolved by the server's CDM rather than a local device (defaultfalse)servicesno Per-service local config overrides, keyed by service tagPort default
The
"},{"location":"dev/rest-api/remote-sessions/#selecting-a-server","title":"Selecting a server","text":"unshackle servedefault port is8786(not8080). Use the port your server binds. Some older example snippets show8080, which is not the default.If you configure exactly one server, unshackle uses it implicitly:
unshackle dl EXAMPLE 0ABCDEF\nIf you configure more than one server, you must pick one with
--server:unshackle dl --server my_server EXAMPLE 0ABCDEF\nWith no
"},{"location":"dev/rest-api/remote-sessions/#what-happens-during-a-download","title":"What happens during a download","text":"remote_servicesconfigured at all, unshackle raises a clear error telling you to add the block shown above.From your point of view the command looks like an ordinary download. Under the hood the client walks a remote session through its lifecycle.
1. Create + authenticate2. Interactive prompts (if needed)3. Titles + tracks4. Licensing5. Download + closeThe client calls
POST /api/session/create, forwarding whatever it can so the server does not have to prompt:
- Local credentials for the service/profile (
{username, password, extra?})- Local cookies, compressed and base64-encoded
- A resolved proxy, or, if you did not set one, your detected client region. A client that reports no region is not blocked. The proxy must be a full proxy URI unless the operator gives your API key
server_proxy. Withoutserver_proxythe server does not resolve country codes with its own proxy providers, and it rejects the remote session when it sits in a different region from the reported one, asking the client to pass--proxywith its own proxy, or--no-proxyto accept the server's own connection. Withserver_proxythe server picks a proxy that matches the client region itself. For--proxy controld:cathe client points its Control D profile at the region and sends only the resolver, ascontrold://<resolver>@dns.controld.com. The server counts it as a full proxy URI and runs its own forwarder for it. A bare region skips Control D, because its proxy is a forwarder on the client. See Control D- Track-selection hints (
range_,vcodec,quality,best_available) so the server fetches the right manifests- Your language and audio codec selection (
-l,-vl,-al,-a,-fs) in adl_paramsobject. Some services fetch one manifest for each audio language, and pick the languages from these values- Your local per-service cache files (e.g. refreshed tokens), including files in subdirectories of the service cache directory, but only the files for the active profile. The client withholds a file whose path, in any directory or file name, embeds a hash of another credential or the name of another profile. One profile's tokens therefore never reach the server while you use a different profile. At worst, a withheld file makes the server authenticate again
The server uses its own accounts only when the operator lists the service in
serve.server_accountsand gives your API keyserver_accountsinserve.users. For any other service, a client that sends no credentials or cookies authenticates with nothing. For a listed service the client sends no credentials, cookies, or cache at all: the server picks one of its own accounts that works in your region and rotates through them.--proxy capicks acaaccount.
GET /api/servicesadvertises the regions those accounts cover. When your own region is not one of them and you set no--proxy, the client resolves a proxy for the first advertised region itself, so both sides sit in a region the account works in. This is a bare region query, so it skips Control D. A server-account service does not accept acontrold://resolver from the client.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 remote 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\nA client older than the
service_paramschange sends service options as flat body keys only. A flat key that shares a name with a transport field (such asprofile, which at the top level always means the credentials profile) no longer reaches the service; the server logs a warning naming the dropped key. Update the client to send service options underservice_params.The server also mirrors the service's own log output (
self.log) into a per-session buffer. The client drains it throughGET /api/session/{id}/logsafter each step and prints the records locally, so you see the server-side reason when a step fails.Once authenticated, the client fetches the title list, then the tracks for the chosen title. The tracks come back with playback URLs, and the server ships DASH/ISM manifests as compressed XML so the client can re-parse them locally for downloading. A service can build an AdaptationSet of its own that the served manifest does not contain. Each such track gets a one-AdaptationSet MPD in
track_manifests, and the client re-parses that instead. Anysession_headers/session_cookiesthe server used are merged into the client's local HTTP session.The tracks response repeats the title. A service can rewrite title fields inside
get_tracks, so the client takes this copy in place of the one from the title list. Each track carries the full local field set. After the manifest re-parse the client overlays the JSON-safe part of the service-settrack.data, so a service value wins over the parser's. An attachment that the service wrote to a file on the server travels as base64, and the client writes it to its own temp directory.For a DRM-protected title the client either proxies its CDM challenge through the server, or, if
server_cdm: true, asks the server to do the full CDM flow and return the content keys directly. See Server-side vs. proxied CDM below.The client downloads, decrypts, and muxes locally. While it works, it sends a keep-alive request to the remote session at an interval of one third of
session_ttl, so a long download does not let the remote session expire. On completion, and also whendlexits early (--list, Ctrl+C, an error), it deletes the remote session. If the login belongs to the client, theDELETErequest returns the updated cache files (for example a refreshed token) and the client saves them locally, so the next remote session can skip interactive authentication. The login belongs to the client when the client sent cookies, credentials, or cache files, or answered a login prompt (a device code or an OTP) that led to a successful login. A server-account login never does, and neither does a remote session on the server's device (see below). The client saves only the returned files that pass the same profile check it applies before it sends them, so a file for another profile cannot overwrite that profile's local file.Renaming remote titles locally
You can rename titles for a remote service you do not have installed locally by adding a
title_mapunder that service in your clientremote_services.<name>.servicesconfig. The server sends raw titles. Your local map wins.This is deliberate: the server does no
"},{"location":"dev/rest-api/remote-sessions/#server-side-vs-proxied-cdm","title":"Server-side vs. proxied CDM","text":"title_mapremapping 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.There are two ways DRM keys get resolved, chosen by the client's
Proxied CDM (default)Server-side CDM (server_cdm: true)server_cdmflag.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 only relays the license request.
- Used when
server_cdmisfalse(the default).- Also used for a run with
dl --cdm <name>, and for a service whose own entry in yourcdmconfig named the loaded device. In each case the client sendsserver_cdm: falsewhen it creates the remote session, and the server builds the service on your device even when the API key has the server CDM.- A service option that the service lists in
SERVER_DEVICE_OPTIONSkeeps the server CDM. The server reads the option fromservice_params, then from the top level of the request. The create response gives the result inserver_cdm, and the client shows a warning when the server did not apply its choice.- The service on the server sees your device, not the server's: its DRM system, security level, system ID and device type. A CDM call that the service makes (for example a session-key exchange at login) goes to your client as a
cdm_call, and your local CDM answers it. See Your own device identity.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:KEYpairs directly. The client needs no CDM.
- Enable with
server_cdm: truein the server entry.- The server tells the client which DRM type it used (
widevineorplayready).- A content key the server took from its own vault is unproven. The response lists its KID in
vault_keys, the client decrypts and checks that the output decodes, and a wrong pair goes back toPOST /api/session/{id}/keys/bad. The server flags the pair in every local vault it holds, reports it to the vault that served it, and the next licence for that KID skips the pair and reaches the CDM. A server with no local SQLite vault cannot store the flag. The server accepts a report only for a pair it served to that remote session. The client never learns the server vault names.- A content key in the client's own vaults goes first, even when the server already answered. The client proves it, and a server key waits as the next candidate.
A height limit on the server CDM
An API key with
"},{"location":"dev/rest-api/remote-sessions/#your-own-device-identity","title":"Your own device identity","text":"server_cdm_max_heightgets the server CDM only up to that height. The session create response tells the client who licenses the session inserver_cdmand gives the limit inserver_cdm_max_height. In a server CDM session, the batch licence response lists each track the server refused to license live undercapped_tracks. A single-track server CDM licence answers403 SERVER_CDM_CAPPEDfor such a track. In both cases the client licenses those tracks in proxy mode with its own local CDM. Seeserver_cdm_max_height.A service can need more than a licence challenge from your device: an ESN, model keys, or a session-key exchange that runs on the CDM. The device never leaves your machine. Only these cross the wire:
- Device facts. When it creates the remote session, the client sends its DRM system, security level, system ID and device type. The service sees a stand-in with these facts.
- CDM calls. When the service calls the CDM, the server sends a
cdm_callto the client and waits. The client makes the challenge, or parses the licence, on its own CDM. When the service closes a CDM session, the server sends aclosecall, because a CDM holds only a few sessions. The client polls for calls during login and while one of its own requests runs service code, so an idle remote session sends nothing extra.- Session keys only. A
keyscall returns WidevineOPERATOR_SESSIONandSIGNINGkeys. It never returns a content key, and the client refuses a PlayReadykeyscall. So a server cannot use your device to license titles for itself.- Identity config. A service names the config keys a client may send (for example
esn,Kpe,Kph,esn_map). The server lists them in the service listing asclient_config. The client sends only those keys, from its ownservices: <TAG>:block inunshackle.yaml. When the client sends an identity, the server drops its own value for each of those keys, so the two identities never mix. A mapping (such asesn_map) merges the client's entries over the server's. A client entry replaces a server entry with the same key, also when the server's key is a number. The server keeps these values in the remote session's memory only.A service can also offer an option that builds the remote session on the server's own device and identity, for the quality that device reaches. The session then makes no live licence at all: each content key comes from the server vault or the client's own vaults, and a content key that no vault holds stops the download with an error. The create response sets
server_devicetotruefor such a remote session. The service then uses the server's own config: the client's identity values do not apply. The client reads the server vault only when the API key has theserver_vaultgrant (orserver_cdm); without it, the client reads only its own vaults. The login tokens of such a remote session hold the server's device identity, so the server keeps them:DELETEreturns no cache files, and the HTTP session headers andtrack.datalose their secret-looking keys, as for a server login.The server vault in a session your device licenses
With the
"},{"location":"dev/rest-api/remote-sessions/#remote-session-lifecycle-and-expiry","title":"Remote session lifecycle and expiry","text":"server_vaultgrant (orserver_cdm) on the API key, a remote session that your own device licenses still takes content keys from the server vault. The server tries the vault first. A track that no server vault holds goes back to your device. Seeserver_vault.Remote sessions live in an in-memory store on the server and expire on a timer. You generally never touch this directly, but it explains behaviour you might observe.
Behavior Value Notes Idle session TTL 300s (5 min) default Refreshed on every request to the session and when atitlesortracksrequest ends. A remote session does not expire while a request works on it Max concurrent sessions 100 default Oldest (least recently used) is evicted when full. A remote session that a request works on goes last 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 cancelledAuth is never rushed
A remote session that is still
authenticatingor waiting onpending_inputis 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 remote session vanishing mid-prompt.The TTL and max-session limits are read from server config:
unshackle.yaml (server)"},{"location":"dev/rest-api/remote-sessions/#security-properties-worth-knowing","title":"Security properties worth knowing","text":"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/#developer-reference","title":"Developer reference","text":"
- API key required. Every remote session request carries
X-Secret-Key. The server rejects requests without a valid API key (health check aside). See Authentication.- IP binding. A remote session records the creator's IP when the server makes it. If a later request for the same remote session comes from a different IP, the server returns
403 FORBIDDEN. Remote sessions are not portable between hosts.- Namespaced, isolated cache. Each remote session gets its own cache directory, namespaced by a hash of the API key and the remote session ID, so remote sessions cannot read each other's cached tokens. The server deletes the directory when the remote session ends. A remote session that uses a server account (
serve.server_accounts) gets a persistent per-account cache instead, so the account's tokens outlive the remote session.- The server redacts secrets in logs. It redacts remote session IDs, service tags, and other user values before it writes them to the log.
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/#remote-session-endpoints","title":"Remote session endpoints","text":"The server mounts all these routes, even in
--remote-onlymode. Paths use thesession_idthatcreatereturns.Why
--remote-onlyexistsMethod Path Purpose
--remote-onlynarrows the server to only the health, services, search, and remote 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-onlymode, 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 want only the local HTTP API.POST/api/session/createBuild + authenticate a service instance; returns a session ID immediatelyGET/api/session/{id}/titlesFetch and cache the title listPOST/api/session/{id}/tracksResolve tracks, manifests, chapters, headers/cookies for a titlePOST/api/session/{id}/segmentsResolve per-segment/track download descriptorsPOST/api/session/{id}/segment_filterUnwanted HLS segment URIs for one track (ads, bumpers)POST/api/session/{id}/licenseDRM licensing (proxy or server CDM)POST/api/session/{id}/keys/badReport a server-vault content key that did not decryptGET/api/session/{id}/logsDrain the service's server-side log outputGET/api/session/{id}/promptPoll interactive auth status / pending promptPOST/api/session/{id}/promptSubmit an answer to a pending promptGET/api/session/{id}Session info (validity, TTL, counts)DELETE/api/session/{id}Close the remote 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/createrequiresserviceandtitle_id. It also acceptscredentials,cookies(base64 of zlib-compressed Netscape cookie file),proxy,no_proxy,profile,cache(a map offilename \u2192 base64(zlib(bytes))),client_region,server_cdm(falsewhen the user picked the local CDM),cdm_type,cdm_security_level,cdm_relay(truewhen the client answers CDM calls),cdm_system_idandcdm_device_type(Widevine only),service_config(the identity config keys the service lists inclient_config),client(a freeform identity object the dashboard shows as sent), and the track-selection hintsrange_,vcodec,quality,best_available, plus arbitrary service CLI options (additionalProperties: true).
dl_paramsholds thedltrack selection:lang,v_langanda_lang(arrays of language strings),acodec(an array of audio codec names, such asEC3), andforced_subs(boolean). The server gives these values to the service inctx.parent.params, never as service options, so a service option with the same name, such as an own--lang, does not get them. The server uses thedldefault for each absent or malformed value:langis[\"orig\"],v_lang,a_langandacodecare[], andforced_subsisfalse. The server drops an unknown codec name. An empty array is a valid value and stays empty.The server does not forward the
dlmode flags:video_only,audio_only,subs_only,chapters_only,list_,skip_dl,sub_formatandno_attachments. A service that reads one of them to skip work fetches more on the server, but the result is the same.In a client-login session (not a server account), the server gives the service the client's
profileinctx.parent.params, the same as a local run with that profile. The profile name keys the service's token cache, so the cache files the client sends and gets back belong to that profile. A service can also use the name to select per-profile entries in the server's service config, such as a per-profile ESN.The response returns before authentication finishes:
{ \"session_id\": \"...uuid4...\", \"service\": \"EXAMPLE\", \"status\": \"authenticating\",\n \"server_cdm\": false, \"server_cdm_max_height\": null, \"server_vault\": true, \"server_device\": false }\n
server_vaulttells the client that the server vault serves a remote session its own device licenses.server_devicetells it that the service runs on the server's device, so every content key comes from a vault.Authentication runs on a background thread (
"},{"location":"dev/rest-api/remote-sessions/#interactive-auth-the-inputbridge","title":"Interactive auth: theasyncio.to_thread(authenticate, ...)). The remote session starts inAUTHENTICATING. The client must poll the prompt endpoint until it reachesauthenticated(orfailed).InputBridge","text":"When the service calls its input function during
authenticate()on the server thread, anInputBridgepauses that thread and exposes the prompt to the HTTP layer.
AuthStatusvalues:authenticating,pending_input,authenticated,failed.InputBridge.request_input(prompt, timeout=600)blocks the sync auth thread on athreading.Eventuntilsubmit_response()orcancel()fires.- A timeout raises
TimeoutErrorand marks the remote sessionFAILED.- A prompt after authentication (in
get_titles(),get_tracks()or licensing) raisesRuntimeErrorat once. The client polls for prompts only during authentication, so no answer can come.- A CDM call (
InputBridge.request_cdm) works during and after authentication, because the client also polls while its own requests run. A call that no client fetches within 10 seconds fails then, because no client is listening. A fetched call waits 60 seconds for its answer. The call leaves the auth status as it was. An answer never marks the remote session as client-authenticated.AUTH_INPUT_TIMEOUT = 600.0seconds. This is also the TTL granted toAUTHENTICATING/PENDING_INPUTsessions in the store.
GET /api/session/{id}/promptreturns one of:{ \"status\": \"authenticated\" }\n{ \"status\": \"authenticating\" }\n{ \"status\": \"pending_input\", \"prompt\": \"Enter code: \" }\n{ \"status\": \"pending_input\", \"prompt\": \"...\", \"cdm_call\": { \"op\": \"challenge\", \"drm\": \"widevine\", \"init_data\": \"...\", \"license_type\": \"OFFLINE\", \"privacy_mode\": true, \"service_certificate\": \"...\" } }\n{ \"status\": \"failed\", \"error\": \"...message...\" }\nA
cdm_callcomes first, in any auth state. The client answers it with a JSON object inresponse:{\"session\": \"...\", \"challenge\": \"<base64>\"}forchallenge,{\"keys\": [{\"kid\", \"type\", \"key\", \"permissions\"}]}forkeys,{}forclose, or{\"error\": \"...\"}when its CDM refuses. An older client refusesclose, and the server ignores that refusal. An answered call stops being pending at once, so a client that polls again straight away does not see it twice.A missing remote session returns
"},{"location":"dev/rest-api/remote-sessions/#sessionstore-internals","title":"404 SESSION_NOT_FOUND. An IP mismatch returns403 FORBIDDEN.POST /api/session/{id}/prompttakes{ \"response\": \"...\" }and returns{ \"status\": \"accepted\" }. A post with no pending prompt is anINVALID_INPUTerror.SessionStoreinternals","text":"Source:
unshackle/core/api/session_store.py. A singleton you get fromget_session_store().
- Config-driven limits.
serve.session_ttl(default300) andserve.max_sessions(default100) are read as properties, so config changes take effect without recreating the store.create()evicts the least-recently-accessed remote session when at capacity, then stores a newSessionEntry. It defaultsauth_statustoAUTHENTICATED, and thecreatehandler overrides it toAUTHENTICATING.get()refresheslast_accessedthroughtouch(). It returnsNone(and deletes the entry) if an authenticated remote session has been idle longer than the TTL. Remote sessions inAUTHENTICATING/PENDING_INPUTare exempt from TTL expiry.cleanup_expired()runs every 60s: authenticated remote sessions expire atttl, in-flight-auth remote sessions expire atAUTH_INPUT_TIMEOUT. The removal of a remote session cancels itsInputBridgeand deletes its cache directory (pruning empty parent dirs up to, but not including, the cache root).- The server calls
cancel_all_bridges()on shutdown, to unblock any waiting auth threads.Field Description
SessionEntryfields:session_idUUID4 stringservice_tagNormalized service tagservice_instanceThe authenticated service object kept alive between callstitles/title_mapResult ofget_titles()and atitle_id \u2192 Titlemaptracks/tracks_by_title/chapters_by_titleCached resolved tracks and chapterscreator_ipIP recorded at create time for IP-binding checkscache_tagPer-session cache directory taginput_bridgeInputBridgefor interactive auth, if anyauth_status/auth_errorCurrentAuthStatusand last error messagecreated_at/last_accessedTimestamps;last_accesseddrives TTL and LRU eviction"},{"location":"dev/rest-api/remote-sessions/#per-session-cache-namespacing","title":"Per-session cache namespacing","text":"The
createhandler builds aCachernamespaced as:_sessions/<pbkdf2_hmac(sha256, X-Secret-Key, \"unshackle-session-ns\", 100000)[:12]>/<session_id>/<service>\nThe handler writes forwarded
cachefiles into that directory before authentication. OnDELETE, when the login belongs to the client (client_auth) and the remote session does not run on the server's device, the handler harvests updated cache files from the whole directory tree (compressing each with zlib and base64-encoding, excludingtitles_*files) and returns them under acachefield, so the client can keep refreshed tokens. Each cache key is the file path relative to that directory, with/separators and no.jsonsuffix, which is theCachercache key the service reads it with:{\n \"status\": \"ok\",\n \"cache\": {\n \"tokens\": \"...base64(zlib(bytes))...\",\n \"session_web/<sha1>\": \"...base64(zlib(bytes))...\"\n }\n}\nBoth sides reject a cache key with a drive letter, a root, a
"},{"location":"dev/rest-api/remote-sessions/#remote-session-info-response","title":"Remote session info response","text":"..segment, an empty segment, or more than 8 segments before it becomes a path, so a peer cannot write outside the cache directory. Both sides also refuse acachemap with more than 64 entries. The server skips a cache key it cannot write, such as one that names an existing file as a directory, and logs a warning.
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_inis the configured TTL, not time remainingThe
"},{"location":"dev/rest-api/remote-sessions/#reference-client-remoteservice","title":"Reference client:expires_invalue reports the store's configuredsession_ttl, not the seconds left before this specific remote session expires.RemoteService","text":"Source:
unshackle/core/remote_service.py. This is the canonical consumer of the remote session API and a good template for any client."},{"location":"dev/rest-api/remote-sessions/#troubleshooting","title":"Troubleshooting","text":"
RemoteClient.requestsetsX-Secret-KeyandUser-Agent: unshackle/<version>, uses the server'stimeoutfromremote_services(default 120s) as the read timeout, and treats anystatus_code >= 400as fatal: it logsServer error [<error_code>]: <message>and raisesSystemExit(1). A200body with\"status\": \"error\"is also fatal (see the heartbeat below).- Heartbeat. The
titles,tracksandlicenseroutes send a newline every 30s while the service works. The read timeout starts again at each byte, so a slow request stays open for as long as the server works on it, and a reverse proxy does not close it as idle. A route that answers within 30s sends the usual response. After 30s the status is already200, so a failure after that time arrives as the usual error body with status200. Your client must checkstatusin the body. JSON parsers ignore the leading newlines. The server does not gzip-compress a heartbeat response, because gzip output cannot start before the body is ready.- 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()\u2192create(+ pollpromptevery 2s up to a 600s deadline, answeringpending_inputprompts. With stdin closed the client logs an error and raisesSystemExit(1)instead of posting an empty answer) \u2192get_titles()\u2192get_tracks()(merging returnedsession_headers/session_cookies, re-parsingmanifests) \u2192 get the licence in proxy mode orserver_cdmmode \u2192close()(DELETE, saving any returnedcache).- Server resolution.
resolve_server()readsconfig.remote_services.<name>into{url, api_key, services, server_cdm}, injecting theserver_cdmflag into the services map as_server_cdm.\\\"Could not connect to remote server ... Is it running?\\\"
The client could not reach the URL. Make sure that
unshackle serveoperates on the server, theurl/port inremote_servicesare correct (default port8786), and any firewall or reverse proxy allows the connection.
403 FORBIDDENmid-downloadThe request came from a different IP than the one that made the remote session. Remote sessions are IP-bound. Do not 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 server marks the remote session
failedand cleans it up. Operate the download again. If the server never prompts you, make sure that the credentials and cookies the client forwarded are valid for the service.\\\"Multiple remote services configured. Use --server ...\\\"
You have more than one entry under
"},{"location":"getting-started/configuration-file/","title":"The Configuration File","text":"remote_services. Pass--server <name>to pick one.A single configuration file named
unshackle.yamlcontrols almost everything about how unshackle behaves. It sets where unshackle saves downloads, which CDM it uses, your credentials, proxies, filename templates, and more. This page gives where that file lives, how unshackle finds it, and the shape of its contents. It also gives the directory layout unshackle makes 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 uses plain YAML. You can open and edit it in any text editor, or manage individual values with theunshackle cfgcommand.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<clone>/unshackle/unshackle.yaml(auvtool install:.../site-packages/unshackle/unshackle.yaml) 2 The parent of the package folder<clone>/unshackle.yaml(auvtool install:.../site-packages/unshackle.yaml) 3 Your OS user-config directory see the table belowWhere the file belongs depends on how you installed unshackle:
- Git clone (recommended). Keep the file at
unshackle/unshackle.yamlinside the clone, which is location 1.unshackle setupwrites a new config there, and it stays with the clone when you update it.uvtool install. Use location 3, your per-user config directory. Auvtool upgrade replaces the package folder, so a file at location 1 or 2 is lost.unshackle setupwrites a new config there.The path of the per-user config directory depends on your operating system:
LinuxWindowsmacOS~/.config/unshackle/unshackle.yaml\n%LOCALAPPDATA%\\unshackle\\unshackle.yaml\n~/Library/Application Support/unshackle/unshackle.yaml\nNot sure which file is being used?
Operate
unshackle env info. It prints the path unshackle loaded the config from, or tells you that it found none. It also prints every directory unshackle currently uses.The config is read exactly once, when unshackle starts. If you edit the file, the change takes effect on the next command.
"},{"location":"getting-started/configuration-file/#top-level-structure","title":"Top-level structure","text":"
unshackle.yamlis a flat map of top-level config keys. Each config key configures one area of the program. You include only the config 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\nThe top-level keys group loosely into these areas:
Area Example keys Downloading & DRMdl,cdm,remote_cdm,decryptionNetworkingnetwork,headersCredentials & cookiescredentials,firefox_cookiesTracks & muxingsubtitle,audio,muxing,language_tagsKey vaultskey_vaults,vault_timeoutProxies & remoteproxy_providers,remote_services,serve,servicesNaming & taggingtag,output_template,chapter_fallback_nameExternal API keystmdb_api_key,tvdb_api_key,simkl_client_id,ipinfo_api_key,metadata_providersBehaviour & loggingupdate_checks,redact_paths,debug,unicode_filenamesPost-scriptspost_scriptsPathsdirectories,filenamesFor 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 config key, unshackle skips it and uses the default. You will not get an error. Examine the config 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
unshackle.yamldlflags (a language, a resolution, a codec), put them under adl:config key once and unshackle applies them to every download. Any flag from Downloading works here. The config key is the flag's long name with dashes turned into underscores (--best-available\u2192best_available).dl:\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\nYou 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
Per-service defaultsdl:block under it:dl:\n lang: [en] # default for everything\nservices:\n EXAMPLE:\n dl:\n lang: [en, ja] # Example downloads English + Japanese\nA few keys are named after the flag's internal name
Most keys are obvious, but set these exact ones:
"},{"location":"getting-started/configuration-file/#the-directory-layout","title":"The directory layout","text":"range(-r),list(--list),tmdb_id(--tmdb),imdb_id(--imdb),tvdb_id(--tvdb),no_atmos(--noatmos), andoutput_dir(-o). The Configuration Reference has the full list and every available config key.The
unshackle.yamldirectoriesconfig 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. A path can use~for your home directory.directories:\n downloads: ~/Videos/unshackle\n temp: /mnt/fast/unshackle-temp\n cache: ~/.cache/unshackle\nThe directories unshackle uses:
Name Purpose OverridabledownloadsDefault output folder for finished downloads YestempTemporary working files during a download YescacheCache store (title cache, update checks, service caches) YescookiesPer-service cookie files YeslogsLog files YesexportsExport JSONs YeswvdsWidevine devices (.wvdfiles) YesprdsPlayReady devices (.prdfiles) YesdcslDCSL data YesservicesSearch paths for service code (see below) YescommandsCLI command modules YesvaultsVault modules YesfontsBundled fonts YesSome directories cannot be moved
unshackle protects five internal entries:
"},{"location":"getting-started/configuration-file/#the-services-directory-is-special","title":"Theapp_dirs,core_dir,namespace_dir,user_configs, anddata. If you write any of them underdirectories, unshackle ignores the override. This is intentional. Those paths come from the package location and your OS user directories.servicesdirectory is special","text":"Unlike the other entries,
unshackle.yamlservicesis a list, and each entry can be either a local folder or a remote repository. This lets you mix your own local services with services pulled from Git:directories:\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\nunshackle examines the entries in the order you wrote them, and the first source to define a given service tag wins. Put local folders last if you want them to act as fallbacks and not 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-config-key","title":"Thefilenamesconfig key","text":"Alongside
"},{"location":"getting-started/configuration-file/#editing-the-config-with-unshackle-cfg","title":"Editing the config withdirectories, thefilenamesconfig key lets you override the templates unshackle uses when it names its own working and log files. Two examples are the log filename and the temporary chapters file. Most users never need to touch this. The Configuration Reference gives the available names and their default templates.unshackle cfg","text":"You do not have to edit
unshackle.yamlby hand. Theunshackle cfgcommand 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 config key. Nested config keys use dot notation:
$ unshackle cfg tag\n$ unshackle cfg cdm.default\nSet a value by passing a config key and a value:
$ unshackle cfg tag MYGRP\n$ unshackle cfg cdm.default my_device_l3\nunshackle parses the value as a Python literal, so write booleans as
True/Falseand quote the strings inside a list (\"['en']\"). unshackle stores anything that is not valid Python literal syntax as a plain string:$ unshackle cfg update_checks False\n$ unshackle cfg vault_timeout 30\nRemove a value with
--unset:$ unshackle cfg cdm.default --unset\nShow everything currently set with
--list:$ unshackle cfg --list\nWhen it writes,
unshackle cfgtargets the config file that unshackle loaded. If none exists yet, it makesunshackle.yamlin theunshacklepackage folder (location 1 above):
- Git clone. That is
unshackle/unshackle.yamlin the clone, which is the correct place.uvtool install. A tool upgrade replaces the package folder and deletes that file. Rununshackle setupfirst, which writes the config to your user config directory, or make the file there yourself. Thenunshackle cfgwrites to it.Editing with
cfgstrips commentsBecause
"},{"location":"getting-started/configuration-file/#next-steps","title":"Next steps","text":"unshackle cfgrewrites the whole file when it saves, a write removes any comments inunshackle.yaml. If you keep important notes as comments, edit the file by hand instead, or keep those notes elsewhere."},{"location":"getting-started/installation/","title":"Installation","text":"
- Browse the Configuration Reference for every config key and default.
- Set up your first download in Downloading.
- Learn how unshackle discovers and updates services in Creating a Service.
unshackle is a modular archival tool for movies, TV, and music. It ships as a single Python package named
unshacklethat installs one console command, also calledunshackle. This page covers the install script, theunshackle setupcommand, the Python version unshackle needs, and the external command-line tools it expects to find.What you get
Installing the package registers a single entry point, the
"},{"location":"getting-started/installation/#install-from-a-git-clone-recommended","title":"Install from a git clone (recommended)","text":"unshacklecommand. It covers everything: downloading, key-vault management, device provisioning, the REST server, and the helper utilities.A git clone with the install script is the recommended way to install unshackle. A clone keeps unshackle, its external tools, its config, its data folders, and your services together in one folder that you can see.
The repository has an install script for Linux (
LinuxWindowsinstall.sh) and for Windows (install.bat). The script installsuvif it is not found, installs unshackle, and then runsunshackle setup.git clone https://github.com/unshackle-dl/unshackle.git\ncd unshackle\nsh install.sh\ngit clone https://github.com/unshackle-dl/unshackle.git\ncd unshackle\n.\\install.bat\nIn a clone, the script runs
uv syncand thenuv run unshackle setup. The last lines of setup tell you to check your tools withuv run unshackle env check. Run unshackle from the clone withuv run unshackle ....To install without a clone, see Advanced: install as a uv tool.
"},{"location":"getting-started/installation/#what-unshackle-setup-does","title":"Whatunshackle setupdoes","text":"From a cloneInstalled as a uv tool
unshackle setupprepares a new installation. The install script runs it for you, and you can run it yourself at any time:uv run unshackle setup\nunshackle setup\nThe command does these steps:
- It shows which external tools are missing, and where to get the ones that it cannot download.
- It asks once for each tool or group of tools to download. The required tools come first. shaka-packager and
mp4decryptshare one DRM question, somp4decryptcomes with the required tools, butenv checkstill shows it as optional. The HDR and subtitle groups are optional. The prompts show the version that setup downloads. On Windows, the subtitle question also shows the download size of CCExtractor, which is much larger than the other tools. It puts the downloads in abinariesfolder (see Where setup puts files).- If no
unshackle.yamlexists, it asks for a downloads folder and writes a minimal config file.- It makes the data folders for cookies, WVDs, PRDs, and services.
- It asks for
.wvdand.prddevices to import. You can give a file or a folder of devices, and it asks again until you give an empty answer. It validates each file, copies it into the WVDs or PRDs folder, and prints one line for each file. If setup wrote a new config in this run, it setscdm.defaultto the imported device. If you imported more than one, it asks which one is the default.- It prints a summary of the tools that it found. If it wrote a config, it gives the path of that config and a link to the configuration reference. Then it tells you to run
env checknext.You can run the command again. It skips the tools that it finds and never overwrites an existing config.
Not every tool has a download
Setup downloads tools only on Linux and Windows, and only when the tool has a portable build for your system. For other tools, setup prints where to get them, for example a package manager. On macOS, setup downloads nothing and prints these links for each missing tool.
On Linux, setup cannot download MKVToolNix, which is a required tool. Install the
mkvtoolnixpackage with your package manager (for exampleapt,dnf, orpacman).CCExtractor on Linux needs FUSE
On Linux, setup downloads CCExtractor as an AppImage, which needs FUSE to run. Setup runs the AppImage once after the download. If it does not run, setup removes it and prints the manual install link. Install FUSE with your package manager and run setup again, or install the
"},{"location":"getting-started/installation/#where-setup-puts-files","title":"Where setup puts files","text":"Item From a git clone From accextractorpackage instead.uvtool install External toolsunshackle/binariesin the clonebinariesin the user data directory New config (unshackle.yaml)unshackle/unshackle.yamlin the clone The user config directory Data folders (cookies, WVDs, PRDs, cache, logs, services, temp) The default folders in the clone The user data directory Downloads The folder that you give at the prompt The folder that you give at the promptOn Windows, CCExtractor and its libraries go into their own
ccextractorsubfolder of the tools folder.On Linux, the user data directory is usually
~/.local/share/unshackleand the user config directory is usually~/.config/unshackle. On Windows, both are%LOCALAPPDATA%\\unshackle. Runuv run unshackle env infoto see the paths that unshackle uses. ItsUser_Binariesrow shows thebinariesfolder in the user data directory.Setup writes a new config only when it finds no
unshackle.yamlin any of the config locations. It keeps an existing config where it is. In a clone, the new config sets onlydirectories.downloads, so the data folders stay at their defaults in the clone.A
"},{"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, or \u2265 3.10.0 DRM decryption Bento4 \u2265 1.6.0-639 Alternate DRM decryption (uvtool upgrade replaces the package folder. For auvtool install, setup puts the tools in the user data directory and writes adirectoriesblock that moves the data folders there, so an upgrade keeps them. If an existing config keeps data folders inside the package of auvtool install, setup shows a warning and prints thedirectorieslines to add.mp4decrypt) dovi_tool \u2265 2.1 Dolby Vision handling SubtitleEdit \u2265 5.0 Subtitle conversion (optional)The sections below tell you about each of these, and how to make sure that your setup is complete.
"},{"location":"getting-started/installation/#python-versions","title":"Python versions","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 refuses to assemble the environment.You do not usually need to install a matching Python interpreter by hand, since
"},{"location":"getting-started/installation/#installing-by-hand","title":"Installing by hand","text":"uvcan download and manage one for you. If you work inside an existing virtual environment, make sure that it is on a supported version.If you do not use the install script, install unshackle with
"},{"location":"getting-started/installation/#running-from-a-clone","title":"Running from a clone","text":"uvand then rununshackle setup, or install the external tools yourself.Use
Run from a git cloneuv runinside the clone. It keeps the project's virtual environment active for the duration of the command, so you do not have to activate it yourself. It also installs the dependencies on the first run.git clone https://github.com/unshackle-dl/unshackle.git\ncd unshackle\nuv run unshackle setup\nEvery command shown elsewhere in these docs works the same way from a clone. Prefix it with
uv run:"},{"location":"getting-started/installation/#advanced-install-as-a-uv-tool","title":"Advanced: install as a uv tool","text":"uv run unshackle env check\nThis way is for users who know
uvtools and want theunshacklecommand on theirPATHwithout a clone.Where a uv tool install keeps files
uv tool installputs unshackle into the tool folder ofuv, not into a folder that you choose.unshackle setuptherefore puts the external tools, the config, and the data folders in your user data and config directories (see Where setup puts files). A git clone is the easier setup: its tools, config, data, and services stay in one folder that you can see. On the first run from auvtool install,unshackle setuptells you this and asks if you want to continue.Install the tool and run setup:
Install as a uv tooluv tool install git+https://github.com/unshackle-dl/unshackle.git\nunshackle setup\nThe install scripts also do this when you run one alone, without a clone: when the script does not find the unshackle
pyproject.tomlnext to it, it runsuv tool installand thenunshackle setup. When it finishes, it tells you to open a new terminal, because thePATHchange only applies to new terminals. Rununshackle env checkthere.Upgrading and removing
Because you installed it from a git URL, re-run the same
uv tool installcommand (uv will update in place) to get a newer version, and useuv tool uninstall unshackleto remove it.Developer note
Contributors will also want the development and test dependency groups. The project defines
"},{"location":"getting-started/installation/#external-tools-on-your-path","title":"External tools on your PATH","text":"dev(linters, type checkers) andtest(pytest and friends) groups inpyproject.toml. Install them withuv sync --group dev --group testinside the clone. End users can ignore these entirely.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, then in thebinariesfolder of your user data directory, and then on your systemPATH.unshackle setupinstalls tools into the first folder in a clone and into the second for auvtool install. Installing a tool system-wide or putting it into one of those folders both work."},{"location":"getting-started/installation/#required-tools","title":"Required tools","text":"
unshackle setupinstalls the required tools and some optional ones for you. Use the tables below when you install a tool by hand.These must be present for core downloading, decryption, and muxing to work:
Tool Binary looked up Why it is needed FFmpegffmpegMedia processing: remuxing, conversion, track handling FFprobeffprobeMedia analysis: inspecting tracks and container details MKVToolNixmkvmergeMuxing downloaded tracks into a final MKV mkvpropeditmkvpropeditEditing MKV metadata after muxing shaka-packagershaka-packager/packagerDecrypting 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
decryptionconfig key if you want to switch tomp4decrypt.Do not use shaka-packager 3.0 through 3.9.x
These versions stop with a segmentation fault on a single-track MP4 whose internal
"},{"location":"getting-started/installation/#recommended-and-optional-tools","title":"Recommended and optional tools","text":"track_idis 2 or more. A track that unshackle downloads from a DASH or HLS manifest usually has such atrack_id.These extend unshackle's capabilities. Install the ones relevant to what you download. For example,
Tool Binary looked up Why it is needed Bento4 (dovi_toolonly matters if you download Dolby Vision video.mp4decrypt)mp4decryptAlternative DRM decryptor to shaka-packager MP4Box (GPAC)MP4BoxMuxing DTS:X Profile 2 (DTS-UHD) audio dovi_tooldovi_toolDolby Vision metadata handling HDR10Plus_toolhdr10plus_toolHDR10+ metadata handling SubtitleEditseconv,SubtitleEditSubtitle conversion (theSeConvCLI from 5.x) CCExtractorccextractor,ccextractorwin,ccextractorwinfullExtracting closed captions FFplayffplaySimple preview player MPVmpvAdvanced preview player (used byutil crop/util rangepreviews) HolaProxyhola-proxyHola proxy provider support CaddycaddyOptional reverse proxy forunshackle serve --caddyDockerdockerGluetun VPN proxy support GitgitFetching and updating remote service repositoriesMP4Box is only used for DTS:X Profile 2
Titles with DTS:X Profile 2 (DTS-UHD) audio mux to MP4; everything else muxes to MKV with
mkvmerge. Install withapt install gpac.SubtitleEdit specifics
unshackle looks for
"},{"location":"getting-started/installation/#verifying-your-installation","title":"Verifying your installation","text":"seconvfirst, the batch CLI shipped with SubtitleEdit 5.x, before falling back to aSubtitleEdit(4.x) binary. The 5.0 GUI has no batch mode, so theSeConvCLI is what you want for automated subtitle conversion.After installation, make sure that unshackle can see the tools it depends on.
"},{"location":"getting-started/installation/#examine-the-external-dependencies","title":"Examine the external dependencies","text":"The
From a cloneInstalled as a uv toolenv checksubcommand is the first check. It inspects your environment and reports which tools it found:uv run unshackle env check\nunshackle env check\nIt 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 \u2713orMissing required: <names>.If a required tool shows as missing,
env checktells you to rununshackle setup. You can also install the tool by hand and put it on yourPATH(or in one of thebinariesfolders), then re-run the check.
unshackleis not found after a uv tool installThe bin directory of
"},{"location":"getting-started/installation/#inspect-environment-paths","title":"Inspect environment paths","text":"uvtools is probably not on yourPATH. Runuv tool update-shelland open a new terminal.To see where unshackle will read its configuration from and where it stores downloads, caches, cookies, and devices, use:
uv run unshackle env info\nIf no configuration file exists yet, this command lists the locations unshackle searches for one, so you know where to make it. See the configuration guide for the details of that file.
"},{"location":"getting-started/installation/#next-steps","title":"Next steps","text":""},{"location":"getting-started/quickstart/","title":"Quick Start","text":"
- Set up your configuration file (
unshackle.yaml), or letunshackle setupwrite a first one.- Start downloading titles when
env checkfinds every required tool.This page takes you from a fresh install to your first downloaded file. You will make the one piece of configuration unshackle insists on, supply the service module and CDM it needs to fetch and decrypt media, operate a basic
unshackle dlcommand, and learn where the finished file lands.Before you start
This guide assumes that you installed unshackle as Installation describes. The examples use the bare
unshacklecommand. From a git clone, the recommended install, run each one asuv run unshackle ..., for exampleuv run unshackle env check.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).
"},{"location":"getting-started/quickstart/#1-run-setup","title":"1. Run setup","text":"EXAMPLEin this guide stands in for the tag you give yours.unshackle drives several external tools (FFmpeg, MKVToolNix, shaka-packager, and others) to decrypt, repack, and mux media, and it needs a config file. The
unshackle setupcommand prepares both. If you used the install script, setup has already run. You can run it again at any time:unshackle setup\nSetup downloads the missing required tools and, if you agree, groups of optional tools. If no
unshackle.yamlexists, it writes a minimal one with your downloads folder. It also makes the data folders and lets you import.wvdand.prddevices, one file or a whole folder at a time. Installation tells you what each step does and where the files go.Then make sure that unshackle finds the tools:
unshackle env check\nThe 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.Set up by hand
To do these steps yourself, install the tools from the tables in Installation. Then run
"},{"location":"getting-started/quickstart/#2-check-the-output-template","title":"2. Check the output template","text":"unshackle env infoto see where unshackle looks forunshackle.yaml, and make the file in one of those locations. The configuration guide gives the details.unshackle refuses to start a download unless it knows how to name the output file. That means
unshackle.yamloutput_templateis the one setting a first run genuinely requires. Everything else has a sensible default. The config that setup writes copiestagandoutput_templatefrom the example config. A minimal, working config looks like this:output_template:\n movies: \"{title} ({year}) {quality} {source}\"\n series: \"{title} {season_episode} {episode_name?} {quality} {source}\"\nunshackle fills in each
{variable}from the title and the tracks you downloaded. Output and Naming gives the full set of valid variables, includingresolution,video,audio,hdr,edition,tag, and more.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 makes scene-style
Title.S01E01.1080pnames. 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
"},{"location":"getting-started/quickstart/#3-write-a-service","title":"3. Write a service","text":"unshackle cfg(for exampleunshackle cfg tag MYGROUPorunshackle 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.The service tag you pass to
dl(likeEXAMPLE) 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
"},{"location":"getting-started/quickstart/#4-add-a-cdm-for-drm","title":"4. Add a CDM for DRM","text":"directories.servicesfolder (unshackle env infoshows the path). The tag then works withdl,search, and the other service commands.Most streaming services encrypt their titles. To fetch content keys, unshackle needs a CDM, a Widevine device (
.wvd) or a PlayReady device (.prd). If you imported devices duringunshackle setup, they are already in place. If setup also wrote your config, it setcdm.defaultto the device that you chose. Otherwise, add a Widevine device you already have with:unshackle wvd add /path/to/device.wvd\nThis validates the file and moves it into your WVDs directory. Then point services at it in your config. The
unshackle.yamlcdmmap uses the service tag as its config key, with adefaultthat covers everything else:cdm:\n default: my_device # the .wvd file's name, without the extension\n EXAMPLE: my_other_device # override for a specific service\nPlayReady works the same way with
.prdfiles created and managed by theprdcommand. If a title is DRM-free, unshackle needs no CDM.Tip
Operate
"},{"location":"getting-started/quickstart/#5-supply-authentication","title":"5. Supply authentication","text":"unshackle wvd parse my_deviceto inspect a device's security level and contents, andunshackle env infoto show where unshackle stores WVDs and PRDs.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, thencookies/{SERVICE}/{profile}.txt, thencookies/{SERVICE}/default.txt. So unshackle finds a file atcookies/EXAMPLE.txtautomatically for theEXAMPLEservice.unshackle repairs the usual export damage in memory and does not change your file: a missing header line, a byte-order mark, spaces in place of tabs, and rows a text editor broke. unshackle refuses a JSON export. Export the cookies again in Netscape format.
Credentials: store a username and password per service in your config:
unshackle.yamlcredentials:\n EXAMPLE: \"email@example.com:your-password\"\nUse the
"},{"location":"getting-started/quickstart/#6-find-a-title-optional","title":"6. Find a title (optional)","text":"-p/--profileflag 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.If you have a URL or ID already, skip this. Otherwise, find a title on the service and note the
idit prints, because that is what you feed todl:unshackle search EXAMPLE \"My Show\"\nYou can also show what a service gives for a given title without downloading:
"},{"location":"getting-started/quickstart/#7-do-your-first-download","title":"7. Do your first download","text":"unshackle dl --list-titles EXAMPLE 81234567 # show seasons/episodes\nunshackle dl --list EXAMPLE 81234567 # show available tracks\nA download always has three parts:
unshackle dl <FLAGS> <SERVICE-TAG> <TITLE>\n
dlcarries 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\nunshackle will fetch the title, select the tracks that match your flags, and get the content keys through your CDM (and any key vaults). It then decrypts, muxes, and tags the result.
"},{"location":"getting-started/quickstart/#handy-first-flags","title":"Handy first flags","text":"Flag Meaning-q,--qualityTarget resolution(s), e.g.-q 1080or-q 1080,720. Defaults to best available.-l,--langLanguage(s) for video and audio, e.g.-l enor-l orig,en.orig= the title's original language. Defaults toorig.-sl,--s-langSubtitle language(s); defaults toall.-v,--vcodecVideo codec, e.g.-v H.265. Defaults to any.-r,--rangeDynamic range, e.g.-r HDR10or-r DV. Defaults toSDR.-w,--wantedWhich episodes, e.g.-w S01or-w S01E01-S01E03. For music, which tracks, e.g.-w 1-5or-w 2x3for disc 2 track 3.-o,--outputOverride the output directory for this run.--listList 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\nSee 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
downloadsdirectory.unshackle env infoshows its exact path. A config fromunshackle setupuses the folder that you gave at its prompt. Without that setting, the default is adownloadsfolder one level above the installedunshacklepackage. Override it for one command with-o:unshackle dl -q 1080 -o /mnt/media/incoming EXAMPLE 81234567\n
- unshackle writes movies as a single
.mkvfile named from yourmoviestemplate.- unshackle groups TV episodes into a per-show / per-season folder (from your
seriestemplate) unless you pass--no-folder.- unshackle builds the filename from your
output_templateand 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.yaml"},{"location":"getting-started/quickstart/#where-to-go-next","title":"Where to go next","text":"directories:\n downloads: /mnt/media/incoming\n
- Downloading. The full
dlcommand: quality, codecs, languages, track selection, hybrid Dolby Vision, and output control.- Configuration. Every
unshackle.yamlconfig key, plus directories, key vaults, proxies, and naming templates.- REST API. Operate the
serveserver to drive downloads over HTTP.You are never far from help
"},{"location":"guide/cli-reference/","title":"CLI Reference","text":"
unshackle --help,unshackle dl --help, andunshackle <command> --helpshow every option. When in doubt, add--listto adlcommand to preview what it would do before it downloads anything.A complete reference for every
unshacklecommand, subcommand, and flag. This page groups the commands 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.setup: install the external tools and make a first config.cfg,env: manage configuration and the environment.kv: Key Vault operations.wvd,prd: Widevine / PlayReady device management.serve: operate the local CDM and REST API server.util: helper media utilities.Running commands from a clone
This page uses the bare
unshacklecommand. From a git clone, the recommended install, run each command asuv run unshackle ....Getting help on any command
Every command accepts
"},{"location":"guide/cli-reference/#root-command","title":"Root command","text":"-h,--help, or-?. For exampleunshackle dl --helporunshackle wvd new --help. Each service defines its own arguments fordlandsearch(such as a title ID or a query), so examineunshackle dl SERVICE --helpfor those.unshackle [OPTIONS] COMMAND [ARGS]...\nunshackle: Modular Movie, TV, and Music Archival Software.
Every invocation prints a banner and, when you enable
Option Descriptionupdate_checksin your config, checks for a newer release.-v,--versionPrint version information and exit.-d,--debugEnableDEBUG-level logs and JSON debug logging. Silences noisy HTTP libraries unlessdebug_requestsis 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 thedllevel.SERVICE: a service tag (for exampleEXAMPLE1orEXAMPLE2). Tags are case-insensitive and honour each service's aliases (example+,EXAMPLE2, and the other alias forms all give the same tag).SERVICE ARGS: the title, URL, or ID, plus any service-specific options. These belong to the service, not todl.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\nConfig-driven defaults
You can give any
"},{"location":"guide/cli-reference/#quality-codec-bitrate-range","title":"Quality, codec, bitrate & range","text":"Flag Default Descriptiondlflag a default under thedl:section ofunshackle.yaml, and per-service underservices.<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.-p,--profile- Profile for credentials and cookies.-q,--qualitybest Resolution(s), comma-separated, e.g.1080,720.-q 1080also matches anamorphic tracks by 16:9 canvas.-v,--vcodecany Video codec(s). Accepts names or values:AVC/H.264/H264,HEVC/H.265/H265,VC1/VC-1,VP8,VP9,AV1.-a,--acodecany Audio codec(s), comma-separated. AcceptsAAC,AC3/DD,EC3/DD+/eac3/ddp,AC4/AC-4,OPUS,OGG/VORB/vorbis,DTS,DTSX/DTS-X,ALAC,FLAC.-vb,--vbitratehighest Exact video bitrate in kbps.-ab,--abitratehighest 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,--rangeSDRColour range(s):SDR,HLG,HDR10,HDR10P(HDR10+),DV,HYBRID.-c,--channels- Audio channels; matches sub-layouts (5.1 \u2248 6.0).-naa,--noatmosoff Exclude Dolby Atmos audio tracks.--worstoff Pick the lowest bitrate within the requested quality. Requires-q.--best-available,--warn-onlyoff Warn and continue when a requested resolution, range, or language is absent, instead of failing.-rvb,--real-video-bitrateoff Probe real media size for true video bitrates, overriding the manifest.-rab,--real-audio-bitrateoff Same for audio (slower).
HYBRIDrange"},{"location":"guide/cli-reference/#language-subtitles","title":"Language & subtitles","text":"Flag Default Description
-r HYBRIDfetches both HDR10/HDR10+ and Dolby Vision and merges them withdovi_tool. It requires thedovi_toolbinary on yourPATH.-l,--langorigLanguage(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-lfor video). A-prefix excludes.-al,--a-lang- Audio-only language (overrides-lfor audio). A-prefix excludes.-sl,--s-langallSubtitle language(s). A-prefix excludes, e.g.all,-es.--require-audio- Audio langs that must exist. The title fails even with--best-available. Does not select tracks.--require-video- Video langs that must exist. The title fails even with--best-available. Does not select tracks.--require-subs- Subtitle langs that must exist. Fails the title if one is missing.--s-langstill decides what to keep.-fs,--forced-subsoff Include forced subtitle tracks.-fsl,--forced-s-langnone Language(s) wanted for forced subtitles; implies-fs. A-prefix excludes.--exact-langoff Exact matching only:-l es-419matcheses-419, notes-ES. Applies to selection and to sort order.--sub-format- Output subtitle format (SRT/srt,VTT/webvtt,ASS/ssa,TTML,SMI, ...), ororiginalto keep the source format.These flags select languages, they do not order them
Naming languages with
-lor-slremoves the others. To keep every language but put your preferred ones first, useaudio.language_priorityandsubtitle.language_priorityin your configuration file.unshackle honours the special language tokens
"},{"location":"guide/cli-reference/#title-episode-selection","title":"Title & episode selection","text":"Flag Descriptionorig,all, andbesteverywhere it expects a language.-w,--wantedWanted episodes, e.g.S01-S05,S07,S01E01-S02E03. Supports exclusions with a leading-(e.g.-S03). For a split episode,.Npicks one part (S01E01.2), a range must stay inside the episode (S01E01.1-S01E01.3), andS01E01on its own takes every part. For a dated episode, 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). For a music release, a token is a track number (1-5,1,3,7), or{disc}x{track}(2x3) when the release has more than one disc.--select-titlesInteractively select what to download: episodes of a series, or films when a title has more than one. Cannot combine with-w.--latest-episodeDownload only the single most recent episode.--latest-episodes NDownload only the N most recent episodes.--list-titlesList 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.
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-nt,--no-attachments(cover art, subtitle fonts, and files the service attaches) Flag Description-ad,--audio-descriptionInclude descriptive (audio-description) tracks.--skip-subtitle-errorsSkip 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-audioWrite a separate output file per audio codec instead of merging. Defaults to configmuxing.merge_audio.--merge-videoMux video tracks that share a height, range, and codec into one file, so only language varies inside a file. Defaults to configmuxing.merge_video.-o,--outputOverride the output directory for this run.--no-folderDisable folder creation for TV shows.--no-sourceRemove the source tag from the filename/path.--no-muxDo not mux; keep individual track files.--tagGroup tag override.--repackAdd aREPACKtag to the filename.--postscriptRun a command after each output file, with{variable}placeholders substituted. Repeatable. Replaces thepost_scriptsconfig for this run. See Post-scripts.--no-postscriptRun no post-script for this run, neither the configured ones nor--postscript."},{"location":"guide/cli-reference/#metadata-tagging","title":"Metadata & tagging","text":"Flag Description--tmdbTMDB ID (integer). Used for the tags. Skips the title search.--enrichreads it too. Needstmdb_api_key.--imdbIMDb ID, e.g.tt1375666. Used for the tags. Skips the title search.--enrichreads it too. Needs no API key.--tvdbTVDB ID (integer). Used for the tags. Skips the series lookup that--tvdb-orderwould otherwise do.--enrichreads it too. Needstvdb_api_key.--anilistAniList ID (integer), e.g.--anilist 21. A MyAnimeList ID is accepted asmal:12345and resolved to the AniList entry. Used for the tags. Skips the title search.--enrichreads it too. Needs no API key.--enrichOverwrite show title, year and original language with the external source's. Requires one of--tmdb,--imdb,--tvdb, or--anilist.--dailyTreat the title as daily/date-based content and fill missing episode air dates from TVDB. The fill needs--enrichand a TVDB ID. See Daily and date-based content.--tvdb-orderRenumber episodes to a TVDB season order:official(aired),dvd,absolute,alternate, orregional. Needstvdb_api_key.One ID at a time
You cannot combine
"},{"location":"guide/cli-reference/#episode-ordering","title":"Episode ordering","text":"--tmdb,--imdband--tvdb. Give one and unshackle finds the others from it, and writes all three to the tags.--anilistis 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 metadata provider that can find it, such as--tmdbwith notmdb_api_key, fails before the download starts.--anilistneeds no API key, so it never fails this check.A service does not always number a series the way TVDB's aired order does. Some services use TVDB's
alternate(Streaming) order for Futurama, for example.--tvdb-orderworks 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 ignore episodes the service carries. TVDB's
"},{"location":"guide/cli-reference/#drm-keys-decryption","title":"DRM, keys & decryption","text":"Flag Descriptiondvdorder 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.--cdm-only/--vaults-onlyUse only the CDM, or only Key Vaults, for key acquisition.--cdm <name>Use the named CDM device from thecdmconfig mapping for this run, ignoring the service/default mapping. Over--remote, that device licenses the remote session, not the server CDM.--skip-dlSkip the download but still retrieve keys.--exportExport track info and keys to amediaexportJSON file in the exports directory."},{"location":"guide/cli-reference/#network-proxy","title":"Network & proxy","text":"Flag Description--proxyProxy URI, a country or location code resolved from configured providers, orprovider:region(e.g.nordvpn:ca,gluetun:us,protonvpn:de:berlin,controld:yul).--no-proxyForce-disable all proxy use.--no-proxy-downloadBypass the proxy for all downloads (manifest, licence, and auth stay proxied).--proxy-downloadUse a different proxy for all downloads, in the same forms as--proxy(manifest, licence, and auth stay on--proxy).--remoteUse a remote unshackle server.--serverName a remote server from theremote_servicesconfig."},{"location":"guide/cli-reference/#concurrency-caching-listing","title":"Concurrency, caching & listing","text":"Flag Default Description--workersdownloader default Per-track download threads.--adaptive-workersoff Scale per-track segment workers dynamically (up to the--workerscap) based on measured CDN throughput and errors.--download-processes1Split large segment batches (24+) across this many download processes to exceed the single-process throughput cap on multi-gigabit connections. Ignored while a speed limit is set: the cap is one shared budget, which extra processes cannot share, so the download stays in a single process. Also ignored when the service HTTP session carries state a child process cannot rebuild, such as a custom TLS adapter.--continue-downloadsoff Keep completed segment files across runs and resume a previously failed download. One-off enable of thecontinue_downloadsconfig option.--downloads1Number of tracks downloaded concurrently.--speed-limitunlimited Cap total download speed across all threads and tracks, e.g.500k,5M,1.5Gor plain bytes/sec. Values are bytes, not bits (5M= 5.0 MB/s).offdisables a configured limit.--no-cacheoff Bypass the title cache.--reset-cacheoff Clear the title cache.--listoff 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
"},{"location":"guide/cli-reference/#search","title":"
--select-titlesand--wantedare mutually exclusive.--worstneeds--quality.--vbitrateand--vbitrate-range(and the audio equivalents) are mutually exclusive.search","text":"Find titles on a service. Like
dl,searchis a group whose subcommands are the installed services, and it reusesdl's authentication, cookie, and proxy machinery.unshackle search [OPTIONS] SERVICE [QUERY...]\nEach service defines its own query syntax. unshackle prints the results as a tree of titles with their service IDs. Feed an ID straight into
Option Descriptiondl.-p,--profileProfile for credentials and cookies.--proxyProxy URI, country or location code, orprovider:region(e.g.controld:ca).--no-proxyForce-disable all proxy use.Example
"},{"location":"guide/cli-reference/#import","title":"unshackle search EXAMPLE \"My Show\"\nunshackle search -p myprofile EXAMPLE \"Another Show\"\nunshackle search --proxy nordvpn:ca SERVICE \"query\"\nimport","text":"Reconstruct a download (download \u2192 decrypt \u2192 mux) from an
--exportJSON file without re-contacting the service. It re-fetches the manifest, injects the stored keys, and then runs the normaldlpipeline. The service tag is read from the export file, not passed by you.unshackle import EXPORT_FILE [DL_ARGS...]\nunshackle forwards any
dloptions after the file verbatim, so you can override quality, range, proxy, and so on.Example
unshackle import export.json\nunshackle import export.json -r HDR10 --proxy US\nThe export file can be a
"},{"location":"guide/cli-reference/#setup","title":"mediaexportfile fromdl --export, an export from an older version of unshackle, or an export from unidl. The file must name the service it came from.setup","text":"Install the external tools and make a first config.
unshackle setup\nThe command takes no options and asks its questions at the prompt. It shows the missing external tools and downloads the ones you accept. It puts them in a
binariesfolder, in the package folder of a git clone or in your user data directory for auvtool install. If nounshackle.yamlexists, it writes a first one: inside the clone, or to your user config directory for auvtool install. It then makes the data folders and imports.wvdand.prddevices, one file or folder per answer, until you give an empty answer. It ends with a summary of the tools, the path of a config that it wrote, and a pointer toenv check. You can run it again: it skips the tools it finds and never overwrites a config. Installation gives the full steps and where each file goes.Example
"},{"location":"guide/cli-reference/#cfg","title":"uv run unshackle setup # from a git clone\nunshackle setup # from a uv tool install\ncfg","text":"Read, set, delete, or show configuration values in
unshackle.yamlwithout hand-editing YAML.Argument / Option Descriptionunshackle cfg [KEY] [VALUE] [OPTIONS]\nKEYDotted path into the config, e.g.tag,serve.api_secret,directories.downloads.VALUEValue to set. Parsed as a Python literal when possible, sotrue,123,['a','b'], and{'k':1}become real types; bare words stay strings.--unsetRemove the configuration value.--listList all set configuration values.Comments are stripped
Writing a value through
cfgrewrites the configuration file and removes all comments from it. If your config relies on comments, edit it by hand instead. Setting a value and using--unsettogether is an error.Example
"},{"location":"guide/cli-reference/#env","title":"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\nenv","text":"Inspect and manage the project environment.
"},{"location":"guide/cli-reference/#env-check","title":"unshackle env COMMAND [ARGS]...\nenv check","text":"unshackle env check\nPrints a dependency table (Category / Tool / Status / Required / Purpose) that shows which external tools unshackle finds on your
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, gitPATH. It also gives a summary of how many required tools are present. If a required tool is missing, it tells you to rununshackle setup.* required. All others are optional.
"},{"location":"guide/cli-reference/#env-info","title":"env info","text":"unshackle env info\nShows the location unshackle loaded the configuration file from. If it found no file, it shows the candidate locations instead. It then 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\nPrints a sample of every available CLI theme. Each sample has colour swatches, help text with option rows, and a track listing with video, audio and subtitle entries. It also has log lines, a progress bar, and the gradient pulse bar. unshackle marks the active theme and shows each theme's aliases. Set your choice with the
"},{"location":"guide/cli-reference/#env-clear","title":"themeconfig key.env clear","text":"Clear an environment directory. unshackle empties the directory, makes it again, and reports the number of files and bytes it freed.
Command Descriptionunshackle env clear cache [SERVICE]Clear the cache directory, or only one service's cache subdirectory.unshackle env clear tempClear the temp directory.Example
"},{"location":"guide/cli-reference/#kv","title":"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\nkv","text":"Manage Key Vaults. You configure vaults under
key_vaultsinunshackle.yaml, each with aname, atype(sqlite,mysql,http,api), and type-specific options. unshackle normalises service tags automatically."},{"location":"guide/cli-reference/#kv-copy-to_vault-from_vault","title":"unshackle kv COMMAND [ARGS]...\nkv copy TO_VAULT FROM_VAULT...","text":"Copy content keys from one or more source vaults into a destination vault. unshackle skips rows whose KIDs match, unless the existing row has no content key. unshackle never alters or deletes existing data.
Option Description-s,--serviceOnly copy data for a specific service.-l,--local-onlyOnly copy data for services installed locally. Mutually exclusive with--service."},{"location":"guide/cli-reference/#kv-sync-vault","title":"kv sync VAULT...","text":"Make sure that two or more vaults hold the same set of content keys, essentially a chained bidirectional copy. More than one vault is necessary. Accepts the same
"},{"location":"guide/cli-reference/#kv-add-file-service-vault","title":"--service/--local-onlyoptions ascopy.kv add FILE SERVICE VAULT...","text":"Add content keys to one or more vaults for a service.
"},{"location":"guide/cli-reference/#kv-search-kid","title":"FILEcontains oneKID:KEYpair per line (32 hex : 32 hex, UTF-8). unshackle skips every line that does not match that form.kv search KID","text":"Find a KID (32 hex characters, no dashes) in the configured vaults and report its content key.
Option Description-s,--serviceLimit the search to a specific service tag.-v,--vaultLimit the search to a specific configured vault by name.Note
unshackle cannot enumerate remote vaults without a service, so pass
"},{"location":"guide/cli-reference/#kv-prepare-vault","title":"--servicewhen you examine them.kv prepare VAULT...","text":"Make service tables on vaults that use tables, for every installed service, where they do not already exist.
Example
"},{"location":"guide/cli-reference/#wvd","title":"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\nwvd","text":"Manage Widevine Device (
.wvd) files. Devices live in the configured WVDs directory.Command Descriptionunshackle wvd COMMAND [ARGS]...\nwvd add PATHS...Validate and move one or more.wvdfiles into the WVDs directory.wvd delete NAMES...Delete.wvdfiles by name (without extension). Prompts for confirmation.wvd parse PATHParse a.wvdand print its System ID, security level, type, flags, and client info. Relative paths resolve against the WVDs directory.wvd dump WVD_PATHS... OUT_DIRExtract a device's contents (metadata, private key, client ID, VMP) intoOUT_DIR/<name>/. With no paths, dumps every WVD in the WVDs directory.wvd new NAME PRIVATE_KEY CLIENT_ID [FILE_HASHES]Create a new.wvdfrom a PEM private key and aClientIdentificationblob, optionally with a VMP (FileHashes) blob.Option Default Description
wvd newoptions:-t,--typeAndroidDevice type.-l,--level1Security level (1-3).-o,--outputWVDs dir Output directory.Example
"},{"location":"guide/cli-reference/#prd","title":"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\nprd","text":"Manage PlayReady Device (
.prd) files. Devices live in the configured PRDs directory. Built onpyplayready."},{"location":"guide/cli-reference/#prd-new-paths","title":"unshackle prd COMMAND [ARGS]...\nprd new PATHS...","text":"Make a new
Option Description.prd. Give either a single folder that containszgpriv.dat(group key) andbgroupcert.dat(group certificate), or two file paths (group key, then group certificate).-e,--encryption_keyOptional device ECC private encryption key (generated if omitted).-s,--signing_keyOptional device ECC private signing key (generated if omitted).-o,--outputOutput directory or.prdfile path."},{"location":"guide/cli-reference/#prd-reprovision-prd_path","title":"
prd newwill not overwrite an existing file.prd reprovision PRD_PATH","text":"Reprovision an existing device by replacing its leaf certificate and keys. Only a device of version 3 or higher can do reprovisioning. Accepts the same
"},{"location":"guide/cli-reference/#prd-test-device","title":"-e/-s/-ooptions. By default it overwrites the device in place.prd test DEVICE","text":"Do a test of a device against the Microsoft PlayReady demo server and print the returned content keys.
Option Default Description-c,--cktaesctrContent key encryption type:aesctroraescbc.-sl,--security-level2000Minimum security level:150,2000, or3000.Example
"},{"location":"guide/cli-reference/#serve","title":"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\nserve","text":"Serve your local Widevine/PlayReady devices and the REST API for remote access. Built on
aiohttp.Option Default Descriptionunshackle serve [OPTIONS]\n-h,--host127.0.0.1Host to serve from.-p,--port8786Port to serve from.--caddyoff Also serve through Caddy (requires thecaddybinary and aCaddyfile).--api-onlyoff Serve only the REST API, not the CDM endpoints. Incompatible with--no-widevine/--no-playready.--no-widevineoff Disable the Widevine CDM endpoints.--no-playreadyoff Disable the PlayReady CDM endpoints.--no-keyoff Disable API-key authentication (allows all requests).--debug-apioff Include tracebacks/stderr in API error responses.--debugoff Enable debug logging for API operations.--remote-onlyoff 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_secretfirstUnless you pass
unshackle.yaml--no-key,serve.api_secretmust be set in your config. Requests authenticate with theX-Secret-Keyheader.--no-keydisables authentication entirely. Only use it on a trusted, private network.serve:\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\nExample
"},{"location":"guide/cli-reference/#util","title":"unshackle serve\nunshackle serve -h 0.0.0.0 -p 8080\nunshackle serve --api-only\nunshackle serve --remote-only\nunshackle serve --caddy\nutil","text":"Various helper media utilities. When you give a command a directory, it processes every
.mkv/.mp4inside it in natural (S01E01 before S01E10) order."},{"location":"guide/cli-reference/#util-refresh-services","title":"unshackle util COMMAND [ARGS]...\nutil refresh-services","text":"unshackle util refresh-services\nForce a refresh (git pull / hard reset) of all service repos configured under
"},{"location":"guide/cli-reference/#util-crop-path-aspect","title":"directories.services. This updates the clones on disk. A server that is already running reloads the changed services throughPOST /api/maintenance/refresh-servicesorserve.services_refresh_intervalinstead.util crop PATH ASPECT","text":"Losslessly crop H.264/H.265 video at the bitstream level.
Option Default DescriptionASPECTis aW:Htarget such as2.39:1.--letter/--pillar--letterCrop top/bottom (--letter) or the sides (--pillar).-o,--offset0Fine-tune the computed crop area if not perfectly centred.-p,--previewoff 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,--previewoff 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. By default it does a test of every track. It cannot do a test of subtitles.
Option Default Description-m,--map0Do a test of specific tracks with FFmpeg's-map, for example0:v:0or0:a.Example
"},{"location":"guide/cli-reference/#see-also","title":"See also","text":"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/downloading/","title":"Downloading","text":"
- Installation: install unshackle and its dependencies.
- Quickstart: your first download.
- Configuration file: set defaults for any
dlflag and configure vaults, proxies, and directories.
unshackle dlis 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, use
"},{"location":"guide/downloading/#command-shape","title":"Command shape","text":"unshackle dl --help. Every flag shown here can also be given a default in your configuration file. See Configuration file.A download is always three layers: the root CLI, the
dlcommand (which is where unshackle parses all the flags below), and a service tag subcommand that carries the title argument.unshackle dl [OPTIONS] SERVICE [SERVICE ARGUMENTS]\nA minimal download
unshackle: the root command.dl: the download command. unshackle parses all of the options on this page here, so they go before the service tag.SERVICE: a service tag such asEXAMPLE1,EXAMPLE2,EXAMPLE3. Service tags are case-insensitive and honour each service's aliases (for exampleexample+resolves to its real tag).SERVICE ARGUMENTS: usually a title ID or URL. This positional argument belongs to the service, not todl, so it comes after the tag.Flags belong to dl, the title belongs to the serviceunshackle dl EXAMPLE 81234567\nunshackle dl -q 1080 -v H.265 -r HDR10 --lang en EXAMPLE 'https://www.example.com/...'\nSee what a service accepts
Because each service defines its own positional argument, use
"},{"location":"guide/downloading/#the-download-flow","title":"The download flow","text":"unshackle dl SERVICE --help(for exampleunshackle dl EXAMPLE --help) to see that service's own argument and any service-specific options.When you start a download,
dlmoves through the pipeline below. The order tells you why, for example,--liststops early, and why unshackle fetches the content keys 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,--latest-episodes, 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: get the 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."},{"location":"guide/downloading/#choosing-quality","title":"Choosing quality","text":""},{"location":"guide/downloading/#resolution","title":"Resolution","text":"
--listprints the tracks a title exposes and stops before selection.--list-titlesprints every title the service returned and stops before--wanted/--latest-episode/--latest-episodesfiltering.--skip-dlruns the license step but skips the actual segment download.Single resolution
-q/--qualitytakes one or more target resolutions (heights) as a comma-separated list. Without it, unshackle picks the best available resolution.Multiple resolutions in one rununshackle dl -q 1080 EXAMPLE 81234567\nunshackle dl -q 2160,1080,720 EXAMPLE 81234567\n16:9 canvas matching
Resolution matching is by track height first. If no track height matches exactly, unshackle falls back to a 16:9-canvas match, so
"},{"location":"guide/downloading/#best-available-and-worst","title":"Best-available and worst","text":"-q 1080also selects an anamorphic1920\u00d7804track (computed asint(width \u00d7 9 / 16)). unshackle applies the fallback to each range and codec on its own. A service that crops only its HDR10 tracks therefore keeps its3840\u00d71920HDR10 track, even when another range has an exact2160height match.By default a missing requested resolution is an error. Two flags change that:
Flag Behaviour--best-available(--warn-only) If the requested resolution(s) are not present, continue with the best that is available instead of failing. Also softens missing video/audio/subtitle languages and hybrid fallbacks.--worstWithin the specified quality, pick the lowest bitrate rendition. Requires-q/--quality. Never fail on a missing resolution"},{"location":"guide/downloading/#video-codec-and-colour-range","title":"Video codec and colour range","text":""},{"location":"guide/downloading/#codec","title":"Codec","text":"unshackle dl -q 2160 --best-available EXAMPLE 0ABC123\nName Value
-v/--vcodecselects one or more video codecs. The default is any codec. It accepts either enum names or their values, comma-separated.AVCH.264HEVCH.265VC1VC-1VP8VP8VP9VP9AV1AV1Either spelling works
H264andH265are aliases ofAVCandHEVC."},{"location":"guide/downloading/#colour-range","title":"Colour range","text":"unshackle dl -v HEVC EXAMPLE 81234567\nunshackle dl -v H.265 EXAMPLE 81234567\nunshackle dl -v hevc,avc EXAMPLE 81234567\nRange Meaning
-r/--rangeselects one or more colour ranges. The default isSDR.SDRStandard dynamic range (default)HLGHybrid Log-GammaHDR10HDR10HDR10PHDR10+DVDolby VisionHYBRIDFetch 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 '...'\nHYBRID requires dovi_tool
-r HYBRIDmakes a single hybrid track by injecting the Dolby Vision RPU metadata onto an HDR10/HDR10+ base layer with dovi_tool. It requires thedovi_toolbinary, resolved from unshackle'sbinaries/folder, thebinariesfolder of your user data directory, or yourPATH. 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 needshdr10plus_tool. With no DV and no HDR10+, the title fails. When HDR10+ is present, unshackle prefers it over HDR10 as the base layer.To keep an HDR10+ deliverable alongside the hybrid, request both ranges:
"},{"location":"guide/downloading/#bitrate-selection","title":"Bitrate selection","text":"-r HYBRID,HDR10P.-r HYBRIDon its own muxes only the merged hybrid.By default unshackle keeps the highest-bitrate rendition for each selected resolution/codec/range/language combination. You can constrain this.
Flag Purpose-vb/--vbitrateExact video bitrate in kbps.-ab/--abitrateExact audio bitrate in kbps.-vb-range/--vbitrate-rangeVideo bitrate range in kbps, e.g.6000-7000; picks the highest within it.-ab-range/--abitrate-rangeAudio bitrate range in kbps, e.g.128-256.Exact vs range are mutually exclusive
You cannot combine
"},{"location":"guide/downloading/#real-bitrate-probing","title":"Real bitrate probing","text":"--vbitratewith--vbitrate-range, and likewise for the audio pair. Pick one form.Manifest-declared bitrates are sometimes rounded or wrong. Services often advertise a peak or nominal bandwidth that is far from the track's real average. This matters because a track's bitrate drives the track listing, the sort order, and the
--vbitrate/--vbitrate-rangeselection 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
unshackle measures a single-file track (DASH
SegmentBase/BaseURL) 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. unshackle probes only the five highest declared-bitrate renditions of each quality tier, in parallel (video grouped by codec and range, audio by codec, channels, language, and descriptive flag). It 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. A track whose duration unshackle cannot find falls back to
"},{"location":"guide/downloading/#audio-codec-channels-and-atmos","title":"Audio codec, channels, and Atmos","text":""},{"location":"guide/downloading/#codec_1","title":"Codec","text":"ffprobe. A probe failure is non-fatal: the manifest value stays in place.Name Value Codec
-a/--acodecselects one or more audio codecs (comma-separated). The default is any.AACAACAdvanced Audio CodingAC3DDDolby DigitalEC3DD+Dolby Digital PlusAC4AC-4Dolby AC-4OPUSOPUSOpusOGGVORBVorbisDTSDTSDTSDTSXDTS-XDTS:X Profile 2 (DTS-UHD)ALACALACApple LosslessFLACFLACFLACunshackle accepts names, values, and a few aliases:
Prefer Dolby Digital Plus, fall back to AACeac3andddpboth giveEC3, andvorbisgivesOGG."},{"location":"guide/downloading/#channels-and-atmos","title":"Channels and Atmos","text":"unshackle dl -a EC3,AAC EXAMPLE 81234567\n5.1 audio, no Atmos
-c/--channels: desired channel layout, for example5.1or2.0. Matching is by ceiling, so5.1implicitly matches a6.0-reported layout. An immersive layout such as5.1.4matches on its total number of channels, so-c 10selects it.-naa/--noatmos: exclude Atmos audio tracks from selection."},{"location":"guide/downloading/#music-quality","title":"Music quality","text":"unshackle dl -c 5.1 --noatmos EXAMPLE 0ABC123\nA music service gives one
Audiotrack for each codec and bitrate it offers for a song. The audio options above choose between them:-apicks the codec and-abor-ab-rangepicks the bitrate. If you set neither, unshackle takes the best track.Take the lossless track
-q/--qualityis a video height. It has no effect on a music download.Take the lossy track at 320 kb/sunshackle dl -a FLAC EXAMPLE 81234567\nunshackle dl -a AAC -ab 320 EXAMPLE 81234567\nTwo FLAC tracks in one release
A service can offer both a CD FLAC and a hi-res FLAC. The two have the same codec, so the bitrate is what separates them. Use
"},{"location":"guide/downloading/#languages","title":"Languages","text":""},{"location":"guide/downloading/#video-and-audio-language","title":"Video and audio language","text":"--listto read the bitrates, then give-abthe one you want.Original language plus English
-l/--langsets the wanted language(s) for both video and audio. The default isorig, the title's original language.unshackle dl -l orig,en EXAMPLE 81234567\nunshackle changes the special token
origto the title's own original language everywhere you use it. You can override each track type independently:English audio over the original video
-vl/--v-lang: language for video only (overrides-lfor video). Useful when the burned-in video language differs from the audio you want.-al/--a-lang: language for audio only (overrides-lfor audio)."},{"location":"guide/downloading/#excluding-languages","title":"Excluding languages","text":"unshackle dl -al en EXAMPLE 81234567\nPut a
Every subtitle language except Spanish-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.unshackle dl -sl all,-es EXAMPLE 81234567\nunshackle subtracts the exclusions 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:
Only exclusions-sl -esis the same as-sl all,-es, and-l -esis the same as-l orig,-es.# 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\nCascade
-vland-alhave no default of their own: they cascade to-l. An override that names a language replaces-lfor that track type, exclusions included. An override that gives only exclusions adds them to the ones from-l.# video keeps the Japanese track; only the audio drops Spanish\nunshackle dl -l en,-es -vl ja EXAMPLE 81234567\nRules for exclusion tokens
"},{"location":"guide/downloading/#sort-order","title":"Sort order","text":"
origbecomes the title's own original language, so-sl -origdrops the subtitles in that language.--exact-langapplies to exclusions with the same matching rules as selection:-esremoves exactly the tracks thateswould select. Without it,-esalso removeses-419andes-ES.- Tracks with no language tag are never excluded.
- unshackle rejects
-all. Name the languages you do not want instead.- The comma form is the documented style.
-sl -esalso works, but a shell can read a lone-esas another option.unshackle.yaml
-land-slselect languages: naming some removes the rest. To keep every language but decide which ones come first, set a priority list in your configuration file:audio:\n language_priority: [orig, en]\nsubtitle:\n group_by: language\n language_priority: [en, es, fr]\nSubtitles then start with English, Spanish, and French. The languages you leave out follow alphabetically, after the title's original language. unshackle removes nothing. Audio works the same way, but the languages you leave out keep their bitrate and codec order instead of an alphabetical one.
See
"},{"location":"guide/downloading/#exact-vs-fuzzy-matching","title":"Exact vs fuzzy matching","text":"subtitle.language_priorityfor the details.By default language matching is fuzzy:
-l enalso acceptsen-US,en-GB, and the other regional forms (up to a small distance). Pass--exact-langwhen only an exact match is acceptable: with it,-l es-419selects onlyes-419, notes-ES.The
allandbesttokensFor audio, the tokens
"},{"location":"guide/downloading/#subtitles","title":"Subtitles","text":""},{"location":"guide/downloading/#selecting-subtitle-languages","title":"Selecting subtitle languages","text":"allandbestbypass the usual one-track-per-language pick and instead select the best track for each language present. For subtitles,allkeeps every subtitle language.Only English and Spanish subtitles
-sl/--s-langsets the wanted subtitle language(s). The default isall, so unshackle downloads every available subtitle language.unshackle dl -sl en,es EXAMPLE 81234567\nTo keep the default and drop one language, use the
"},{"location":"guide/downloading/#requiring-subtitles","title":"Requiring subtitles","text":"-prefix described in Excluding languages.-sl all,-eskeeps every subtitle language except Spanish, forced Spanish subtitles included.
--require-substakes a list of languages that must exist. If any is missing, the title fails, even under--best-available/--warn-only. Use it when a download is only worth keeping if a specific subtitle track is present.It gates the title. It does not select tracks:
--s-langstill decides what to keep, and it defaults toall:"},{"location":"guide/downloading/#requiring-audio-and-video-languages","title":"Requiring audio and video languages","text":"# every subtitle language, but only if English is one of them\nunshackle dl --require-subs en EXAMPLE 0ABC123\n\n# English, Korean and Japanese only, and only if English is present\nunshackle dl -sl en,ko,ja --require-subs en EXAMPLE 0ABC123\n
--require-audioand--require-videotake a list of languages that must exist. If one is missing, the title fails, even under--best-available/--warn-only. Like--require-subs, they gate the title without selecting tracks:-l/-al/-vlstill decide what unshackle downloads.This matters most with
-l bestor-l all, which accept whatever the service offers and so never report a missing language on their own:# every audio language except French, but only if English is one of them\nunshackle dl -l best,-fr --require-audio en EXAMPLE 0ABC123\n\n# English is required, French is optional\nunshackle dl -l en,fr --require-audio en --warn-only EXAMPLE 0ABC123\nThe check runs against the full track list, before any language selection or exclusion.
"},{"location":"guide/downloading/#forced-subtitles-and-output-format","title":"Forced subtitles and output format","text":"origresolves to the title language, and--exact-langapplies. Audio carried inside the video track counts.--no-audioand--no-videodrop the matching requirement.Value Format
-fs/--forced-subs: include forced subtitle tracks (signs/foreign dialogue). Without this flag, unshackle drops forced subtitle tracks from the selection.-fsl/--forced-s-lang: keep forced subtitles only in these languages (implies-fs). Works independently of--s-lang, so-sl all -fsl engrabs every full subtitle but only the English forced track. It accepts exclusions too:-fsl all,-eskeeps every forced subtitle except the Spanish one, and-fsl -esmeans 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, and the other codec aliases), or the literaloriginalto keep the source format.SRT/srtSubRipVTT/vttWebVTTASS/assAdvanced SubStation AlphaSSA/ssaSubStation AlphaTTML/ttmlTimed Text MarkuporiginalKeep the source format, no conversion Convert subtitles to SRTunshackle dl --sub-format srt EXAMPLE 81234567\nSDH stripping happens by default
When a subtitle track carries the SDH flag (for the deaf/hard-of-hearing) and there is no plain same-language subtitle, unshackle makes a stripped, non-SDH track automatically. The
"},{"location":"guide/downloading/#selecting-episodes-and-tracks","title":"Selecting episodes and tracks","text":"subtitle.strip_sdhconfig option controls this behaviour (default on).For series, several flags control which episodes unshackle downloads. The same flags select the tracks of a music release. By default, unshackle downloads all episodes and all tracks.
"},{"location":"guide/downloading/#wanted-ranges","title":"Wanted ranges","text":"Whole seasonsSpecific episodesExclusionsSplit episodesAir dates
-w/--wantedaccepts season and episode ranges, comma-separated. Prefix a token with-to exclude it. For a music release, the same option takes track numbers. Read music tracks below.unshackle dl -w S01 EXAMPLE 81234567\nunshackle dl -w S01-S03 EXAMPLE 81234567\nunshackle dl -w S01,S03,S05 EXAMPLE 81234567\nunshackle 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"},{"location":"guide/downloading/#music-tracks","title":"Music tracks","text":"# 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\nTracksMore than one discExclusions
-wselects the tracks of an album, EP, single, or playlist. Give a track by its number. A release with more than one disc uses the{disc}x{track}form. A number on its own is a track on disc 1, which is the number the track list shows for a single-disc release.# Track 3\nunshackle dl -w 3 EXAMPLE 81234567\n\n# Tracks 1 through 5\nunshackle dl -w 1-5 EXAMPLE 81234567\n\n# Tracks 1, 3 and 7\nunshackle dl -w 1,3,7 EXAMPLE 81234567\n# Disc 2, track 3\nunshackle dl -w 2x3 EXAMPLE 81234567\n\n# Disc 2, tracks 1 through 4\nunshackle dl -w 2x1-2x4 EXAMPLE 81234567\n\n# Disc 1 track 3, through disc 2 track 2\nunshackle dl -w 1x3-2x2 EXAMPLE 81234567\n# Tracks 1 through 8, but not track 4\nunshackle dl -w 1-8,-4 EXAMPLE 81234567\nRead the track list first
"},{"location":"guide/downloading/#daily-and-date-based-content","title":"Daily and date-based content","text":"
--list-titlesprints the release with its disc and track numbers, so you can see which numbers to give-w.Talk shows, news and sports have no official episode numbering, so unshackle names them by air date. unshackle writes an episode that carries an air date as
Show.YYYY.MM.DDinstead ofSxxExx, the{date}token holds the ISO date, and the season folder becomes the year.A service that only carries this kind of title sets
DAILY = Trueon its class, and a service can setair_dateon each episode itself. Add--dailyto mark any other title as date-based:unshackle dl --daily --tvdb 73871 --enrich EXAMPLE 81234567\nWith
--enrichand a TVDB ID,--dailyfills in the air date of every episode that has none. An air date the service already set is kept. unshackle skips dates before 1970 and dates in the future, because TVDB carries placeholder schedule dates for episodes that have not aired. Without--enrichunshackle has no source to fill from, and says so.A dated episode answers to its air date in
"},{"location":"guide/downloading/#split-episodes","title":"Split episodes","text":"-w, as well as to itsSxxExxtoken. 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. You can mix date tokens andSxxExxtokens in one-w.A few services split one episode into several separately playable videos. Where a service reports that, add
Token Selects.Nafter the episode to pick one part.S01E01All parts of episode 1.S01E01.2Part 2 of episode 1, and nothing else.S01E01.1-S01E01.3Parts 1 to 3 of episode 1.S01,-S01E01.2All of season 1 except episode 1 part 2. Parts 1 and 3 are kept.A season token such as
S01covers every episode and every part, so you only need.Nwhen you want a part on its own.A part range stays inside one episode
S01E01.1-S01E01.3is valid. unshackle rejectsS01E01.1-S01E02.3, because the parser cannot know how many parts episode 1 has and so cannot work out where the range ends. To span episodes, write the parts you want as separate tokens:-w S01E01.2,S01E02.1.A part of an unsplit episode selects nothing
"},{"location":"guide/downloading/#other-selection-flags","title":"Other selection flags","text":"Flag Behaviour
-w S01E02.2on 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. Use--list-titlesto see which episodes carry parts.--select-titlesInteractively pick episodes of a series, or films when a title has more than one. Cannot combine with-w.--latest-episodeDownload only the single most recent episode.--latest-episodes NDownload only the N most recent episodes. The newest episodes can come from more than one season. A split episode counts as one episode, and all its parts download.--list-titlesList every title the service returned, then stop.-w,--latest-episodeand--latest-episodesare not applied to this listing.When
Grab just the newest episode--latest-episodeor--latest-episodesis set,-whas no effect.Grab the three newest episodesunshackle dl --latest-episode EXAMPLE 81234567\n"},{"location":"guide/downloading/#including-and-excluding-track-types","title":"Including and excluding track types","text":"unshackle dl --latest-episodes 3 EXAMPLE 81234567\nYou can restrict a download to certain track categories, either positively (only these) or negatively (everything but these).
Only these types (
*-onlyflags):
-V/--video-only-A/--audio-only-S/--subs-only-C/--chapters-onlySkip these types (
no-*flags):
-nv/--no-video-na/--no-audio-ns/--no-subs-nc/--no-chapters-nt/--no-attachments(cover art, subtitle fonts, and files the service attaches)Additional track-type flags:
Subtitles only
-ad/--audio-description: include descriptive (audio-description) tracks, which unshackle drops 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.Everything except chaptersunshackle dl -S -sl en EXAMPLE 81234567\nunshackle dl -nc EXAMPLE 81234567\nNote
You can combine an
"},{"location":"guide/downloading/#listing-and-dry-runs","title":"Listing and dry runs","text":"*-onlyflag withno-*flags to fine-tune:*-onlychooses the starting set of categories, thenno-*subtracts from it. Attachments (for example fonts) are always kept.Before committing to a long download, inspect what unshackle would do:
Flag Effect--listList the tracks the service exposes for each title, then stop. No selection, no download.--list-titlesList every title the service returned, then stop.-w,--latest-episodeand--latest-episodesare not applied to this listing.--skip-dlSkip downloading but still acquire the decryption keys. See the track selection without downloading"},{"location":"guide/downloading/#output-and-muxing","title":"Output and muxing","text":""},{"location":"guide/downloading/#output-location-and-folders","title":"Output location and folders","text":"unshackle dl -q 1080 -v H.265 -r HDR10 --list EXAMPLE 81234567\nSend this download somewhere specific
-o/--output: override the output directory for this run (otherwise unshackle uses the configured downloads directory).--no-folder: do not make a per-show folder for TV downloads.--no-source: remove the service source tag from the filename and path."},{"location":"guide/downloading/#muxing-behaviour","title":"Muxing behaviour","text":"unshackle dl -o ~/Videos/incoming EXAMPLE 81234567\nBy default,
Flag Behaviour Default sourcemkvmergemuxes the tracks of a movie or an episode into a single Matroska (.mkv) file. These flags change how unshackle assembles the output:--no-muxDo not mux; keep the individual track files. ---split-audioWrite a separate output file per audio codec instead of merging all audio. configmuxing.merge_audio(on)--merge-videoMux video tracks that share a height, range, and codec into one file, so only language varies inside a file. configmuxing.merge_video(off)Music is never muxed
A song skips the muxer. unshackle keeps the container that the service delivered, such as
"},{"location":"guide/downloading/#after-the-download","title":"After the download","text":".flac,.m4a, or.mp3, and writes the metadata into that file. Read Music output files.Hand each finished file to an uploader
--postscript \"<command>\"runs your own command once per output file, with unshackle's metadata substituted into{variable}placeholders. It is repeatable, and it replaces thepost_scriptsconfig for that run. Nosuccesspost-script operates under--no-mux, because that run writes no muxed output. Afailurepost-script still operates if the download fails.unshackle dl --postscript \"python /opt/upload.py {filepath} --service={service}\" EXAMPLE 81234567\nSkip the uploader for one run
--no-postscriptoperates the run with no post-script at all. It silences thepost_scriptsconfig and--postscripttogether, for every event and mode.unshackle dl --no-postscript EXAMPLE 81234567\nFor post-scripts that continue across runs, for the
"},{"location":"guide/downloading/#naming-tags","title":"Naming tags","text":"seasonandrunmodes, forfailurepost-scripts and for the full variable list, see Post-scripts.Custom group tag and a REPACK label
--tag: set the release group tag (overrides the configured tag).--repack: add aREPACKtag to the output filename."},{"location":"guide/downloading/#proxies","title":"Proxies","text":"unshackle dl --tag MYGRP --repack EXAMPLE 81234567\nProxy forms
--proxyaccepts a full proxy URI, a country or location code (resolved through your configured proxy providers), or aprovider:regionform. See Proxies & VPN for the exact grammar.unshackle dl --proxy us EXAMPLE 81234567\nunshackle dl --proxy nordvpn:ca EXAMPLE 81234567\nunshackle dl --proxy controld:yul EXAMPLE 81234567\nunshackle dl --proxy 'http://user:pass@host:8080' EXAMPLE 81234567\nThree related flags:
"},{"location":"guide/downloading/#performance-and-caching","title":"Performance and caching","text":"Flag Purpose
--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 you need the proxy only to satisfy geo-checks, not to move the bulk of the data.--proxy-download: use a different proxy for all downloads. The manifest, license, and authentication requests still go through--proxy. Takes the same forms as--proxy.--workers NThreads used per track for segment downloads. Default depends on the downloader.--adaptive-workersOpt-in: start the per-track worker count at--workers, reduce it when the CDN returns errors, then raise it again while the extra workers add measured throughput. The count never goes above--workers. Off by default (fixed worker count).--download-processes NOpt-in: split a large segment batch acrossNdownload processes, each with its own worker pool. A single process tops out around 1.3 Gb/s (Python interpreter limit);2reaches 2.5GbE line rate on fast CDNs. Only engages for batches of 24+ segments. Ignored while--speed-limit(or serve'sglobal_speed_limit) is set: the cap is one shared budget, which extra processes cannot share, so the download stays in a single process. Also ignored when the service HTTP session carries state a child process cannot rebuild, such as a custom TLS adapter. The download stays in a single process instead of running with the wrong settings. Default1.--continue-downloadsOpt-in: keep completed segment files when a download fails so the next run resumes instead of restarting from zero. One-off enable of thecontinue_downloadsconfig option, which documents what can and cannot resume.--downloads NNumber of tracks downloaded concurrently. Default1.--slow [MIN-MAX]Add a delay between titles to look more like a real device.--slowalone means 60-120s;--slow 20-40sets a custom range. Minimum 20s.--no-cacheBypass the title cache for this download.--reset-cacheClear the title cache before fetching. Two tracks at a time, eight threads eachSpace out a season downloadunshackle dl --downloads 2 --workers 8 EXAMPLE 81234567\n"},{"location":"guide/downloading/#keys-vaults-and-export","title":"Keys, vaults, and export","text":"unshackle dl -w S01 --slow 30-60 EXAMPLE 81234567\nBy default, unshackle checks your key vaults first and only asks a CDM to license a content key when the vault misses. You can force one side or the other:
Fetch keys only, no download
--cdm-only: only use the CDM (skip vaults). Over aserver_cdmremote session the flag does not reach the server, whose vault shortcut still runs; it only skips the client's decode check on a server key.--cdm <name>: use the named CDM device for this run, ignoring thecdmconfig mapping (including its quality and Widevine/PlayReady sub-entries). Over--remote, that device licenses the remote session, not the server CDM.--vaults-only: only use key vaults and never license through the CDM. A missing content key fails.unshackle dl --skip-dl EXAMPLE 81234567\nExport track info and keys
--exportwrites amediaexportJSON file into the configured exports directory. It holds the manifest, the DRM init data, the content keys, the KIDs of each track and the title metadata for each title.unshackle importreads it to reconstruct a download later, and gives each track only the content keys for its KIDs. The importer also reads the older unshackleversion: 2files and unidl's own export files.unshackle dl --skip-dl --export EXAMPLE 81234567\nThe file name tells what the file holds, in the form
Export File name A movie{title}.{scope}.{resolutions}.{codecs}.{ranges}.{manifests}.{audio}-{SERVICE}.json:Example.Movie.2024.1080p.h264.sdr.hls.aac2.0-EXAMPLE.jsonA whole seasonExample.Show.S01.2160p.h265.sdr.hdr10.dash.dd5.1-EXAMPLE.jsonEpisodes 5 to 9 and 12Example.Show.S01E05-E09.S01E12.1080p.h264.sdr.dash.aac2.0-EXAMPLE.json
- A dot separates each part and each word of the title. The name has no spaces or underscores. It does not follow your
output_template: a template with spaces does not change the export name.- The scope is
S01only when the file holds every episode the service lists for that season. Otherwise it names each run of episodes. Whole seasons in a row becomeS01-S03. After three parts,+Ncounts the parts left out. A movie has no scope.- The quality parts come from the video and audio tracks selected for the titles in the file. Each value shows once.
- unshackle renames the file after each write, so the name always matches what the file holds. When a run stops early, the file is complete for the titles it holds.
- When another file already has the name, unshackle adds
-2,-3, and so on. It never writes over another export.The run logs the file name at the first write, the track and content key counts for each title, and at the end the path and the total counts.
The export holds a content key for each track your flags selected and no others. To make a file another person can use, select generously: for example
-a AAC,EC3and every language you want them to have. An import can only pick tracks whose key is in the file. A track that another tool exported as a direct file URL is added next to the manifest's own tracks. An export with no manifest, for example a unidl export of whole media files, gets all of its tracks from those URLs. If such an export does not give the title a language,orighas no value: select the languages with-land-vl.An import uses only the content keys in the file and in your vaults. It never gets a licence from a CDM. When a track needs a content key that neither has, the import stops and names the KID. To fix this, export the title again with that track selected.
The export file is a secret
It holds the content keys and usually a signed manifest URL. Hand it over on purpose, not by accident.
The mediaexport package specifies the
mediaexportformat and is the reader and writer unshackle uses.Region is recorded only with a proxy
When you use
--proxy, the export records the region so an import can reproduce the correct geofence. Without a proxy, the export stores no region.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.languageon 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.An HLS master playlist gives no language for a video track. When the export gives no title language, the video track takes the language of the default audio track. When the master playlist gives no audio language either, the import stops with a message.
"},{"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
Flag Example Purpose--enrich. Give at most one of--tmdb,--imdband--tvdb, and unshackle works the rest out from it. To stop the automatic lookups but keep the IDs you give, setdisable_metadatain your config.--tmdb--tmdb 27205Use this TMDB ID instead of an automatic search. Needstmdb_api_key.--imdb--imdb tt1375666Use this IMDb ID instead of an automatic search. Needs no API key.--tvdb--tvdb 73871Use this TVDB ID instead of looking the series up. Needstvdb_api_key.--anilist--anilist 21Use this AniList ID instead of an automatic search.mal:12345is also accepted. Needs no API 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 dvdRenumber episodes to a TVDB season order. Needstvdb_api_key. Force the right IMDb match, and keep the service's own namingForce the match and take IMDb's title, year and language toounshackle dl --imdb tt1375666 EXAMPLE 81234567\n"},{"location":"guide/downloading/#what-each-metadata-provider-supplies","title":"What each metadata provider supplies","text":"unshackle dl --imdb tt1375666 --enrich EXAMPLE 81234567\nMetadata providers differ in what they answer with, so the fields
Provider Config key Title and year Original language External IDs it returns TMDB--enrichcan replace depend on which ID you gave. Every metadata provider exceptimdbandanilistonly operates when you set its config key:tmdb_api_keyyes yes, alpha-2 such askoIMDb, TMDB, TVDB TVDBtvdb_api_keyyes yes, alpha-3 such askorTMDB, TVDB, sometimes IMDb OMDbomdb_api_keyyes yes, as an English name such asKoreanIMDb IMDb (no config key needed) yes yes, alpha-2 such askoIMDb SIMKLsimkl_client_idyes no, it publishes a country and no language IMDb, TMDB, TVDB AniList (no config key needed) yes yes, worked out from the country of origin, such asjaAniListWhatever the tag looks like, unshackle normalises it before use, so
ko,korandKoreanall end up as the same language.AniList only answers for anime
It is last in the default metadata provider order and returns nothing for a title that is not anime. The only cost is one search that misses. Put
anilistearlier inmetadata_providersif you mostly download anime.anilist_title_languagesets which of the three AniList title variants unshackle uses.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_providersorder:
--tmdbreads TMDB.--imdbreads IMDb, then falls back to OMDb. IMDb needs no API key, so this path works out of the box, and an OMDb API key only matters when IMDb has no answer.--tvdbreads TVDB. It needstvdb_api_key.--anilistreads AniList, which needs no API 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,--imdbor--tvdbwhen you want those tags as well.Give one of
--tmdb,--imdband--tvdb. You cannot combine them, because one ID does the job on its own: unshackle finds the other two from it and writes all three to the tags. Passing two is an error rather than a silent choice between them.--anilistis 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 metadata provider that can find it, so unshackle makes sure of that before it downloads anything.
--tmdbwithouttmdb_api_key, or an ID whose metadata providers yourmetadata_providerslist leaves out, fails immediately with a message naming what to set.--imdbneeds no API key, because theimdbmetadata 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 metadata provider with a thin record cannot blank out what the service told you.
--enrichalso 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 nothing here applies only to anime. This only adds the{absolute}naming variable.--enrichnever changes the season and episode numbers.With
--daily,--enrichalso fills in the air date of each episode from TVDB. See Daily and date-based content.
--enrichreplaces the original language, which affects more than the filenameTrack 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
"},{"location":"guide/downloading/#episode-ordering","title":"Episode ordering","text":"--enrich, unshackle keeps the service's own value and does not change track selection.A service does not always number a series the way TVDB's aired order does. Some services use TVDB's
Download Futurama in DVD orderalternate(Streaming) order for Futurama, for example, while TVDB'sofficialorder holds back four season-one episodes to the start of season two.--tvdb-orderworks out which order the service used, then renumbers the episodes into the order you asked for:unshackle dl --tvdb-order dvd EXAMPLE 81234567\nAvailable orders are
official(aired),dvd,absolute,alternate, andregional. Settvdb_orderin 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
"},{"location":"guide/downloading/#configuration-defaults","title":"Configuration defaults","text":"dvdorder does not contain 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.You can set every flag on this page as a default in your configuration file so you do not have to type it each time, and you can scope defaults 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 through an environment variable) always takes precedence over both the global
"},{"location":"guide/downloading/#quick-reference","title":"Quick reference","text":"dl:defaults and any per-servicedl:overrides. Config only fills in options you did not set explicitly.A condensed lookup of the most-used options. Use
Flag Short Meaningunshackle dl --helpfor the complete, authoritative list.--quality-qTarget resolution(s), e.g.1080,720.--vcodec-vVideo codec(s).--acodec-aAudio codec(s).--range-rColour range(s). DefaultSDR.--channels-cAudio channel layout.--noatmos-naaExclude Atmos audio.--lang-lVideo + audio language(s); defaultorig.-excludes, e.g.all,-es.--a-lang-alAudio-only language override.-excludes.--v-lang-vlVideo-only language override.-excludes.--s-lang-slSubtitle language(s); defaultall.-excludes, e.g.all,-es.--forced-subs-fsInclude forced subtitles.--forced-s-lang-fslForced subtitle language(s); implies-fs.-excludes.--sub-formatOutput subtitle format.--wanted-wEpisode/season range, an air date, or a track number for a music release (1-5,2x3).--dailyFill missing air dates from TVDB during--enrich.--select-titlesInteractively pick episodes or films.--latest-episodeOnly the newest episode.--latest-episodes NOnly the N newest episodes.--video-only/--audio-only/--subs-only-V/-A/-SRestrict track types.--no-video/--no-audio/--no-subs/--no-chapters/--no-attachments-nv/-na/-ns/-nc/-ntSkip track types.--worstLowest bitrate within-q.--require-audioAudio languages that must exist. The title fails if one is missing.--require-videoVideo languages that must exist. The title fails if one is missing.--best-availableDegrade gracefully instead of failing.--output-oOutput directory for this run.--split-audio/--merge-video/--no-muxMuxing behaviour.--postscript/--no-postscriptRun a command after each output file, or run none.--proxy/--no-proxy/--no-proxy-download/--proxy-downloadProxy control.--workers/--downloads/--slowConcurrency and pacing.--list/--list-titles/--skip-dlDry runs.--cdm-only/--vaults-onlyContent key source control.--cdm <name>Pin one CDM device, bypassing thecdmconfig mapping.--exportExport track info and keys to JSON.--tmdb/--imdb/--tvdb/--anilist/--enrichMetadata overrides.--tvdb-orderRenumber episodes to a TVDB season order."},{"location":"guide/drm-and-cdm/","title":"DRM & CDM Setup","text":"A Digital Rights Management (DRM) system protects most commercial streaming titles. To download a title and play it back, unshackle needs to get the content keys for the encrypted tracks and then decrypt them. This page explains the DRM systems unshackle supports, how to install 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 have a Developer mark. They give internal behaviour that is useful when you write a service, but you do not need them to download a protected title.
"},{"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. A device backs it: a file that carries the cryptographic identity of a real client, such as a phone or a browser.
- 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 AES content 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).wvdCDM challenge \u2192 license server \u2192 CDM parses \u2192 keys shaka-packager / mp4decrypt PlayReady (Microsoft).prdCDM challenge (WRM header) \u2192 license server \u2192 CDM parses \u2192 keys shaka-packager / mp4decrypt ClearKey (CENC), W3C EMEorg.w3.clearkeynone JSON{\"kids\":[...],\"type\":\"temporary\"}\u2192 license server returns a JWK Set. No CDM. shaka-packager / mp4decrypt ClearKey (HLS), AES-128EXT-X-KEYnone Key fetched directly from a URI ordata:URL. No CDM, no challenge. in-process AES-CBC MonaLisa (proprietary, WASM).mldTicket from the service API; keys extracted locally via WASM. No license server. ML-Worker binary + AES-ECB, per segmentTwo different ClearKeys
\"ClearKey (HLS)\" and \"ClearKey (CENC)\" are entirely different systems that happen to share a name. The HLS variant is the AES-128 content key that an
"},{"location":"guide/drm-and-cdm/#widevine-vs-playready","title":"Widevine vs PlayReady","text":"EXT-X-KEYtag references, and unshackle decrypts it in-process. The CENC variant is the W3C EMEorg.w3.clearkeyscheme. It delivers keys as a JWK Set, and shaka-packager or mp4decrypt decrypts it like Widevine. Neither involves a device file.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
.wvddevice. For PlayReady you need a.prddevice. You can hold both, and unshackle automatically selects the appropriate one for each track.Obtaining device files
A
"},{"location":"guide/drm-and-cdm/#providing-a-device-with-the-wvd-and-prd-commands","title":"Providing a device with the.wvdor.prdencodes the private keys of a real client device. unshackle does not ship with one and cannot make the underlying secret material for you. You must supply your own. Thewvdandprdcommands below manage and provision device files once you have the raw cryptographic material.wvdandprdcommands","text":"Device files live in dedicated directories, by default:
Kind Default directory Widevine.wvddata/WVDsPlayReady.prddata/PRDsYou can move these with the
unshackle.yamldirectoriessection of yourunshackle.yaml:"},{"location":"guide/drm-and-cdm/#widevine-devices-the-wvd-command","title":"Widevine devices: thedirectories:\n wvds: /home/george/unshackle-data/WVDs\n prds: /home/george/unshackle-data/PRDs\nwvdcommand","text":"The
Add an existing deviceInspect a deviceCreate from raw materialDelete / dumpwvdcommand group manages.wvdfiles.$ unshackle wvd add ./nexus_6p.wvd\nunshackle validates the file and moves it into the WVDs directory. You can add several at once by passing multiple paths.
$ unshackle wvd parse nexus_6p\nPrints the System ID, Security Level, Type, Flags, and whether a Private Key, Client ID, and VMP are present. unshackle finds a bare name (no extension) inside the WVDs directory. It uses a path with an extension as-is.
$ unshackle wvd new \"Nexus 6P\" ./private_key.pem ./client_id.bin ./vmp.bin -t ANDROID -l 3\nBuilds a
.wvdfrom a PEM private key, a Client ID blob, and an optional FileHashes (VMP) blob.-t/--typeis the device type (defaultAndroid) and-l/--levelis the security level 1 to 3 (default1). The VMP argument is optional.$ unshackle wvd delete nexus_6p\n$ unshackle wvd dump nexus_6p ./out\n
deleteremoves a device (with a confirmation prompt).dumpextracts 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:
"},{"location":"guide/drm-and-cdm/#playready-devices-the-prd-command","title":"PlayReady devices: the$ pywidevine migrate --help\nprdcommand","text":"The
Create a new deviceReprovisionTest against Microsoft's demo serverprdcommand group creates and maintains.prdfiles.$ unshackle prd new ./zgpriv.dat ./bgroupcert.dat\nProvision a new device from a group key and group certificate. You can instead point at a single folder that contains
zgpriv.datandbgroupcert.dat:$ unshackle prd new ./device_folder/\nunshackle makes a fresh leaf certificate and writes the device to the PRDs directory (or to
-o/--output). Use-e/--encryption_keyand-s/--signing_keyto supply your own ECC keys instead of generating them.$ unshackle prd reprovision ./data/PRDs/mydevice.prd\nRegenerates the leaf certificate and encryption/signing keys of an existing device in place. The device must be able to reprovision (a group key must be present, that is, a version 3 or higher device).
$ unshackle prd test ./data/PRDs/mydevice.prd -sl 2000 -c aesctr\nRuns a full challenge/license/keys cycle against Microsoft's public PlayReady test server and prints the returned keys, confirming the device works.
"},{"location":"guide/drm-and-cdm/#selecting-which-device-to-use-the-cdm-config","title":"Selecting which device to use: the-sl/--security-levelaccepts150,2000, or3000(default2000).-c/--cktacceptsaesctroraescbc.cdmconfig","text":"A device on disk is not enough. You must tell unshackle which device to use for which service. That is the job of the
unshackle.yamlcdmsection, which maps a service tag to a device name (the file's name without extension). Adefaultentry acts as the fallback:cdm:\n default: nexus_6p_26830_l3\n EXAMPLE1: chromecdm_l3\n EXAMPLE2: sm_g935f_l1\nLookups are case-insensitive.
The device name is the file name
A device named
"},{"location":"guide/drm-and-cdm/#advanced-selection-by-quality-drm-or-profile","title":"Advanced selection: by quality, DRM, or profile","text":"chromecdm_l3in the config resolves tochromecdm_l3.wvdin the WVDs directory (orchromecdm_l3.prdin the PRDs directory). It may also be thenameof a remote CDM defined inremote_cdm. See Remote CDMs below.Instead of a plain name, a
By qualityBy DRM systemBy profilecdmentry can be a nested mapping, letting unshackle pick a different device based on the track quality, the DRM system, or the active service profile.unshackle compares each config key against the video height. You can use exact heights or comparisons (
>=1080,>720,<=576,<480). The most specific one wins.cdm:\n SOMESERVICE:\n \">=1080\": sm_g935f_l1\n default: chromecdm_l3\nKeys
widevineandplayreadyselect a device per system. unshackle applies these automatically, using thewidevinedevice for Widevine tracks and theplayreadydevice for PlayReady tracks.cdm:\n SOMESERVICE:\n widevine: sm_g935f_l1\n playready: mydevice_sl3\nunshackle compares each config key against the active profile, with a
defaultfallback."},{"location":"guide/drm-and-cdm/#local-vs-remote-cdms","title":"Local vs remote CDMs","text":"cdm:\n SOMESERVICE:\n premium: sm_g935f_l1\n default: chromecdm_l3\nA CDM can operate in two places:
- A local CDM loads a
.wvdor.prddevice from your machine and performs the challenge/license/keys handshake locally. This is what thecdmmapping 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 CDM host. unshackle sends it PSSH/challenge data and receives keys back. This is useful for sharing a high-security-level device, using a hosted content key service, or keeping device secrets off your download machine.
The device name in your
"},{"location":"guide/drm-and-cdm/#remote-cdms","title":"Remote CDMs","text":"cdmmapping resolves to a remote CDM whenever it matches thenameof an entry in theremote_cdmlist. Otherwise it falls back to a local.prd/.wvdfile of that name.You declare remote CDMs as a list under
"},{"location":"guide/drm-and-cdm/#widevine-remote-cdm-pywidevine-serve","title":"Widevine remote CDM (pywidevine serve)","text":"remote_cdm. Each entry has aname(which you then reference from thecdmmapping) and a shape that depends on itstype. There are four kinds.With no
unshackle.yamltypefield and a non-PlayReady device type, unshackle treats the entry as a standard pywidevine \"serve\" endpoint:remote_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"},{"location":"guide/drm-and-cdm/#playready-remote-cdm","title":"PlayReady remote CDM","text":"
security_leveldefaults to3000if omitted. unshackle reads field names case-insensitively, soDevice Type/device_type,System ID/system_id, and the other field names are all accepted.If the device type is
unshackle.yamlPLAYREADY, unshackle routes the entry to the pyplayready remote CDM:remote_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\nThe
hostmust end in/playreadyFor a PlayReady remote CDM the
hostURL has to include the trailing/playreadypath segment (e.g.https://cdm.example.com/playready). pyplayready'sRemoteCdmtreatshostas 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 is404responses from the server rather than an obvious configuration error.A pywidevine or pyplayready serve API can operate behind a reverse proxy or a CDN. Refer to Serve APIs behind a proxy.
"},{"location":"guide/drm-and-cdm/#decrypt-labs-hosted-keyxtractor-api","title":"Decrypt Labs (hosted KeyXtractor API)","text":"Set
unshackle.yamltype: decrypt_labsto use Decrypt Labs' hosted KeyXtractor service, which includes an intelligent key-caching layer:remote_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\nunshackle sends the API key as the
"},{"location":"guide/drm-and-cdm/#custom-api-custom_api","title":"Custom API (decrypt-labs-api-keyheader. If you do not writesecreton the entry, unshackle falls back to the globaldecrypt_labs_api_keyvalue. If neither is present it raises an error. Thehostdefaults tohttps://keyxtractor.decryptlabs.com. PlayReady devices (SL2/SL3) default to security level2000/3000respectively. Widevine devices default tosystem_id: 26830,security_level: 3.custom_api)","text":"For an arbitrary CDM HTTP API,
unshackle.yamltype: custom_apiprovides a fully configurable client that adapts to almost any request/response shape through YAML alone. A minimal example:remote_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\nThe
custom_apitype supports much more: request/response field remapping, transforms (base64/hex/JSON encoding,kid:keyparsing), conditional parameters, success/error detection, and vault-backed caching. Its full schema is for developers who integrate a bespoke CDM API. See the developer note below.Developer: full
custom_apischemaA
custom_apientry accepts these sub-objects:
device(name,type,system_id,security_level)auth(typeofheader/bearer/basicwith the matching credential fields, pluscustom_headers)endpoints(get_requestanddecrypt_response, each{path, method, timeout})- per-endpoint
request_mapping(param_names,static_params,conditional_params,transforms,nested_params,exclude_params) andresponse_mapping(fields,transforms,response_types,success_conditions,error_fields,key_fields)caching(enabled,use_vaults,check_cached_first)legacyandtimeoutunshackle infers PlayReady mode when
"},{"location":"guide/drm-and-cdm/#decryption-backends","title":"Decryption backends","text":"device.type == PLAYREADYor the device name isSL2/SL3.Once unshackle knows the content keys, one of two external tools decrypts the encrypted file (for Widevine, PlayReady, and ClearKey-CENC):
- shaka-packager is the default.
- mp4decrypt is part of the Bento4 tools.
Both must be discoverable in unshackle's bundled
binaries/directory, thebinariesfolder of your user data directory, or on yourPATH. 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
Global defaultPer service unshackle.yamldecryptionconfig key. It can be a single value applied globally, or a per-service mapping:unshackle.yamldecryption: shaka\ndecryption:\n default: shaka\n SOMESERVICE: mp4decrypt\nHow the value is interpreted
The default is
shaka. Only the exact valuemp4decryptselects mp4decrypt. Anything else (includingshaka) selects shaka-packager. Per-service lookups are case-insensitive, with thedefault/DEFAULTentry used as the fallback.The two backends give equivalent output for a standard CENC title. In most conditions shaka-packager is the better backend. Switch a specific service to
mp4decryptif you find a track that shaka-packager cannot decrypt.ClearKey (HLS) does not use these tools
unshackle decrypts an HLS AES-128 ClearKey title in-process with AES-CBC, and ignores the
"},{"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":"decryptionsetting entirely. You do not need to configure anything.When an HLS playlist references a content key with an
"},{"location":"guide/drm-and-cdm/#clearkey-over-cenc-w3c-eme-orgw3clearkey","title":"ClearKey over CENC (W3C EMEEXT-X-KEYtag, unshackle fetches the content key directly (from the given URI or an inlinedata:URL) and decrypts each segment in-process with AES-CBC. There is no device, no CDM, and no license challenge, so you do not set anything up. unshackle decrypts such tracks automatically during download.org.w3.clearkey)","text":"The CENC ClearKey scheme is a proper license flow, but the \"license server\" returns the keys as a JWK Set and not a DRM-wrapped blob. unshackle builds a W3C EME request of the form:
{\"kids\": [\"<base64url-kid>\", ...], \"type\": \"temporary\"}\nIt 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. You do not need a device file. The decrypted file still goes through shaka-packager or mp4decrypt like any other CENC track, so the
"},{"location":"guide/drm-and-cdm/#widevine-and-playready-on-the-same-title","title":"Widevine and PlayReady on the same title","text":"decryptionsetting applies.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 cannot supply, it transparently switches to the appropriate device for that track using your
cdmmapping. 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.\nUse the
"},{"location":"guide/drm-and-cdm/#keys-vaults-and-caching","title":"Keys, vaults, and caching","text":"cdmmapping's DRM-based selection to configure a device for each system. unshackle selects the right one per track automatically.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 cache of content keys, for the current
dlrun.- Your configured key vaults.
- Only if still missing, a live license request.
After a successful license request, unshackle writes the recovered keys back to all configured vaults, so future downloads can skip the license server. Two flags let you control this trade-off on the
Flag Effectdlcommand:--vaults-onlyNever send a license request; use only cached/vault keys. Fails if a content key is missing.--cdm-onlyIgnore vaults for sourcing keys and always perform a fresh license request. Over aserver_cdmremote session the server still reads its own vaults; the flag only skips the client's decode check on the key it gets back.--cdm <name>Use the named CDM device for this run instead of the one thecdmconfig maps for the service. Over--remote, that device licenses the remote session, not the server CDM.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 EffectdebugfalseGlobal debug output.debug_keysfalseLog decryption keys.debug_requestsfalseLog HTTP requests. unshackle.yamldebug_keys: true\ndebug_requests: true\nKeys and paths in logs
When you enable key logging, unshackle writes content keys (KID:key pairs) to the logs. Treat those logs as sensitive. The unrelated
"},{"location":"guide/drm-and-cdm/#see-also","title":"See also","text":"redact_pathsoption (defaulttrue) masks local directory paths in log output."},{"location":"guide/output-and-naming/","title":"Output & Naming","text":"
- Downloading: the
dlcommand and its DRM-related flags.- Key vaults: caching content keys across downloads.
- Configuration: the full
unshackle.yamlreference.- Dependencies: installing shaka-packager and mp4decrypt.
This page tells you 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). Both take
{variable}placeholders that unshackle fills in from the downloaded media. This page also covers muxing options, release-group tagging, Unicode filenames, and how to relocate unshackle's working directories.If you have not configured
"},{"location":"guide/output-and-naming/#how-unshackle-assembles-an-output-path","title":"How unshackle assembles an output path","text":"unshackle.yamlyet, read Configuration File first. Everything below lives in that file.When a download finishes, unshackle builds the final path from three pieces:
<output directory> / <folder template> / <filename template><extension>\n
- Output directory:
directories.downloadsfrom your config, or whatever you pass to-o/--outputon the command line for a single run.- Folder template: an optional per-title subfolder. unshackle always puts TV episodes and songs in a folder. Movies only get one if you define a
moviesfolder template, or a single-stringfoldertemplate that applies to all title kinds (see Folder templates).- Filename template: the
output_templatefor the title kind (movie, series, or song).- Extension: chosen by the muxer for a movie or an episode:
.mkvfor video,.mkafor audio-only,.mksfor subtitle-only,.mp4for DTS:X Profile 2 audio. unshackle does not mux a song. It remuxes a song into the container of its codec, such as.flac,.opus, or.m4a. Read Music output files.A typical episode path
The folder name comes from the/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\nseriesfolder template and the file from theseriesoutput template.Two command-line flags change this layout for a single run:
Flag Effect-o,--outputUse this directory instead ofdirectories.downloads.--no-folderSkip the per-title subfolder; write the file directly into the output directory.--no-sourceOmit 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
unshackle.yamloutput_templateconfig key, keyed by title kind. Each value is a format string built from{variable}placeholders.output_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}\"\nThe recognized kinds are:
Kind Applies to Default when unsetmoviesMovies (required for movie downloads to be named, no built-in default)seriesTV episodes (required for episode downloads to be named)songsMusic tracks\"{track_number}. {title}\"albumsAlbum folders only (see below) -Note
"},{"location":"guide/output-and-naming/#required-vs-optional-variables","title":"Required vs. optional variables","text":"
moviesandserieshave no hardcoded filename fallback. If you download those title types, define their templates.songsfalls back to\"{track_number}. {title}\"when omitted.
{variable}: required. If it is missing from the download's context, formatting raises an error. Note that most variables are always present in context. A variable that does not apply becomes an empty string, so \"present but empty\" is different from \"missing\".{variable?}: optional. A trailing?marks the variable conditional. When it is empty, unshackle removes the placeholder and one adjacent separator (.,-, or space), so the surrounding text collapses cleanly.Use
?for anything that is not always thereEpisode names, editions, tags, HDR labels and audio extras are not present on every title. Mark them optional so you never get doubled dots or dangling separators:
For an episode with no on-screen name,series: \"{title}.{season_episode}.{episode_name?}.{quality}.{hdr?}.{source}-{tag?}\"\n{episode_name?}and its adjacent dot 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 sanitises with spaces. If not, it uses dots (the scene-style default). unshackle also applies this style to the auto-generated date separators of daily 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}\"\nThe.Movie.2024.1080p.EXAMPLE.WEB-DL.DDP5.1.H.264-TAG.mkv\noutput_template:\n movies: \"{title} ({year}) {quality} {source} WEB-DL {audio_full} {video}\"\n"},{"location":"guide/output-and-naming/#template-variables","title":"Template variables","text":"The Movie (2024) 1080p EXAMPLE WEB-DL DDP5.1 H.264.mkv\nEvery variable below is valid in both output and folder templates. unshackle takes the values from the downloaded media (through MediaInfo), from the title metadata, or from your config. A variable that does not apply to a title becomes an empty string.
"},{"location":"guide/output-and-naming/#common-video","title":"Common / video","text":"Variable Meaning ExampletitleTitle name (movie/show/song name;$is rendered asS)The Showtitle_typeMedia kind of the titlemovie,series,musicyearRelease year2024sourceService tag / class name (empty with--no-source)EXAMPLEqualityResolution with scan suffix1080p,2160p,576iresolutionResolution number only1080videoVideo codecH.264,H.265hdrDynamic-range labelHDR,HDR10P,DV,DV.HDR,DV.HDR10P,HLGhfrHigh frame rate marker (frame rate > 30)HFReditionEdition label from the track, if anyDirectors CuttagRelease-group tag (fromconfig.tagor--tag)TAGrepackREPACKwhen the--repackflag is usedREPACKlang_tagResult of yourlanguage_tagsrules, if configuredMULTiHow
language_tagsrules matchunshackle tries the rules in order, but language comparison is fuzzy: a rule condition of
"},{"location":"guide/output-and-naming/#episode-specific","title":"Episode-specific","text":"Variable Meaning Exampleenmatchesen-US,en-GB, and any otheren-*form. Within a single rule, all conditions must match (AND logic) for it to apply. The first rule that fully matches wins. If none match, unshackle removes{lang_tag?}from the filename cleanly.seasonZero-padded season (S%02d)S01episodeZero-padded episode (E%02d)E05season_episodeCombinedS01E05episode_nameEpisode titlePilotpartPart index of a split episode, empty otherwise2absoluteAbsolute episode number, zero-padded to 3 digits, empty when unknown007dateISO air date, empty when the episode has none2024-06-01Daily & sports content
When an episode has an air date, unshackle switches to date-based naming automatically.
"},{"location":"guide/output-and-naming/#split-episodes","title":"Split episodes","text":"seasonandseason_episodebecome the formatted air date, unshackle clearsepisodeandyear, and{date}holds the ISO date. The date's internal separator (dots or spaces) follows yourseriestemplate style.A few services split one episode into several separately playable videos. Where a service reports that, unshackle folds the part index into
{episode}and{season_episode}, so the stockseriestemplate names it with no change to your config.Show.Name.S01E01.Part.2.1080p.EXAMPLE.WEB-DL.DDP5.1.H.264-TAG.mkv\nThe 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 daily content does, so a spaced template gives
S01E01 Part 2instead.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\nThe same holds for a folder template of your own:
{season}never picks up the part, and{season_episode}drops it inside a folder template. unshackle names an episode with no part 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 uses a closed-source scanner, so what it does with text after the part token is not known.
For custom templates there is also a standalone
"},{"location":"guide/output-and-naming/#audio","title":"Audio","text":"Variable Meaning Example{part}variable. You rarely want it, see the caveat on that page.audioAudio codecDDP,DD,AAC,DTS-Xaudio_channelsChannel layout; bed.LFE.heights when the track has height channels5.1,2.0,5.1.4audio_fullCodec + channels combinedDDP5.1,DTS-X.5.1.4atmosAtmoswhen any track carries JOCAtmosdualDUALfor two audio languages (seedual_multi_mode)DUALmultiMULTifor more than two audio languagesMULTidubbedDUBBEDfor 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 Meaningtrack_numberZero-padded track numberartist/album_artistArtist / album artist (falls back to artist)albumAlbum namediscDisc number (only shown when greater than 1)track_total/disc_totalTotalsrelease_typealbum,single,ep, or another type the service suppliesgenreGenreexplicitExplicitwhen flaggedisrc/upc/labelISRC, UPC and record labelUnknown variables are ignored, not errors
If you use a variable that is not in the list above, unshackle gives a warning at startup but continues. Double-check spelling. A typo'd
"},{"location":"guide/output-and-naming/#folder-templates","title":"Folder templates","text":"{quailty}stays in the filename literally, or unshackle drops it. unshackle does not fill it in.Folder templates sit under a special
Single folder templatePer-kind folder templates unshackle.yamlfolderconfig key insideoutput_template. unshackle reads this config key separately from the filename kinds. You can give it a single string (for all title kinds) or a per-kind map.unshackle.yamloutput_template:\n movies: \"{title}.{year}.{quality}.{source}-{tag}\"\n series: \"{title}.{season_episode}.{quality}.{source}-{tag}\"\n folder: \"{title}.{year}\"\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})/Season {season}\"\n albums: \"{artist} - {album} ({year})\"\nThe per-kind folder config keys are
movies,series,songs, andalbums. Any other config key gives a startup warning. Folder templates permit path separators (/or\\). unshackle formats each segment independently, so you can assemble nested directory structures likeShow/Season 01. unshackle reads the separator style from the whole template, so it spaces every segment alike.Fallback behaviour:
- A per-kind folder template wins if present. If there is none, unshackle uses the single
folderstring. If there is nofolderstring either, 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
moviesfolder template (or a single-stringfoldertemplate) exists. Series fall back to a folder derived from theseriesoutput template (stripping{episode},{episode_name}, and collapsing{season_episode}down to{season}). Music albums fall back to{artist} - {album} ({year}). If the song has no year, the folder name is only{artist} - {album}.Movies are flat by default
Episodes and songs are always foldered. unshackle writes movies directly into the output directory unless you define a
"},{"location":"guide/output-and-naming/#music-output-files","title":"Music output files","text":"moviesfolder template (or a single-stringfoldertemplate, which folders every kind). Set one if you want each movie in its own directory.A song does not go through the muxer. Music services deliver audio in a fragmented MP4, which is not a music file. Its header states a length of zero, so a player stops after the first fragment. FLAC inside an MP4 is also not a FLAC stream. A rename cannot correct either fault, so unshackle remuxes each downloaded audio track into the container of its codec, then moves the file to its final path.
Codec Extension FLAC.flacOpus.opusVorbis.oggAAC, ALAC, and all other codecs.m4aunshackle re-encodes FLAC in this step. The audio does not change, but the new file states its true length. A plain copy cannot, because it keeps the source header and the zero in it.
unshackle writes no
.mkvfile for music, and the muxing options below do not apply.The
songsoutput template names the file. Thealbumsfolder template names the folder around it."},{"location":"guide/output-and-naming/#music-metadata","title":"Music metadata","text":"/home/user/Downloads/\n \u2514\u2500 The Artist - The Album (2024)/\n \u251c\u2500 01. First Track.flac\n \u2514\u2500 02. Second Track.flac\nAfter the file moves, unshackle writes the metadata into the audio file. It uses Vorbis comments for FLAC and Ogg, ID3 frames for MP3, and MP4 atoms for M4A. The tags hold the track title, artist, album artist, track and disc numbers, date, genre, ISRC, UPC, copyright, and record label. When
tagandtag_group_nameare set, unshackle also writes the group name into aGROUPtag.unshackle embeds cover art when the service gives an artwork URL. It downloads the image and puts it in the file.
unshackle writes lyrics when the service supplies them: a
LYRICSVorbis comment for FLAC, aUSLTframe for MP3, and a\u00a9lyratom for M4A. Ogg uses the sameLYRICSfield as FLAC. unshackle does not write synchronized lyrics (SYLT).A metadata fault does not stop the run. The file is already in place, so unshackle writes a warning and continues.
--no-muxleaves music files untagged"},{"location":"guide/output-and-naming/#muxing-options","title":"Muxing options","text":"
--no-muxmoves each downloaded track file to the output path without the tagging step. A music file from such a run holds only the metadata that the service put in it.Height channels
A track with height channels is named bed.LFE.heights, such as
"},{"location":"guide/output-and-naming/#dtsx-profile-2-output","title":"DTS:X Profile 2 output","text":"5.1.4.A title whose audio holds a
dtsxordtsysample entry (DTS:X Profile 2, DTS-UHD) muxes to MP4 with MP4Box, because Matroska has no CodecID for it. Without MP4Box installed the mux stops.DTS:X Profile 1 is DTS-HD Master Audio and muxes to MKV as usual.
An MP4 title loses:
- the forced, hearing-impaired and original track flags
- attachments, such as subtitle fonts
- SubStation Alpha (
.ass) subtitles- container tags (
mkvpropedit)You configure muxing (the combination of video, audio, subtitle, chapter and attachment tracks into a single Matroska file with
unshackle.yamlmkvmerge) under themuxingconfig key. unshackle muxes only movies and episodes. Read Music output files for what a music download does instead.Key Type Default Effectmuxing:\n set_title: true\n merge_video: false\n merge_audio: true\n default_language:\n audio: en\n subtitle: en\nset_titlebooltrueWrite the title name into the MKV container title with--title. Set tofalseto omit it.merge_videoboolfalseGroup video tracks that share the same resolution, range, and codec into one file so only language varies inside it.merge_audiobooltrueMerge audio tracks of the same kind so multiple languages sit in one file.default_languagemap (unset) Preferred language per track type (video/audio/subtitle). A track in the preferred language is flagged as the default track.When no track has the preferred language, unshackle applies sensible defaults. The video default falls back to the title language, then the original-language track, then the first track. The audio default is the original-language track. The subtitle default is a forced track in the first audio's language.
default_languageonly sets the default-track flag
default_languagecontrols which track carries the MKV--default-trackflag. Nothing else. It does not change track selection (that stays with-l/--a-langand friends). It also does not touch which track carries the original flag:--original-flagstill 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 is not present in the manifest, each track type falls back to its normal default rule described above.
merge_videocollapses only the language dimension
merge_videogroups 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-videogives one file per range, never a single fused file. English and French video of identical resolution, range, and codec gives one file that holds both video tracks.merge_audioworks the same way for audio languages.A few muxing behaviors are automatic and not configurable:
--no-dateis 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.
- unshackle writes the chapters from the title's chapter list (see Chapters below).
The
"},{"location":"guide/output-and-naming/#tags-group-naming","title":"Tags & group naming","text":"--no-muxflag skips muxing entirely and writes the individual track files, each with a track-type suffix, into the same output/folder structure.The release-group tag is the
unshackle.yaml-TAGportion at the end of scene-style names, which the{tag}variable gives. You can swap it per release withtag_rules: ordered rules whose conditions read the release-attribute variables only (quality,resolution,hdr,source,lang_tag,title_type, and so on), and the first rule that matches wins.tag_rulescannot read the title's own fields, such astitleandseason_episode. See the reference page for the exact list.Key Type Default Effecttag: \"MYGROUP\"\ntag_group_name: true\ntag_imdb_tmdb: true\ntagstr\"\"The release-group tag used for{tag}.tag_group_namebooltrueWrite the group name (config.tag) into the MKVGroupmetadata tag.tag_imdb_tmdbbooltrueLook up and embed IMDb / TMDB / TVDB external-ID tags in the MKV metadata (usestmdb_api_key/tvdb_api_key/simkl_client_idwhen available).unshackle writes the IDs as the Matroska
IMDB,TMDB, andTVDB2tags.TVDB2values carry the entity prefix the Matroska tagging spec requires:series/73871for a show,movies/113for a film.You can override the tag per run without editing config:
unshackle dl --tag OTHERGROUP EXAMPLE \"...\"\nThe
--repackflag adds theREPACKmarker (surfaced through the{repack}variable) to the filename for that run:unshackle dl --repack EXAMPLE \"...\"\nEmpty tags collapse cleanly
If
"},{"location":"guide/output-and-naming/#unicode-filenames","title":"Unicode filenames","text":"config.tagis 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.By default unshackle transliterates non-ASCII characters into ASCII equivalents. Korean, Japanese, Chinese, and accented Latin are examples. This is safest for older tools, DDL, and P2P sharing. Set
unshackle.yamlunicode_filenamesto keep the native characters.Value Resultunicode_filenames: true\nfalse(default)\uae30\uc0dd\ucda9\u2192 transliterated ASCII; accents stripped.trueNative characters preserved in filenames and folders, with the vowel and tone marks of languages such as Thai, Hindi and Bengali.With each value, unshackle first normalises the name to Unicode NFC. A title that a service sends in composed form and in decomposed form gives the same name.
Regardless of this setting, unshackle always removes or replaces a set of filesystem-unsafe and structural characters during sanitisation. For example,
/and;become&, and unshackle removes characters like\\ * ! ? , ' \" < > | $ # ~. This is why you must never put path-unsafe characters directly in a template.unshackle warns at startup about a template that contains
"},{"location":"guide/output-and-naming/#directory-filename-configuration","title":"Directory & filename configuration","text":"< > : \" / \\ | ? *. It examines a folder template per path segment, so you can use/there as a directory separator.The
unshackle.yamldirectoriesconfig key relocates unshackle's working folders. You can move only the folders in the table below. unshackle protects the core package paths and silently ignores them if you try to override them.Name Purposedirectories:\n downloads: \"~/Media/unshackle\"\n temp: \"/mnt/fast/unshackle-temp\"\n cache: \"~/.cache/unshackle\"\n cookies: \"~/.config/unshackle/cookies\"\n logs: \"~/.config/unshackle/logs\"\ndownloadsDefault output directory for finished files.tempWorking directory for in-progress downloads, muxing, and intermediate files.cacheTitle/HTTP cache and update-check state.cookiesPer-service cookie files.logsLog files.exportsExport JSONs.services,vaults,fonts,commandsSearch paths for services, key-vault backends, bundled fonts, and CLI commands.wvds,prds,dcslWidevine devices, PlayReady devices, and DCSL data.Paths can hold a
"},{"location":"guide/output-and-naming/#filename-patterns","title":"Filename patterns","text":"~for expansion. unshackle protects the namesapp_dirs,core_dir,namespace_dir,user_configs, anddata, so you cannot change them.The
unshackle.yamlfilenamesconfig 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.Name Default Wherefilenames:\n log: \"unshackle_{name}_{time}.log\"\n chapters: \"Chapters_{title}_{random}.txt\"\n subtitle: \"Subtitle_{id}_{language}.srt\"\nlogunshackle_{name}_{time}.logdirectories.logsdebug_logunshackle_debug_{service}_{time}.jsonldirectories.logsconfigconfig.yamlper-service directoryroot_configunshackle.yamlmain config filenamechaptersChapters_{title}_{random}.txtdirectories.tempsubtitleSubtitle_{id}_{language}.srtdirectories.temp"},{"location":"guide/output-and-naming/#chapters-naming","title":"Chapters naming","text":"When a title has chapters, unshackle writes them to an OGM chapters file (
unshackle.yamlfilenames.chapters) and passes it to the muxer. Unnamed chapters use thechapter_fallback_nametemplate:chapter_fallback_name: \"Chapter {i:02}\"\nThe fallback can hold two placeholders:
"},{"location":"guide/output-and-naming/#removed-migrated-options","title":"Removed & migrated options","text":"{i}(chapter number, starting at 1) and{j}(which increments only for unnamed chapters). It permits standard format directives like{i:02}.
scene_namingwas removedThe old
"},{"location":"guide/output-and-naming/#worked-examples","title":"Worked examples","text":"scene_namingoption no longer exists. If it is present in your config, unshackle exits immediately with an error asking you to configureoutput_templateinstead. Migrate any scene-naming setup to the templates described on this page.Each tab shows a template and what it produces for a few different downloads, so you can see how the optional (
MoviesTV seriesMulti-languageDaily showsMusic?) variables appear and disappear.output_template:\n movies: \"{title}.{year}.{edition?}.{quality}.{source}.WEB-DL.{audio_full}.{atmos?}.{video}.{hdr?}-{tag?}\"\n folder:\n movies: \"{title} ({year})\"\nA 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\nThe 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\nA 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\noutput_template:\n series: \"{title}.{season_episode}.{episode_name?}.{quality}.{source}.WEB-DL.{audio_full}.{video}-{tag}\"\n folder:\n series: \"{title} ({year})/Season {season}\"\nThe Show (2023)/Season S02/\n \u2514\u2500 The.Show.S02E04.The.Reckoning.2160p.EXAMPLE.WEB-DL.DDP5.1.H.265-TAG.mkv\nunshackle decides the separator style once for the whole folder template, and every segment then follows it. Spaces outnumber dots in
{title} ({year})/Season {season}, soSeason {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\nPlace
{dual?},{multi?}and{dubbed?}next to each other. At most one of them is ever set (seedual_multi_mode), and the empty ones collapse:output_template:\n movies: \"{title}.{year}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{dubbed?}.{audio_full}.{video}-{tag}\"\nOriginal English audio plus a French dub (
-l en,fr):The.Movie.2024.1080p.EXAMPLE.WEB-DL.DUAL.DDP5.1.H.264-TAG.mkv\nOriginal plus several dubs (
-l en,fr,de,es):The.Movie.2024.1080p.EXAMPLE.WEB-DL.MULTi.DDP5.1.H.264-TAG.mkv\nOnly a dub, original audio left out (
-l deon an English-original title):The.Movie.2024.1080p.EXAMPLE.WEB-DL.DUBBED.DDP5.1.H.264-TAG.mkv\nTwo 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\nFor finer control (for example a
SUBBEDtag driven by subtitle languages), use{lang_tag?}withlanguage_tagsrules instead.Daily 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}\"\nA 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\nThe date separator follows your template style: dots here, spaces if your
seriestemplate uses spaces.In folder names,
{season}becomes the air year (Season 2024style grouping), and unshackle skips a{year?}in the folder template when it would only repeat that year.output_template:\n songs: \"{track_number}. {title}\"\n folder:\n albums: \"{album_artist} - {album} ({year})\"\nThe Artist - The Album (2024)/\n \u2514\u2500 01.Opening.Track.mka\nIn
\"{track_number}. {title}\"the number of dots and spaces is equal, and a tie goes 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"},{"location":"guide/output-and-naming/#see-also","title":"See also","text":"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/proxies-and-vpn/","title":"Proxies & VPN","text":"
- Configuration File: the full
unshackle.yamlstructure.- Downloading: the
dlcommand and per-run flags like-o,--no-folder,--tag, and--repack.Most streaming services are geo-restricted, region-priced, or 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
--proxyflag controls proxying, and the proxy providers unshackle supports change a query to a real proxy URL: basic static proxies, Control D, 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 proxy provider configuration lives under the top-level
"},{"location":"guide/proxies-and-vpn/#the-proxy-flag","title":"Theproxy_providers:config key in yourunshackle.yaml. Each proxy provider has its own sub-key, and the config keys inside it map directly onto that proxy provider's settings. See the Configuration file page for where the file lives and how unshackle loads it.--proxyflag","text":"Four options on the
Option Effectdlcommand control proxying at download time:--proxyThe proxy to use. Either an explicit URI, or a query that unshackle resolves against your configured providers.--no-proxyForce all proxy use off. No providers are initialised and no proxy query is resolved.--no-proxy-downloadUse the proxy for the manifest, licence, and authentication, but bypass it for the downloads themselves.--proxy-downloadUse--proxyfor the manifest, licence, and authentication, and a different proxy for the downloads. Takes the same forms as--proxy. Explicit proxy URIResolve a US server from your configured providersunshackle dl --proxy http://user:pass@1.2.3.4:8080 EXAMPLE 81234567\nTarget a specific providerunshackle dl --proxy us EXAMPLE 81234567\nunshackle dl --proxy nordvpn:us EXAMPLE 81234567\nSet a default proxy in config
Like every
"},{"location":"guide/proxies-and-vpn/#-no-proxy-download","title":"dlflag, you can give--proxya default in your configuration, so you do not have to pass it on every command. See Downloading for howdloptions and config defaults interact.--no-proxy-download","text":"Segment downloads are usually the largest share of traffic, and a residential-grade VPN proxy is frequently the slowest part of the chain.
Proxy the manifest and licence, download segments direct--no-proxy-downloadlets 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.unshackle dl --proxy gb --no-proxy-download EXAMPLE 10a1234\nWhen 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
"},{"location":"guide/proxies-and-vpn/#-proxy-download","title":"--no-proxy-download, remove it.--proxy-download","text":"Manifest and licence over NordVPN, downloads over Windscribe
--proxy-downloadis the middle ground: the manifest, licence, and authentication go through--proxy, and the downloads go through a second proxy. Use it when one proxy passes the geo-check and another one is faster for bulk traffic. The value takes the same forms as--proxy, so a country code orprovider:regionresolves against the same providers.unshackle dl --proxy nordvpn:us --proxy-download windscribevpn:us EXAMPLE 10a1234\n"},{"location":"guide/proxies-and-vpn/#how-proxy-resolution-works","title":"How proxy resolution works","text":"
--no-proxyand--no-proxy-downloadboth override--proxy-download. The same region warning applies: if the service ties segment delivery to the manifest region, keep both proxies in that region.unshackle interprets the value you pass to
--proxyin one of three ways. It tries them in this 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. unshackle uses anything shaped like
http://...,https://..., or asocks...URI exactly as you give it. unshackle logsUsing explicit Proxy: ...and does no lookup. Ahost:portvalue such aslocalhost:8080is also an explicit proxy.- A provider-prefixed query:
provider:query, for examplenordvpn:usorgluetun:windscribe:us. unshackle finds the proxy provider whose name matches the prefix (case-insensitive). It then asks only that proxy provider to find a proxy for the remainder. If unshackle loads no such proxy provider, or if that proxy provider returns no proxy, the download errors.- A bare query: a region like
us,gb, orus1234. unshackle asks each loaded proxy provider in order and uses the first proxy any of them returns. A bare city query such asus:seattledoes not work: unshackle readsusas a proxy provider name. Use the proxy provider prefix, for examplenordvpn:us:seattle. If no proxy provider has a proxy for the query, unshackle stops withNo proxy provider had a proxy for <query>and does not continue without a proxy.unshackle compares the query against a region grammar: a country or location code of two to four letters, with an optional
_codepart (res_yyz), then an optional server number and/or a:cityor-citypart. Ahost:portvalue never matches. It then lowercases the query and hands it to the proxy provider. Its section below gives the exact forms each proxy provider accepts.A Control D resolver,
"},{"location":"guide/proxies-and-vpn/#provider-load-order","title":"Provider load order","text":"controld://<resolver>@dns.controld.com, is a fourth form. A--remoteclient sends it to a server in place of a proxy URI, and the server starts a forwarder for it. See Control D.When you do a download without
--no-proxy, unshackle instantiates every configured proxy provider once, in this fixed order. It uses that same order for bare-query resolution:Basic \u2192 ExpressVPN \u2192 NordVPN \u2192 Proton VPN \u2192 Surfshark \u2192 Windscribe \u2192 Gluetun \u2192 Hola \u2192 Control D
So if you have both Basic and NordVPN configured and use
--proxy us, ausentry in your Basic config wins, because unshackle tries Basic first. To skip ahead to a specific proxy provider, prefix the query (--proxy nordvpn:us).If a proxy provider cannot load, for example because its server list cannot be fetched or its config is not valid, unshackle logs a warning and continues with the other proxy providers. A bare query skips the proxy provider that did not load. A query that names it, such as
--proxy nordvpn:us, fails and shows the reason it did not load.A bare region reaches Control D last
unshackle asks Control D for a bare region such as
--proxy usonly when no other loaded proxy provider has a proxy for it. Hola loads automatically when thehola-proxybinary is on yourPATH, and it comes before Control D. When Control D answers, it changes your Control D account: it points a profile at that country, and it can create anunshackle-usprofile and endpoint. The geofence auto-proxy uses the same order. To use Control D directly, prefix the query (--proxy controld:us).Auto-loading providers
Most providers only load when you configure them under
"},{"location":"guide/proxies-and-vpn/#connection-feedback","title":"Connection feedback","text":"proxy_providers:. Three are special: ExpressVPN and Proton VPN also load automatically once their cache file exists on disk (even with no YAML), and Hola loads automatically whenever thehola-proxybinary is found on yourPATH.After a proxy provider resolves a query, unshackle prints a line so you can see where you came out:
"},{"location":"guide/proxies-and-vpn/#exit-check","title":"Exit check","text":"
- 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 proxy providers log the resolved proxy URL.
Before the service sends a request, unshackle looks up the proxy exit IP through the proxy. The service stops with
Proxy check failedin two cases:
- No IP lookup gets through the proxy. The proxy is down, or it refused the connection.
- The proxy exit IP is the same as your own IP, so traffic does not go through the proxy.
unshackle tries each geolocation API in turn, and a 429 moves on to the next one. If at least one API answers through the proxy but none of them gives an IP, the proxy passes the check without the IP comparison. unshackle does not compare the exit country with the region you asked for, because geolocation data can be out of date.
Over the REST API, the server returns this failure to the client as
"},{"location":"guide/proxies-and-vpn/#basic-static-proxies","title":"Basic (static proxies)","text":"INVALID_PROXY.The
Basicproxy provider is pure static configuration, with no accounts and no network calls. You record proxy URLs under each 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\nQuery forms:
Query MeaningusA proxy for the US. If several are listed, one is chosen at random.us2The 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, unshackle picks one at random. Append an index (
de1,de2) to pin a specific entry.- unshackle normalises each URL (a missing scheme defaults to
http) and validates it, so it rejects a malformed URI up front and does not fail mid-download."},{"location":"guide/proxies-and-vpn/#control-d","title":"Control D","text":"unshackle dl --proxy de2 EXAMPLE 81234567\nControl D is a DNS service, not a proxy. It answers the hostnames it redirects with the IP of a transparent proxy in the region you chose, and that proxy relays onwards by SNI. There is no proxy URI to hand to Requests, so this proxy provider runs a small forwarder on loopback: it resolves each connection through your Control D resolver and relays the bytes. Everything downstream (the service session, the downloads,
--proxy-download) sees an ordinary HTTP proxy.unshackle picks the region itself, by pointing a profile's default rule at it. A profile holds one region at a time, so unshackle gives each region in use a profile of its own, each with its own endpoint and its own forwarder port.
--proxy controld:yyz --proxy-download controld:yultherefore uses two profiles, when two are free: Toronto for the service and Montreal for the downloads. When no second profile is free, the second region moves the first profile (see step 5).Keep both locations in one country. Some CDNs also check the country of the download IP, and refuse a
--proxy-downloadexit in another country with a 403. This applies to every proxy provider."},{"location":"guide/proxies-and-vpn/#what-you-need","title":"What you need","text":"
unshackle search --proxy controld:caworks the same way asdl.unshackle.yaml
- A plan with traffic redirection. On the personal plans that is Full Control; Some Control has no redirection, and every redirect fails. The 14-day trial includes it. See Control D's plans for current prices.
- An API token of type Write. A Read token cannot change a profile's default rule. Create it in the dashboard under API. If you set Allowed IPs on the token, include the address of each machine that runs unshackle with it.
proxy_providers:\n controld:\n token: api.xxxxxxxxxxxx # a Write API token, from the Control D dashboard\nunshackle dl --proxy controld:ca EXAMPLE 81234567\nThe token is the only required key. You do not need to create a profile or an endpoint, or to give a
profileandresolver: unshackle creates anunshackle-<region>profile and endpoint on your account the first time it needs one, and uses it again on the next run. Giveprofileandresolveronly to use a profile that you made yourself (see How unshackle chooses a profile).The returned proxy is HTTP on loopback,
Query Meaninghttp://127.0.0.1:{port}. The locations themselves are read from Control D at startup (107 of them across 68 countries, at the time of writing), so there is nothing to list:caOne of Control D's Canadian locations, at random.yulThat exact location, by its code.res_yyzA residential location, by its code. A country pick never uses a residential location, so name it this way.The configuration reference lists every
"},{"location":"guide/proxies-and-vpn/#how-unshackle-chooses-a-profile","title":"How unshackle chooses a profile","text":"controldconfig key.Each region query gets a profile and an endpoint named after it:
--proxy controld:causesunshackle-ca. For each region, unshackle uses the first match in this list:
- A profile that this process already pointed at that region.
- The endpoint named
unshackle-<region>on your account, made by an earlier run.A profile from your config that this process does not use yet. These are optional:
unshackle.yamlproxy_providers:\n controld:\n token: api.xxxxxxxxxxxx\n profile: abcd123 # a profile kept for unshackle, and nothing else\n resolver: efgh456 # the resolver ID of an endpoint using that profile\n profiles: # more pairs, in the same shape\n - profile: ijkl789\n resolver: mnop012\n max_profiles: 4 # the most profiles unshackle uses, yours included (default 4)\nThe resolver ID is the last part of the endpoint's DoH URL,
https://dns.controld.com/<resolver>. You can also give the full URL.A new profile and endpoint named
unshackle-<region>, while your config pairs plus theunshackle-*endpoints on the account are fewer thanmax_profiles. unshackle keeps them for the next run.max_profileslimits only this step: unshackle uses every pair in your config, even more thanmax_profiles.- When no profile is free, a profile moves to the new region, with everything that still uses it. unshackle first moves a profile that this process has not pointed at a region, which another process can still use. Next, it moves the profile that this process pointed at a region first. An
unshackle-*profile that moves, and its endpoint, get the new region's name. unshackle logsEvery Control D profile is in use; moving profile \u2026 to \u2026, and connections already open keep the old exit until they close.Because the names live on the account, every unshackle process agrees on them: two runs, or two jobs on a
serveinstance, that ask forcaboth useunshackle-ca. Two processes can still move a profile under each other when they run out of profiles (step 5).unshackle changes only the profiles in your config and the profiles of
unshackle-*endpoints. It skips anunshackle-*endpoint whose profile an endpoint without that name also uses, so unshackle never redirects a profile that serves your own devices. Leave theunshackle-*profiles empty: no services, no custom rules. unshackle sets their default rule toREDIRECT, and the Services and Custom Rules tabs stay at zero by design. To remove them, delete the endpoints and profiles in the dashboard. unshackle creates new ones when it next needs them.Business accounts pay per endpoint
Personal plans allow an unlimited number of profiles and endpoints. Business plans bill each endpoint every month, so each
unshackle-<country>endpoint adds to the bill. On a business account, configure your own pairs, as many as the countries you use at the same time, and setmax_profilesto that number. unshackle then never creates a profile.Everything goes through the proxy, including the segments
With the default rule redirecting, the whole download leaves through Control D's transparent proxy. Control D serves it on a best-effort basis. Pair it with
--no-proxy-downloadto keep the geo-check on the manifest and the licence while pulling the files over your own connection.The switch is not instant
unshackle drops its own DNS cache when it changes region, but Control D's edge may still serve the previous answer for the remainder of the record's TTL.
Same machine, same IP, over IPv4
Control D authorises its proxies by source IP: the address that queried DNS is the one allowed to use the proxy. The forwarder resolves and connects from the same host, so this holds. Control D also answers a redirected name only to a query over IPv4, so the forwarder asks over IPv4 even on a host with IPv6, and ignores
HTTPS_PROXYfor these queries.The forwarder refuses a connection to an IP address with a
502, because an IP address skips Control D's DNS and connects from your own address. A request that names a host by IP address fails with this proxy provider.With
--remote, the server resolvesunshackle does not send your API token to a server. For
--proxy controld:cawith--remote, unshackle pointsunshackle-caat Canada with your token, then sends the server only that endpoint's resolver ID, ascontrold://<resolver>@dns.controld.com. The server runs its own forwarder with it, from its own address, and needs no Control D configuration. The resolver ID lets anyone who has it use that endpoint's proxy, so treat it as a secret: unshackle masks it in logs and in stored job parameters.The server must run a version of unshackle that knows
"},{"location":"guide/proxies-and-vpn/#troubleshooting-control-d","title":"Troubleshooting Control D","text":"Symptom Cause and fixcontrold://. An older server rejects it withINVALID_PROXY:Proxy provider 'controld' not foundwhen your API key hasserver_proxy, else a message that asks for a full proxy URI. A bare region, such as--proxy ca, skips Control D with--remote, because the proxy it gives is on your machine. A service whose accounts the server manages does not accept a proxy from the client, and that includes a Control D resolver.Proxy check failed, because the exit IP is your own IP. The profile does not redirect. Make sure that the plan includes traffic redirection (Full Control, not Some Control). After a region change, wait for the TTL of the previous answer to end. A502 Bad Gatewayfor one host. With-d, the log showsControl D returned no A record for \u2026. Control D gave no IPv4 address for that host. A502 Bad Gatewayfor one host only. With-d, the log showsRefused \u2026, an address that Control D cannot redirect. The request names the host by IP address. The forwarder refuses it. See the note on IPv4 above. An HTTP401or403fromapi.controld.com. The token is a Read token, or its Allowed IPs do not include this machine. Use a Write token.Control D needs both a profile and a resolverorControl D needs max_profiles of 1 or moreat startup. Thecontroldconfig is not valid. Giveprofileandresolvertogether, and amax_profilesof 1 or more. Every run fails until you correct it, also a run that does not use Control D.Could not read Control D's locations. unshackle could not get the location list. It tries again at the next Control D query. A403for the segments only, with--proxy-downloadin another country. The CDN checks the country of the download IP. Keep--proxy-downloadin the same country as--proxy."},{"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/gluetunimage. That container establishes a WireGuard or OpenVPN tunnel to your chosen VPN provider and exposes a plain HTTP proxy onlocalhost, 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.
Not available with
--remoteGluetun listens on your machine, so a server on another machine cannot reach it. With
--remote, unshackle stops with an error for--proxy gluetun:..., and for any proxy that resolves tolocalhostor a loopback address, such as a region query that Gluetun answers. Give--remotea proxy the server can reach, or run the service locally. A server on this machine (aremote_servicesURL on127.0.0.1) can reach a local proxy, so it is exempt.Docker is required
Gluetun needs a working Docker installation on your
"},{"location":"guide/proxies-and-vpn/#query-format","title":"Query format","text":"PATH. If Docker is not found, the proxy provider fails to load with an error linking to the install docs. The container also needs theNET_ADMINcapability and access to/dev/net/tunto bring up the tunnel. unshackle requests both automatically.Gluetun uses a nested selector: the proxy provider prefix picks Gluetun, and the rest names the VPN provider and the region.
A Windscribe US exit via Gluetungluetun:<vpn-provider>:<region>\nA NordVPN Germany exit via Gluetununshackle dl --proxy gluetun:windscribe:us EXAMPLE 81234567\nunshackle dl --proxy gluetun:nordvpn:de EXAMPLE 81234567\nAfter unshackle strips the leading
"},{"location":"guide/proxies-and-vpn/#configuration","title":"Configuration","text":"gluetun:, the proxy provider receives exactlyprovider:region. It must be two colon-separated parts, and anything else is an error. The<vpn-provider>must be one you have configured undergluetun.providers, and unshackle normalises friendly names to Gluetun's identifiers (for examplepiaandprivateinternetaccessboth map toprivate internet access).Each VPN provider you want to use gets an entry under
unshackle.yamlproviders:, carrying its VPN type, credentials, and how your region aliases map onto Gluetun's server selectors.proxy_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\nPer-provider keys:
Key Purposevpn_typewireguard(default) oropenvpn.credentialsThe secrets for the tunnel; see the credential rules below.server_countriesMaps your region alias (e.g.us) to Gluetun's country value (e.g.US).server_citiesOptional. Maps an alias to a Gluetun city.server_hostnamesOptional. Maps an alias directly to a specific server hostname.extra_envOptional. Raw environment variables merged into the container last, for any Gluetun setting not covered above.Credential rules by VPN type:
WireGuardOpenVPN
private_keyis always required.surfshark,mullvad, andivpnalso requireaddresses.windscribeadditionally requires bothaddressesandpreshared_key(preshared_keymay be an empty string, but the config key must be present).- NordVPN and Proton VPN need only
private_key.
- Requires
usernameandpassword.Which
vpn_typeshould 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
addressesand apreshared_key). OpenVPN is the simplest to configure (only a username and password) at the cost of some speed. If you start out, or if you have credential trouble, OpenVPN is the path of least resistance. Switch to WireGuard when you want the extra throughput.Global keys:
Key Default Purposebase_port8888The first local port to expose proxies on. Additional containers take the next free ports.auto_cleanuptrueRemove containers on exit. Iffalse, containers are only stopped, not deleted.container_prefixunshackle-gluetunPrefix for Docker container names.auth_user/auth_passwordnullOptional HTTP-proxy credentials. If set, the local proxy requires auth and the credentials are embedded in the proxy URL.verify_iptrueAfter 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, orserver_countriesfor that alias always wins.- unshackle changes a
us1239-style query (country code + number) with no explicit mapping into a provider-specific server hostname (for exampleus1239.nordvpn.comfor NordVPN). Surfshark has no numbered servers, so map a Surfshark region to a server name such asus-dal.prod.surfshark.cominserver_hostnames.- unshackle expands a bare two-letter code to the full country name Gluetun expects.
A few providers (Windscribe, VyprVPN, VPN Secure) select by region rather than country, and unshackle converts Windscribe region names automatically. For instance
"},{"location":"guide/proxies-and-vpn/#container-lifecycle","title":"Container lifecycle","text":"usbecomesUS Eastanduk/gbbecomeUnited Kingdom.Understanding what Gluetun does behind the scenes helps when something goes wrong:
- Image check. unshackle pulls
qmcgaw/gluetun:latestif it is not already present.- Reuse. If a container for this exact
provider:regionalready runs (from a concurrent unshackle process), unshackle adopts it and does not start a new one. It removes stopped or dead containers with the same name 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. unshackle chooses a free local port, starting from
base_port.- Start. unshackle launches the container bound to
127.0.0.1:{port}. It passes credentials through a temporary,0600-permission env-file (never as-eflags), and it overwrites and deletes that file immediately after.- Readiness wait. unshackle polls the container logs (up to 60 seconds) until the HTTP proxy listens and the VPN reports a completed connection. Fatal errors like
authentication failedorinvalid credentialsabort early, and unshackle attaches recent logs to the error to help you diagnose it.- IP verification. When
verify_ipis 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
unshackle cleans up containers with an
"},{"location":"guide/proxies-and-vpn/#troubleshooting","title":"Troubleshooting","text":"atexithandler on normal exit. Gluetun deliberately does not install its own signal handlers, soCtrl+Ccontinues to work as you expect. If something kills a run hard (for examplekill -9), a container can stay behind. Withauto_cleanup: truethe next run that adopts that name still works, or you can remove it manually withdocker rm -f.Common Gluetun issues
"},{"location":"guide/proxies-and-vpn/#expressvpn","title":"ExpressVPN","text":"
RuntimeErrorabout Docker not found. Install Docker and make sure thatdockeris on yourPATH.invalid credentials/authentication failedin the readiness wait. Your WireGuard/OpenVPN credentials are wrong or in the wrong fields. Double-checkprivate_key,addresses, and (for Windscribe)preshared_key.- Region mismatch error. The VPN connected but exited in a different country than requested. Examine your
server_countriesmapping, or setverify_ip: falseif you intentionally use a nearby region.- Timeout after 60 seconds. The tunnel never came up. Inspect the container logs (
docker logs <container>) for the underlying VPN error.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 you signed in headlessly.
Enable it and do a download interactively once:
unshackle.yamlproxy_providers:\n expressvpn:\n enable: true\nWith
unshackle.yaml (optional)enable: trueand an interactive terminal, unshackle prints a code and a URL to enter it at. Once you approve it, unshackle caches the tokens and reuses them automatically, so later runs need no terminal. ExpressVPN also auto-loads once its token cache exists, so you can dropenableafter the first login, but you can keep a YAML block to pin regions or servers: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\nDefault file location:
File Default path Token cache{cache}/vpn/expressvpn_tokens.jsonWhere
{cache}is your configured cache directory.Query forms:
Query MeaningusA smart, random US location.us-nyUS, New York.us-ny-2/us-ny2US, New York, the 2nd server.usny-newyork-2A full location slug.hostname.expressprovider.comA 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
unshackle writes ExpressVPN's token cache with
"},{"location":"guide/proxies-and-vpn/#nordvpn","title":"NordVPN","text":"0600permissions, so the refresh token is never briefly world-readable.NordVPN uses your Service Credentials, not your normal NordVPN login. Make 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\nRequired 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_mapis optional and maps a region (orcode:city) to a pinned NordVPN server ID. Its main use is to tune performance: for a given streaming service one specific server often performs noticeably better than NordVPN's recommended pick. So you can hard-code that server for a day or two as a workaround, until it degrades and you swap in another.Use
gb, notuk, for the United KingdomQuery the UK as
gb. unshackle uses the Alpha-2 country codegbdeliberately to stay consistent with its other regional/country-code systems and avoid the language-vs- country-code confusion thatukinvites.Query forms:
Query MeaningusA recommended US server.us1234The specific serverus1234.nordvpn:us:seattleA recommended server in Seattle.228A NordVPN numeric country ID.The returned proxy is HTTPS on port 89 (NordVPN disabled its plain-HTTP proxies on port 80 in 2021).
"},{"location":"guide/proxies-and-vpn/#proton-vpn","title":"Proton VPN","text":"unshackle dl --proxy nordvpn:us:seattle EXAMPLE 81234567\nProton VPN supports two authentication methods, and unshackle can work with either. Note that free Proton accounts work, but Proton limits them to its free-tier exit countries.
TV login (recommended)Exported cookiesProton's TV device-login flow gives a self-sustaining, refreshable sign-in. This is the only method that can refresh itself headlessly, so it keeps working without re-exporting anything. Enable it and do a download interactively once:
unshackle.yamlproxy_providers:\n protonvpn:\n enable: true\nWith
enable: trueand an interactive terminal, unshackle prints a code and asks you to enter it at account.proton.me/vpn/tv/code. Once you approve it, unshackle caches the sign-in and reuses it automatically.Export your
account.proton.mecookies (theAUTH-<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 HttpOnlyThis 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 \"does not 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. That is why the sign-in lapses at Proton's ~24h TTL and you must export it again. This is exactly why TV login is the better method for set-and-forget use.
Default file locations:
File Default path Cookies{cookies}/vpn/protonvpn.txtSession cache{cache}/vpn/protonvpn.jsonProton VPN auto-loads if its cookie file exists. Like ExpressVPN, unshackle writes its cache file with
0600permissions.Cache vs cookie precedence
If both a TV-login cache and a cookie file exist, unshackle prefers the cache unless the cookie file is newer. unshackle reads cookies fresh every run and never writes them back to the cache. So to deliberately force use of the cookie file, re-save (re-export) it so its modification time is newer than the cache.
Query forms (after the
Query Meaningprotonvpn:prefix):usA random US server (paid tiers preferred over free).us12Proton US server #12.us:nyA server in New York.The returned proxy is HTTPS on port 4443 (or port 443 for Secure Core servers). unshackle excludes Secure Core and Tor servers 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
"},{"location":"guide/proxies-and-vpn/#hola","title":"Hola","text":"protonvpn:deNN, do not guess a low number. A country may have non-contiguous IDs like#203,#813, and so on. Copy the number from a previous run's log line (e.g.DE#203) instead.Hola requires no configuration at all. It uses the
hola-proxybinary, and auto-loads whenever that binary is found on yourPATH. If you query Hola but the binary is missing, the proxy provider raises an error that tells you to install it.The
Build hola-proxy, then query a countryhola-proxyGitHub repository is no longer online, so build the binary with Go. The Go module proxy still has the source for version 1.18.2.go installputs the binary in$(go env GOPATH)/bin, which must be on yourPATH.go install github.com/Snawoot/hola-proxy@v1.18.2\nunshackle dl --proxy hola:us EXAMPLE 81234567\nQuery forms: a two-letter country code (e.g.
us,gb). unshackle askshola-proxyfor 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. While a ban is active,
"},{"location":"guide/proxies-and-vpn/#surfshark","title":"Surfshark","text":"hola-proxydoes not stop, so unshackle waits 30 seconds and then reports the ban. Hola currently uses only datacenter proxies.Surfshark, like NordVPN, uses Service Credentials rather than your login. Make 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: region -> server name\n us: us-dal\n us:seattle: us-sea\nRequired keys:
username,password, validated with the same 48-character rule as NordVPN (combined username+password must be 48 alphanumeric characters, case-insensitive, no@).server_mapoptionally pins a server per region. Give the server name from Surfshark's server list, for exampleus-dal. A full hostname such asus-dal.prod.surfshark.comalso works. Surfshark does not use numeric server IDs. unshackle ignores a numeric or empty value and logs a warning. Aus:seattleentry applies only to that city query, and it does not fall back to theusentry.Query forms:
Query Meaningsurfsharkvpn:usA random US server.ukandgbboth give a UK server.surfsharkvpn:us-bosA specific server from Surfshark's server list.surfsharkvpn:us:seattleA random server in Seattle.unshackle ignores case, spaces, hyphens and accents in a city name, so
us:new-yorkmatches New York. A city also matches the start of a longer name:de:frankfurtmatches Frankfurt am Main. A bareusalso reaches Surfshark. A bareus-boscan stop at a proxy provider earlier in the order, and a bareus:seattledoes not work, so use thesurfsharkvpn:prefix for those forms.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. unshackle only makes sure that a username and a password are present (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\nRequired keys:
username,password.server_mapoptionally maps a region (orregion:city) directly to a hostname.Query forms:
Query MeaningusA random US server.us150/sg007A specific numbered server.windscribevpn:us:seattleA random server in Seattle.The returned proxy is HTTPS on port 443.
Windscribe is not loaded by the REST API resolver
The
"},{"location":"guide/proxies-and-vpn/#developer-notes","title":"Developer notes","text":"dlcommand loads the standalone Windscribe proxy provider (and Gluetun), but the proxy resolver that the REST API and the remote-download client use does not. If you drive downloads through the API and need Windscribe or Gluetun exits, get the proxy on the CLI side, or use a different proxy provider for API jobs. See the developer note below.Two resolution paths (developers)
unshackle has two proxy-resolution code paths that are intentionally not identical:
unshackle/commands/dl.pyinlines its own logic and loads all nine 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, a bare country query tried against providers in order, or acontrold://resolver), but the set of available providers differs. Keep this in mind when you add a proxy provider: an entry indl.pydoes not make it available over the API, and the opposite is also true.The remote-service client resolves
--proxywith its ownresolve_remote_proxy_arg(). It changes acontrold:query to acontrold://resolver for the server withget_remote_proxy(), and it removes Control D from a bare query, because a loopback forwarder is of no use to a server on another machine.Implementing a new provider (developers)
Every proxy provider subclasses
unshackle.core.proxies.proxy.Proxyand has three methods:
__init__(self, **kwargs)receives itsproxy_providerssub-dict splatted as keyword arguments, so YAML keys map 1:1 to constructor parameters. Do authentication and catalogue pre-fetching here.__repr__(self)is a human summary, conventionally\"{n} Countries ({m} Servers)\".get_proxy(self, query)returns a Requests-compatible proxy URI, orNoneonly when the query was valid but no proxy exists. Raise on genuine errors or bad queries.Optionally add
"},{"location":"guide/proxies-and-vpn/#see-also","title":"See also","text":"get_connection_info(query)(as Gluetun does) orlast_connection_display()(as ExpressVPN and Proton VPN do) to makedlprint a friendly connection line instead of the raw proxy URL."},{"location":"guide/subtitles/","title":"Subtitles","text":"
- Downloading: the full
dlcommand guide, including where proxy flags sit in the download flow.- Configuration file: the
unshackle.yamlreference, including directories for cookies and cache used by the cookie-based providers.- Configuration reference: the developer-facing configuration API, including the
proxy_providersstructure.Subtitles are first-class tracks in unshackle. When you download a title, unshackle parses the subtitle tracks from the manifest alongside video and audio. It downloads them, cleans them up, and optionally converts them to the format you want. It then muxes them into the final Matroska file, or writes them out as sidecar files next to it. The sections below cover the supported formats, how unshackle normalizes tracks after download, how to select and filter subtitles, and every subtitle-related configuration option.
A handful of
"},{"location":"guide/subtitles/#supported-formats","title":"Supported formats","text":"dlflags and a smallsubtitleblock in your configuration file control most of this. See Downloading for how subtitle flags fit into a full download command.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 NotesSubRipSRTsrtSubRip, the most widely compatible plain-text formatSubStationAlphaSSAssaSubStation Alpha (styled)SubStationAlphav4ASSassAdvanced SubStation Alpha (styled)TimedTextMarkupLangTTMLttmlTimed Text Markup Language (IMSC/DFXP)WebVTTVTTvttWebVTTSAMISMIsmiSynchronized Accessible Media InterchangeMicroDVDSUBsubMicroDVDMPL2MPL2mpl2MPL2TMPTMPtmpTMPfTTMLSTPPstppMPEG-DASH box-encapsulated TTML (IMSC1)fVTTWVTTwvttMPEG-DASH box-encapsulated WebVTTThe two boxed formats convert automatically
"},{"location":"guide/subtitles/#what-happens-to-a-subtitle-after-download","title":"What happens to a subtitle after download","text":"
fTTML(STPP) andfVTT(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 (fTTMLbecomes TTML andfVTTbecomes 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 write them as an output target, so they can never be a conversion destination.A subtitle download does more than save a file. Once the bytes are on disk, unshackle runs format-appropriate clean-up to make a single, valid file:
- Encoding: unshackle forces non-boxed subtitles to UTF-8, and unescapes stray
‎/‏bidirectional entities.- Segment merging: when a WebVTT or boxed subtitle arrives as many DASH/HLS segments, unshackle merges the segments 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 (likeQ0,Q1) that confuse downstream parsers, and merges multi-line cues that a service split into separate overlapping cues. By default unshackle keeps the original styling and layout (seepreserve_formatting).- Cue markup normalisation: unshackle removes the WebVTT-only parts of a cue payload, so no player draws them as dialogue. See Cue markup.
- TTML for muxing: Matroska cannot carry TTML. If you do not request a specific output format, unshackle automatically converts any plain TTML track to WebVTT before muxing (the closest lossless equivalent). Boxed
fTTMLis likewise unwrapped to TTML first.You normally do not need to think about any of this. It gives a clean file.
"},{"location":"guide/subtitles/#selecting-and-filtering-subtitles","title":"Selecting and filtering subtitles","text":"Subtitle selection happens through
Flag Purposedlflags. The most important is language selection.-sl,--s-langLanguage(s) wanted for subtitles. Defaults toall. A-prefix excludes, e.g.all,-es.--require-subsRequire these languages to exist; if present, download all subtitles. Cannot be combined with--s-lang.-fs,--forced-subsInclude forced subtitle tracks (excluded by default).-fsl,--forced-s-langLanguage(s) wanted for forced subtitles; implies-fs. A-prefix excludes.--exact-langExact language matching, with no regional variants.-S,--subs-onlyDownload only subtitle tracks.-ns,--no-subsDo not download subtitle tracks at all.--sub-formatOutput subtitle format / conversion target (see conversion).--skip-subtitle-errorsSkip a subtitle that fails to download instead of aborting the whole title."},{"location":"guide/subtitles/#choosing-languages","title":"Choosing languages","text":"By default (
English and Spanish subtitles only--s-lang all) unshackle keeps every subtitle language the service offers. To narrow it down, pass a comma-separated list of language tags:unshackle dl --s-lang en,es EXAMPLE 81234567\nLanguage matching is fuzzy by default:
enmatchesen-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
Only Latin-American Spanish, no other Spanish variantses-419matches onlyes-419and neveres-ES, add--exact-lang:"},{"location":"guide/subtitles/#requiring-languages-before-downloading","title":"Requiring languages before downloading","text":"unshackle dl --s-lang es-419 --exact-lang EXAMPLE 'https://www.example.com/...'\nFail unless both English and French subs exist, then grab everything
--require-subsis a gate rather than a filter. It downloads all available subtitles, but only if every language you name is present. If any is missing, the title errors out. This is useful for batch jobs where you want a title without your must-have language to fail loudly, and not to silently give a partial result.unshackle dl --require-subs en,fr EXAMPLE B0ABCDEFGH\n
--require-subsand--s-langare mutually exclusiveBecause
"},{"location":"guide/subtitles/#forced-subtitles","title":"Forced subtitles","text":"--require-subsforces downloading of all subtitle languages, it cannot be combined with--s-lang. Choose one or the other.Forced tracks carry only the important on-screen signs and foreign-dialogue lines meant to display even when you watch in your own language. They are excluded by default. Add
Include forced tracks alongside full subtitles-fs/--forced-substo include them:unshackle dl --s-lang en --forced-subs EXAMPLE 81234567\nAll full subtitles, but only the English forced track
-fskeeps forced tracks in every selected language. If you only want forced tracks in certain languages, use-fsl/--forced-s-langinstead. It implies-fs, so you do not need both:unshackle dl --s-lang all --forced-s-lang en EXAMPLE 81234567\nIf you pass both flags,
"},{"location":"guide/subtitles/#track-types-forced-sdh-and-cc","title":"Track types: forced, SDH, and CC","text":"-fslwins and keeps only forced tracks in its languages. It also understandsorig(the title's original language) andall(every forced track, same as plain-fs).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 it 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 hearing-impaired handling, unshackle groups SDH and CC tracks 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_bytolanguage. Usesubtitle.type_priorityto 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 carry the CC or SDH flag. unshackle rejects these combinations 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 track. After unshackle downloads and decrypts a video track, it probes the track for these captions and, when it finds them, extracts them into a SubRip (
"},{"location":"guide/subtitles/#automatic-sdh-stripping","title":"Automatic SDH stripping","text":"SRT) subtitle flagged as CC. This runs automatically unless you disabled subtitles (--no-subs) or video (--no-video/--video-onlysemantics), and it requires the CCExtractor tool on yourPATH.When a language has an SDH track but no plain (non-SDH, non-forced) track, unshackle can make 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_sdhconfig key.The check is deliberately loose: if a close-enough plain track already exists (for example an
en-GBnon-SDH track when the SDH track isen-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
sdh_methodconfig key selects the stripping engine itself. The default (auto) prefers thesubbycleaner for SRT, falls back to SubtitleEdit when you install it, and otherwise uses the built-infilter-subsapproach.Every engine removes the same three things: bracketed sound effects (
[MUSIC PLAYING],(door creaks)), speaker labels (MAN:), and font tags. Every engine keeps plain dialogue and inline<i>styling. They differ on music and lyric lines:sdh_methodEngine that runs Lyric lines (\u266a ... \u266a)autosubbyfor an SRT track. SubtitleEdit for any other format when you install it, otherwisefilter-subs. Kept, unless the fallback reachesfilter-subs.subbysubby, but only for a track that is already SRT. Any other format falls through tofilter-subs. Kept for SRT, removed otherwise.subtitleeditSubtitleEdit when you install it, otherwisefilter-subs. Kept with SubtitleEdit, removed without it.filter-subsfilter-subsalways. Removed.Pick
filter-subswhen you want lyrics gone as well. Picksubbyor install SubtitleEdit when you want to read along with the songs.Why
convert_before_stripexists"},{"location":"guide/subtitles/#converting-subtitle-formats","title":"Converting subtitle formats","text":"
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 forconvert_before_strip: non-SubtitleEdit engines need the subtitle converted to SRT first, or the hearing-impaired cues stay in the file.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
Convert every subtitle to SRT--sub-format:unshackle dl --s-lang en,es --sub-format srt EXAMPLE 81234567\nKeep whatever the service delivered
--sub-formataccepts the common format names and aliases, includingsrt,vtt,ssa,ass, andttml. Passoriginalto explicitly keep the source format and skip all conversion:unshackle dl --sub-format original EXAMPLE 'https://www.example.com/...'\nConversion only runs when the source format differs from the target, so
--sub-format srton 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 with
--sub-format. If you want to preserve styling, convert to a format that supports it (or keeporiginal).Note that this automatic protection guards only the default muxed track: there unshackle skips the conversion and keeps the styled original. It does not cover sidecars. A
"},{"location":"guide/subtitles/#how-the-conversion-backend-is-chosen","title":"How the conversion backend is chosen","text":"sidecar_format: srtwill still lossily flatten styled subtitles, dropping positioning, colours, and styling, and a per-download--sub-format srtforces the muxed track to convert as well. To keep raw styled sidecars, setsidecar_format: original.Under the hood unshackle picks the highest-fidelity converter that supports the source\u2192target pair. Which converter runs depends on the
Backend Best at Requiresconversion_methodconfig key (defaultauto) and the formats involved:subtitleeditHighest fidelity; preserves positioning and italics SubtitleEdit installedsubbyNative SRT output with clean-up -pysubs2Best fidelity for styled SSA/ASS - (pure Python, always available)pycaptionLast-resort fallback; flattens styling -With
"},{"location":"guide/subtitles/#what-each-backend-preserves","title":"What each backend preserves","text":"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 convert the pair.The table below comes from round-tripping a subtitle that carries italics, bold, underline, positioning, and colour through every backend. Each cell shows the styling that survives.
Conversion\u2014means that the backend cannot convert that pair.subtitleeditpysubs2subbypycaptionWebVTT \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 \u2014Reading the table:
- Keep the original: leaving
--sub-formatunset (or set tooriginal) 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 it is best for \u2192 SRT, where it uniquely converts WebVTT cue settings into{\\an8}positioning. ItsCommonIssuesFixermay also drop near-duplicate cues.pycaption: removes all styling. Last-resort fallback only.What
autopicks"},{"location":"guide/subtitles/#cue-markup-and-html-entities","title":"Cue markup and HTML entities","text":"
autopreferssubtitleeditfirst when you install it, then falls back tosubbyfor \u2192 SRT andpysubs2otherwise. The one exception comes from the table above: for ASS/SSA \u2192 TTML or WebVTT it pickspysubs2even when SubtitleEdit is installed, since SubtitleEdit flattens inline styling on those pairs. Most installs do not ship SubtitleEdit, so in practiceautomeanssubby(\u2192 SRT) orpysubs2. Install SubtitleEdit /seconvif you need colour or the highest \u2192 SRT fidelity.Styling is not the only thing a conversion has to handle. WebVTT carries markup that no other format understands, and a player that does not know a tag draws it as dialogue. unshackle removes that markup before any backend runs, so the result is the same for every
WebVTT markup Resultconversion_method:<c.bg_transparent>text</c>Tags removed, text kept. A class only selects a CSS::cuerule that no player ever receives, so it carries no styling.<i.loud>,<b.x>,<u.y>Class list removed, the styling tag kept.<v Bob>,<lang ja>,<ruby>,<rt>Tags removed, text kept.<00:00:02.000>Removed. These are karaoke timings that no subtitle player renders.&, ,'Decoded to real characters for SRT, SSA, and ASS output. Kept escaped for WebVTT and TTML, which need them.A class list breaks more than you expect
FFmpeg reads SubRip markup as a tag only while the tag name stays inside
[0-9a-zA-Z_/]. The dot in<i.loud>fails that test, so FFmpeg prints the opening tag on screen and then closes an italic run that never opened. The FFmpeg WebVTT reader matchesi,b, anduexactly, so there the same class silently loses the styling. mpv, VLC, Plex, and Jellyfin all use one of those two readers.Config wins over a service's preference
A service may set a
"},{"location":"guide/subtitles/#muxed-vs-sidecar-output","title":"Muxed vs. sidecar output","text":"preferred_conversion_methodon its own tracks (for example when it ships subtitles that a particular backend handles best). An explicitconversion_methodinunshackle.yamlalways overrides that per-track preference, so your config takes precedence. This only matters when a service ships a non-default preference. Leaveconversion_methodatautoto let the service's hint stand.By default unshackle muxes subtitles into the final
mux (default)sidecarboth unshackle.yaml.mkv. You can instead (or additionally) write them as separate sidecar files next to the video, controlled by theoutput_modeconfig key:subtitle:\n output_mode: mux\nunshackle embeds the subtitles in the Matroska file. It writes nothing separately.
unshackle.yamlsubtitle:\n output_mode: sidecar\n sidecar_format: srt\nunshackle writes the subtitles as standalone files and does not mux them into the video (when there is a video or audio track to sit beside).
unshackle.yamlsubtitle:\n output_mode: both\n sidecar_format: srt\nunshackle muxes the subtitles into the video and writes them as sidecar files.
unshackle names sidecar files 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\nThe pattern is
"},{"location":"guide/subtitles/#fonts-for-styled-subtitles","title":"Fonts for styled subtitles","text":"{base}.{language}[.forced][.sdh].{extension}. Thesidecar_formatconfig key sets the sidecar file format. Use a format name likesrtorvtt, ororiginalto keep each subtitle in its downloaded format.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":"You can set defaults for all of the behaviour above in the
unshackle.yamlsubtitleblock of your configuration file:Key Default Values Purposesubtitle:\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\nstrip_sdhtruetrue/falseAuto-generate a stripped non-SDH copy when a language has only an SDH track.preserve_formattingtruetrue/falseKeep original WebVTT styling and layout during sanitization. Whenfalse, WebVTT is re-encoded more aggressively (merging identical cues, dropping empty ones).conversion_methodautoauto,subby,pysubs2,subtitleedit,pycaptionWhich conversion backend to prefer.autoranks them per conversion.sdh_methodautoauto,subby,subtitleedit,filter-subsEngine used to strip hearing-impaired cues.convert_before_striptruetrue/falseConvert a subtitle to SRT before stripping SDH (for non-SubtitleEdit engines).output_modemuxmux,sidecar,bothWhether subtitles are embedded, written as sidecars, or both.sidecar_formatsrta format name (e.g.srt,vtt) ororiginalFormat used for sidecar files.group_bytypetype,languageWhether 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."},{"location":"guide/subtitles/#which-settings-to-change","title":"Which settings to change","text":"The defaults give a clean, player-safe file, so most people change nothing. Start here only when you want something the defaults do not do:
If you want Set Every original style kept, with no re-encode at all--sub-format original, andsidecar_format: originalfor sidecars The highest conversion fidelity, including colour Install SubtitleEdit.conversion_methodstaysautoMusic and lyric lines stripped along with the sound effectssdh_method: filter-subsLyric lines keptsdh_method: subby, or install SubtitleEdit No stripped copies made at allstrip_sdh: falseOne backend for every conversion, whatever you installconversion_method: <backend>Two keys read as if they control markup, and do not:
preserve_formattingdecides whether unshackle re-encodes a WebVTT file after the repair pipeline. Lefttrue, the cue text passes through as written. Set tofalse, unshackle re-encodes the file through pycaption, which merges identical cues and drops empty ones but also flattens styling. Neither setting leaves WebVTT-only markup in the file: cue markup normalisation runs either way.convert_before_stripapplies to SDH stripping, not to your output format. It converts the working copy to SRT so a non-SubtitleEdit engine can read it. Turning it off makessubbyandfilter-subsno-ops on anything that is not already SRT.SubtitleEdit is optional but recommended
Several high-fidelity paths (the best-quality conversions and the SubtitleEdit SDH stripper) only activate when you install the SubtitleEdit CLI and put it on your
"},{"location":"guide/subtitles/#developer-reference","title":"Developer reference","text":"PATH. See Installation for how to add it. Without it, unshackle falls back to its built-in Python backends.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 bothccandsdh, and you cannot combineforcedwith either. Both raiseValueErrorat construction.convert(codec, *, forced=False): converts the downloaded file in place through the backend chain insubtitle_convert.run_conversion.forced=True(what--sub-formatsets) is the only way to permit lossy styled down-converts such as ASS\u2192SRT. RaisesNotImplementedErrorif no backend supports the pair andRuntimeErrorif all attempts fail.strip_hearing_impaired(): removes SDH cues using the engine selected byconfig.subtitle[\"sdh_method\"].reverse_rtl(): fixes right-to-left sentence-ending positioning with SubtitleEdit (requires the binary).parse(data, codec): the central parser, returning apycaption.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 (
"},{"location":"guide/troubleshooting/","title":"Troubleshooting & Debugging","text":"SubtitleEditBackend,SubbyBackend,Pysubs2Backend,PycaptionBackend) each have a small protocol (is_available(),can_convert(),rank(),convert()), and unshackle tries them in rank order, lowest rank first. See the Tracks API reference for the full model.When a download fails, a service misbehaves, or unshackle cannot get a content key, 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: make sure that unshackle finds the external tools it depends on, and see exactly which directories and config file are in use.- Targeted toggles:
debug_keysanddebug_requestsfor 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 give, 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/--debugflagThedebugconfig keyThe root-level flag turns on verbose logging for a single invocation:
unshackle -d dl SERVICE TITLE_ID\nIt must come before the subcommand, because it is an option on the root
unshacklecommand, not ondl.
-ddoes 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
-deach time, set it in yourunshackle.yaml:debug: true\nor with the
cfgcommand:unshackle cfg debug true\n
debug: trueis not identical to-dThe
debugconfig key enables the JSON debug log file, but it does not raise the console toDEBUGlevel and does not add source-path annotations. Those only happen when you pass the-dflag. Use the flag when you want to watch the verbose output live. Use the config key when you want only the log file written every time. Passing-dis 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. unshackle automatically redacts secrets, URLs, and local paths (see Redaction), but you should still read a log before you share 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. You can filter, examine, and analyse this file with standard tooling (
"},{"location":"guide/troubleshooting/#where-it-is-written","title":"Where it is written","text":"jq,grep, a text editor). It is safe to attach to a bug report, because unshackle redacts it.unshackle makes the debug log during a
dlrun, and duringimport, which wrapsdl. The path is:<logs directory>/unshackle_debug_<service>_<timestamp>.jsonl\nFor 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
"},{"location":"guide/troubleshooting/#what-a-log-entry-looks-like","title":"What a log entry looks like","text":"logsfolder inside the package data directory, but you can move it with thedirectories.logsconfig key. To see the exact resolved path on your machine, useunshackle env infoand read the Directories table.Every line is a self-contained JSON object. The first line of each log file 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\"}}\nQuote
unshackle_code_hashin a bug report. It fingerprints the framework source you operate, so it tells us the exact code state even when you did not install from git. A hash that matches no release means that someone edited the source locally.Subsequent lines record the operations: service calls, DRM licence requests, vault lookups, downloader progress, and errors. Common fields include:
Field MeaningtimestampUTC ISO-8601 time of the entry.session_idShort random ID shared by every entry in one run, handy for correlating lines.levelDEBUG,INFO,WARNING, orERROR.operationThe action being logged, e.g.service_call,download_init,drm_get_widevine_license,vault_get_key,session_end.messageHuman-readable description (redacted).serviceService tag the entry belongs to, when applicable.contextArbitrary structured detail for the operation.request/responseHTTP method/URL and response metadata for network operations.duration_msHow long a timed operation took.successWhether the operation succeeded.errorOn failures: the exceptiontype,message, and a fulltracebackarray (all redacted)."},{"location":"guide/troubleshooting/#reading-and-filtering-the-log","title":"Reading and filtering the log","text":"Because each line is independent JSON,
jqis 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\nunshackle logs the binary-tool versions (shaka-packager, mp4decrypt, mkvmerge, FFmpeg, FFprobe) near the start of a
"},{"location":"guide/troubleshooting/#debugging-drm-keys-with-debug_keys","title":"Debugging DRM keys withdlrun. Often that is the fastest way to make sure that a muxing or decryption failure is really a wrong-version problem.debug_keys","text":"By default, unshackle redacts the content keys in the JSON debug log. It writes any field that looks like a content key as
[REDACTED]. This keeps logs shareable. When you specifically debug a key-retrieval or decryption problem and need to see the actual KID/KEY values unshackle fetched, enabledebug_keys:debug_keys: true\nunshackle cfg debug_keys true\nWith
debug_keys: true, the debug log stops masking content key fields. You can then see which content keys came back from a licence server or vault, and compare them against what decryption expected.Keys are sensitive, turn this back off
debug_keyswrites real content keys into the log file in cleartext. Only enable it while you diagnose a content key problem. Never share a log written with it on, and set it back tofalsewhen you are done. Note thatdebug_keysonly affects the JSON debug log file. It does not change what the console prints.Fields that are always redacted regardless of
"},{"location":"guide/troubleshooting/#debugging-http-traffic-with-debug_requests","title":"Debugging HTTP traffic withdebug_keys(anything whose name containspassword,token,secret,auth, orcookie) stay masked, so credentials never leak even in a keys-on log.debug_requests","text":"When you pass
-d, unshackle raises its own modules toDEBUGbut deliberately silences the noisy low-level HTTP libraries (urllib3,requests,rnet,httpx,httpcore,hpack,h2) back down toWARNING. Without this, a single download would bury the useful output under thousands of connection-pool and HTTP/2 frame messages.Sometimes a request fails at the TLS or connection layer, and you need the underlying library's own logs. If you need that low-level traffic, enable
debug_requests:debug_requests: true\nunshackle cfg debug_requests true\nWith
debug_requests: true, unshackle leaves those libraries atDEBUGlevel when you operate it with-d, so their request/response chatter appears in the console.
debug_requestsneeds-dto have any effectThe
"},{"location":"guide/troubleshooting/#what-gets-redacted","title":"What gets redacted","text":"debug_requeststoggle only decides whether unshackle silences the HTTP libraries when the-dflag is active. If you do not operate unshackle with-d(verbose console logging), it changes nothing. Combine them:unshackle -d dl ...withdebug_requests: truein config.unshackle redacts logged strings so that debug output is safe to share. It applies three passes to every logged message, error, and structured value:
- Secrets: unshackle replaces passwords, tokens, API secrets, auth values, and cookies with
[REDACTED].- URLs: unshackle collapses every
http(s)URL toredacted. It keeps only the file extension, so a manifest still shows asredacted.mpdand a segment asredacted.m4s. This hides the CDN, manifest, segment, and licence-server locations, but keeps the type of resource.- Local paths: unshackle replaces install-root, virtualenv, and home-directory prefixes with tokens, so your username and machine layout do not appear in logs.
The
redact_pathsconfig key controls path redaction, and it is on by default:redact_paths: true # default - mask local base directories in logged paths\nSet
"},{"location":"guide/troubleshooting/#checking-your-environment","title":"Checking your environment","text":"redact_paths: falseif you debug a path problem locally and want to see full, unmasked paths in the console and logs. unshackle always applies secret and URL redaction in the JSON debug log, and this toggle does not change that.Many \"download failed\" problems are really missing external tools or a config file that is not where you think it is. The
"},{"location":"guide/troubleshooting/#env-check","title":"unshackle envcommand group answers both questions before you dig into logs.env check","text":"
unshackle env checkverifies every external binary unshackle can use and prints a table of what's installed:unshackle env check\nIt reports each tool's status (
\u2713installed /\u2717missing), whether it is required or optional, and what it is 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 decryptionOptional 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), andgit(service repositories) ordocker(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 will not process, use
"},{"location":"guide/troubleshooting/#env-info","title":"unshackle env checkfirst. A red\u2717next to the relevant tool is the answer far more often than a bug in unshackle.env info","text":"
unshackle env infoshows where unshackle reads configuration from and where every working directory lives:unshackle env info\n
- If unshackle found a config file, it prints the path it was loaded from. If it found none, it shows every location it would accept one (see Config file discovery below). You can then make the file in the right place.
- It then prints a Directories table for every configured path:
downloads,temp,cache,cookies,logs,exports,wvds,prds, theservicessearch paths, and more.This is the authoritative way to answer \"where do my logs, downloads and cookies go?\" The defaults are relative to the installed package. The table also shows any
"},{"location":"guide/troubleshooting/#env-clear","title":"directories:overrides from your config.env clear","text":"Stale cache or leftover temp files can cause confusing behaviour (an old cached title list, a half-written temp file).
env clearwipes 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\nEach 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
"},{"location":"guide/troubleshooting/#common-failure-modes-and-how-to-inspect-them","title":"Common failure modes and how to inspect them","text":"unshackle env clear cache <SERVICE>. See Vaults for the separate, persistent key-vault store, whichenv cleardoes not touch.A required tool is missing
Symptom: decryption, muxing, or analysis fails early, sometimes with a \"binary not found\" style error.
Inspect: use
unshackle env checkand look for a red\u2717on a required row. Rununshackle setupto install it, or install it by hand, and re-run.Decryption produces garbage or fails
Symptom: the download completes but the file will not play, or the decrypt step errors.
Inspect: enable
debug_keysand operate unshackle with-d. Then examine the JSON debug log'sdrm_*andvault_*operations to see which content keys unshackle fetched, and from where. Also make sure that shaka-packager (ormp4decrypt) shows a sane version in thedownload_initbinary-versions entry. See DRM & CDM.A vault served a wrong content key
Symptom: a warning names a content key and the vault it came from, either
was bad, trying other vaults(the decode check failed) oris flagged bad, skipping(an earlier run flagged it). The download stops withThe content key from the vault did not decrypt the track; run again,was bad and no other source has the key, orNo vault or CDM produced a content key that decrypts the trackwhen no source is left to try.Inspect: while it decrypts a track whose content key came from a vault, unshackle keeps the ciphertext as a
.encsibling of the track file in the temp directory, and removes it afterwards; a leftover one is from a killed run and is safe to delete. Read thebad_keystable of your local SQLite vault to see which pairs are flagged and which vault each came from. See Wrong keys from a poisoned vault.A network request fails at the connection or TLS layer
Symptom: timeouts, connection resets, or handshake errors, and unshackle's own logs do not give the full cause.
Inspect: set
debug_requests: trueand operate unshackle with-dto show the underlyingurllib3/rnet/httpxlogs. In the JSON debug log, filter forservice_calloperations to see which request failed. If you use a proxy, also read 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 thedlno-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: use
"},{"location":"guide/troubleshooting/#why-is-my-config-not-being-picked-up","title":"Why is my config not being picked up?","text":"unshackle env infoto see which config file unshackle loaded, andunshackle cfg --listto dump the effective values. It is common to edit a config file in a location unshackle does not read from. See below.unshackle loads the first
unshackle.yamlit finds, in this order:
unshackle.yamlinside the package's namespace directory.unshackle.yamlin the parent of that directory.unshackle.yamlin 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 unshackle silently ignores your edits to a later file.
unshackle env infoprints the path it loaded (or, if none exists, shows all three candidate locations so you can make the file correctly). See Configuration for editing values with thecfgcommand.Developer note: structured logging in code
Service and command code sends entries through the shared
"},{"location":"guide/troubleshooting/#logging-conventions-when-adding-entries","title":"Logging conventions when adding entries","text":"log_event(...)helper (and thetimed_operation(...)context manager for timing a block) fromunshackle.core.utilities. These are no-ops when you disable debug logging, so they are always safe to leave in place. When you add a new feature, calllog_event(\"my_feature_event\", message=\"...\", context={...}). Do not write the \"is the logger enabled?\" guard again. unshackle does the redaction and the JSON-serialisation for you, and redacts any field whose name looks like a content key, a secret, or a token. Content keys only appear when the run hasdebug_keysenabled.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. The
log_eventsignature does not enforce them, so they are easy to miss and worth stating explicitly.Log levels are a flow skeleton, not just severity
Treat
levelas structure, not decoration. Send exactly oneINFOmilestone per pipeline stage so thatjq 'select(.level==\"INFO\")'reads back the whole end-to-end flow of a run. Everything internal goes toDEBUG. Failures go toERROR. A sparseINFOis 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\nNo raw dumps
Never log a full
Tracksobject, an MPD/manifest body, or a response payload. Log only counts, ids, sizes, andsafe_display_url(url). This keeps the log low-noise and prevents leaking title 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 registryOperation names are plain lowercase strings that follow an
<area>_<event>convention:manifest_dash_parse,drm_decrypt,vault_get_key,tool_run. We write them inline at each call site on purpose. There is no central registry or enum. Do not look for one, and do not make one. The absence is a deliberate design choice, not an omission.Message reads alone; data lives in fields
Give every entry a single-sentence
messagethat stands on its own. Put all structured data (context, counts, ids,duration_ms) in fields, not in the prose. This separation lets the log be both human-scannable and machine-filterable. A message like\"Parsed DASH manifest\"withcontext={\"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,
"},{"location":"guide/vaults/","title":"Key Vaults","text":"dovi_tool, and friends), route throughrun_step/ffprobe, which auto-emit atool_runentry capturing the binary's version, return code, and duration consistently. Only fall back to callinglog_tool_runmanually for a directsubprocess.runyou genuinely cannot route throughrun_step. Auto-logging is the preferred path precisely because it captures those fields the same way every time.A key vault is a store of DRM content keys that unshackle remembers between downloads. Once unshackle has recovered a title's keys, it 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. The same pair frequently covers several resolutions or bitrates of the same title. Once unshackle has recovered a key it writes it to your vaults immediately. The moment the next track presents a KID it has already seen, the key comes from the vault instead of 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":"A vault stores every content key as a KID \u2192 KEY pair, grouped by service:
- KID (Key ID): a 16-byte identifier for a specific encrypted track, stored as 32 lowercase hex characters with no dashes.
- KEY: the 16-byte content key that decrypts that track, also 32 hex characters.
- Service: the canonical service tag (for example
EXAMPLE, notExampleService) 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 content key made entirely of zeroes (
"},{"location":"guide/vaults/#how-lookups-and-caching-work","title":"How lookups and caching work","text":"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.When unshackle needs a content key during a download it asks your vaults in the order they appear 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, unshackle skips it and continues the search with the next one.
When unshackle recovers new keys from a license, it pushes them to all configured vaults, so every vault gradually converges on the same set of keys. Two rules apply to pushing:
- unshackle skips keys that already exist in a vault (it never overwrites or deletes existing data).
- A vault marked
no_push: truereceives lookups, but unshackle never writes to it. This is useful for read-only or shared upstream vaults you do not own.unshackle pushes keys from every source, not only a local CDM. A run against a remote server that licenses with its own CDM (
"},{"location":"guide/vaults/#wrong-keys-from-a-poisoned-vault","title":"Wrong keys from a poisoned vault","text":"--remote) writes the keys the server returns, andunshackle importwrites the keys it reads from an export file.A public vault can return the right KID with a wrong content key. The decrypters accept a wrong content key and write noise. unshackle therefore trusts a content key from a vault only after FFmpeg decodes the decrypted track with strict error detection. For a fragmented MP4, unshackle reads from the encrypted file which fragments each KID encrypts, and FFmpeg decodes up to three windows of each KID: from its first, middle and last fragment on through the next fragments of that KID, for up to 4 seconds. A window never runs into a fragment of another KID or a clear fragment. A clear lead or a lead under another KID therefore cannot hide a wrong content key, and only the KID that fails is flagged. A KID that is not in this map, and every KID of any other file, gets one check of the whole file. FFmpeg decodes the first seconds and the last seconds. A failure there flags each of those KIDs. When a mapped KID fails, the KIDs that are not in the map get no verdict from that pass; the retry with the replaced key judges them. Content keys from a licence skip this check. A file this FFmpeg build cannot judge counts as a pass, never as a wrong content key: a codec it has no decoder for, or a header it cannot read. Without FFmpeg there is no check at all.
When the decode fails, unshackle:
- Flags the KID:KEY in the
bad_keystable of every local SQLite vault, with the service and the name of the vault it came from. The pair also goes out of the service table, except in a vault markedno_push, where only the flag is written. A flagged pair cannot go back into a local vault from another vault, a later run, orkv copy. A licence that returns the pair clears the flag and stores it. AnAPIvault that served the pair also gets a report;HTTPandMySQLvaults have no report route. A pair from a local vault goes to no remote vault, even when a remote vault holds the same pair.- Restores the ciphertext from a hard link it made before decryption, then asks for the content key again. Every vault lookup skips a flagged pair, so the request reaches the remaining vaults and then the CDM. A remote answer that arrived after the winning one is reused instead of queried again.
- Removes the flag when the CDM returns the same content key, because then the decode check was wrong, not the content key. A content key from another vault is checked in its turn, and flagged as well if it also fails.
The download stops with an error instead of writing noise when no ciphertext is left to retry with, or when every vault and the CDM are used up. Without a local SQLite vault nothing remembers a flag, so the same vault serves the same pair again and the download stops there.
Per-segment decryption gives up the retry. With
decrypt_segmentson, and always with MonaLisa, the plaintext replaces each segment as it arrives, so no ciphertext is left. A failed decode then flags the pair and stops the download. The next run skips the flagged pair.unshackle copies a content key from one vault to the others only after this check passes, so a wrong content key does not spread. Without FFmpeg, a content key from a vault is not copied at all. To see or clear the flags, open the SQLite file and read or delete rows in
bad_keys.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":"Declare vaults under the
unshackle.yamlkey_vaultslist in your unshackle config file. Each entry is a mapping with two required fields,type(which backend to use) andname(a label you choose), plus whatever settings that backend needs.key_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\"\nThe
typevalue is matched to a vault backend by name. The four backends that ship with unshackle areSQLite,MySQL,HTTP, andAPI, 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.
"},{"location":"guide/vaults/#vault_timeout","title":"MySQL,HTTP, andAPIvaults are loaded leniently: if one fails to load it is logged and skipped, and the download continues with the remaining vaults.vault_timeout","text":"Network-backed vaults (
unshackle.yamlHTTPandAPI) accept an optional per-vaulttimeoutin seconds. If you omit it, unshackle injects the global default:vault_timeout: 10.0 # default, in seconds\nA
timeoutset inline on an individual vault always wins over this global value. TheSQLiteandMySQLbackends do not take atimeoutfield.MySQLusesvault_timeoutas itsconnect_timeoutinstead, and falls back to the pymysql default of 10 seconds whenvault_timeoutis0. To use a different connect timeout, setconnect_timeouton the vault.unshackle pushes content keys to all vaults at the same time, so one slow or unreachable vault costs only its own timeout and does not delay the others.
"},{"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.yamlField Required Descriptionkey_vaults:\n - type: SQLite\n name: local\n path: ~/.local/share/unshackle/vaults/local.db\ntypeyes Must beSQLite.nameyes A label for this vault.pathyes Path to the database file.~is expanded. The file (and its tables) are created automatically on first use.no_pushno Iftrue, keys are read but never written. Defaults tofalse.Each service gets its own table (named after the service tag), created on demand with
kidandkey_columns. Connections use WAL journaling with a 30-second busy timeout, so multiple threads can safely use the same database file.One more table,
bad_keys, is created on demand and holds every KID:KEY that failed the decode check described in Wrong keys from a poisoned vault, with the service, the vault it came from, and the time it was flagged. It is not a service table, and every command that walks the service tables skips it.Moving the .db file silently forks your vault
Because the file is created on demand, if you relocate an existing
.dbwithout updatingpathto match, unshackle will create a fresh empty database at the old location. That leaves 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
"},{"location":"guide/vaults/#mysql-backend","title":"MySQL backend","text":"MySQLvault. 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.A remotely-accessed MySQL/MariaDB database, ideal for sharing keys across several machines. It connects via
pymysql. Prefer MariaDB over MySQL where you have the choice.The vault connects directly to the database server
A
unshackle.yamlMySQLvault (likeSQLite) 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. A database on shared hosting is therefore 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.Field Required Descriptionkey_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\ntypeyes Must beMySQL.nameyes A label for this vault.hostyes Database server hostname or IP.databaseyes Database (schema) name.usernameyes Login user.no_pushno Iftrue, keys are read but never written.Any additional fields you provide (such as
password,port, orssl) are passed straight through topymysql.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
rootaccount, 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 theunshackledatabase, and grant normal users onlySELECTandINSERT. Reserve theCREATEgrant for a single trusted user who bootstraps the service tables (seekv 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
"},{"location":"guide/vaults/#http-backend","title":"HTTP backend","text":"SHOW GRANTS. It requires at leastSELECTto load at all (a missingSELECTgrant makes the vault fail to load). Writing keys additionally needsINSERT, and creating a new service table needsCREATE. A read-only account still works fine as a lookup source. It cannot push new keys. BecauseMySQLis loaded leniently during downloads, a permission problem will not abort the run.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_modeTransport Authentication Notesquery(default)GETwith query parametersusername+passwordFull read and write.jsonPOSTwith a JSON payloadpasswordused as a token Read and write per content key; cannot bulk-enumerate keys.decrypt_labsPOSTin DecryptLabs formatapi_keysent as a header Read-only.no_pushis 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\"\nQuery mode requires
unshackle.yamlusername. Both the username and password are sent as query parameters on each request.key_vaults:\n - type: HTTP\n name: jsonvault\n host: https://vault.example.com/rpc\n api_mode: json\n password: \"your-api-token\"\nRequests are
unshackle.yamlPOSTed as{\"method\": ..., \"params\": ..., \"token\": ...}using methods likeGetKey,InsertKey, andGetServices. Bulk writes (kv copy,kv add) first tryInsertKeyswithparams: {\"keys\": {kid: key, ...}, \"service\": ..., \"title\": ...}(up to 500 content keys per call) and expect{\"inserted\": <count>}back. A server that answers HTTP 404 orUnknown JSON-RPC methodtoInsertKeysgets oneInsertKeycall per content key for the rest of the run instead. This mode cannot list all keys in bulk, so akv copyfrom a json vault relies on the remote server's own duplicate handling.key_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\"\nA read-only lookup source. The API key is sent in a
Field Required Descriptiondecrypt-labs-api-keyheader and the vault refuses all writes.typeyes Must beHTTP.nameyes A label for this vault.hostyes Base URL of the vault API.api_modenoquery(default),json, ordecrypt_labs.usernameforqueryRequired in query mode; ignored otherwise.passwordone of these Password (query mode) or token (json mode).api_keyone of these Alternative topassword; used fordecrypt_labs. If you give both,api_keywins.no_pushno Read-only whentrue(alwaystruefordecrypt_labs).timeoutno Request timeout in seconds; defaults tovault_timeout.You must supply either
"},{"location":"guide/vaults/#api-backend","title":"API backend","text":"passwordorapi_key, or the vault will fail to load.A simple RESTful HTTP vault with a fixed, well-defined protocol. Authentication is a bearer token.
unshackle.yamlField Required Descriptionkey_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\"\ntypeyes Must beAPI.nameyes A label for this vault.uriyes Base URI of the vault API (a trailing slash is trimmed).tokenyes Bearer token, sent asAuthorization: Bearer <token>.no_pushno Iftrue, keys are read but never written.timeoutno Request timeout in seconds; defaults tovault_timeout.headersno Extra HTTPheaderssent with every request, for example the Cloudflare Access service-token headersCF-Access-Client-IdandCF-Access-Client-Secret.The backend talks to endpoints under
uri(for exampleGET {uri}/{service}/{kid}to look up a single key). It interprets a numericcodefield 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
APIbackend 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 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 theHTTPbackend 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
"},{"location":"guide/vaults/#managing-vaults-with-unshackle-kv","title":"Managing vaults withAPIvault whosenamecontainsdecrypt_labswill have itstokenfilled in automatically from a globaldecrypt_labs_api_keyin your config if you leavetokenunset. This lets you keep the API key in one place.unshackle kv","text":"The
kvcommand group manages the contents of your configured vaults. Every subcommand refers to vaults by thenameyou gave them inkey_vaults; an unknown name is an error."},{"location":"guide/vaults/#kv-add-import-keys-from-a-file","title":"$ unshackle kv --help\nkv add: import keys from a file","text":"Add
KID:KEYpairs to one or more vaults for a given service. The file must contain one pair per line asHEX:HEX(32 hex characters each); lines that do not match are ignored.keys.txt$ unshackle kv add keys.txt EXAMPLE local shared\n9a04f07998404286ab92e65be0885f95:b1c2d3e4f5a6978012345678deadbeef\n0e7cc1a4e2f9411db0f24a3fbb8f8b21:00112233445566778899aabbccddeeff\nThe service argument (
"},{"location":"guide/vaults/#kv-search-find-a-content-key-by-kid","title":"EXAMPLEabove) is normalized to its canonical tag automatically. Keys that already exist in a vault are reported as skipped.kv search: find a content key by KID","text":"Search your vaults for a KID and print any matching content key.
$ unshackle kv search 9a04f07998404286ab92e65be0885f95\nThe KID must be 32 hex characters (dashes are stripped for you). By default the search scans every configured vault and every service table; narrow it with options:
Option Description-s,--serviceLimit the search to a single service tag.-v,--vaultLimit the search to one configured vault by name.Note
Remote vaults that cannot enumerate their service tables (for example an
"},{"location":"guide/vaults/#kv-copy-copy-keys-between-vaults","title":"HTTPvault injsonordecrypt_labsmode) can only be searched when you also pass--service. Without it, they are skipped.kv copy: copy keys between vaults","text":"Copy every content key from one or more source vaults into a single destination vault.
kv copynever alters existing rows; it adds only genuinely new KIDs and skips null keys.$ unshackle kv copy shared local\nThe first argument is the destination; the rest are sources. You can supply several sources at once:
Option Description$ unshackle kv copy local shared community\n-s,--serviceOnly copy the given service.-l,--local-onlyOnly copy services you have installed locally, skipping tables for unknown services.
--serviceand--local-onlyare mutually exclusive.A pair the destination vault has flagged in
"},{"location":"guide/vaults/#kv-sync-mirror-vaults-both-ways","title":"bad_keysis left behind, so a copy does not undo a flag.bad_keysis never treated as a service, so its rows are never copied into a service table.kv sync: mirror vaults both ways","text":"Make each of two or more vaults hold every content key the others have. This is effectively a two-way
copybetween each pair, so afterwards all listed vaults hold the same set of keys.$ unshackle kv sync local shared\nIt accepts the same
"},{"location":"guide/vaults/#kv-prepare-pre-create-service-tables","title":"--serviceand--local-onlyoptions ascopy, and requires at least two vaults. It runscopybetween each pair, so it skips flagged pairs and thebad_keystable in the same way.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-backedHTTPandAPIbackends) are skipped.$ unshackle kv prepare shared\nThis is mainly useful with a
"},{"location":"guide/vaults/#see-also","title":"See also","text":"MySQLvault where you want the tables created once by an account that has theCREATEgrant."},{"location":"reference/api/config/","title":"Config API","text":"
- Downloading: how keys are recovered and used during a download.
Auto-generated reference for the
"},{"location":"reference/api/config/#unshackle.core.config.Config","title":"Config","text":"Configobject that models theunshackleconfiguration file. For a human-friendly walkthrough of every config key, see the Configuration Reference."},{"location":"reference/api/config/#unshackle.core.config.Config.validate_output_templates","title":"validate_output_templates","text":"Config(**kwargs)\nvalidate_output_templates()\nValidate output template configurations and warn about potential issues.
"},{"location":"reference/api/config/#unshackle.core.config.Config.get_folder_template","title":"get_folder_template","text":"get_folder_template(kind)\nFind 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')\nGet 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 Defaulttemplate_typestrOne of \"movies\", \"series\", or \"songs\".
'movies'"},{"location":"reference/api/config/#unshackle.core.config.restore_bool_languages","title":"restore_bool_languages","text":"restore_bool_languages(data)\nPut back the language tags
"},{"location":"reference/api/config/#unshackle.core.config.resolve_decryption","title":"resolve_decryption","text":"noandyes, which YAML reads as booleans.resolve_decryption(decryption_map, default, service)\nPick the decryption backend 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)\nFind a service's top-level CDM entry (case-insensitive), with default fallback.
"},{"location":"reference/api/config/#unshackle.core.config.cdm_entry_names_device","title":"cdm_entry_names_device","text":"cdm_entry_names_device(entry, profile=None)\nWhether a per-service
cdmentry names a device when no quality is known.An entry that selects only by quality, or by a different profile, names none.
"},{"location":"reference/api/config/#unshackle.core.config.get_config_path","title":"get_config_path","text":"get_config_path()\nGet 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)\nAES-128 ClearKey (HLS) DRM system.
Give the IV where possible. If you do not give an IV, unshackle sets it to \u0000 of the same bit-size as the content key.
"},{"location":"reference/api/drm/#unshackle.core.drm.ClearKey.warn_clear","title":"warn_clear","text":"warn_clear(reason)\nWarn once per track that unshackle passed a segment through undecrypted.
"},{"location":"reference/api/drm/#unshackle.core.drm.ClearKey.decrypt","title":"decrypt","text":"decrypt(path)\nDecrypt a Track with AES-128 ClearKey (HLS) DRM.
"},{"location":"reference/api/drm/#unshackle.core.drm.ClearKey.from_m3u_key","title":"from_m3u_keyclassmethod","text":"from_m3u_key(m3u_key, session=None)\nLoad a ClearKey from an M3U(8) Playlist's EXT-X-KEY.
Parameters:
Name Type Description Defaultm3u_keyKeyA
requiredKeyobject parsed from a m3u(8) playlist using them3u8library.sessionOptional[Session]Optional HTTP 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)\nW3C EME ClearKey (org.w3.clearkey) DRM System over MPEG-CENC tracks.
Distinct from the HLS AES-128
"},{"location":"reference/api/drm/#unshackle.core.drm.ClearKeyCENC.kid","title":"kidClearKeyclass: a license server gives the keys here as a JWK Set keyed by KID, and the media is standard CENC (decrypted with shaka-packager/mp4decrypt KID:KEY pairs, same as Widevine).property","text":"kid\nGet 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:
ExceptionKID (Encryption Key ID) was not found.
"},{"location":"reference/api/drm/#unshackle.core.drm.ClearKeyCENC.Exceptions.CEKNotFound","title":"CEKNotFound","text":"Bases:
ExceptionCEK (Content Encryption Key) for KID was not found in License.
"},{"location":"reference/api/drm/#unshackle.core.drm.ClearKeyCENC.Exceptions.EmptyLicense","title":"EmptyLicense","text":"Bases:
ExceptionLicense returned no Content Encryption Keys.
"},{"location":"reference/api/drm/#unshackle.core.drm.ClearKeyCENC.to_dict","title":"to_dict","text":"to_dict()\nSerialise this DRM instance for export/import (KIDs + license URL).
unshackle stores the content keys once at the export's track level, and does not duplicate them here.
"},{"location":"reference/api/drm/#unshackle.core.drm.ClearKeyCENC.get_license_challenge","title":"get_license_challenge","text":"get_license_challenge()\nAssemble 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)\nGet Content Keys for this DRM Instance from a ClearKey license server.
The licence param is a function. unshackle gives it the W3C JSON license request as
"},{"location":"reference/api/drm/#unshackle.core.drm.ClearKeyCENC.decrypt","title":"decrypt","text":"challenge. It may return the JWK Set license as a dict, JSON str, or bytes. If it returns None and the manifest gave a Laurl, unshackle POSTs the challenge there directly instead.decrypt(path)\nDecrypt 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 unshackle has not yet downloaded the track. SubprocessError if the decryption process returned a non-zero exit code.
"},{"location":"reference/api/drm/#unshackle.core.drm.ClearKeyCENC.mp4decrypt_key_args","title":"mp4decrypt_key_args","text":"mp4decrypt_key_args()\nBuild the mp4decrypt --key arguments for every content key.
"},{"location":"reference/api/drm/#unshackle.core.drm.ClearKeyCENC.decrypt_with_mp4decrypt","title":"decrypt_with_mp4decrypt","text":"decrypt_with_mp4decrypt(path)\nDecrypt 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)\nDecrypt with shaka-packager
"},{"location":"reference/api/drm/#unshackle.core.drm.MonaLisa","title":"MonaLisa","text":"MonaLisa(ticket, device_path, tool_path=None, **kwargs)\nMonaLisa DRM System.
Unlike Widevine/PlayReady, MonaLisa does not use a challenge/response flow with a license server. Instead, the service API gives the PSSH value (ticket) directly, and a WASM module extracts the content keys locally.
Initialise MonaLisa DRM.
Parameters:
Name Type Description DefaultticketUnion[str, bytes]PSSH value from service API (base64 string or raw bytes).
requireddevice_pathPathPath to the CDM device file (.mld).
requiredtool_pathOptional[Path]Optional path to an external decryption binary.
None**kwargsAnyAdditional metadata stored in self.data.
{}Raises:
Type DescriptionTicketNotFoundIf ticket/PSSH is empty.
KeyExtractionFailedIf content key extraction fails.
"},{"location":"reference/api/drm/#unshackle.core.drm.MonaLisa.kid","title":"kidproperty","text":"kid\nGet the Key ID.
"},{"location":"reference/api/drm/#unshackle.core.drm.MonaLisa.key","title":"keyproperty","text":"key\nGet the content key as hex string.
"},{"location":"reference/api/drm/#unshackle.core.drm.MonaLisa.pssh","title":"psshproperty","text":"pssh\nGet the raw PSSH/ticket value as a string.
Returns:
Type DescriptionstrThe raw PSSH value as a base64 string.
"},{"location":"reference/api/drm/#unshackle.core.drm.MonaLisa.content_id","title":"content_idproperty","text":"content_id\nExtract 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 DescriptionOptional[str]The Content ID string if extractable, None otherwise.
"},{"location":"reference/api/drm/#unshackle.core.drm.MonaLisa.content_keys","title":"content_keysproperty","text":"content_keys\nGet content keys in the same format as Widevine/PlayReady.
Returns:
Type Descriptiondict[UUID, str]Dictionary mapping KID to the content key hex string.
"},{"location":"reference/api/drm/#unshackle.core.drm.MonaLisa.key_pair","title":"key_pairproperty","text":"key_pair\nThe formatted
"},{"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":"KID:KEYfor CLI decryptors (32hex:32hex).Bases:
ExceptionRaised when the caller gives no PSSH/ticket data.
"},{"location":"reference/api/drm/#unshackle.core.drm.MonaLisa.Exceptions.KeyExtractionFailed","title":"KeyExtractionFailed","text":"Bases:
ExceptionRaised when content key extraction from the ticket fails.
"},{"location":"reference/api/drm/#unshackle.core.drm.MonaLisa.extract_keys","title":"extract_keys","text":"extract_keys()\nExtract keys from the ticket using the MonaLisa CDM.
"},{"location":"reference/api/drm/#unshackle.core.drm.MonaLisa.from_ticket","title":"from_ticketclassmethod","text":"from_ticket(ticket, device_path, tool_path=None)\nMake a MonaLisa DRM instance from a PSSH/ticket.
Parameters:
Name Type Description DefaultticketUnion[str, bytes]PSSH value from service API.
requireddevice_pathPathPath to the CDM device file (.mld).
requiredtool_pathOptional[Path]Optional path to an external decryption binary.
NoneReturns:
Type DescriptionMonaLisaMonaLisa DRM instance with extracted keys.
"},{"location":"reference/api/drm/#unshackle.core.drm.MonaLisa.decrypt_segment","title":"decrypt_segment","text":"decrypt_segment(segment_path, output_path=None)\nDecrypt an individual segment file using the configured CLI tool via subprocess.
Parameters:
Name Type Description Defaultsegment_pathPathPath to the segment file to decrypt.
requiredoutput_pathOptional[Path]Optional path for the decrypted output segment.
None"},{"location":"reference/api/drm/#unshackle.core.drm.MonaLisa.decrypt","title":"decrypt","text":"decrypt(path, output_path=None, *, is_segment=False)\nDecrypt a media file (segment or track) using the configured CLI tool via subprocess.
Parameters:
Name Type Description DefaultpathPathPath to the target file to decrypt.
requiredoutput_pathOptional[Path]Optional path for the decrypted output file.
Noneis_segmentboolSet True if this is an individual segment rather than a full track.
False"},{"location":"reference/api/drm/#unshackle.core.drm.PlayReady","title":"PlayReady","text":"PlayReady(pssh, kid=None, pssh_b64=None, **kwargs)\nPlayReady DRM System.
"},{"location":"reference/api/drm/#unshackle.core.drm.PlayReady.extract_kids_from_pssh_b64","title":"extract_kids_from_pssh_b64","text":"extract_kids_from_pssh_b64(pssh_b64)\nExtract all KIDs from base64-encoded PSSH data.
"},{"location":"reference/api/drm/#unshackle.core.drm.PlayReady.to_dict","title":"to_dict","text":"to_dict()\nSerialise this DRM instance for export/import (PSSH + KIDs).
unshackle stores the content keys once at the export's track level, and does not duplicate them here.
"},{"location":"reference/api/drm/#unshackle.core.drm.PlayReady.header_kids","title":"header_kidsstaticmethod","text":"header_kids(header)\nReturn the KIDs one WRM header names, in header order.
"},{"location":"reference/api/drm/#unshackle.core.drm.PlayReady.absorb","title":"absorb","text":"absorb(*others)\nFold sibling PlayReady objects into this one so their headers and KIDs get licensed too.
A manifest can attach one PlayReady object per WRM header to the same track (ISM shares its Protection list across tracks; HLS emits one per EXT-X-KEY). The caller keeps this object's identity, which the decrypt path and drm_lock depend on.
"},{"location":"reference/api/drm/#unshackle.core.drm.PlayReady.distinct_headers","title":"distinct_headers","text":"distinct_headers()\nReturn the WRM headers to license, deduplicated by the KID set each one names.
Two headers naming the same KIDs (a v4.0 header beside its v4.3 twin, or one PRH per rendition for one content key) cost one challenge. A header with no readable KID is kept: it may still be the only route to a content key nothing else names.
"},{"location":"reference/api/drm/#unshackle.core.drm.PlayReady.pro_b64_for","title":"pro_b64_forstaticmethod","text":"pro_b64_for(header)\nBuild a base64 one-record PlayReady Object holding only this header.
Remote CDMs send the value given to set_pssh_b64 rather than the header passed to get_license_challenge, so a per-header challenge needs a per-header PRO.
"},{"location":"reference/api/drm/#unshackle.core.drm.PlayReady.extract_keys_from_cdm","title":"extract_keys_from_cdm","text":"extract_keys_from_cdm(cdm, session_id)\nExtract keys from CDM session with cross-library compatibility.
Parameters:
Name Type Description DefaultcdmCdmCDM instance
requiredsession_idbytesSession identifier
requiredReturns:
Type DescriptiondictDictionary mapping KID UUIDs to hex keys
"},{"location":"reference/api/drm/#unshackle.core.drm.PlayReady.get_content_keys","title":"get_content_keys","text":"get_content_keys(cdm, certificate, licence)\nLicense every distinct WRM header whose KIDs still lack a content key.
A v4.2+ header may list several KIDs and one response may stack several licences, so one challenge often covers everything. Content that ships several headers (ISM with one ProtectionHeader per track type, HLS with one PRH per playlist) gets one challenge per header that is not yet covered. Headers are sent as-is: Microsoft lets the server ignore the header, so a merged header could ask for keys it never grants.
A header that a round refused, or that yielded nothing, is not sent again by this object: every track of a title runs this method, and a repeat would only repeat the refusal.
"},{"location":"reference/api/drm/#unshackle.core.drm.PlayReady.detect_revocation","title":"detect_revocationstaticmethod","text":"detect_revocation(text)\nReturn the decoded error string if a revocation HRESULT is present, else None.
Reads the code from the raw SOAP body (0x8004C065) or from an exception message that carries it, so this method covers any service.
"},{"location":"reference/api/drm/#unshackle.core.drm.PlayReady.decrypt","title":"decrypt","text":"decrypt(path)\nDecrypt 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 unshackle has not yet downloaded the track. SubprocessError if the decryption process returned a non-zero exit code.
"},{"location":"reference/api/drm/#unshackle.core.drm.PlayReady.own_kids","title":"own_kids","text":"own_kids()\nReturn the KIDs that identify this track's own content key.
The KID given to the constructor (from the manifest or the init segment) names the track exactly. A PSSH can name the KIDs of other renditions too.
"},{"location":"reference/api/drm/#unshackle.core.drm.PlayReady.mp4decrypt_key_args","title":"mp4decrypt_key_args","text":"mp4decrypt_key_args()\nBuild the mp4decrypt --key arguments for every content key and the zero-KID fallback.
"},{"location":"reference/api/drm/#unshackle.core.drm.PlayReady.decrypt_with_mp4decrypt","title":"decrypt_with_mp4decrypt","text":"decrypt_with_mp4decrypt(path)\nDecrypt using mp4decrypt
"},{"location":"reference/api/drm/#unshackle.core.drm.PlayReady.decrypt_with_shaka_packager","title":"decrypt_with_shaka_packager","text":"decrypt_with_shaka_packager(path)\nDecrypt with shaka-packager (original method)
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine","title":"Widevine","text":"Widevine(pssh, kid=None, **kwargs)\nWidevine DRM System.
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine.pssh","title":"psshproperty","text":"pssh\nGet Protection System Specific Header Box.
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine.kid","title":"kidproperty","text":"kid\nGet first Key ID, if any.
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine.kids","title":"kidsproperty","text":"kids\nGet 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:
ExceptionPSSH (Protection System Specific Header) was not found.
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine.Exceptions.KIDNotFound","title":"KIDNotFound","text":"Bases:
ExceptionKID (Encryption Key ID) was not found.
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine.Exceptions.CEKNotFound","title":"CEKNotFound","text":"Bases:
ExceptionCEK (Content Encryption Key) for KID was not found in License.
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine.Exceptions.EmptyLicense","title":"EmptyLicense","text":"Bases:
ExceptionLicense returned no Content Encryption Keys.
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine.from_track","title":"from_trackclassmethod","text":"from_track(track, session=None)\nGet 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.
Make a Widevine DRM System object from a track's information. Use this method only when a PSSH cannot be given directly. It is rare to need to use this.
You can give your own
requests.Sessionto use custom headers and more.Raises:
Type DescriptionPSSHNotFoundIf the PSSH was not found within the data.
KIDNotFoundIf the KID was not found within the data or PSSH.
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine.from_init_data","title":"from_init_dataclassmethod","text":"from_init_data(init_data)\nGet PSSH and KID from within Initialization Segment Data.
Use this method only when a PSSH cannot be given directly. It is rare to need to use this.
Raises:
Type DescriptionPSSHNotFoundIf the PSSH was not found within the data.
KIDNotFoundIf 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()\nSerialise this DRM instance for export/import (PSSH + KIDs).
unshackle stores the content keys once at the export's track level, and does not duplicate them here.
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine.get_content_keys","title":"get_content_keys","text":"get_content_keys(cdm, certificate, licence)\nMake a CDM Session and get Content Keys for this DRM Instance. The certificate and license params are functions. unshackle gives them the challenge and the session ID.
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine.decrypt","title":"decrypt","text":"decrypt(path)\nDecrypt 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 unshackle has not yet downloaded the track. SubprocessError if the decryption process returned a non-zero exit code.
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine.own_kids","title":"own_kids","text":"own_kids()\nReturn the KIDs that identify this track's own content key.
The KID given to the constructor (from the manifest or the init segment) names the track exactly. A PSSH can name the KIDs of other renditions too.
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine.mp4decrypt_key_args","title":"mp4decrypt_key_args","text":"mp4decrypt_key_args()\nBuild the mp4decrypt --key arguments for every content key and the zero-KID fallback.
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine.decrypt_with_mp4decrypt","title":"decrypt_with_mp4decrypt","text":"decrypt_with_mp4decrypt(path)\nDecrypt using mp4decrypt
"},{"location":"reference/api/drm/#unshackle.core.drm.Widevine.decrypt_with_shaka_packager","title":"decrypt_with_shaka_packager","text":"decrypt_with_shaka_packager(path)\nDecrypt with shaka-packager (original method)
"},{"location":"reference/api/drm/#unshackle.core.drm.drm_from_dict","title":"drm_from_dict","text":"drm_from_dict(data)\nReconstruct 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/drm/#unshackle.core.drm.real_kids","title":"real_kids","text":"real_kids(drm)\nReturn the KIDs a DRM object names, without the all-zero and test-pattern placeholders.
"},{"location":"reference/api/drm/#unshackle.core.drm.own_kids","title":"own_kids","text":"own_kids(drm)\nReturn the real KIDs that identify the track's own content key, as the DRM object knows them.
"},{"location":"reference/api/service/","title":"Service API","text":"Auto-generated reference for the
"},{"location":"reference/api/service/#unshackle.core.service.TimeoutSession","title":"TimeoutSession","text":"Servicebase class that every service plugin subclasses. See Creating a Service for a guided walkthrough and Service Architecture for the wider object model.Bases:
Sessionrequests.Session applying DEFAULT_TIMEOUT when the caller passes none.
requests has no native default timeout. Without one, a stalled connect or read hangs forever. RnetSession bounds every request through its client's connect_timeout/read_timeout, so this mirrors that on the requests path. A per-request non-None
"},{"location":"reference/api/service/#unshackle.core.service.TimeoutHTTPAdapter","title":"TimeoutHTTPAdapter","text":"timeout=wins. :class:TimeoutHTTPAdapteron the mounted adapters still replaces an explicittimeout=Nonewith the default, so there is no unbounded read, as with RnetSession where the client-level timeouts always apply. Pass a large timeout instead.TimeoutHTTPAdapter(\n *args, timeout=DEFAULT_TIMEOUT, **kwargs\n)\nBases:
HTTPAdapterHTTPAdapter applying DEFAULT_TIMEOUT when the caller passes none.
Backstops :class:
Source code inTimeoutSessionfor thesession.send(prepared)path, which bypassesSession.request. RnetSession bounds those too through its client, so this keeps parity. A per-request non-Nonetimeout=wins.None(unset, or explicitly passed) gets the default, because the adapter cannot distinguish the two, and rnet has no unbounded mode either.unshackle/core/service.py"},{"location":"reference/api/service/#unshackle.core.service.TrackRequest","title":"TrackRequestdef __init__(self, *args: Any, timeout: Any = DEFAULT_TIMEOUT, **kwargs: Any) -> None:\n self.default_timeout = timeout\n super().__init__(*args, **kwargs)\ndataclass","text":"TrackRequest(\n codecs=list(),\n ranges=(lambda: [SDR])(),\n best_available=False,\n)\nHolds 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 Descriptioncodecslist[Codec]Requested codecs from CLI. Empty list means no filter (accept any).
rangeslist[Range]Requested ranges from CLI. Defaults to [SDR].
"},{"location":"reference/api/service/#unshackle.core.service.Service","title":"Service","text":"Service(ctx)\nThe Service Base Class.
A Service must define the abstract methods. The rest are optional overrides that fall back to the base implementation when a Service does not define them. The main flow operates the HTTP session and authentication methods first, then titles, then tracks and chapters. The license callbacks operate later still, during track download.
Source code inunshackle/core/service.py"},{"location":"reference/api/service/#unshackle.core.service.Service.is_region_allowed","title":"is_region_alloweddef __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 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 whose = \"the server's\" if ctx.parent and ctx.parent.params.get(\"served\") else \"your\"\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 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 if proxy_provider_name:\n full_proxy_key = f\"{proxy_provider_name}:{proxy_query}\"\n else:\n full_proxy_key = proxy_query\n\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 if proxy_provider_name:\n # Specific provider requested\n try:\n proxy_provider = find_provider(ctx.obj.proxy_providers, proxy_provider_name)\n except ValueError:\n proxy_provider = None\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__}: \"\n f\"{sanitize_proxy_for_log(proxy, mask_host=isinstance(proxy_provider, Basic))}\"\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 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__}: \"\n f\"{sanitize_proxy_for_log(proxy, mask_host=isinstance(proxy_provider, Basic))}\"\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 or self.GEOBLOCK:\n try:\n current_region = (get_ip_info(self.session) or {}).get(\"country\", \"\").lower()\n if not current_region:\n self.log.warning(f\"Could not find {whose} region, so the region check does not run\")\n elif self.is_region_allowed(current_region):\n self.log.info(\"Service is not Geoblocked in your region\")\n elif not (fence_targets := [x for x in self.GEOFENCE if self.is_region_allowed(x)]):\n raise click.ClickException(\n f\"Service is not available in {whose} region ({current_region.upper()}). \"\n \"Pass --proxy with a proxy outside the blocked regions.\"\n )\n else:\n requested_proxy = fence_targets[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 if not proxy:\n self.log.warning(\n f\"No proxy available for {requested_proxy}. \"\n f\"Pass --proxy with a proxy in {requested_proxy}, or the request can fail.\"\n )\n except click.ClickException:\n raise\n except Exception as e:\n self.log.warning(f\"Failed to check geofence: {e}\")\n else:\n self.log.info(\"Service has no region restrictions\")\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 # Verify the proxy IP every time, because a proxy can change its exit node. A dead\n # proxy fails here, not after every service request has used up its retries.\n exit_region = verify_proxy_exit(self.session).get(\"country\")\n self.current_region = exit_region\n # Only GEOBLOCK applies here: an explicit --proxy outside GEOFENCE stays the user's call.\n if exit_region and any(x.lower() == exit_region.lower() for x in self.GEOBLOCK):\n raise click.ClickException(\n f\"Service is not available in {whose} proxy's region ({exit_region.upper()}). \"\n \"Pass --proxy with a proxy outside the blocked regions.\"\n )\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\nclassmethod","text":"is_region_allowed(region)\nTrue when an exit IP in
region(two-letter region code, any case) can use the service.An unknown region (None or empty) is allowed, because there is nothing to compare.
Source code inunshackle/core/service.py"},{"location":"reference/api/service/#unshackle.core.service.Service.get_tracks_for_variants","title":"get_tracks_for_variants","text":"@classmethod\ndef is_region_allowed(cls, region: Optional[str]) -> bool:\n \"\"\"True when an exit IP in `region` (two-letter region code, any case) can use the service.\n\n An unknown region (None or empty) is allowed, because there is nothing to compare.\n \"\"\"\n if not region:\n return True\n region = region.lower()\n if any(x.lower() == region for x in cls.GEOBLOCK):\n return False\n return not cls.GEOFENCE or any(x.lower() == region for x in cls.GEOFENCE)\nget_tracks_for_variants(title, fetch_fn)\nCall fetch_fn for each codec/range combo in track_request, merge results.
Services that need separate API calls per codec/range combo can use this helper from their get_tracks() implementation.
The fetch_fn signature should be: (title, codec, range_) -> Tracks
For HYBRID range, this helper calls fetch_fn with HDR10 and DV separately, then merges the DV video tracks into the HDR10 result.
Parameters:
Name Type Description DefaulttitleTitle_TThe title to process.
requiredfetch_fnCallable[..., Tracks]A callable that fetches tracks for a specific codec/range.
required Source code inunshackle/core/service.py"},{"location":"reference/api/service/#unshackle.core.service.Service.get_session","title":"get_sessiondef get_tracks_for_variants(\n self,\n title: Title_T,\n fetch_fn: Callable[..., Tracks],\n) -> Tracks:\n \"\"\"Call fetch_fn for each codec/range combo in track_request, merge results.\n\n Services that need separate API calls per codec/range combo can use this\n helper from their get_tracks() implementation.\n\n The fetch_fn signature should be: (title, codec, range_) -> Tracks\n\n For HYBRID range, this helper calls fetch_fn with HDR10 and DV separately,\n then merges the DV video tracks into the HDR10 result.\n\n Args:\n title: The title to process.\n fetch_fn: A callable that fetches tracks for a specific codec/range.\n \"\"\"\n all_tracks = Tracks()\n first = True\n\n codecs = self.track_request.codecs or [None]\n ranges = self.track_request.ranges or [Video.Range.SDR]\n\n for range_val in ranges:\n if range_val == Video.Range.HYBRID:\n # HYBRID: fetch HDR10 first (full tracks), then DV (video only)\n for codec_val in codecs:\n try:\n hdr_tracks = fetch_fn(title, codec=codec_val, range_=Video.Range.HDR10)\n except (ValueError, SystemExit) as e:\n if self.track_request.best_available:\n self.log.warning(f\" - HDR10 not available for HYBRID, skipping ({e})\")\n continue\n raise\n if first:\n all_tracks.add(hdr_tracks, warn_only=True)\n if hdr_tracks.manifest_url and not all_tracks.manifest_url:\n all_tracks.manifest_url = hdr_tracks.manifest_url\n first = False\n else:\n for video in hdr_tracks.videos:\n all_tracks.add(video, warn_only=True)\n\n try:\n dv_tracks = fetch_fn(title, codec=codec_val, range_=Video.Range.DV)\n for video in dv_tracks.videos:\n all_tracks.add(video, warn_only=True)\n except (ValueError, SystemExit):\n self.log.info(\" - No DolbyVision manifest available for HYBRID\")\n else:\n for codec_val in codecs:\n try:\n tracks = fetch_fn(title, codec=codec_val, range_=range_val)\n except (ValueError, SystemExit) as e:\n if self.track_request.best_available:\n codec_name = codec_val.name if codec_val else \"default\"\n self.log.warning(f\" - {range_val.name}/{codec_name} not available, skipping ({e})\")\n continue\n raise\n if first:\n all_tracks.add(tracks, warn_only=True)\n if tracks.manifest_url and not all_tracks.manifest_url:\n all_tracks.manifest_url = tracks.manifest_url\n first = False\n else:\n for video in tracks.videos:\n all_tracks.add(video, warn_only=True)\n\n return all_tracks\nstaticmethod","text":"get_session()\nCreates a Python-requests HTTP session, adds common headers from config, cookies, retry handler, and a proxy if available. :returns: Prepared Python-requests HTTP session
Source code inunshackle/core/service.py"},{"location":"reference/api/service/#unshackle.core.service.Service.get_binaries","title":"get_binaries@staticmethod\ndef get_session() -> requests.Session:\n \"\"\"\n Creates a Python-requests HTTP session, adds common headers\n from config, cookies, retry handler, and a proxy if available.\n :returns: Prepared Python-requests HTTP session\n \"\"\"\n session = TimeoutSession()\n session.headers.update(config.headers)\n # Retry / pool policy mirrors RnetSession via the shared constants in session.py:\n # same total, forcelist, backoff cap, allowed_methods (incl. POST, so license\n # requests retry on both paths), and pool size. Accepted divergence: urllib3's\n # backoff_jitter adds an absolute 0..N seconds whereas rnet jitters \u00b110% of the\n # backoff, the only intentional difference, not worth hand-rolling a retry loop.\n session.mount(\n \"https://\",\n TimeoutHTTPAdapter(\n max_retries=Retry(\n total=MAX_RETRIES,\n backoff_factor=BACKOFF_FACTOR,\n backoff_max=MAX_BACKOFF,\n backoff_jitter=0.2,\n status_forcelist=STATUS_FORCELIST,\n allowed_methods=RETRY_METHODS,\n respect_retry_after_header=True,\n ),\n pool_connections=POOL_MAX_SIZE,\n pool_maxsize=POOL_MAX_SIZE,\n pool_block=True,\n ),\n )\n session.mount(\"http://\", session.adapters[\"https://\"])\n return session\nstaticmethod","text":"get_binaries()\nDeclare custom binary dependencies required by this service. :returns: List of dicts, each with a
Source code innameand optionalcandidatesanddesc.unshackle/core/service.py"},{"location":"reference/api/service/#unshackle.core.service.Service.authenticate","title":"authenticate","text":"@staticmethod\ndef get_binaries() -> list[dict]:\n \"\"\"\n Declare custom binary dependencies required by this service.\n :returns: List of dicts, each with a ``name`` and optional ``candidates`` and ``desc``.\n \"\"\"\n return []\nauthenticate(cookies=None, credential=None)\nAuthenticate 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. unshackle operates this method 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.
Do not store the cookies outside this function either. However, you can load the cookies into the service HTTP session.
Source code inunshackle/core/service.py"},{"location":"reference/api/service/#unshackle.core.service.Service.request_input","title":"request_input","text":"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. unshackle operates this method before\n any of the 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 Do not store the cookies outside this function either. However, you can load\n the cookies into the service HTTP 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\nrequest_input(prompt)\nRequest interactive input from the user.
Locally, prompts through the shared rich console so the prompt renders alongside Live progress. A remote-dl session relays it through its :class:
Source code inInputBridge, and a REST download job relays it throughprompt_userto the job'sinput_prompt.unshackle/core/service.py"},{"location":"reference/api/service/#unshackle.core.service.Service.search","title":"search","text":"def request_input(self, prompt: str) -> str:\n \"\"\"Request interactive input from the user.\n\n Locally, prompts through the shared rich console so the prompt renders alongside\n Live progress. A remote-dl session relays it through its :class:`InputBridge`, and a\n REST download job relays it through ``prompt_user`` to the job's ``input_prompt``.\n \"\"\"\n if self._input_bridge is not None:\n return self._input_bridge.request_input(prompt)\n return prompt_user(prompt)\nsearch()\nFind titles from the Service by query.
The Service class must take the query as a CLI argument. Ideally re-use the title ID argument (that is, self.title).
unshackle displays the search results in the order yielded.
Source code inunshackle/core/service.py"},{"location":"reference/api/service/#unshackle.core.service.Service.get_widevine_service_certificate","title":"get_widevine_service_certificate","text":"def search(self) -> Generator[SearchResult, None, None]:\n \"\"\"\n Find titles from the Service by query.\n\n The Service class must take the query as a CLI argument.\n Ideally re-use the title ID argument (that is, self.title).\n\n unshackle displays the search results in the order yielded.\n \"\"\"\n raise NotImplementedError(f\"Search functionality has not been implemented by {self.__class__.__name__}\")\nget_widevine_service_certificate(\n *, challenge, title, track\n)\nGet 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
Source code inTitlefrom get_titles that unshackle processes now. unshackle gives this in case it holds data you need, for example for an HTTP request. :param track: The currentTrackneeding decryption. Provided for same reason astitle. :return: The Service Privacy Certificate as Bytes or a Base64 string. Do not Base64 Encode or Decode the data, return as is to reduce unnecessary computations.unshackle/core/service.py"},{"location":"reference/api/service/#unshackle.core.service.Service.get_widevine_license","title":"get_widevine_license","text":"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 unshackle processes now. unshackle\n gives this in case it holds data you need, for example for an 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. Do not Base64 Encode or\n Decode the data, return as is to reduce unnecessary computations.\n \"\"\"\nget_widevine_license(*, challenge, title, track)\nGet a Widevine License message by sending a License Request (challenge).
This License message contains the encrypted content 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 the service to detect your CDM device. The service can then ban, revoke, disable, or downgrade that device.
:param challenge: The license challenge from the Widevine CDM. :param title: The current
Source code inTitlefrom get_titles that unshackle processes now. unshackle gives this in case it holds data you need, for example for an HTTP request. :param track: The currentTrackneeding decryption. Provided for same reason astitle. :return: The License response as Bytes or a Base64 string. Do not Base64 Encode or Decode the data, return as is to reduce unnecessary computations.unshackle/core/service.py"},{"location":"reference/api/service/#unshackle.core.service.Service.get_playready_license","title":"get_playready_license","text":"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 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 the service to detect your CDM device.\n The service can then ban, revoke, disable, or downgrade that device.\n\n :param challenge: The license challenge from the Widevine CDM.\n :param title: The current `Title` from get_titles that unshackle processes now. unshackle\n gives this in case it holds data you need, for example for an 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. Do not Base64 Encode or\n Decode the data, return as is to reduce unnecessary computations.\n \"\"\"\nget_playready_license(*, challenge, title, track)\nGet a PlayReady License message by sending a License Request (challenge).
This License message contains the encrypted content 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 the service to detect your CDM device. The service can then ban, revoke, disable, or downgrade that device.
:param challenge: The license challenge from the PlayReady CDM. :param title: The current
Source code inTitlefrom get_titles that unshackle processes now. unshackle gives this in case it holds data you need, for example for an HTTP request. :param track: The currentTrackneeding decryption. Provided for same reason astitle. :return: The License response as Bytes or a Base64 string. Do not Base64 Encode or Decode the data, return as is to reduce unnecessary computations.unshackle/core/service.py"},{"location":"reference/api/service/#unshackle.core.service.Service.get_clearkey_license","title":"get_clearkey_license","text":"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 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 the service to detect your CDM device.\n The service can then ban, revoke, disable, or downgrade that device.\n\n :param challenge: The license challenge from the PlayReady CDM.\n :param title: The current `Title` from get_titles that unshackle processes now. unshackle\n gives this in case it holds data you need, for example for an 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. Do not Base64 Encode or\n Decode the data, return as is to reduce unnecessary computations.\n \"\"\"\n # A service that implements Widevine licensing only still gets PlayReady, with the\n # arguments its own signature declares.\n return self.get_widevine_license(\n **declared_kwargs(self.get_widevine_license, {\"challenge\": challenge, \"title\": title, \"track\": track})\n )\nget_clearkey_license(*, challenge, title, track)\nGet a W3C ClearKey License (JWK Set) by sending a License Request (challenge).
Used for DASH
org.w3.clearkeytracks. unshackle uses no CDM here: 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
Source code inTitlefrom get_titles that unshackle processes now. unshackle gives this in case it holds data you need, for example for an HTTP request. :param track: The currentTrackneeding decryption. Provided for same reason astitle. :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'scontent_keysin get_tracks.unshackle/core/service.py"},{"location":"reference/api/service/#unshackle.core.service.Service.get_titles","title":"get_titlesdef 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` tracks. unshackle uses no CDM here: 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 unshackle processes now. unshackle\n gives this in case it holds data you need, for example for an 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\nabstractmethod","text":"get_titles()\nGet 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.
You must return at least one object. If you do not, unshackle presumes an invalid Title ID.
You can use the
Source code indatadictionary 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 you need later, like any sub-asset IDs, or such.unshackle/core/service.py"},{"location":"reference/api/service/#unshackle.core.service.Service.get_titles_cached","title":"get_titles_cached","text":"@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 You must return at least one object. If you do not, unshackle presumes an invalid\n Title ID.\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 you need later, like any sub-asset IDs, or such.\n \"\"\"\nget_titles_cached(title_id=None)\nCached 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 Defaulttitle_idstrOptional title ID for cache key generation. If not provided, will try to extract from service instance.
NoneReturns:
Type DescriptionTitles_TTitles object (Movies, Series, or Album)
Source code inunshackle/core/service.py"},{"location":"reference/api/service/#unshackle.core.service.Service.apply_title_map","title":"apply_title_map","text":"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 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 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 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)\napply_title_map(titles)\nRewrite service-provided titles using the per-service
title_mapconfig.Source code in
title_maplives underservices.<TAG>in unshackle.yaml. Applied after the title cache so config edits take effect without a cache reset, and before any--enrichoverride so enrich wins. Seeremap_titlesfor the match rules.unshackle/core/service.py"},{"location":"reference/api/service/#unshackle.core.service.Service.get_tracks","title":"get_tracksdef 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 {})\nabstractmethod","text":"get_tracks(title)\nGet 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 track, Representation, or Adaptation, or a Subtitle file.
While one Track should only hold information for one downloadable track, try to get as many unique Track objects per track type so track selection by the root code can give you more options in terms of Resolution, Bitrate, Codecs, Language, and such.
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 have encryption, and which DRM System they use, with the
drmproperty.If you can get the Track's KID (Key ID) as a 32 char (16 bit) HEX string, give it to the Track's
kidvariable, as it will speed up the decryption process later on. The service decides whether it is necessary. Generally if you can give it, without downloading any of the Track's media data, then do.:param title: The current
Source code inTitlefrom get_titles that unshackle processes now. :return: Tracks object containing Video, Audio, Subtitles, and Chapters, if available.unshackle/core/service.py"},{"location":"reference/api/service/#unshackle.core.service.Service.get_chapters","title":"get_chapters@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 track, Representation, or\n Adaptation, or a Subtitle file.\n\n While one Track should only hold information for one downloadable track, try to get as many\n unique Track objects per track type so track selection by the root code can give you more\n options in terms of Resolution, Bitrate, Codecs, Language, and such.\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 have encryption, and which DRM System they\n use, with the `drm` property.\n\n If you can get the Track's KID (Key ID) as a 32 char (16 bit) HEX string, give it to the\n Track's `kid` variable, as it will speed up the decryption process later on. The service\n decides whether it is necessary. Generally if you can give it, without downloading any of\n the Track's media data, then do.\n\n :param title: The current `Title` from get_titles that unshackle processes now.\n :return: Tracks object containing Video, Audio, Subtitles, and Chapters, if available.\n \"\"\"\nabstractmethod","text":"get_chapters(title)\nGet Chapters for the Title.
Parameters:
Name Type Description DefaulttitleTitle_TThe current Title from
requiredget_titlesthat unshackle processes now.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 is 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
Source code inchapter_fallback_name. For example,\"Chapter {i:02}\"for \"Chapter 01\".unshackle/core/service.py"},{"location":"reference/api/service/#unshackle.core.service.Service.on_segment_downloaded","title":"on_segment_downloaded","text":"@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 unshackle processes now.\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 is 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 \"\"\"\non_segment_downloaded(track, segment)\nCalled when one of a Track's Segments has finished downloading.
Parameters:
Name Type Description DefaulttrackAnyTrackThe Track object that had a Segment downloaded.
requiredsegmentPathThe Path to the downloaded Segment.
required Source code inunshackle/core/service.py"},{"location":"reference/api/service/#unshackle.core.service.Service.on_track_downloaded","title":"on_track_downloaded","text":"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 downloaded Segment.\n \"\"\"\non_track_downloaded(track)\nCalled when a Track has finished downloading.
Parameters:
Name Type Description DefaulttrackAnyTrackThe downloaded Track object.
required Source code inunshackle/core/service.py"},{"location":"reference/api/service/#unshackle.core.service.Service.on_track_decrypted","title":"on_track_decrypted","text":"def on_track_downloaded(self, track: AnyTrack) -> None:\n \"\"\"\n Called when a Track has finished downloading.\n\n Parameters:\n track: The downloaded Track object.\n \"\"\"\non_track_decrypted(track, drm, segment=None)\nCalled when a Track has finished decrypting.
Parameters:
Name Type Description DefaulttrackAnyTrackThe decrypted Track object.
requireddrmDRM_TThe DRM object it decrypted with.
requiredsegmentOptional[Segment]The decrypted HLS segment information. None for DASH and ISM tracks and for an HLS track that merged during the download (merge_segments).
NoneSource code inunshackle/core/service.py"},{"location":"reference/api/service/#unshackle.core.service.Service.on_track_repacked","title":"on_track_repacked","text":"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 decrypted Track object.\n drm: The DRM object it decrypted with.\n segment: The decrypted HLS segment information. None for DASH and ISM tracks and\n for an HLS track that merged during the download (merge_segments).\n \"\"\"\non_track_repacked(track)\nCalled when a Track has finished repacking.
Parameters:
Name Type Description DefaulttrackAnyTrackThe repacked Track object.
required Source code inunshackle/core/service.py"},{"location":"reference/api/service/#unshackle.core.service.Service.on_track_multiplex","title":"on_track_multiplex","text":"def on_track_repacked(self, track: AnyTrack) -> None:\n \"\"\"\n Called when a Track has finished repacking.\n\n Parameters:\n track: The repacked Track object.\n \"\"\"\non_track_multiplex(track)\nCalled immediately before unshackle multiplexes a Track into a Container.
Note: Right now unshackle multiplexes only MKV containers. In the future unshackle can also call this when it multiplexes to other containers like MP4 with FFmpeg/mp4box.
Parameters:
Name Type Description DefaulttrackAnyTrackThe repacked Track object.
required Source code inunshackle/core/service.py"},{"location":"reference/api/service/#unshackle.core.service.grow_session_pool","title":"grow_session_pool","text":"def on_track_multiplex(self, track: AnyTrack) -> None:\n \"\"\"\n Called immediately before unshackle multiplexes a Track into a Container.\n\n Note: Right now unshackle multiplexes only MKV containers. In the future\n unshackle can also call this when it multiplexes to other containers like\n MP4 with FFmpeg/mp4box.\n\n Parameters:\n track: The repacked Track object.\n \"\"\"\ngrow_session_pool(session, size)\nGrow a shared requests session's connection pool to
sizebefore track downloads start.The worker threads of every track draw on this one pool, because the downloader never remounts an HTTP session the caller passes in (see downloaders/requests.py). The pool must hold
downloads * workersconnections, or threads queue for a slot instead of reading. Call this before any download thread exists: the rebuild races with other threads that callget_adapter. RnetSession does not block on its idle-pool cap, so this function skips it.Every mounted adapter grows in place, through its own
Source code ininit_poolmanager. A service can mount an :class:HTTPAdaptersubclass, such asSSLCiphers, on any prefix. Mounting a new adapter over it would drop that subclass state, and its TLS context with it.unshackle/core/service.py"},{"location":"reference/api/service/#unshackle.core.service.sanitize_proxy_for_log","title":"sanitize_proxy_for_log","text":"def grow_session_pool(session: Any, size: int) -> None:\n \"\"\"Grow a shared requests session's connection pool to ``size`` before track downloads start.\n\n The worker threads of every track draw on this one pool, because the downloader never\n remounts an HTTP session the caller passes in (see downloaders/requests.py). The pool must hold\n ``downloads * workers`` connections, or threads queue for a slot instead of reading.\n Call this before any download thread exists: the rebuild races with other threads that call\n ``get_adapter``. RnetSession does not block on its idle-pool cap, so this function skips it.\n\n Every mounted adapter grows in place, through its own ``init_poolmanager``. A service can\n mount an :class:`HTTPAdapter` subclass, such as ``SSLCiphers``, on any prefix. Mounting a new\n adapter over it would drop that subclass state, and its TLS context with it.\n \"\"\"\n if not isinstance(session, requests.Session):\n return\n for adapter in {id(a): a for a in session.adapters.values()}.values():\n if not isinstance(adapter, HTTPAdapter) or getattr(adapter, \"_pool_maxsize\", 0) >= size:\n continue\n adapter.init_poolmanager(size, size, block=True)\n adapter.proxy_manager.clear()\nsanitize_proxy_for_log(uri, mask_host=False)\nSanitise a proxy URI for logs by masking any embedded userinfo (username/password).
Source code in
servesends these log lines to the client of a remote session, so the mask is unconditional and debug mode never lifts it.mask_hosthides the hostname as well, for a proxy that came from the user-suppliedBasicproxy provider.unshackle/core/service.py"},{"location":"reference/api/titles/","title":"Titles API","text":"def sanitize_proxy_for_log(uri: Optional[str], mask_host: bool = False) -> Optional[str]:\n \"\"\"\n Sanitise a proxy URI for logs by masking any embedded userinfo (username/password).\n\n ``serve`` sends these log lines to the client of a remote session, so the mask is\n unconditional and debug mode never lifts it. ``mask_host`` hides the hostname as\n well, for a proxy that came from the user-supplied ``Basic`` proxy provider.\n \"\"\"\n if uri is None:\n return None\n if not isinstance(uri, str):\n return str(uri)\n return mask_proxy(uri, mask_host=mask_host, allow_debug=False)\nAuto-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)\nBases:
"},{"location":"reference/api/titles/#unshackle.core.titles.Episode.matches_wanted","title":"matches_wanted","text":"Titlematches_wanted(wanted)\nWhether a parsed
-wkey set selects this episode.A part-ful episode answers to both its base key and its part key, so
"},{"location":"reference/api/titles/#unshackle.core.titles.Episode.part_suffix","title":"part_suffix","text":"-w s1e1takes every part. A dated episode also answers to its ISO air date.!keys are part-qualified exclusions resolved here.part_suffix()\n"},{"location":"reference/api/titles/#unshackle.core.titles.Episode.build_template_context","title":"build_template_context","text":"
.Part.2/Part 2in the series template's own separator style.build_template_context(\n media_info, show_service=True, include_part=True\n)\nBuild template context dictionary from MediaInfo.
"},{"location":"reference/api/titles/#unshackle.core.titles.Episode.air_date_display","title":"air_date_display","text":"air_date_display()\nRender air_date using the series template's own separator (dots or spaces).
"},{"location":"reference/api/titles/#unshackle.core.titles.Episode.folder_season","title":"folder_season","text":"folder_season()\nSeason folder label: air year when the air date parsed to a date, else SxxExx-style season.
"},{"location":"reference/api/titles/#unshackle.core.titles.Episode.part_label","title":"part_label","text":"part_label()\n"},{"location":"reference/api/titles/#unshackle.core.titles.Movie","title":"Movie","text":"
.2selection-syntax suffix for console output, empty when part-less.Movie(\n id_,\n service,\n name,\n year=None,\n language=None,\n data=None,\n description=None,\n)\nBases:
"},{"location":"reference/api/titles/#unshackle.core.titles.Movie.build_template_context","title":"build_template_context","text":"Titlebuild_template_context(media_info, show_service=True)\nBuild template context dictionary from MediaInfo.
"},{"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)\nBases:
MusicBackward-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)\nBases:
SortedKeyList,ABCA 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=1,\n year=None,\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)\nBases:
"},{"location":"reference/api/titles/#unshackle.core.titles.Song.matches_wanted","title":"matches_wanted","text":"Titlematches_wanted(wanted)\nWhether a parsed
-wkey set selects this song.A song shares the episode key space, its disc reading as the season and its track as the episode, so
"},{"location":"reference/api/titles/#unshackle.core.titles.Song.build_template_context","title":"build_template_context","text":"-w 1-5takes tracks 1 to 5 and-w 2x3takes disc 2 track 3. A song has no parts and no air date, so it answers to its disc-and-track key only.build_template_context(media_info, show_service=True)\nBuild template context dictionary from MediaInfo.
"},{"location":"reference/api/titles/#unshackle.core.titles.remap_titles","title":"remap_titles","text":"remap_titles(titles, title_map)\nRewrite 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.
"},{"location":"reference/api/tracks/","title":"Tracks API","text":"title_mapmaps a source title string to the desired output title. Episodes are matched on theirtitle(the show name), Movies and Songs on theirname. Returns the same collection for convenient chaining.Auto-generated reference for the track model:
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Attachment","title":"Attachment","text":"Tracks,Track, and the media-specific subclasses. See Service Architecture for how these fit together.Attachment(\n path=None,\n url=None,\n name=None,\n mime_type=None,\n description=None,\n session=None,\n)\nMake a new Attachment.
If providing a path, the file must already exist. If providing a URL, download() fetches the file during the download phase. You must give either path or url.
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 DefaultpathUnion[Path, str, None]Path to an existing file.
NoneurlOptional[str]URL to download the attachment from.
NonenameOptional[str]Name of the attachment.
Nonemime_typeOptional[str]MIME type of the attachment.
NonedescriptionOptional[str]Description of the attachment.
NonesessionOptional[AnySession]Optional requests HTTP session to download the file with.
None"},{"location":"reference/api/tracks/#unshackle.core.tracks.Attachment.id","title":"idproperty","text":"id\nCompute an ID from the attachment data.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Attachment.download","title":"download","text":"download(\n session=None,\n *,\n no_proxy_download=False,\n proxy_download=None,\n)\nDownload a URL-backed attachment to the temp directory.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Attachment.to_dict","title":"to_dict","text":"to_dict()\nSerialise a URL-backed attachment for export/import.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Attachment.delete","title":"delete","text":"delete()\nDelete the file only if it is in the temp directory. Files in other locations belong to the user.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Attachment.from_url","title":"from_urlclassmethod","text":"from_url(\n url,\n name=None,\n mime_type=None,\n description=None,\n session=None,\n)\nMake an attachment from a URL.
Parameters:
Name Type Description DefaulturlstrURL to download the attachment from.
requirednameOptional[str]Name of the attachment.
Nonemime_typeOptional[str]MIME type of the attachment.
NonedescriptionOptional[str]Description of the attachment.
NonesessionOptional[AnySession]Optional requests HTTP session to download the file with.
NoneReturns:
Name Type DescriptionAttachment'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)\nBases:
TrackMake a new Audio track object.
Parameters:
Name Type Description DefaultcodecOptional[Codec]An Audio.Codec enum representing the audio codec. If not specified, unshackle uses MediaInfo to get the codec after it downloads the track.
NonebitrateOptional[Union[str, int, float]]A number or float representing the average bandwidth in bits/s. unshackle rounds float values up to the nearest integer.
NonechannelsOptional[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.
NonejocOptional[int]The number of Joint-Object-Coding Channels/Objects in the audio track.
NonedescriptiveUnion[bool, int]Mark this audio as being descriptive audio for the blind.
FalseNote: 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":"atmosproperty","text":"atmos\nReturn True if the audio track contains Atmos.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Audio.parse_channels","title":"parse_channelsstaticmethod","text":"parse_channels(channels)\nConverts a Channel string to a float representing the audio channel layout. E.g. \"3\" -> \"3.0\", \"2.1\" -> \"2.1\", \".1\" -> \"0.1\".
An immersive layout names its height channels in a third figure, e.g. \"5.1.4\", which no float can hold, so it stays the string it came as. Use channel_total for arithmetic on either form.
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.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Audio.channel_total","title":"channel_totalstaticmethod","text":"channel_total(channels)\nTotal number of channels in either channel form, so a caller can compare both.
A float layout keeps its own value, because a caller compares those by rounding the sub channel up, e.g. 5.1 and 6.0 both match. An immersive layout has no such float, so this adds its figures: \"5.1.4\" -> 10.0.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Audio.to_music_container","title":"to_music_container","text":"to_music_container()\nRemux the track into the container of its codec, as a standalone audio file.
Music titles never reach the muxer, so the downloaded file is the delivered file, and after decryption that is still a fragmented MP4. Its header states a length of zero, so a player stops after the first fragment. FLAC inside an MP4 is also not a FLAC stream. A rename cannot correct either fault. Returns True once the new file replaces the downloaded one; every failure raises.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Audio.get_track_name","title":"get_track_name","text":"get_track_name()\nReturn the base Track Name.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Chapter","title":"Chapter","text":"Chapter(timestamp, name=None)\nMake 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.,90323120is25:05:23.120. - a timecode float in seconds, e.g.,90323.12is25:05:23.120.If you have a timecode integer in seconds, multiply it by 1000. If you have a timecode float in milliseconds (no decimal value), convert it to an integer.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Chapter.id","title":"idproperty","text":"id\nCompute an ID from the Chapter data.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Chapter.named","title":"namedproperty","text":"named\nTrue if the Chapter has a name.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Chapters","title":"Chapters","text":"Chapters(iterable=None)\nBases:
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Chapters.id","title":"idSortedKeyList,ABCproperty","text":"id\nCompute an ID from the Chapter data.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Chapters.loads","title":"loadsclassmethod","text":"loads(data)\nLoad chapter data from a string.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Chapters.load","title":"loadclassmethod","text":"load(path)\nLoad chapter data from a file.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Chapters.dumps","title":"dumps","text":"dumps(fallback_name='')\nReturn chapter data in OGM-based Simple Chapter format. https://mkvtoolnix.download/doc/mkvmerge.html#mkvmerge.chapters.simple
Parameters:
Name Type Description Defaultfallback_namestrName 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\".unshackle formats these with f-strings, so format directives work. For example,
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Chapters.dump","title":"dump","text":"\"Chapter {i:02}\"will result in\"Chapter 01\".dump(path, *args, **kwargs)\nWrite chapter data in OGM-based Simple Chapter format to a file.
Parameters:
Name Type Description DefaultpathUnion[Path, str]The file path to write the Chapter data to, overwriting any existing data.
requiredSee
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Hybrid","title":"Hybrid","text":"Chapters.dumpsfor more parameter documentation."},{"location":"reference/api/tracks/#unshackle.core.tracks.Hybrid.log","title":"logHybrid(videos, source, output_name=None)\ninstance-attribute","text":"log = logging.getLogger('hybrid')\nTakes 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)\nMake Level 5 active area metadata through crop detection on the HDR10 track.
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_l6staticmethod","text":"sanitize_l6(max_mdl, min_mdl, max_cll, max_fall)\nClamp 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 this method keeps it as-is.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Hybrid.level_6","title":"level_6","text":"level_6()\nEdit 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)\nExtract HDR10+ metadata from the video track
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Hybrid.probe_hdr_metadata","title":"probe_hdr_metadata","text":"probe_hdr_metadata()\nExtract mastering display and Content Light Level metadata from the HDR10 track with FFprobe.
Returns (max_mdl, min_mdl, max_cll, max_fall) in dovi_tool level6 units: - max_mdl: nits (integer) - min_mdl: 0.0001 nit units (integer) - max_cll / max_fall: nits (integer)
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Hybrid.convert_hdr10plus_to_dv","title":"convert_hdr10plus_to_dv","text":"convert_hdr10plus_to_dv()\nConvert 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)\nBases:
TrackMake a new Subtitle track object.
Parameters:
Name Type Description DefaultcodecOptional[Codec]A Subtitle.Codec enum representing the subtitle format. If not specified, unshackle uses MediaInfo to get the format after it downloads the track.
NoneccboolClosed Caption. - Intended as if you could not hear the audio at all. - Can have Sound as well as Dialogue, but does not have to. - Original source would be from EIA-CC encoded video. Typically all upper-case characters. Indicators of it being CC without knowing original source: - Extracted with CCExtractor, or - >>> (or similar) at the start of some or all lines, or - All text is uppercase or at least the majority, or - Subtitles use the scrolling-text style (one line appears, oldest line then disappears). The fact that you downloaded it as a SRT or VTT or such does not mean it is not from an EIA-CC stream. And I would not take the streaming services (CC) as gospel either as they tend to get it wrong too.
FalsesdhboolDeaf or Hard-of-Hearing. Also known as HOH in the UK (EU?). - Intended as if you could not hear the audio at all. - MUST have Sound as well as Dialogue to count as SDH. - It has no \"syntax\" or \"format\", but it is not transmitted in archaic forms like EIA-CC streams. It is intended for transmission through SubRip (SRT), WebVTT (VTT), TTML, and the other modern subtitle formats. If you can see important audio/sound transcriptions and not only dialogue and it does not have the indicators of CC, then it is most likely SDH. If it does not have important audio/sounds transcriptions it might only be regular subtitling (you would not mark as CC or SDH). This would be the case for most translation subtitles. Like Anime for example.
FalseforcedboolTypically used if there is important information at some point in time like watching a Dubbed title and an important Sign or Letter is shown or someone talking in a different language. The Matroska Spec recommends that the player plays Forced tracks if the player's current playback audio language matches a subtitle marked as \"forced\". However, that does not 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. Know what to expect with \"forced\" subtitles.
FalseNote: If codec is not specified, unshackle can skip some checks or assume a value. Give as much information as possible.
Information on Subtitle Typeshttps://bit.ly/2Oe4fLC (3PlayMedia Blog on SUB vs CC vs SDH). However, I would not pay much attention to the claims about SDH needing to be in the original language. It is logically not true.
CC == Closed Captions. Source: Every site. SDH = Subtitles for the Deaf or Hard-of-Hearing. Source: 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()\nReturn the base Track Name.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.strip_webvtt_cue_classes","title":"strip_webvtt_cue_classesstaticmethod","text":"strip_webvtt_cue_classes(text)\nReduce a cue payload to the markup every target format shares:
<i>,<b>and<u>without their class list. It removes class spans (<c.foo>),<v>,<lang>,<ruby>/<rt>and karaoke timestamps (<00:00:01.000>).A class only selects a CSS
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.strip_webvtt_timestamp_map","title":"strip_webvtt_timestamp_map::cuerule that no player receives, so it never carries styling, but it does break rendering. FFmpeg's SubRip reader treats a tag name as a tag only while it matches[0-9a-zA-Z_/]. The dot fails that test, so FFmpeg prints<i.loud>as cue text and then emits the closing italic-off with nothing opened. FFmpeg's WebVTT reader matchesi/b/uexactly and drops the rest, so there a class silently loses the styling instead. The tags this method removes outright are the ones both readers already discard, and that libass would draw as text if a conversion carried them into ASS.staticmethod","text":"strip_webvtt_timestamp_map(text)\nRemove 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_timestampsstaticmethod","text":"sanitize_webvtt_timestamps(text)\nFix invalid timestamps in WebVTT files, particularly negative timestamps.
Parameters:
Name Type Description DefaulttextstrThe WebVTT text as string
requiredReturns:
Type DescriptionstrSanitized WebVTT text
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.has_webvtt_cue_identifiers","title":"has_webvtt_cue_identifiersstaticmethod","text":"has_webvtt_cue_identifiers(text)\nExamine WebVTT text for cue identifiers that need removal.
Parameters:
Name Type Description DefaulttextstrThe WebVTT text as string
requiredReturns:
Type DescriptionboolTrue if the text has cue identifiers, False otherwise
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.sanitize_webvtt_cue_identifiers","title":"sanitize_webvtt_cue_identifiersstaticmethod","text":"sanitize_webvtt_cue_identifiers(text)\nRemove WebVTT cue identifiers that can confuse subtitle parsers.
Some services use cue identifiers such as \"Q0\" and \"Q1\" that appear on their own line before the timing line. These can be incorrectly parsed as part of the previous cue's text by some parsers (like pysubs2).
Parameters:
Name Type Description DefaulttextstrThe WebVTT text as string
requiredReturns:
Type DescriptionstrSanitized WebVTT text with cue identifiers removed
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.parse_vtt_time","title":"parse_vtt_timestaticmethod","text":"parse_vtt_time(t)\nParse WebVTT timestamp to milliseconds. Returns 0 for malformed input.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.has_overlapping_webvtt_cues","title":"has_overlapping_webvtt_cuesstaticmethod","text":"has_overlapping_webvtt_cues(text)\nExamine WebVTT text for 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 DefaulttextstrThe WebVTT text as string
requiredReturns:
Type DescriptionboolTrue if the text has overlapping cues, False otherwise
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.merge_overlapping_webvtt_cues","title":"merge_overlapping_webvtt_cuesstaticmethod","text":"merge_overlapping_webvtt_cues(text)\nMerge 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 DefaulttextstrThe WebVTT text as string
requiredReturns:
Type DescriptionstrWebVTT text with overlapping cues merged
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.sanitize_webvtt","title":"sanitize_webvttstaticmethod","text":"sanitize_webvtt(text)\nMore thorough sanitization of WebVTT files that corrects multiple potential issues.
This is lossy, so use it only as a fallback once normal parsing has failed. This method discards everything before the WEBVTT header line and reduces the header itself to a bare \"WEBVTT\" line. Negative timestamps become 00:00:00.000, and it pads timestamps that have no hours field to HH:MM:SS.mmm.
Parameters:
Name Type Description DefaulttextstrThe WebVTT text as string
requiredReturns:
Type DescriptionstrSanitized WebVTT text
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.convert","title":"convert","text":"convert(codec, *, forced=False)\nConvert this Subtitle to another format.
Backend selection is data-driven (see
tracks/subtitle_convert.py): unshackle uses the best available backend that can convert source->target, and falls back through the capability chain on failure. Theconversion_methodconfig key (auto|subby|pysubs2|subtitleedit|pycaption) pins the backend, andpreferred_conversion_methodnudges it per-service. An explicit config value always wins."},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.extract_fonts","title":"extract_fonts
forcedmarks an explicit user request (--sub-format). unshackle skips lossy downconverts of styled formats (SSA/ASS -> SRT) unlessforcedis True.staticmethod","text":"extract_fonts(text)\nFont names referenced by an ASS/SSA subtitle.
Covers both sources that need attaching for correct rendering: - the
Fontnamecolumn of everyStyle:line in[V4+ Styles]/[V4 Styles](column located from the section'sFormat:line, not assumed by index), and - inline\\fnfont overrides insideDialogueoverride blocks.This method removes a leading
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.sanitize_broken_webvtt","title":"sanitize_broken_webvtt@(vertical-writing prefix), de-duplicates the names case-insensitively, and prefers a mixed-case spelling over an all-lowercase one.staticmethod","text":"sanitize_broken_webvtt(text)\nRemove or fix corrupted WebVTT lines, particularly those with invalid timestamps.
Parameters:
Name Type Description DefaulttextstrThe WebVTT text as string
requiredReturns:
Type DescriptionstrSanitized WebVTT text with corrupted lines removed
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.space_webvtt_headers","title":"space_webvtt_headersstaticmethod","text":"space_webvtt_headers(data)\nSpace 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 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_cuesstaticmethod","text":"merge_same_cues(caption_set)\nMerge 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_cuesstaticmethod","text":"filter_unwanted_cues(caption_set)\nFilter out subtitle cues containing only \u00a0 or whitespace.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.merge_segmented_wvtt","title":"merge_segmented_wvttstaticmethod","text":"merge_segmented_wvtt(data, period_start=0.0)\nConvert Segmented DASH WebVTT cues into a pycaption Caption List. Also returns an ISO 639-2 alpha-3 language tag if available.
Code ported originally by xhlove to Python from shaka-player. rlaphoenix has since improved it with pymp4 and pycaption functions.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Subtitle.strip_hearing_impaired","title":"strip_hearing_impaired","text":"strip_hearing_impaired()\nRemove the SDH captions.
The 'sdh_method' setting in config gives the SDH stripping method: - '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()\nReverse RTL (Right to Left) Start/End on Captions. Use this to fix the positioning of sentence-ending characters.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.DownloadContext","title":"DownloadContextdataclass","text":"DownloadContext(\n save_path,\n save_dir,\n progress,\n session=None,\n proxy=None,\n max_workers=None,\n adaptive_workers=False,\n download_processes=1,\n license_widevine=None,\n cdm=None,\n)\nShared arguments passed to each manifest's
"},{"location":"reference/api/tracks/#unshackle.core.tracks.DownloadContext.ensure_session","title":"ensure_session","text":"download_track.ensure_session()\nReturn the HTTP session, or a new
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Track","title":"Track","text":"Sessionif none was set."},{"location":"reference/api/tracks/#unshackle.core.tracks.Track.data","title":"dataTrack(\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)\npropertywritable","text":"data\nArbitrary track data dictionary.
This uses a defaultdict 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 note that these values can change or be removed at any point.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Track.drm_preference","title":"drm_preferencepropertywritable","text":"drm_preference\nDRM system this track must license with, one of the names in DRM_PREFERENCE_TYPES.
None (the default) lets the loaded CDM choose. Set it when the manifest advertises more than one DRM system but only one of them licenses this track.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Track.prefers_playready","title":"prefers_playready","text":"prefers_playready(cdm)\nWhether to try PlayReady before Widevine. The track's preference wins over the loaded CDM.
"},{"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 proxy_download=None,\n adaptive_workers=False,\n download_processes=1,\n)\nDownload and optionally Decrypt this Track.
For a URL-descriptor Video or Audio track with no
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Track.move","title":"move","text":"drmset, unshackle probes the DRM from the track's init data and stores it on the track, so a service need not declaredrmitself.move(target)\nMove the Track's file from current location, to target location. This will overwrite anything at the target path.
Raises:
Type DescriptionTypeErrorIf the target argument is not the expected type.
ValueErrorIf track has no file to move, or the target does not exist.
OSErrorIf 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()\nSerialise 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_dictstaticmethod","text":"base_kwargs_from_dict(data)\nAssemble the shared Track constructor kwargs from a
to_dict()payload.DRM is not reconstructed here:
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Track.from_dict","title":"from_dictto_dictdoes not serialise it, and the import flow attaches the licensed DRM + content keys separately.classmethod","text":"from_dict(data)\nReconstruct the correct Track subclass from a
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Track.get_track_name","title":"get_track_name","text":"to_dict()payload.get_track_name()\nGet 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)\nReturn 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)\nProbe the DRM encryption Key ID (KID) for this specific track.
It can find the Key ID by probing the track with FFprobe for
enc_key_iddata, as well as for mp4tenc(Track Encryption) boxes.It explicitly ignores PSSH information like the
PSSHbox, 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_segmentwith the positional and keyword arguments. This method then uses the return value ofget_init_segmentto find the Key ID.Returns:
Type DescriptionOptional[UUID]The Key ID as a UUID object, or None if unshackle cannot find the Key ID.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Track.load_drm_if_needed","title":"load_drm_if_needed","text":"load_drm_if_needed(service=None)\nLoad DRM information for this track if the parser deferred it.
Parameters:
Name Type Description DefaultserviceService | NoneService instance that can fetch track-specific DRM info
NoneReturns:
Type DescriptionboolTrue if the DRM loaded or is already present, False if the load failed
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Track.load_drm_from_playlist","title":"load_drm_from_playlist","text":"load_drm_from_playlist()\nFallback 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)\nGet the Track's initial segment data.
HLS and DASH tracks must explicitly give a URL to the init segment or file. Give the byte-range for the init segment where possible.
If
byte_rangeis not set, it will make a HEAD request and examine the size of the file. If it cannot find the size, it will download up to the first 20KB only, which should contain the entirety of the init segment. You may override this by changing themaximum_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 Defaultmaximum_sizeintSize to assume as the response body length if byte-range is not used, if unshackle cannot find the body size, or if the body size is larger than it. Use a value of 20000 (20KB) or higher.
20000urlOptional[str]Explicit init map or file URL to probe from.
Nonebyte_rangeOptional[str]Range of bytes to download from the explicit or implicit URL.
NonesessionOptional[Session]HTTP session context, for example authorization and headers.
None"},{"location":"reference/api/tracks/#unshackle.core.tracks.Track.repackage","title":"repackage","text":"repackage(bsf_v=None)\nRemux the track with FFmpeg
-c copy.A given
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Tracks","title":"Tracks","text":"bsf_vgoes into the same pass as-bsf:v, which normalises video VUI colour metadata without a second full-file remux. Repackaging is mandatory. The bitstream filter is best-effort: if the combined pass fails, and it is not the AAC-retry case, unshackle tries it again once withoutbsf_vso the remux still succeeds. Returns True if unshackle applied the requestedbsf_v(always False whenbsf_vis None, because the caller requested nothing).Tracks(*args, manifest_url=None)\nVideo, 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)\nExamine whether a track already exists, by various methods.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Tracks.add","title":"add","text":"add(tracks, warn_only=False)\nAdd a provided track to its appropriate array.
A track whose ID is already in the collection raises ValueError. With
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Tracks.sort_videos","title":"sort_videos","text":"warn_onlyset, this method skips such a track and logs how many it skipped.sort_videos(by_language=None, exact_match=False)\nSort 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)\nSort 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)\nSort subtitle tracks by various track attributes to a common P2P standard. You may optionally give a sequence of languages to prioritise to the top.
Section OrderType Order
- by_language groups prioritized to top, and ascending alphabetically
- then rest ascending alphabetically after the prioritized groups (Each section ascending alphabetically, but separated)
- 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 sort order. \"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)\nReturn a new Tracks with tracks filtered by predicate, preserving metadata.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Tracks.merge_video_selections","title":"merge_video_selectionsstaticmethod","text":"merge_video_selections(*groups)\nConcatenate video selections, dropping duplicates (by track id, order-preserving).
A caller can choose a DV track as both the hybrid ingredient (lowest) and an explicit deliverable. Without dedup, unshackle would mux and download the same track twice.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Tracks.partition_hybrid_videos","title":"partition_hybrid_videosstaticmethod","text":"partition_hybrid_videos(videos, non_hybrid_ranges)\nSplit videos into hybrid-ingredient candidates and the standalone-deliverable pool.
HDR10/HDR10+/DV tracks are hybrid ingredients. They only enter the standalone pool when the user explicitly requested their range alongside HYBRID, so for example
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Tracks.flag_hybrid_ingredients","title":"flag_hybrid_ingredients-r HYBRIDmuxes only the hybrid while-r HYBRID,HDR10Palso delivers HDR10+.staticmethod","text":"flag_hybrid_ingredients(\n hybrid_selected, non_hybrid_selected\n)\nMark tracks selected only as hybrid ingredients so the standalone mux loop skips them.
A track that the caller also selected as an explicit deliverable (same track in both selections) stays unflagged, and the standalone mux loop muxes it 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)\nMultiplex all the Tracks into a Matroska Container file.
A failed mux does not raise. mkvmerge's exit code and error lines come back alongside the output path, and the caller must examine them.
Parameters:
Name Type Description DefaulttitlestrSet the Matroska Container file title. Usually displayed in players instead of the filename if set.
requireddeleteboolDelete all track files after multiplexing.
TrueprogressOptional[partial]Update a rich progress bar with
completed=.... This must be the progress object's update() func, pre-set with task id by functools.partial.Noneaudio_expectedboolWhether the output must have audio. unshackle uses this to decide if it adds embedded audio metadata.
Truetitle_languageOptional[Language]The title's intended language. Used to select the best video track for audio metadata when multiple video tracks exist.
Noneskip_subtitlesboolSkip muxing subtitle tracks into the container.
Falseoutput_pathOptional[Path]Explicit destination for the muxed container. When None (default) unshackle derives the path 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)\nBases:
TrackMake a new Video track object.
Parameters:
Name Type Description DefaultcodecOptional[Codec]A Video.Codec enum representing the video codec. If not specified, unshackle uses MediaInfo to get the codec after it downloads the track.
Nonerange_Optional[Range]A Video.Range enum representing the video colour range. Defaults to SDR if not specified.
NonebitrateOptional[Union[str, int, float]]A number or float representing the average bandwidth in bytes/s. unshackle rounds float values up to the nearest integer.
NonewidthOptional[int]The horizontal resolution of the video.
NoneheightOptional[int]The vertical resolution of the video.
NonefpsOptional[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.
NoneNote: If codec, bitrate, width, height, or fps is not specified, unshackle can skip some checks or assume a value. Give as much information as possible.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Video.Range","title":"Range","text":"Bases:
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Video.Range.from_cicp","title":"from_cicpstr,Enumstaticmethod","text":"from_cicp(primaries, transfer, matrix)\nConvert CICP (Coding-Independent Code Points) values to Video Range.
ITU-T H.273 and ISO/IEC 23091-2 define CICP for signalling video colour 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 - Coding-independent code points - 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 after user feedback (GitHub issue) and matches 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_)\nChange the Video's Colour Range to Limited (0) or Full (1).
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Video.vui_bsf","title":"vui_bsf","text":"vui_bsf()\nReturn the
-bsf:vvalue that rewrites SPS VUI colour metadata to matchself.range.Returns None when a rewrite is not necessary: SDR/DV/HYBRID ranges, non-AVC/HEVC codecs, a missing file, or when the bitstream already ships the correct colour metadata. Otherwise returns a
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Video.normalize_vui","title":"normalize_vui","text":"{h264,hevc}_metadata=...string suitable for FFmpeg-bsf:v.normalize_vui()\nRewrite 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 unshackle rewrote the bitstream.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Video.ccextractor","title":"ccextractor","text":"ccextractor(track_id, out_path, language, original=False)\nReturn 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()\nExtract Apple-Style c608 box (CEA-608) subtitle using CCExtractor.
This is not much more than a wrapper to the track.ccextractor function. All this does is examine if a c608 box exists, and only then does it call CCExtractor.
Even though there is a possibility of more than one c608 box, unshackle can extract only one. It is also 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, before unshackle canextract more than one CEA-608 track.
"},{"location":"reference/api/tracks/#unshackle.core.tracks.Video.remove_eia_cc","title":"remove_eia_cc","text":"remove_eia_cc()\nRemove 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 only the SEI data). unshackle can do this only for bitstreams with x264 encoding information, because of the obscurity on the MDAT mp4 box structure. Therefore, we need to use hacky regex.
"},{"location":"reference/configuration/","title":"Configuration Reference","text":"Every config 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 config key is optional except
output_template, whichdlrequires. Without it,dlaborts with an error. If you do not write any other config key, unshackle uses the built-in default listed here. There is no schema validation: unshackle silently ignores unknown config keys (they never become settings), and reads the file withyaml.safe_load. An empty or missing file means every other config key falls back to its default.Two keys behave specially on load
curl_impersonateis a deprecated alias ofnetworkand emits aDeprecationWarning.scene_naminghas been removed. If it is set to any value, unshackle exits immediately with an error telling you to useoutput_templateinstead.See Deprecated & removed keys.
"},{"location":"reference/configuration/#quick-index","title":"Quick index","text":"Area Keys Directoriesdirectories,filenames,services_repo_forceServices & authenticationservices,credentials,firefox_cookies,remote_services,serveDownload & processingdl,continue_downloads,merge_segments,subtitle,audio,muxing,language_tags,dual_multi_modeOutput & namingoutput_template,tag,tag_rules,tag_group_name,tag_imdb_tmdb,chapter_fallback_name,unicode_filenamesDRM & CDMcdm,remote_cdm,decryptionNetwork & proxynetwork,headers,proxy_providersKey vaultskey_vaults,vault_timeoutPost-scriptspost_scriptsExternal API keysomdb_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_keyCaching & updatestitle_cache_enabled,title_cache_time,title_cache_max_retention,update_checks,update_check_intervalAppearancethemeLogging, privacy & debugredact_paths,debug,debug_keys,debug_requests,set_terminal_bgDeprecated & removedcurl_impersonate,downloader,scene_naming"},{"location":"reference/configuration/directories/","title":"Directories","text":"The
unshackle.yamldirectoriesconfig key is a dict mapping directory names to filesystem paths. unshackle honours only the names listed below. A handful are protected and you cannot move them, and unshackle silently ignores an override of one. unshackle computes all defaults relative to the installed package, and passes every user-settable path throughPath(...).expanduser(), so~works.Key Type Default Overridable Purposedirectories:\n downloads: ~/Media/unshackle\n temp: /mnt/fast-scratch/unshackle-temp\n wvds: ~/.unshackle/WVDs\n prds: ~/.unshackle/PRDs\ndownloadspath<repo>/downloadsYes Default output directory for finished files.temppath<repo>/tempYes Temporary working files during download/decrypt/mux.cachepath<data>/cacheYes Generic cache, title cache, and the update-check store.cookiespath<data>/cookiesYes Per-service cookie files (and VPN cookie files).logspath<data>/logsYes Log files.exportspath<data>/exportsYes Export JSON files.wvdspath<data>/WVDsYes Widevine device files (.wvd).prdspath<data>/PRDsYes PlayReady device files (.prd).dcslpath<data>/DCSLYes DCSL data.commandspathunshackle/commandsYes CLI command modules.serviceslist | path[unshackle/services]Yes Service search paths and/or remote repo specs (see below).vaultspathunshackle/vaultsYes Vault backend modules.fontspathunshackle/fontsYes Bundled fonts.user_configspathunshackle/No protected Whereunshackle.yamllives.datapathunshackle/No protected Base for the data subdirectories above.core_dirpathunshackle/coreNo protected Package core.namespace_dirpathunshackle/No protected Package root.app_dirs-AppDirs(\"unshackle\", False)No protected Internal AppDirs instance.The
servicesdirectory is special
servicesmay 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) orowner/reposhorthand. unshackle clones and updates repo specs automatically, and uses plain paths as they are. 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\nHow git-backed service repos are handled
unshackle's use of git is read-only on the remote: it only ever does a shallow
clone,fetch,pull, and a localreset. unshackle never pushes anything. Your existing git credential helper authenticates private repos, and unshackle stores no tokens of its own. Clones live under<first-local-services-dir>/_repos/<host>__<owner>__<repo>/(or the bundledunshackle/services), and unshackle writes nothing 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/searchrun 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 manualunshackle util refresh-servicescommand instead hard-resets the clone to upstream, discarding any local edits.Set
services_repo_force: trueto make the automatic 24 h refresh behave likerefresh-services. When a refresh is due, the run fetches and hard-resets every clone, and discards any local edits in_repos/. Runs between refreshes do not touch the network. Use it on unattended hosts where nobody edits the clones by hand.services_repo_force: true\nRefresh inside
serveA running server does not restart to pick up a refresh. Both the periodic refresh (
serve.services_refresh_interval, see serve) and the manualPOST /api/maintenance/refresh-servicesendpoint hard-reset the clones and then re-import only the services that changed. A service with a running or queued download job keeps its current code until that job finishes, then swaps;GET /api/servicesmarks it withpending_updatein the meantime.Read-only installs and reinstalls
If the services directory lives inside the installed package, a reinstall can delete the
"},{"location":"reference/configuration/directories/#filenames","title":"Filenames","text":"_reposclones. They are re-cloned on next use. On read-only installs you must pointservicesat a writable path, or cloning will fail.The
Key Type Default Notesfilenamesconfig key is a dict of templated file/name patterns. unshackle uses each value verbatim (no path processing). It fills in braced fields like{time}and{service}at runtime.logstr\"unshackle_{name}_{time}.log\"Written underdirectories.logs.debug_logstr\"unshackle_debug_{service}_{time}.jsonl\"Structured debug log, underdirectories.logs.configstr\"config.yaml\"Per-service config file, under that service's directory.root_configstr\"unshackle.yaml\"The main config filename itself.chaptersstr\"Chapters_{title}_{random}.txt\"Underdirectories.temp.subtitlestr\"Subtitle_{id}_{language}.srt\"Underdirectories.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
dlcommand. unshackle uses the block as Click'sdefault_map, so you can give anydlflag a default here. See Downloading and the CLI Reference for what each flag does.Config key naming. A config key is the flag's long name with dashes replaced by underscores (
Flag Config key--best-available\u2192best_available,--sub-format\u2192sub_format). A few config keys use the flag's internal destination and not its visible name. Set these exact config keys:-r/--rangerange--listlist--tmdbtmdb_id--imdbimdb_id--anilistanilist_id-naa/--noatmosno_atmos-o/--outputoutput_dir--cdm-only/--vaults-onlycdm_onlyPrecedence (highest first): an explicit CLI flag \u2192 a per-service
services.<TAG>.dloverride \u2192 this globaldl:block \u2192 the built-in default. In other words, config only fills in options you did not set on the command line.Common config keys. This is a useful subset, and every
Key Type Default Setsdlflag works:langlist[\"orig\"]Video/audio language(s);orig= original language. A-prefix excludes, e.g.[all, -es].a_lang/v_langlist[]Audio- / video-only language override. A-prefix excludes.s_langlist[\"all\"]Subtitle language(s). A-prefix excludes, e.g.[all, -es].qualitylist[](best) Resolution(s), e.g.[1080].vcodeclist[](any) Video codec(s), e.g.[H265].acodeclist[](any) Audio codec(s), e.g.[EC3].rangelist[\"SDR\"]Colour range(s):SDR,HLG,HDR10,HDR10P,DV,HYBRID.channelsfloat (unset) Audio channels, e.g.6for 5.1.sub_formatstr (unset) Convert subtitles to this format (srt,vtt,original, ...).forced_subsboolfalseInclude forced subtitle tracks.forced_s_langlist[]Forced subtitle language(s); impliesforced_subs. A-prefix excludes.no_subs/no_audio/no_chapters/no_attachmentsboolfalseSkip that track type.no_attachmentsalso skips attaching subtitle fonts.downloadsint1Tracks downloaded concurrently.workersint (downloader default) Threads per track.speed_limitstr (unlimited) Total download speed cap across all downloads combined, e.g.500k,5M,1.5Gor plain bytes/sec. Values are bytes, not bits (5M= 5.0 MB/s).slowstr (unset) Inter-title delay, e.g.\"20-40\".best_availableboolfalseWarn instead of failing when a requested resolution, range, or language is missing. The CLI flag is--best-available, also spelled--warn-only.proxystr (unset) Default proxy URI or 2-letter country.no_folderboolfalseDo not create a per-title folder.
noandyesare language tagsYAML reads a bare
Global download defaultsnoasfalseand a bareyesastrue. unshackle puts both back to the tagsno(Norwegian) andyes(Nyankpa) in every config key that takes a language:lang,v_lang,a_lang,s_lang,forced_s_lang,require_audio,require_video,require_subs,language_priority,muxing.default_languageand thelanguage_tagsrules. Quotes (lang: \"no\") work as well.dl:\n lang: [en]\n quality: [1080]\n range: [HDR10]\n vcodec: [H265]\n sub_format: srt\n downloads: 2\nLanguage 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\"\nSee Excluding languages for the rules.
Per-service overrides
A
dlblock nested under a service inservicestakes 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
serveuses these same keysThe
"},{"location":"reference/configuration/download/#continue_downloads","title":"serveblock accepts most of the samedloption keys at its top level to set server-wide download defaults for every request it handles. The API ignores the config keys it does not know there,speed_limitfor example.servehas its ownglobal_speed_limit.continue_downloads","text":"
- Type:
bool\u00a0\u00b7\u00a0 Default:falseKeep a failed download's completed segment files so the next download resumes from where it stopped instead of restarting from byte zero.
unshackle reuses only whole, fully downloaded segments. It always discards partially written segments, and no partial-byte (
Range) resume across runs takes place. Reuse is possible only with proof that the segmentation did not change. The manifest URL, segment count, and byte ranges must all match the previous download, or the download silently restarts clean.What can and cannot resume
- Segmented DASH, ISM, and HLS tracks resume.
- Single-file (non-segmented) downloads and DASH tracks collapsed to a single ranged download never make segment files, so this option does not apply to them (the downloader has its own in-flight partial-file handling for those).
- HLS resumes Widevine/PlayReady and unencrypted titles only. An AES-128 title always restarts. An HLS failure during the post-download merge step also restarts.
- A track downloaded with
merge_segmentsordecrypt_segmentsnever resumes. Both options consume or rewrite the segment files during the download.For a one-off resume without changing config, pass
Resume failed downloadsdl --continue-downloadson the command line (the flag can only enable resume, never disable it)."},{"location":"reference/configuration/download/#merge_segments","title":"continue_downloads: true\nmerge_segments","text":"
- Type:
bool\u00a0\u00b7\u00a0 Default:falseAppends each segment to the output file as soon as it and every segment before it have arrived, instead of joining all the segment files after the last one lands. The join runs during the download, so the \"Merging\" step after the last segment goes away. The output file is the same either way.
Segments arrive out of order because the workers run in parallel. unshackle keeps a cursor on the next segment it needs and appends every segment that is ready in order, then deletes the segment file. With
decrypt_segmentsalso on, each segment is appended after its decryption completes, never before.This option is off by default because it disables resume, and because its cost during the download is not measured. The append reads each segment straight after a worker wrote it, so the read usually comes from the page cache. On a hard disk, the append to one file while the workers write many segment files can still make the head seek. If a download gets slower with the option on, turn it off.
This applies to video and audio tracks from DASH, ISM, and HLS. An HLS track qualifies only when every segment shares one init segment and one
EXT-X-KEY, with noEXT-X-DISCONTINUITY, and the DRM is Widevine, PlayReady, or none. AES-128 tracks, tracks with content key rotation or discontinuities, and subtitle tracks keep the merge after the download.No resume
A track that merges segment by segment always downloads from the start.
continue_downloadscannot reuse its segments, because unshackle deletes each segment after it appends it."},{"location":"reference/configuration/download/#subtitle","title":"merge_segments: true\nsubtitle","text":"
- Type:
dict\u00a0\u00b7\u00a0 Default:{}Subtitle handling: SDH stripping, format conversion, and whether unshackle muxes the subtitles in or writes them as sidecar files. See Subtitles for the full guide.
Sub-key Type Default Descriptionstrip_sdhbooltrueStrip SDH/CC cues (hearing-impaired annotations) into a clean track.sdh_methodstr\"auto\"SDH-stripping backend to use.preserve_formattingbooltruePass WebVTT cue text through as written.falsere-encodes it through pycaption, which merges identical cues but flattens styling. unshackle removes WebVTT-only cue markup either way.convert_before_stripbooltrueConvert to the working format before stripping SDH.conversion_methodstr\"auto\"Subtitle conversion backend.output_modestr\"mux\"muxembeds subtitles in the MKV;sidecarwrites separate files;bothwrites sidecars and still muxes.sidecar_formatstr\"srt\"Format for sidecar files whenoutput_modeissidecarorboth.type_prioritylist (unset) Ordered ranking of subtitle types (forced,normal,sdh; CC counts as SDH). Types you leave out fall to the end.group_bystr\"type\"Which key groups the subtitle tracks.typeputs all forced tracks together, then all normal, then all SDH.languagekeeps each language next to its own variants.language_prioritylist (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_bytrack ordergroup_byResulttypeDanish, Finnish, French, \u2026 Danish SDH, Finnish SDH, French SDHlanguageDanish, Danish SDH, Finnish, Finnish SDH, French, French SDH
type_prioritystill sets the order of the variants, so withgroup_by: languageit controls the order inside each language.
language_prioritysorts languages, it does not select themResult for an English title Default English, Arabic, Bulgarian, Spanish, French, Japanese With the list above English, Spanish, French, Arabic, Bulgarian, Japanesesubtitle:\n group_by: language\n language_priority: [en, es, fr]\nunshackle removes nothing, unlike
-sl, which drops every subtitle language it does not name.Use
origin the list for the title's original language. A base tag also covers its regional forms, soensortsen-USanden-GB, butzhdoes not coverzh-Hant, which you must name. The sort ignores entries that match no track, and it ignores entries that are not valid language tags.
--exact-langapplies here too: with it,ensorts onlyen, anden-USfalls to the alphabetical tail with the rest. Name each tag you want when you use that flag.
type_prioritypicks the default subtitle only withmuxing.default_languageAt mux time the
defaultflag goes to every forced subtitle whose language matches the first audio track, so a full dialogue track never becomes default on its own.type_priorityalone does not change that. Name a subtitle language inmuxing.default_languageand the first track in that language gets the flag instead, which is wheretype_prioritydecides which track that is:"},{"location":"reference/configuration/download/#audio","title":"muxing:\n default_language:\n subtitle: en\nsubtitle:\n type_priority: [normal, sdh, forced]\naudio","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_priorityis 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_priorityis a soft priority: nothing is dropped"},{"location":"reference/configuration/download/#muxing","title":"
codec_priorityoutranks 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 overcodec_priority, and audio-description tracks are always forced to the end regardless of it. Whencodec_priorityis unset, tracks sort by bitrate alone, with those same Atmos/descriptive rules still applied.muxing","text":"
- Type:
dict\u00a0\u00b7\u00a0 Default:{}Matroska (MKV) muxing options.
Sub-key Type Default Descriptionset_titlebooltrueWrite a human-readable title into the MKV container.default_languagedict{}Force which language is flagged default per track type, e.g.{audio: en, subtitle: en}.merge_audiobooltrueMerge all audio into one file.--split-audioon the CLI flips this off.merge_videoboolfalseMerge video tracks that share height, range, and codec into one file, so only language varies inside a file.--merge-videoon the CLI flips this on.concurrencyint4How many mkvmerge processes run at the same time when a title muxes more than one output file (one per video variant or audio codec). Each process uses one CPU core, so this shortens the mux step on an SSD. Set it to1on a hard disk, where parallel writes are slower."},{"location":"reference/configuration/download/#language_tags","title":"muxing:\n set_title: true\n concurrency: 4\n default_language:\n audio: en\n subtitle: en\nlanguage_tags","text":"
- Type:
dict\u00a0\u00b7\u00a0 Default:{}Language-tag remapping and the rule engine behind the
Condition Meaning{lang_tag}filename variable. When aruleslist is present, unshackle evaluates each rule in order and uses the first match'stag. A rule can examine:audioAt least one audio language matches.subs_containAt least one subtitle language matches.subs_contain_allEvery listed language must be present.dual/multi/dubbedtruematches when that filename variable is set,falsewhen it is not.Each language condition takes one language tag or a list of them. A list matches when any entry matches, except
subs_contain_all, which needs them all. An entry that is not a valid language tag (for examplejapanese) never matches.dual,multianddubbedtake a baretrueorfalse, not a quoted string. unshackle skips a rule with a condition name it does not know, or with any other value in a boolean condition. It logs a warning in each of these cases and tries the next rule.
dual,multianddubbedcarry the same meaning as the filename variables of those names. Pair one with a language to name a release yourself, in place of the fixedDUAL,MULTiandDUBBEDtext. A rule that namesdubbed: trueand a language tags a localized dub without tagging a native release the same way.language_tags:\n rules:\n - audio: [ja]\n subs_contain: [en]\n tag: \"SUBBED\"\n - audio: th\n dubbed: true\n tag: \"THAIDUB\"\n - audio: th\n dual: true\n tag: \"THAIDUAL\"\nPut
{lang_tag}where you want the nameThese rules fill
"},{"location":"reference/configuration/download/#dual_multi_mode","title":"{lang_tag}. To use your own name in place of the fixed text, keep{lang_tag?}in the template and leave out{dual?},{multi?}and{dubbed?}.dual_multi_mode","text":"
- Type:
str\u00a0\u00b7\u00a0 Default:strictRule 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\nDialects count as one language
Regional variants of the same base language (e.g.
"},{"location":"reference/configuration/drm/","title":"DRM & CDM","text":"en-USanden-GB) collapse to a single language in both modes, so they never trigger{dual}on their own.These are the config keys. For concepts, device provisioning, and the
"},{"location":"reference/configuration/drm/#cdm","title":"wvd/prdcommands see the DRM & CDM guide. Device files themselves live indirectories.wvds(.wvd) anddirectories.prds(.prd).cdm","text":"
- Type:
dict\u00a0\u00b7\u00a0 Default:{}Maps a service tag to the CDM (Widevine/PlayReady device) to use, with a
defaultfallback. Resolution is case-insensitive: an override (dl --cdm <name>, or thecdmfield of an API job) wins, then the per-service entry, thendefault. An override pins one device for the whole run and skips any quality or Widevine/PlayReady sub-entries.Over
--remote, an override also makes your device license the remote session, not the server CDM. A per-service entry does the same when it names a device without a quality: a device name, awidevineorplayreadysub-entry, or a profile ordefaultsub-entry. A per-service entry that selects only by quality, or only for a different profile, loads no device when the run starts. unshackle then shows a warning, and the server decides who licenses the remote session. With only the top-leveldefaultentry, the server CDM licenses when the API key has it. Seeremote_services.cdm:\n default: chromecdm_l3\n EXAMPLE2: android_l1\nA 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\nQuality keys can hold
1080,>=1080,>720,<=576,<480style comparisons. DRM keys arewidevine/playready. unshackle matches the quality keys first, but only when a video height is known. If the value is still a dict after that, unshackle useswidevine/playreadywhen present, or else the credential profile name, thendefault.unshackle finds the resolved name in
"},{"location":"reference/configuration/drm/#remote_cdm","title":"remote_cdmbynamefirst. If no entry matches, unshackle loads it as a local device file, in this order:<name>.prdindirectories.prds,<name>.prdindirectories.wvds, then<name>.wvdindirectories.wvds.remote_cdm","text":"
- Type:
list[dict]\u00a0\u00b7\u00a0 Default:[]A list of remote CDM definitions. unshackle matches each entry by its
name(referenced fromcdm), and itstypeselects the backend:typeBackend Key fieldsdecrypt_labsDecrypt Labs KeyXtractorhost,device_name,secret(fields)custom_apiFully YAML-configurable remote APIhost(required),device,auth,endpoints,request_mapping,response_mapping,caching,timeout(fields) (none),Device Type: PLAYREADYpyplayreadyRemoteCdmhost,secret,device_name,security_level(default 3000) (none), otherwise pywidevineRemoteCdmhost,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\nFor 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.Serve APIs behind a proxy
unshackle does not send upstream's
HEADrequest or examine theServerresponse header, so the server can operate behind a reverse proxy or a CDN that removes or changes that header. Thus ahostthat is not a serve API fails at the first licence request, not when unshackle loads the CDM. On a redirect to a different host or port, unshackle removes thesecretfrom the request.PlayReady
hostneeds a/playreadysuffixpyplayready's
"},{"location":"reference/configuration/drm/#remote-cdm-decrypt-labs","title":"RemoteCdmtreatshostas a base URL and appends its own endpoint paths, so a PlayReady entry'shostmust include the trailing/playreadysegment (for examplehttps://cdm.example.com/playready). Without it the server returns404rather than a configuration error.decrypt_labsfields","text":"Field Default Noteshosthttps://keyxtractor.decryptlabs.comdevice_nameChromeCDMChromeCDM,L1,L2(Widevine) orSL2,SL3(PlayReady)secretfromdecrypt_labs_api_keySent as thedecrypt-labs-api-keyheader. An error is raised if neither is set.system_id26830(Widevine),0(PlayReady)security_levelWidevine3; PlayReady2000forSL2, else3000unshackle uses PlayReady mode when
"},{"location":"reference/configuration/drm/#remote-cdm-custom-api","title":"device_typeisPLAYREADYordevice_namestarts withSL.custom_apifields","text":"Field Type Noteshoststr Required. Base URL of the API.devicedictname,type(CHROME,ANDROID,PLAYREADY),system_id,security_levelauthdicttype(defaultheader):headerusesheader_name(defaultAuthorization) andkey;bearerusesbearer_tokenorkey;basicusesusernameandpassword.custom_headersis merged in for any type.endpointsdictget_requestanddecrypt_response, each{path, method, timeout}request_mappingdict Per endpoint:param_names,static_params,conditional_params,transforms,nested_params,exclude_paramsresponse_mappingdict Per endpoint:fields,transforms,response_types,success_conditions,error_fields,key_fieldscachingdictenabled,use_vaults,check_cached_firstlegacydict Legacy mode options.timeoutint Default30. Request timeout in seconds.Transforms cover base64/hex/JSON encoding and
kid:keyparsing. unshackle uses PlayReady mode whendevice.typeisPLAYREADYor the device name isSL2/SL3."},{"location":"reference/configuration/drm/#decryption","title":"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\ndecryption","text":"
- Type:
strordict\u00a0\u00b7\u00a0 Default:\"shaka\"Selects the tool that physically decrypts CENC-encrypted tracks. unshackle compares the value case-insensitively.
\"mp4decrypt\"selects Bento4's mp4decrypt, and anything else (including\"shaka\") selects shaka-packager. When the value is a dict, unshackle compares the service tag case-insensitively and uses thedefaultentry as the fallback.unshackle decrypts HLS AES-128 ClearKey in-process and ignores this setting.
GlobalPer-servicedecryption: shaka\n"},{"location":"reference/configuration/drm/#decrypt_segments","title":"decryption:\n default: shaka\n EXAMPLE1: mp4decrypt\ndecrypt_segments","text":"
- Type:
bool\u00a0\u00b7\u00a0 Default:falseDecrypts each fragmented MP4 segment as it arrives, instead of decrypting the whole track after the download ends. The decryption runs during the download, so the wait after the last segment goes away. The output file is the same either way.
This works with
mp4decryptonly. Setdecryptiontomp4decryptfor the service (or as the default); withshakathe option has no effect, because shaka-packager cannot decrypt a segment without its init segment. It applies to DASH tracks and to HLS tracks that use one init segment, oneEXT-X-KEYand noEXT-X-DISCONTINUITY. Every other track keeps the whole-file decryption.No resume
A track that decrypts segment by segment always downloads from the start.
continue_downloadscannot reuse its segments, because a reused segment is already decrypted."},{"location":"reference/configuration/example/","title":"A complete annotated example","text":"decrypt_segments: true\nThe following config sets a handful of common config keys. Every config 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\nFor 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 answer with, see What each metadata provider supplies.
Key Type Default Descriptionomdb_api_keystr\"\"OMDb API key for IMDb metadata lookups; a fallback for when the keylessimdbprovider has no answer. Free keys are available on the OMDb site.tmdb_api_keystr\"\"TMDB API key for metadata enrichment and external-ID tags.simkl_client_idstr\"\"SIMKL client ID for metadata lookups; an alternative/fallback source to TMDB.tvdb_api_keystr\"\"TheTVDB v4 API key; a fallback source to TMDB, strongest on TV series. Also the source--enrich --tvdbreads. Free keys are available on the TVDB site.tvdb_pinstr\"\"Subscriber PIN, only needed for a user-supported TVDB key. Leave empty for a normal project key.tvdb_orderstr\"\"Default for--tvdb-order: renumber episodes to a TVDB season order (official,dvd,absolute,alternate,regional). Empty keeps the numbering the service gives.metadata_providerslist or map (see below) Metadata providers to use, in the order they are tried.anilist_title_languagestrenglishWhich AniList title variant to use:english,romaji, ornative.disable_metadataboolfalseSet totrueto stop all automatic metadata lookups. An ID you give yourself is still looked up.decrypt_labs_api_keystr\"\"Global Decrypt Labs API key (used by remote CDM / vault).ipinfo_api_keystr\"\"ipinfo.io API key for IP/region lookups.How
metadata_providersis appliedA lookup goes to each metadata provider in turn and stops at the first good match, so the order decides which source wins.
metadata_providerssets that order:metadata_providers: [tvdb, tmdb, simkl]\nNames 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]\nWhatever the order, unshackle skips a metadata provider that has no API key.
imdbandanilistneed no API key, so they are always available. Available names areimdb,omdb,simkl,tmdb,tvdb, andanilist. The old nameimdbapistill works and is read asimdb.
anilistis 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_languagepicksAniList 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\nA 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_metadataturns offWith
disable_metadata: true, unshackle never contacts a metadata provider on its own. The title search is dead, and that includes the keylessimdbmetadata provider. Titles, years and languages then come only from the service.disable_metadata: true\nAn ID you give with
--tmdb,--imdb,--tvdb, or--anilistis your permission to use that ID. That lookup still works. unshackle looks the ID up directly, cross references the other IDs from it, and--enrichstill reads that source. unshackle stops only the lookups you did not ask for.The
imdbprovider needs no keyIt replaces the earlier
imdbapimetadata provider, which read a third-party mirror (api.tiffara.com) and needed theimdb_api_enabledswitch. 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,
tvandmovie, and this example sets both of them and names every metadata provider, so nothing is left to the default:metadata_providers:\n tv: [tvdb, tmdb, simkl, imdb, omdb]\n movie: [tmdb, imdb, omdb, simkl, tvdb]\nEach list is independent: it both orders and filters that kind on its own, so unshackle still uses for
moviea metadata provider you leave out oftv. Here unshackle tries TVDB 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
tvcovers every episode andmoviecovers every film. unshackle only looks up metadata for those two kinds, so unshackle ignores any other config key, such asanimeormusic.
ipinfo_api_keynever touches your service sessionsunshackle sends the token only to
"},{"location":"reference/configuration/misc/#caching-updates","title":"Caching & updates","text":""},{"location":"reference/configuration/misc/#title-cache","title":"Title cache","text":"api.ipinfo.io, as a per-requestAuthorizationheader. It never goes on your service HTTP session, so it cannot leak to a streaming service. 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 \u2192ip-api.inas a last resort.unshackle caches fetched title metadata (region- and account-aware) to avoid repeat API calls.
Key Type Default Descriptiontitle_cache_enabledbooltrueMaster switch for the title cache.title_cache_timeint (seconds)1800(30 min) Lifetime of fresh cached titles.title_cache_max_retentionint (seconds)86400(24 h) Total retention window for a cached title, counted from when it was cached. Pasttitle_cache_timethe 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 Descriptionupdate_checksbooltrueWhether to check for new unshackle releases.update_check_intervalint (hours)24Minimum hours between update checks.Note
Update checks query the GitHub releases API with a fixed 5-second timeout and cache the result in
"},{"location":"reference/configuration/misc/#appearance","title":"Appearance","text":"Key Type Default Descriptiondirectories.cache/update_check.json.themestrcatppuccin-mochaNamed colour theme for all CLI output: banner, logs, progress bars, and help screens. One ofcatppuccin-mocha(aliasesdefault,catppuccin,mocha),dracula,nord,gruvbox,one-dark(aliasonedark),tokyo-night(aliastokyonight),monokai,flexoki,solarized-dark(aliassolarized). Case-insensitive; underscores work in place of hyphens. Unknown names fall back to the default with a warning. Preview them all withunshackle env theme."},{"location":"reference/configuration/misc/#logging-privacy-debug","title":"Logging, privacy & debug","text":"Key Type Default Descriptionredact_pathsbooltrueMask install-root / venv / home prefixes in logged paths (<unshackle>,<venv>,~). Setfalseto show full paths.debugboolfalseGlobal debug mode.debug_keysboolfalseLog decryption keys.debug_requestsboolfalseLog HTTP requests.set_terminal_bgboolfalseAppend the theme's background colour to output styling.Effect of
set_terminal_bgWhen on, unshackle appends the theme's background colour 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_keyson, unshackle writes the content keys to the structured debug log at INFO level during the DRM handshake. Treatdebug,debug_keys, and theunshackle_debug_*.jsonlfiles as sensitive, and keepredact_pathsenabled when sharing logs."},{"location":"reference/configuration/misc/#deprecated-removed-keys","title":"Deprecated & removed keys","text":"Key Status Behaviour
debug_keysaffects only content-encryption keys (thecontent_key/keyfields). Passwords, tokens, cookies, and session tokens are always redacted regardless of this setting, and KIDs, content key counts, and other metadata are always logged either way.curl_impersonateDeprecated \u2192 usenetworkEmits aDeprecationWarning; still honoured only ifnetworkis absent.downloaderDeprecated Any value other than\"requests\"emits aDeprecationWarning; the value is otherwise ignored (the unified requests downloader is always used).scene_namingRemoved If set to any non-null value, unshackle exits with an error directing you to configureoutput_templateinstead.imdb_api_enabledRemoved Ignored. It gated the oldimdbapiprovider, which the keylessimdbprovider 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 HTTP session.
Sub-key Type Default Descriptionbrowserstr\"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_onlybool (unset) Force HTTP/1.1.http2_onlybool (unset) Force HTTP/2.connect_timeoutint10Connect timeout in seconds.read_timeoutint30Read timeout in seconds (max gap between response chunks).timeoutint (unset) Total request timeout in seconds.allow_redirectsbool (unset) Follow redirects automatically (rnet client default when unset).pool_idle_timeoutint55Seconds an idle pooled connection is kept (default stays under typical ~60s CDN idle kills).pool_max_idle_per_hostint64Max idle connections kept per host.pool_max_sizeint (unset) Max total connections in the pool.tcp_keepaliveint30TCP keepalive time in seconds.tcp_keepalive_intervalint (unset) TCP keepalive probe interval in seconds.tcp_keepalive_retriesint (unset) TCP keepalive probe retry count.tcp_nodelaybool (unset) Disable Nagle's algorithm.network:\n browser: Firefox135\n http2_only: true\nMeasured effect of
http1_onlyBoth
http1_onlyandhttp2_onlyare 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_impersonateThe old
"},{"location":"reference/configuration/network/#headers","title":"curl_impersonatesection is a deprecated alias. If you still use it, unshackle honours it (only whennetworkis absent) but emits aDeprecationWarning. Rename it tonetwork.headers","text":"
- Type:
dict\u00a0\u00b7\u00a0 Default:{}Default HTTP headers merged into every HTTP session unshackle makes.
headers:\n Accept-Language: en-US,en;q=0.9\nDon't set
Accept-Encoding(and similar) hereCompatibility headers such as
"},{"location":"reference/configuration/network/#proxy_providers","title":"Accept-Encodingare set by the rnet HTTP backend as part of its browser-impersonation profile. If you override them, you break the impersonation fingerprint. This block is for cross-service defaults only (for exampleAccept-Language,User-Agent). Per-service headers belong in that service's own config.proxy_providers","text":"
- Type:
dict\u00a0\u00b7\u00a0 Default:{}Proxy/VPN provider configuration. Each sub-key names a proxy provider, and unshackle passes its block straight to that provider's constructor. See Proxies & VPN for the full proxy provider guide. Recognised proxy providers and their exit ports:
Provider Config key Credentials Proxy scheme/port Basic (static)basiccountry \u2192 URI(s) as specified Control DcontroldWrite API token (profile/resolver pairs optional)http://127.0.0.1:{port}(local forwarder) NordVPNnordvpnservice credentials (48 chars combined)https://...:89Surfsharksurfsharkvpnservice credentials (48 chars combined)https://...:443Windscribewindscribevpnservice credentialshttps://...:443ExpressVPNexpressvpndevice login (enable: true) / token cachehttps://cat:...@...:443ProtonVPNprotonvpnTV login or exported cookieshttps://...:4443(or:443Secure Core) Gluetungluetunper-VPN keys/credshttp://localhost:{port}(local Docker) Hola (none, auto) nonehttp://...:{peer}"},{"location":"reference/configuration/network/#controld","title":"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>\ncontrold","text":"Config keys of the Control D proxy provider. See Control D for how unshackle uses them.
Key Type Default Meaningtokenstrrequired A Control D API token of type Write.profilestrnone Optional. The ID of a profile you made for unshackle. Give it together withresolver. Without it, unshackle createsunshackle-<region>profiles itself.resolverstrnone Optional. The resolver ID of an endpoint that usesprofile, or its DoH URLhttps://dns.controld.com/<resolver>.profileslist[]Optional. More pairs, each a mapping withprofileandresolver.max_profilesint4The most profiles unshackle uses: your pairs plus theunshackle-*endpoints on the account. It must be 1 or more. unshackle creates anunshackle-<region>profile and endpoint only while the total is lower.Provider loading differs between CLI and REST server
The
"},{"location":"reference/configuration/output/","title":"Output & naming","text":"dlCLI loads all providers, includinggluetun. The REST API / remote-client path does not loadgluetun, because it starts a local Docker container. ExpressVPN and ProtonVPN also auto-load when their cached session exists, and Hola auto-loads whenever thehola-proxybinary is present.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;dlexits with an error if this is missing)Filename templates keyed by media kind, using
unshackle.yaml{variable}placeholders. Recognised kinds aremovies,series, andsongs(andalbumsfor folder templates). A trailing?on a variable (e.g.{quality?}) marks it optional: if empty, it and one adjacent separator are dropped.output_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})\"\nThe nested
foldersub-key controls output subfolders, and unshackle pops it out ofoutput_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 (
All valid template variables< > : \" / \\ | ? *), empty templates, and unknown folder-kind keys.
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}
partholds 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 shippedseriestemplate 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"},{"location":"reference/configuration/output/#tagging-naming-keys","title":"Tagging & naming keys","text":"Key Type Default Description
absoluteholds the absolute episode number, zero-padded to 3 digits (007), and is empty on titles that have none. You add it to the name yourself, and it never replaces{season}or{episode}. Any series with a TVDB absolute order can use it, anime most often. Services can supply it, and--enrichfills it in from TVDB. Use{absolute?}so the name stays clean when it is unknown.tagstr\"\"Release-group tag. Fills the{tag}template variable and is written into MKV/audio tags.tag_group_namebooltrueInclude the group name (tag) in the MKVGrouptag.tag_imdb_tmdbbooltrueWrite IMDb/TMDB/TVDB external-ID tags into the MKV (needs metadata providers).chapter_fallback_namestr\"\"Fallback template for chapter names, e.g.\"Chapter {i:02}\".unicode_filenamesboolfalseAllow Unicode in filenames; whenfalse, names are ASCII-sanitised."},{"location":"reference/configuration/output/#tag_rules","title":"tag_rules","text":"
- Type:
list\u00a0\u00b7\u00a0 Default:[]Conditionally replaces
unshackle.yamltagfor a given download. Each rule pairs awhendict of conditions with thetagto use when they hold. unshackle evaluates the rules in order and the first match wins. If no rule matches, the globaltagstays.tag_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\nRule semantics:
- Every condition in
whenmust hold (they are AND-ed).- A value can be a scalar or a list. A list matches if any entry matches.
- Comparison is case-insensitive string equality.
- The keys of
whenare the filename template variables for the release itself:title_type,quality,resolution,source,video,hdr,hfr,audio,audio_channels,audio_full,atmos,dual,multi,dubbed,edition,repack, andlang_tag. Only these are available. unshackle adds the title's own fields (title,year,season,episode, and so on) after it evaluates the rules, so a condition cannot use them.- An unknown key in
whenis a mistake: unshackle logs a warning that names the key and skips that rule. unshackle also warns about a rule without conditions or without atag, and skips it.Put the more specific rules first, because unshackle stops at the first match.
Match
hdron the exact valueMatching is exact, and
"},{"location":"reference/configuration/output/#operators","title":"Operators","text":"hdris a composite label for Dolby Vision titles: a DV title with an HDR10 base layer givesDV.HDR, and with an HDR10+ base layerDV.HDR10P. PlainDVdoes not match those. Name every variant you want, as the example above does. Note also that HDR10 givesHDR, notHDR10. The full set of values isHDR,HDR10P,DV,DV.HDR,DV.HDR10P, andHLG, as listed in the variable table.A condition value can start with a comparison operator, which unshackle applies 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 of2160p. 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, soquality: >=2160fails with a \"while scanning a block scalar\" error.!starts a tag, sohdr: !=DVfails 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_typeis the media kind of the title:movie,series, ormusic. 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\nThe resolved tag applies to the
{tag}filename variable only. TheGroupmetadata tag written into MKV and music files always usestag.
--tagwinsAn explicit
"},{"location":"reference/configuration/post-scripts/","title":"Post-scripts","text":"dl --tagon the command line disables the rules completely and uses the given tag.Operate your own script when a download finishes, with unshackle's metadata passed in as
"},{"location":"reference/configuration/post-scripts/#post_scripts","title":"{variable}placeholders. Use it to hand a finished file to an uploader, send a notification, or start a library scan.post_scripts","text":"unshackle.yaml
- Type:
list[dict]\u00a0\u00b7\u00a0 Default:[](unshackle operates no scripts)post_scripts:\n - command: \"python /opt/upload.py {filepath} --service={service} --season={season} --episode={episode} --tmdb={tmdb} --subs={sidecars}\"\n event: success # success | failure\n mode: file # file | season | run\n - command: \"python /opt/notify.py --title={title} --episode={episode}\"\nEvery entry that matches the event and mode runs, so one download can feed an uploader and a notifier without a wrapper script. You can also write an entry as a plain string, which is the same as
Key Default Meaning{command: \"...\", event: success, mode: file}.commandrequired The command to run, with{variable}placeholderseventsuccesssuccesswhen a file was written,failurewhen the download failedmodefilefile,season, orrun. See Modeswaitfalsetruemakes unshackle wait for the script to exit before carrying on. See Waiting for a scriptunshackle skips an entry whose
"},{"location":"reference/configuration/post-scripts/#how-the-command-runs","title":"How the command runs","text":"eventormodeis not one of the listed values, and logs a warning that names the bad value, once per command. unshackle also skips an entry whosecommandit cannot split into arguments, such as one with an unbalanced quote, and logs a warning. Neither stops the download or the other entries.unshackle splits the command into arguments first, and then substitutes the variables into those arguments. The process runs without a shell.
This is what keeps a title like
Bob\"; rm -rf ~harmless: a value can never turn into an extra argument or a shell operator, and you never have to think about quoting. It also means a path containing spaces always arrives as a single argument on every OS.Prefix your variables
Titles, episode names and IDs come from the streaming service, not from you. Tokenizing first stops a value becoming a new argument, but a value on its own can still be a whole argument. A bare
{title}whose value is--upload-file=/etc/passwdwould arrive as an option to your script, not as text. Write--title={title}, not a bare{title}, so a service can never forge a flag. unshackle refuses to operate a command when a substituted value would become an option-like token (one that starts with-), and logs a warning instead.No shell features, and name your interpreter
Pipes,
&&,>redirection,~and globs do not work, because unshackle uses no shell. If you want them, make the shell explicit:bash -c \"...\".For the same reason there is no interpreter lookup. Write
python /opt/upload.pyrather than/opt/upload.py. unshackle will not guess whether you meant the system Python or the one in its own virtual environment.On Windows, when a fixed part of an argument contains a space, quote the whole argument (
\"--out=C:\\My Dir\\{filename}\"), not only the value. Substituted values never need quoting on any OS.On Windows, do not target a
.bator.cmdfile directly. Windows runs batch files throughcmd.exe, whose argument quoting differs from every other program and has a history of letting a crafted value break out (CVE-2024-3566). Call the interpreter and pass the script as an argument (python upload.py,pwsh -File upload.ps1) instead.By default, scripts are fire and forget. unshackle starts the process, writes the command to the debug log, and carries on. It does not wait for the script, capture its output, time it out, or change its own exit code when a script fails. A script keeps running if unshackle exits first, and Ctrl+C during a season pack does not kill the scripts already started.
One process per file
In
"},{"location":"reference/configuration/post-scripts/#wait","title":"Waiting for a script","text":"unshackle.yamlfilemode a large season pack starts one process per episode with nothing throttling them. If your script is heavy, setwait: true, queue the work inside the script, or usemode: season.post_scripts:\n - command: \"python /opt/upload.py --file={filepath}\"\n wait: true\nWith
"},{"location":"reference/configuration/post-scripts/#variables","title":"Variables","text":"wait: true, unshackle waits until the script exits, then writes the exit code to the debug log and carries on. Because thefilepost-script runs between titles, unshackle runs one script at a time over a season pack instead of starting them all at once. unshackle only logs the exit code: a failing script does not fail the download or change unshackle's own exit code.--postscriptentries never wait.Metadata comes from the same naming context that produced the file's name, built per output file. So
Variable Value{quality}and{hdr}always give the values for the file unshackle passes in. Downloading-q 1080,2160 -r HDR10,SDRproduces four files, each with its own invocation and its own values.{filepath}Absolute path of the output file{filename}Its filename, with extension{folder}Absolute path of its parent folder{ext}Its extension, including the dot{sidecars}Sidecar subtitle files written for this output, newline separated{title}Show, movie or track name, sanitized{title_raw}Exactly what the service returned, untouched{title_id}The service's ID for the title{service}Service tag{year}{season}{episode}{episode_name}Season and episode are plain numbers (1,5){part}Part number of a split episode as a plain number (1forPart.1). Empty when the episode has no part{season_episode}{absolute}{date}As in the filename template:{season_episode}keeps the part suffix (S01E05.Part.1) while{episode}drops it{quality}{resolution}{hdr}{hfr}{vcodec}{acodec}{edition}{tag}{lang_tag}As in the filename template, for this file{tmdb}{imdb}{tvdb}The tagging IDs in use, otherwise empty. See Tagging IDs{error}Failure message. Empty on successEvery variable from your
output_templateis available here as well, including{video}and{audio}, of which{vcodec}and{acodec}are aliases.Empty variables become an empty string
--tmdb={tmdb}with no ID resolved hands your script--tmdb=, so treat an empty string as absent. Write your script to accept an empty value for every variable.Empty is common, and not only when data is missing:
- No metadata provider found an ID.
{tmdb}is empty when no metadata provider found the title, the matching API key is missing, or the title is a movie you gave no ID for.- The title has no such field. Movies have no
{season},{episode}or{part}, and music has none of them.- The naming context renders it empty on purpose.
{hdr}is empty for an SDR file, because SDR is the absence of an HDR tag in a filename rather than a tag that readsSDR.{edition},{atmos},{multi},{dual}and{hfr}behave the same way. If your script needs the wordSDR, derive it from an empty{hdr}yourself.- The post-script is a
failureone. A failed download has no output file to read metadata from, so everything that comes from the output file is empty. See Events.There is no
{variable?}conditional here, unlikeoutput_template. The token is always passed, so the argument list your script receives keeps the same shape on every run and your argument parser does not have to cope with a flag appearing and disappearing.Season and episode are the plain numbers, not the
"},{"location":"reference/configuration/post-scripts/#music-variables","title":"Music variables","text":"S01E05form used in filenames. The padding and the separator belong to the filename template rather than to the data.A music download builds its variables from the music naming context, so a post-script gets the release fields instead of the season and episode ones.
Variable Value{season},{episode},{part}and{episode_name}are always empty for music.{artist}Track artist{album_artist}Album artist, falling back to the track artist{album}Album name{track_number}{disc}Zero padded.{disc}is empty on a single-disc release{track_total}{disc_total}Zero padded totals, empty when the service did not give them{isrc}{upc}{label}{genre}As the service reported them, empty when absent{release_type}album,single,ep, and so on. A service that reports nothing givesalbum{explicit}The wordExplicitwhen the track is flagged, otherwise emptyIn a
filepost-script{title}is the track name. See Modes for what an album post-script puts in{title}."},{"location":"reference/configuration/post-scripts/#tagging-ids","title":"Tagging IDs","text":"
{year}is empty when the service gives the release no year.{ext}is the container the track arrived in, such as.flac, because a music download is not muxed.
{tmdb},{imdb}and{tvdb}carry the IDs unshackle tags the file with. unshackle reads them at the moment it dispatches the post-script, and not at the start of the run. The title search inside the download loop finds an episode's TMDB ID, so a post-script fires with it already filled in.They hold an ID you passed with
"},{"location":"reference/configuration/post-scripts/#sidecar-files","title":"Sidecar files","text":"--tmdb,--imdbor--tvdb, plus a TMDB ID the episode search resolved. No such search runs for a movie, so a movie post-script gets them empty unless you passed one.
{sidecars}holds the sidecar subtitle files written beside the output, which are the only extra files a download leaves next to it. unshackle muxes attachments such as fonts into the container and deletes temporary artwork, so neither appears. Expect{sidecars}to be empty unlesssubtitle.output_modeissidecarorboth, which is not the default. A music download writes no sidecars at all.Paths are absolute and separated by a newline, because a newline is the only character that cannot appear in a filename on any supported OS. The whole list arrives as a single argument, so your template needs no quoting.
When one download writes several outputs (
Reading {sidecars} in Python-q 1080,720), each output's folder gets its own copy of the sidecar files, and each invocation lists the copies sitting beside its own{filepath}."},{"location":"reference/configuration/post-scripts/#modes","title":"Modes","text":"subs = [p for p in sys.argv[i].removeprefix(\"--subs=\").split(\"\\n\") if p]\nmodeRuns{filepath}{sidecars}fileOnce per output file That file That output's sidecarsseasonOnce per season folder, after that season's last queued title Empty EmptyrunOnce per folder written, at the end of the run Empty Empty
seasonmeans everything this run asked forA
seasonpost-script fires when the last title this run queued for that season finishes. With-w S01-S02the S01 post-script runs while S02 is still downloading. It says nothing about whether the season is complete on disk. Downloading a single episode fires the post-script for that season as soon as the episode lands.A title that fails stops the whole run. unshackle dispatches the
failurepost-script for that title and then returns. It downloads no further title, and noseasonorrunpost-script fires after that point. This applies to every title type. If one episode of S01 fails, the S01 post-script does not fire at all.unshackle attaches the post-script to the folder, not to the season, so one season landing in several folders (
-q 1080,720with a quality-dependent folder template) fires it once per folder, each with its own{folder}. A movie is a group of its own: aseasonpost-script fires for a movie's folder as soon as the movie finishes.In
"},{"location":"reference/configuration/post-scripts/#music","title":"Music","text":"runmode every variable except{folder}is empty, because one run can cover several shows. Only{folder}is meaningful, which makes it useful for starting a library scan once.Music uses the same three modes, with the album standing in for the season.
modeRunsfileOnce per track, after its metadata has been writtenseasonOnce per album folder, after every queued track of that album has landedrunOnce per folder written, at the end of the runIn a
filepost-script{title}and{title_raw}are the track name. In an album post-script they are the album name, and the per-track variables{track_number},{disc}and{isrc}are empty, in the same way that{episode}is empty in a season post-script.An album post-script obeys the same rule as a season post-script. It fires when the last track this run queued for that album lands. This does not mean that the album is complete on disk. A one-track download fires the album post-script as soon as that track lands. If a track fails, the run stops there, so the album post-script never fires, in the same way as an episode and its season.
"},{"location":"reference/configuration/post-scripts/#events","title":"Events","text":"
successruns after the file has moved to its final path.failureruns when the download fails, with{filepath}empty and{error}set. Each track of an album is its own title. A musicfailurepost-script therefore describes the track that failed, not the release.
failureonly fires infilemodeA
failureentry withmode: seasonormode: runnever runs. Leavemodeat its default on a failure entry.A
failurepost-script has no output file to read metadata from, so it carries only what the title object already knew:{title},{title_raw},{title_id},{year},{season},{episode},{part},{season_episode},{absolute},{date},{episode_name}, the music release fields such as{artist}and{album},{service}, the tagging IDs and{error}. Everything that comes from the output file, such as{quality},{hdr},{vcodec}and{acodec}, is empty.A resumed
--continue-downloadsdownload that finishes runssuccessas usual. unshackle sees your script's exit code only when that entry setswait: true, and even then it only writes the code to the debug log. Nothing else reports a failing script. Your script owns its own error handling and logging.A
"},{"location":"reference/configuration/post-scripts/#-postscript","title":"successpost-script always names a file this run wrote. There is no skip-if-exists path indltoday, so nothing fires for a title that was not downloaded.--postscript","text":"
dlaccepts--postscript \"<command>\", repeatable. Every command given this way runs withevent: successandmode: file.$ unshackle dl --postscript \"python /opt/upload.py {filepath}\" SERVICE TITLE_ID\nIt replaces the
"},{"location":"reference/configuration/post-scripts/#-no-postscript","title":"post_scriptsconfig for that run rather than adding to it, so no configured entry runs while it is in use. That includes yourseason,runandfailureentries, which have no--postscriptequivalent.--no-postscript","text":"
dl --no-postscriptoperates the run with no post-script. It silences every configured entry and every--postscriptcommand, for each event and mode. Use it to skip an uploader or a notifier for one run without an edit tounshackle.yaml."},{"location":"reference/configuration/post-scripts/#the-rest-api","title":"The REST API","text":"$ unshackle dl --no-postscript SERVICE TITLE_ID\nThe API never accepts a post-script command.
postscript,post_scriptandpost_scriptsin aPOST /api/downloadbody get400 INVALID_PARAMETERS. A command that arrives from an HTTP caller would be remote code execution.unshackle does operate the scripts defined in
"},{"location":"reference/configuration/post-scripts/#logging","title":"Logging","text":"unshackle.yamlfor API jobs, music jobs included. Only the command itself may not cross the network.unshackle logs the command it operated twice: at
DEBUGlevel on the console, and as apost_script_dispatchentry in the structured JSON debug log when that log is on.unshackle masks both the same way, and honours
redact_paths, which is on by default. Debug logs get shared in issue reports, and a post-script command line is mostly paths. URL credentials andtoken=/api_key=query parameters are also masked.Do not hardcode secrets in the command
unshackle cannot recognise an arbitrary secret in the command line, such as
"},{"location":"reference/configuration/post-scripts/#limitations","title":"Limitations","text":"-H \"Authorization: Bearer <key>\", and logs it verbatim. Read the secret from a file or an environment variable inside your script rather than putting it in thecommand."},{"location":"reference/configuration/services/","title":"Services & authentication","text":""},{"location":"reference/configuration/services/#services","title":"
- No
successpost-script operates under--no-muxor--skip-dl, because neither writes a muxed output. Afailurepost-script still runs under--no-muxif the download itself fails.failurepost-scripts only operate infilemode.- There is no webhook sender. Call
curlfrom a script instead.- Script output is not captured. Redirect it inside your own script if you want a log.
services","text":"
- Type:
dictkeyed 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/--proxyfor this service. A config key is that query, or\"provider:query\"when the flag also named a proxy provider. The value is the query asked of the proxy provider instead. It has no effect when you give-pa 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 globaldlblock.Individual services may read any additional keys they define. unshackle hands the merged result (the service's own
config.yamlwith this block layered on top) to the service asself.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."},{"location":"reference/configuration/services/#credentials","title":"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\"\ncredentials","text":"
- Type:
dictkeyed 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/--profileselects the entry, and unshackle uses thedefaultconfig key when you give no profile, or when the named profile is missing. unshackle parses these intoCredentialobjects. It also uses the credential's SHA-1 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\nCookies vs credentials
unshackle stores cookies as files under
"},{"location":"reference/configuration/services/#firefox_cookies","title":"directories.cookies, not in this config key. A service'sauthenticate()accepts cookies, credentials, or both.firefox_cookies","text":"
- Type:
dictkeyed by service tag \u00a0\u00b7\u00a0 Default:{}Settings for extracting cookies directly from a local Firefox profile. A service block must give
hosts, a list of cookie hostnames. unshackle ignores an entry shorter than 3 characters, to prevent a broad match dumping most of the profile. With an optionallocal_storageboolean, unshackle also pulls matching entries fromwebappsstore.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.sqliteand its WAL file into a0700temp directory, so the copy holds the writes Firefox has not yet flushed to the main DB. 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 (
"},{"location":"reference/configuration/services/#remote_services","title":"cookies/<SERVICE>.txtorcookies/<SERVICE>/<profile>.txt), with no error reported.remote_services","text":"
- Type:
dict\u00a0\u00b7\u00a0 Default:{}Definitions of remote unshackle service servers, used by the
--remotemode. Each entry is named by you (pick it with--server, or do not write that flag when you configure only one) and gives the server'surl(required), an optionalapi_key, an optionalauth_headerslist, an optionalserver_cdmboolean, an optionaltimeoutin seconds, and an optionalservicessub-dict of per-service local overrides such astitle_map.Leave
server_cdmunset and the client follows the server: it uses the server CDM for each service the API key has it on, and the local CDM for the others.trueasks for the server CDM and falls back to the local CDM when the server refuses;falsealways uses the local CDM.dl --cdm <name>on a remote run has the same effect asfalsefor that run: the device you name licenses the remote session. Acdmentry for the service has the same effect for that service when it names the device that unshackle loaded. Both take priority overserver_cdm: true. Remove the entry to use the server CDM for that service. Thedefaultentry does not count.The server has the last word. When it keeps the server CDM for a remote session that your device was to license, unshackle shows a warning. This occurs with a server that does not read your choice, and with a service option that runs the remote session on the server's device.
auth_headerslists extra header names to send the API key in, tried before the defaultsX-Secret-KeyandX-Api-Key, which unshackle always appends as fallbacks. It sends the first name. If the server answers401, it retries the same request with the next name, and keeps the one that works for the rest of the HTTP session. Names you give keep your spelling and are not repeated in the fallbacks, so unshackle triesauth_headers: [\"Authorization\", \"x-secret-key\"]asAuthorization,x-secret-key,X-Api-Key.
timeoutis the time in seconds that the client waits for data from the server. The default is120. The server sends a heartbeat every 30 seconds while it gets titles, tracks or licences, so a slow service does not cause a timeout. Increasetimeoutonly when the server runs an older version that sends no heartbeat. Give a number of seconds. With a value below 30 the client can time out before the first heartbeat.0means the default.In
"},{"location":"reference/configuration/services/#serve","title":"--remotemode unshackle turns the server's service list into synthetic CLI commands that operate against it, falling back to the tags in thatservicessub-dict when unshackle cannot fetch the list. Each synthetic command carries the server-side service's options and documentation, sounshackle dl --remote <TAG> -hshows the same help text as it does on the server. Service names resolve against the server's tags and aliases, not your local services. If the server does not offer a name to your API key, unshackle rejects the name and lists the tags it does offer. See remote sessions for the full setup.serve","text":"
- Type:
dict\u00a0\u00b7\u00a0 Default:{}Configuration for the
Sub-key Type Default Descriptionservecommand (the built-in REST API server). The full server guide is the REST API section. These are the config keys.api_secretstr (unset) Master secret the server accepts in theX-Secret-Keyheader. Required unless you start the server with--no-key.usersdict{}Per-user API keys and their allowlists (see below).tiersdict{}Named settings thatusersentries reference by name (see below).serviceslist (unset) Global service allowlist. Omit to allow all.remote_onlyboolfalseExpose only the remote service session endpoints (health, services, search, session) and disable the rest of the REST API.dashboarddict (unset) Developer dashboard:keyis the API key for the read-only/api/dashboard/endpoints (status, remote sessions, remote session logs, jobs, keys, services, health, logs, SSE events). Unset leaves those routes unregistered. See dashboard endpoints.session_ttlint (s)300Seconds a remote session can stay idle before it expires. A remote client sends keep-alive requests to its remote session during a download, so the remote session does not expire during a long download.max_sessionsint or null100Maximum concurrent remote sessions; at the cap the server evicts the oldest. Set it tonullor0for no limit, which is also what/api/dashboard/statusthen reports, so a dashboard can tell \"no cap\" from a cap that happens to sit at the default.history_limitint100How many finished jobs to retain in history.compression_levelint1gzip level for responses.services_refresh_intervalint (s)0How often the server pulls the git-backed service repositories indirectories.servicesand hot-reloads the services that changed.0turns it off. A service with a running or queued job, or a live remote session, is staged and swaps to the new code as soon as its last job and session finish.services_staged_max_ageint (s)0How long a staged service waits for its jobs and sessions before the server swaps it in anyway.0waits forever. Set it on a server whose popular services never have a quiet moment. A session that is live at the swap keeps the old code until it ends; a running job keeps its old code too.global_speed_limitstr (unlimited) Server-wide download speed cap, e.g.10M,1.5Gor plain bytes/sec (same format asspeed_limit). One shared budget across all concurrent jobs; the server ignores per-job speed limits while it is set.cdm_overrideslist or bool (unset) Allowed per-request CDM overrides: a list of permitted device names, ortruefor any. Unset rejects every override.allow_job_credentialsboolfalsePermit clients to supply credentials per job.server_accountsdict{}Services whose remote sessions use the server's owncredentialsand cookie files instead of the client's (see below).deviceslist (auto) Widevine devices offered; auto-filled fromdirectories.wvds.playready_deviceslist (auto) PlayReady devices; auto-filled fromdirectories.prds.Each entry under
usersuses that user's API key as its name, and can set its ownservices,devices, andplayready_devicesallowlists, narrowing the global ones, plus an optionalusernameused as the log label for that API key (defaults to a truncated form of the API key). A user with noplayready_devicesconfig key gets no PlayReady access at all, not the global list.
server_cdmdecides whether the server runs the CDM licensing for that API key. Set it totrueto enable every service, or to a list of service tags to enable only those. It isfalseunless the entry sets it. For a service the API key does not cover, the server tells a remote client configured withserver_cdm: trueto license with its own local CDM instead, and a client that asks anyway gets aFORBIDDENerror. Because a download job always licenses with the server's CDM, an API key withoutserver_cdmfor that service also cannot submit or retry/api/downloadjobs. Keys that have nousersentry, such asapi_secret, keep server CDM access.A remote client that picks its own device licenses the remote session with that device. It picks with
dl --cdm, with acdmentry for the service, or withserver_cdm: falsein itsremote_servicesentry. The server CDM makes no licence for that remote session, with or without a limit. A service option that runs the remote session on the server's device, such as a server-identity flag, keeps the server CDM.
server_cdm_max_heightlimits the live licences the server's device makes for that API key. Set it to a height in pixels for every service, or to a map of service tag to height with an optionaldefaultentry. A service the map does not name or sets tonullgets thedefault, and with nodefaultit has no limit. The server compares the limit with the lowest-qthat selects the track: the track height, or the 16:9 height from the width when that is lower. The server reads the remote client's-qwhen the remote session starts:
- With no
-q, or a-qat or below the limit, the server CDM licenses the remote session. The server refuses a live licence for a video track taller than the limit, and the client licenses that track with its own local CDM.- With a
-qabove the limit, the client's own local CDM licenses every track of the remote session. The service sees the DRM system and security level of the client's device. A client with no local CDM gets aSERVER_CDM_CAPPEDerror.- A download job must pass a
qualityat or below the limit, and must not setbest_availableor aHYBRIDrange.HYBRIDalways keeps the lowest Dolby Vision track, at any quality. A job with noquality, with a higher one, withbest_available, or withHYBRIDgets aSERVER_CDM_CAPPEDerror. The server checks the values the job runs with, after it applies the service defaults and theserve:download defaults.The limit protects the server's device, not the content keys. For a Widevine track, a server vault still supplies a content key at every height. A PlayReady track never reads the vault, so above the limit it always goes to the client's device. A licence for a lower track can carry the content key of a higher track when the service shares one content key between them. With server accounts enabled, a client that reports a stronger device than it holds makes the server's account request those manifests; the licence server still refuses its challenge.
server_vaultlets a remote session that the client's own device licenses take content keys from the server vault. Set it totruefor every service, or to a list of service tags. It isfalseunless the entry sets it, andserver_cdmfor a service implies it. The server looks up every KID of the track in its vault and makes no live licence. A track that the vault does not hold goes back to the client's device. When the service runs the remote session on the server's own device (a service option, such as a server-identity flag), the client's own vaults, and the server vault with this grant, are the only source, and a missing content key stops the download.
adminis a boolean that lets the API key run the maintenance endpoints (clear-cache, clear-temp, refresh-services). It isfalseunless the entry sets it. Keys that have nousersentry, such asapi_secret, keep that access.A
tiernames an entry underserve.tiers, which holds the settings that several API keys share. Today a tier carriesrate_limit, the requests per hour that API key may make; an API key can also set its ownrate_limit, which wins over its tier's. An API key with neither has no limit. Going over the limit answers429with aRetry-Afterheader. The server keeps a fixed window rather than a sliding one: it opens on the first request and resets an hour later. Atierthat names no entry underserve.tiers, or arate_limitthat is not a positive whole number, stops the server at startup, because the alternative is an API key that silently gets no limit. The rate limit never applies to the dashboard key, and never to/api/health.
server_accountslists, per service, the accounts the server lends to remote sessions. A remote session for a listed service ignores the credentials, cookies, and cache the client sends and uses one of the server's own profiles fromcredentials. For a service that is not listed the server never uses its own accounts: a client that sends nothing authenticates with nothing.credentials:\n EXAMPLE1:\n ca_main: user1@example.com:pw1\n eu_box: user2@example.com:pw2\n shared: user3@example.com:pw3\n\nserve:\n server_accounts:\n EXAMPLE1:\n ca_main: ca # one country\n eu_box: [gb, fr, de] # a list of countries\n shared: global # any region\n EXAMPLE2: true # every profile (or the single credential), any region\nEach entry under a service names a profile from
credentials, or a cookie filecookies/<TAG>/<profile>.txtfor a service that has no credentials. Its value is the region or regions that account works in: a two-letter country code, a list of them, orglobal. Quoteno(Norway), because YAML reads the bare word asfalse.The region of a remote session is the country the client asked for with
--proxy(ca,ca1,provider:ca), the server's own region with--no-proxy, or the client's own region otherwise. A full proxy URI carries no country, so with one only aglobalaccount matches. The server picks the profiles that cover that region (or areglobal) and rotates through them round-robin, one account per remote session. With no matching account it rejects the remote session. These services ignore the client's-p/--profile.Cookies come only from the profile's own file (
cookies/EXAMPLE1/ca_main.txt; the server does not read a barecookies/EXAMPLE1.txt), and each account keeps its own persistent cache undercache/_accounts/EXAMPLE1/<profile>/, so refreshed tokens survive the remote session but never cross accounts.serverefuses to start when a listed profile has neither an entry undercredentialsnor a cookie file./api/search,/api/list-titlesand/api/list-tracksuse the same pool, matched on the client's reported region, and returnFORBIDDENto an API key without the opt-in. Rotation is per process and does not track which accounts are in use, so concurrent remote sessions can share one account.A
usersentry opts in to those accounts withserver_accounts: true(every listed service) or a list of service tags. It is off unless set: an API key without it authenticates with the credentials the client sends, as for any other service, and/api/servicesdoes not advertise the server's accounts to it. API keys with nousersentry, such asapi_secret, keep access.
server_proxydecides whether that API key may use the proxy providers the server has configured. There is no implicit access: only the booleantrueon the API key'susersentry grants it, and an API key without ausersentry has no access either. Without the opt-in the server never spends its own proxy subscriptions on a remote client: not to resolve a country code inproxy, not to match the client's region, and not for a service geofence. This applies to remote sessions, search, title lists, and/api/downloadjobs alike. Such a client must send a full proxy URI, orno_proxyto accept the server's own connection. Setserver_proxy: trueto let that API key send a country code or aprovider:countryvalue, and to let the server pick a proxy for the client's region and for a service geofence. The client reports its own region; a client that reports none is not blocked.serve:\n api_secret: change-me\n remote_only: true\n services: [EXAMPLE1, EXAMPLE2]\n services_refresh_interval: 3600 # pull the service repositories hourly and reload what changed\n # server-wide download defaults (same keys as the `dl:` block)\n downloads: 3\n best_available: true\n tiers:\n bot: { rate_limit: 600 } # requests per hour, for every key on this tier\n users:\n a1b2c3d4: # this user's API key\n services: [EXAMPLE1] # may only use EXAMPLE1\n tier: bot # 600 requests/hour; 429 with Retry-After once over\n e5f6a7b8:\n server_cdm: true # this key may have the server do the licensing\n server_cdm_max_height: 1080 # above 1080p the client licenses with its own device\n c9d0e1f2:\n services: [EXAMPLE1, EXAMPLE2]\n server_cdm: [EXAMPLE1] # the server licenses only EXAMPLE1; EXAMPLE2 needs a local CDM\n server_accounts: [EXAMPLE1] # may authenticate EXAMPLE1 with one of the server's own accounts\n server_proxy: true # this key may also use the server's proxy providers\n f3a4b5c6:\n services: [EXAMPLE1]\n server_vault: [EXAMPLE1] # the client's device licenses; the server vault still serves keys\n
dlkeys insideserveMost
"},{"location":"reference/configuration/vaults/","title":"Key vaults","text":"dlflag keys (downloads,workers,best_available, and so on) can be set directly insideserve, 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.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 the content keys instead of licensing them again. Each entry needs a
type(the backend module name) and aname, plus backend-specific keys.typePurpose Required keys NotesSQLiteLocal SQLite databasename,pathLoaded critically; a failure aborts the run.MySQLRemote MySQL databasename,host,database,usernameExtra keys (e.g.password,port) forwarded to pymysql.connect_timeoutdefaults tovault_timeout.APIRESTful JSON APIname,uri,tokenOptionalheadersmap is sent with every request. Honoursvault_timeout.HTTPHTTP API with modesname,host, one ofpassword/api_key, andusernamein query modeapi_mode:query(default),json,decrypt_labs. Honoursvault_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\nPer-entry options
no_push: truemakes a vault read-only (unshackle fetches content keys from it but never writes to it).- A vault of
type: APIwhosenamecontainsdecrypt_labsauto-fills itstokenfromdecrypt_labs_api_keywhen not set inline. Vaulttypevalues are case-sensitive module names.- unshackle treats an all-zero content key (32 zeros) as \"no key\" everywhere and never stores it.
Bad content keys
A content key from a vault is proven by short FFmpeg decodes of the decrypted output, with windows inside the fragments of each KID in a fragmented MP4. A content key that fails the decode is written to the
"},{"location":"reference/configuration/vaults/#vault_timeout","title":"bad_keystable of everySQLitevault with the name of the vault that supplied it, and every vault lookup skips a flagged pair from then on. ASQLitevault also refuses to store a flagged pair again. Only theSQLitebackend stores flags; anAPIvault that supplied the pair gets a report. Aserveinstance withserver_cdmflags a pair the same way when a remote client reports it, so a server whosekey_vaultshold noSQLiteentry cannot remember a bad content key.vault_timeout","text":"
- Type:
float\u00a0\u00b7\u00a0 Default:10.0Timeout in seconds for vault operations. Injected automatically into any backend whose constructor accepts a
"}]} \ No newline at end of file diff --git a/sitemap.xml b/sitemap.xml index c2bcc2ce..8d399afb 100644 --- a/sitemap.xml +++ b/sitemap.xml @@ -2,154 +2,154 @@timeoutparameter (a per-vaulttimeoutstill wins). TheMySQLbackend uses it as the defaultconnect_timeout.\ No newline at end of file diff --git a/sitemap.xml.gz b/sitemap.xml.gz index 2c1f96e999eb82f3c04b698e96b1cb27527b7491..7f888807f40d729af064e4dd5ed60f1e17fbaaab 100644 GIT binary patch literal 509 zcmV https://docs.unshackle.dev/ -2026-10-02 +2026-10-05 https://docs.unshackle.dev/dev/creating-a-service/ -2026-10-02 +2026-10-05 https://docs.unshackle.dev/dev/service-architecture/ -2026-10-02 +2026-10-05 https://docs.unshackle.dev/dev/rest-api/ -2026-10-02 +2026-10-05 https://docs.unshackle.dev/dev/rest-api/authentication/ -2026-10-02 +2026-10-05 https://docs.unshackle.dev/dev/rest-api/dashboard/ -2026-10-02 +2026-10-05 https://docs.unshackle.dev/dev/rest-api/endpoints/ -2026-10-02 +2026-10-05 https://docs.unshackle.dev/dev/rest-api/errors/ -2026-10-02 +2026-10-05 https://docs.unshackle.dev/dev/rest-api/job-lifecycle/ -2026-10-02 +2026-10-05 https://docs.unshackle.dev/dev/rest-api/quickstart/ -2026-10-02 +2026-10-05 https://docs.unshackle.dev/dev/rest-api/remote-sessions/ -2026-10-02 +2026-10-05 https://docs.unshackle.dev/getting-started/configuration-file/ -2026-10-02 +2026-10-05 https://docs.unshackle.dev/getting-started/installation/ -2026-10-02 +2026-10-05 https://docs.unshackle.dev/getting-started/quickstart/ -2026-10-02 +2026-10-05 https://docs.unshackle.dev/guide/cli-reference/ -2026-10-02 +2026-10-05 https://docs.unshackle.dev/guide/downloading/ -2026-10-02 +2026-10-05 https://docs.unshackle.dev/guide/drm-and-cdm/ -2026-10-02 +2026-10-05 https://docs.unshackle.dev/guide/output-and-naming/ -2026-10-02 +2026-10-05 https://docs.unshackle.dev/guide/proxies-and-vpn/ -2026-10-02 +2026-10-05 https://docs.unshackle.dev/guide/subtitles/ -2026-10-02 +2026-10-05 https://docs.unshackle.dev/guide/troubleshooting/ -2026-10-02 +2026-10-05 https://docs.unshackle.dev/guide/vaults/ -2026-10-02 +2026-10-05 https://docs.unshackle.dev/reference/api/config/ -2026-10-02 +2026-10-05 https://docs.unshackle.dev/reference/api/drm/ -2026-10-02 +2026-10-05 https://docs.unshackle.dev/reference/api/service/ -2026-10-02 +2026-10-05 https://docs.unshackle.dev/reference/api/titles/ -2026-10-02 +2026-10-05 https://docs.unshackle.dev/reference/api/tracks/ -2026-10-02 +2026-10-05 https://docs.unshackle.dev/reference/configuration/ -2026-10-02 +2026-10-05 https://docs.unshackle.dev/reference/configuration/directories/ -2026-10-02 +2026-10-05 https://docs.unshackle.dev/reference/configuration/download/ -2026-10-02 +2026-10-05 https://docs.unshackle.dev/reference/configuration/drm/ -2026-10-02 +2026-10-05 https://docs.unshackle.dev/reference/configuration/example/ -2026-10-02 +2026-10-05 https://docs.unshackle.dev/reference/configuration/misc/ -2026-10-02 +2026-10-05 https://docs.unshackle.dev/reference/configuration/network/ -2026-10-02 +2026-10-05 https://docs.unshackle.dev/reference/configuration/output/ -2026-10-02 +2026-10-05 https://docs.unshackle.dev/reference/configuration/post-scripts/ -2026-10-02 +2026-10-05 https://docs.unshackle.dev/reference/configuration/services/ -2026-10-02 +2026-10-05 https://docs.unshackle.dev/reference/configuration/vaults/ -2026-10-02 +2026-10-05 zb|1j8C&7*J(3x?PzI_cz56N9si2(^=jOG7h>^XSzdg ulEb=c;{Lm1~^f z+M2FSMAwe9aGA!V<2onqtUE@iJ)D|NGY !7Gq24fPDrH{ |g-vVTMFi86oGoQ;)6%+sfP?`3H literal 508 zcmV rN4B59emvj0652Z&`Zu?Pk6CU|!c|y(vTPeKTx57Va zjkhDPQhwQ3!$9ZYV0MJNuCe60Vm>FC=7%6)F?I-g!Zu3>NrEfufk}_UTkiH~$?G9N z@&kq>tcXB_q^SQL_a=q|xF0Smdc%7T?!;nA^cV Z5DwtU^xu2qcF%aJo=t 1<3SZkoG6bW&WiV6aWBGKJ}yk