From 0f0dfe696d6a6bbb091a2f4605b07e57e33b9b26 Mon Sep 17 00:00:00 2001 From: <> Date: Thu, 2 Jul 2026 15:20:50 +0000 Subject: [PATCH] Deployed ef2e880 with MkDocs version: 1.6.1 --- .nojekyll | 0 404.html | 1234 +++ ADVANCED_CONFIG/index.html | 2088 +++++ API/index.html | 1333 ++++ CNAME | 1 + CREATING_A_SERVICE/index.html | 2120 ++++++ DEBUG_LOGGING/index.html | 1565 ++++ DOWNLOAD_CONFIG/index.html | 2090 +++++ DRM_CONFIG/index.html | 2315 ++++++ GLUETUN/index.html | 2395 ++++++ NETWORK_CONFIG/index.html | 1997 +++++ OUTPUT_CONFIG/index.html | 2343 ++++++ SERVICE_CONFIG/index.html | 1916 +++++ SUBTITLE_CONFIG/index.html | 1529 ++++ api/authentication/index.html | 1507 ++++ api/client-configuration/index.html | 1514 ++++ api/download-job-lifecycle/index.html | 1397 ++++ api/endpoint-map/index.html | 1424 ++++ api/endpoints/index.html | 2256 ++++++ api/error-responses/index.html | 1343 ++++ api/quick-start/index.html | 1391 ++++ api/remote-service-sessions/index.html | 1860 +++++ assets/favicon.svg | 1 + assets/images/favicon.png | Bin 0 -> 1870 bytes assets/javascripts/bundle.79ae519e.min.js | 16 + assets/javascripts/bundle.79ae519e.min.js.map | 7 + assets/javascripts/lunr/min/lunr.ar.min.js | 1 + assets/javascripts/lunr/min/lunr.da.min.js | 18 + assets/javascripts/lunr/min/lunr.de.min.js | 18 + assets/javascripts/lunr/min/lunr.du.min.js | 18 + assets/javascripts/lunr/min/lunr.el.min.js | 1 + assets/javascripts/lunr/min/lunr.es.min.js | 18 + assets/javascripts/lunr/min/lunr.fi.min.js | 18 + assets/javascripts/lunr/min/lunr.fr.min.js | 18 + assets/javascripts/lunr/min/lunr.he.min.js | 1 + assets/javascripts/lunr/min/lunr.hi.min.js | 1 + assets/javascripts/lunr/min/lunr.hu.min.js | 18 + assets/javascripts/lunr/min/lunr.hy.min.js | 1 + assets/javascripts/lunr/min/lunr.it.min.js | 18 + assets/javascripts/lunr/min/lunr.ja.min.js | 1 + assets/javascripts/lunr/min/lunr.jp.min.js | 1 + assets/javascripts/lunr/min/lunr.kn.min.js | 1 + assets/javascripts/lunr/min/lunr.ko.min.js | 1 + assets/javascripts/lunr/min/lunr.multi.min.js | 1 + assets/javascripts/lunr/min/lunr.nl.min.js | 18 + assets/javascripts/lunr/min/lunr.no.min.js | 18 + assets/javascripts/lunr/min/lunr.pt.min.js | 18 + assets/javascripts/lunr/min/lunr.ro.min.js | 18 + assets/javascripts/lunr/min/lunr.ru.min.js | 18 + assets/javascripts/lunr/min/lunr.sa.min.js | 1 + .../lunr/min/lunr.stemmer.support.min.js | 1 + assets/javascripts/lunr/min/lunr.sv.min.js | 18 + assets/javascripts/lunr/min/lunr.ta.min.js | 1 + assets/javascripts/lunr/min/lunr.te.min.js | 1 + assets/javascripts/lunr/min/lunr.th.min.js | 1 + assets/javascripts/lunr/min/lunr.tr.min.js | 18 + assets/javascripts/lunr/min/lunr.vi.min.js | 1 + assets/javascripts/lunr/min/lunr.zh.min.js | 1 + assets/javascripts/lunr/tinyseg.js | 206 + assets/javascripts/lunr/wordcut.js | 6708 +++++++++++++++++ .../workers/search.2c215733.min.js | 42 + .../workers/search.2c215733.min.js.map | 7 + assets/stylesheets/main.484c7ddc.min.css | 1 + assets/stylesheets/main.484c7ddc.min.css.map | 1 + assets/stylesheets/palette.ab4e12ef.min.css | 1 + .../stylesheets/palette.ab4e12ef.min.css.map | 1 + index.html | 1421 ++++ search/search_index.json | 1 + sitemap.xml | 83 + sitemap.xml.gz | Bin 0 -> 398 bytes 70 files changed, 44401 insertions(+) create mode 100644 .nojekyll create mode 100644 404.html create mode 100644 ADVANCED_CONFIG/index.html create mode 100644 API/index.html create mode 100644 CNAME create mode 100644 CREATING_A_SERVICE/index.html create mode 100644 DEBUG_LOGGING/index.html create mode 100644 DOWNLOAD_CONFIG/index.html create mode 100644 DRM_CONFIG/index.html create mode 100644 GLUETUN/index.html create mode 100644 NETWORK_CONFIG/index.html create mode 100644 OUTPUT_CONFIG/index.html create mode 100644 SERVICE_CONFIG/index.html create mode 100644 SUBTITLE_CONFIG/index.html create mode 100644 api/authentication/index.html create mode 100644 api/client-configuration/index.html create mode 100644 api/download-job-lifecycle/index.html create mode 100644 api/endpoint-map/index.html create mode 100644 api/endpoints/index.html create mode 100644 api/error-responses/index.html create mode 100644 api/quick-start/index.html create mode 100644 api/remote-service-sessions/index.html create mode 100644 assets/favicon.svg create mode 100644 assets/images/favicon.png create mode 100644 assets/javascripts/bundle.79ae519e.min.js create mode 100644 assets/javascripts/bundle.79ae519e.min.js.map create mode 100644 assets/javascripts/lunr/min/lunr.ar.min.js create mode 100644 assets/javascripts/lunr/min/lunr.da.min.js create mode 100644 assets/javascripts/lunr/min/lunr.de.min.js create mode 100644 assets/javascripts/lunr/min/lunr.du.min.js create mode 100644 assets/javascripts/lunr/min/lunr.el.min.js create mode 100644 assets/javascripts/lunr/min/lunr.es.min.js create mode 100644 assets/javascripts/lunr/min/lunr.fi.min.js create mode 100644 assets/javascripts/lunr/min/lunr.fr.min.js create mode 100644 assets/javascripts/lunr/min/lunr.he.min.js create mode 100644 assets/javascripts/lunr/min/lunr.hi.min.js create mode 100644 assets/javascripts/lunr/min/lunr.hu.min.js create mode 100644 assets/javascripts/lunr/min/lunr.hy.min.js create mode 100644 assets/javascripts/lunr/min/lunr.it.min.js create mode 100644 assets/javascripts/lunr/min/lunr.ja.min.js create mode 100644 assets/javascripts/lunr/min/lunr.jp.min.js create mode 100644 assets/javascripts/lunr/min/lunr.kn.min.js create mode 100644 assets/javascripts/lunr/min/lunr.ko.min.js create mode 100644 assets/javascripts/lunr/min/lunr.multi.min.js create mode 100644 assets/javascripts/lunr/min/lunr.nl.min.js create mode 100644 assets/javascripts/lunr/min/lunr.no.min.js create mode 100644 assets/javascripts/lunr/min/lunr.pt.min.js create mode 100644 assets/javascripts/lunr/min/lunr.ro.min.js create mode 100644 assets/javascripts/lunr/min/lunr.ru.min.js create mode 100644 assets/javascripts/lunr/min/lunr.sa.min.js create mode 100644 assets/javascripts/lunr/min/lunr.stemmer.support.min.js create mode 100644 assets/javascripts/lunr/min/lunr.sv.min.js create mode 100644 assets/javascripts/lunr/min/lunr.ta.min.js create mode 100644 assets/javascripts/lunr/min/lunr.te.min.js create mode 100644 assets/javascripts/lunr/min/lunr.th.min.js create mode 100644 assets/javascripts/lunr/min/lunr.tr.min.js create mode 100644 assets/javascripts/lunr/min/lunr.vi.min.js create mode 100644 assets/javascripts/lunr/min/lunr.zh.min.js create mode 100644 assets/javascripts/lunr/tinyseg.js create mode 100644 assets/javascripts/lunr/wordcut.js create mode 100644 assets/javascripts/workers/search.2c215733.min.js create mode 100644 assets/javascripts/workers/search.2c215733.min.js.map create mode 100644 assets/stylesheets/main.484c7ddc.min.css create mode 100644 assets/stylesheets/main.484c7ddc.min.css.map create mode 100644 assets/stylesheets/palette.ab4e12ef.min.css create mode 100644 assets/stylesheets/palette.ab4e12ef.min.css.map create mode 100644 index.html create mode 100644 search/search_index.json create mode 100644 sitemap.xml create mode 100644 sitemap.xml.gz diff --git a/.nojekyll b/.nojekyll new file mode 100644 index 00000000..e69de29b diff --git a/404.html b/404.html new file mode 100644 index 00000000..a6bd7cce --- /dev/null +++ b/404.html @@ -0,0 +1,1234 @@ + + + +
+ + + + + + + + + + + + + + + + + + + +This document covers advanced features, debugging, and system-level configuration options.
+Configuration for the integrated server that provides CDM endpoints (Widevine/PlayReady) and a REST API for remote downloading.
+Start the server with:
+unshackle serve # Default: localhost:8786
+unshackle serve -h 0.0.0.0 -p 8888 # Listen on all interfaces
+unshackle serve --no-key # Disable authentication
+unshackle serve --api-only # REST API only, no CDM endpoints
+unshackle serve --remote-only # Only expose remote service session endpoints
+| Option | +Default | +Description | +
|---|---|---|
-h, --host |
+127.0.0.1 |
+Host to serve from | +
-p, --port |
+8786 |
+Port to serve from | +
--caddy |
+false |
+Also serve with Caddy reverse-proxy for HTTPS | +
--api-only |
+false |
+Serve only the REST API, disable CDM endpoints | +
--no-widevine |
+false |
+Disable Widevine CDM endpoints | +
--no-playready |
+false |
+Disable PlayReady CDM endpoints | +
--no-key |
+false |
+Disable API key authentication (allows all requests) | +
--debug-api |
+false |
+Include tracebacks and stderr in API error responses | +
--debug |
+false |
+Enable debug logging for API operations | +
--remote-only |
+false |
+Only expose remote service session endpoints (health, services, search, session) | +
api_secret - Secret key for REST API authentication. Required unless --no-key is used. All API requests must include this key via the X-Secret-Key header.compression_level - Compression level for API payloads (manifests, cache, cookies). 0=off, 1=fast, 6=balanced, 9=max. Default: 1.session_ttl - Session inactivity timeout in seconds. Each request resets the timer. Default: 300.max_sessions - Maximum concurrent sessions before the oldest is evicted. Default: 100.services - Optional global service allowlist. Only these service tags are exposed. If omitted, all services are available.devices - List of Widevine device files (.wvd). If not specified, auto-populated from the WVDs directory.playready_devices - List of PlayReady device files (.prd). If not specified, auto-populated from the PRDs directory.users - Dictionary mapping user secret keys to their access configuration:devices - List of Widevine devices this user can accessplayready_devices - List of PlayReady devices this user can accessusername - Internal logging name for the user (not visible to users)services - Optional per-user service allowlist. Effective access is the intersection of global and per-user allowlists.dl defaults¶Any key accepted by /api/download (see docs/API.md) can also be declared directly under serve: and the REST API will treat it as a default. Per-request bodies still win. Use this to raise concurrency, force best_available, etc. without each client repeating the values:
serve:
+ api_secret: "..."
+ users: { ... }
+ downloads: 4 # parallel tracks per job
+ workers: 16 # threads per track
+ best_available: true
+ no_proxy_download: false
+Layering: built-in defaults < serve.* overrides < service-specific defaults < request body.
For example,
+serve:
+ api_secret: "your-secret-key-here"
+ compression_level: 1
+ session_ttl: 300
+ max_sessions: 100
+ # services: # global allowlist (optional)
+ # - EXAMPLE1
+ # - EXAMPLE2
+ users:
+ secret_key_for_jane: # 32bit hex recommended, case-sensitive
+ devices: # list of allowed Widevine devices for this user
+ - generic_nexus_4464_l3
+ playready_devices: # list of allowed PlayReady devices for this user
+ - my_playready_device
+ username: jane # only for internal logging, users will not see this name
+ # services: # per-user allowlist (optional)
+ # - EXAMPLE1
+ secret_key_for_james:
+ devices:
+ - generic_nexus_4464_l3
+ username: james
+ # devices can be manually specified by path if you don't want to add it to
+ # unshackle's WVDs directory for whatever reason
+ # devices:
+ # - 'C:\Users\john\Devices\test_devices_001.wvd'
+ # playready_devices:
+ # - '/path/to/device.prd'
+When the server is running, interactive API documentation is available at:
+http://localhost:8786/api/docs/See API.md for full REST API documentation with endpoints, parameters, and examples.
+Maximum number of /api/download jobs the serve queue manager will execute in parallel. Each job runs the full dl pipeline (authenticate, fetch tracks, decrypt, mux) in its own worker subprocess. This is independent of serve.downloads, which controls parallel tracks inside a single job. Default: 2.
How long completed, failed, or cancelled download jobs remain queryable via /api/download/jobs/{job_id} before the periodic cleanup loop drops them. Default: 24.
How many terminal jobs to keep in the persistent history file (api_history.jsonl, stored under the cache directory so it stays out of the config/data tree). Older entries are dropped as new jobs finish. Set to 0 for unlimited. Default: 100.
Enables comprehensive debug logging. Default: false
When enabled (either via config or the -d/--debug CLI flag):
+- Sets console log level to DEBUG for verbose output
+- Creates JSON Lines (.jsonl) debug log files with structured logging
+- Logs detailed information about sessions, service configuration, DRM operations, and errors with full stack traces
For example,
+ +Controls whether actual decryption keys (CEKs) are included in debug logs. Default: false
When enabled:
+- Content encryption keys are logged in debug output
+- Only affects content_key and key fields (the actual CEKs)
+- Key metadata (kid, keys_count, key_id) is always logged regardless of this setting
+- Passwords, tokens, cookies, and session tokens remain redacted even when enabled
For example,
+ +Controls whether HTTP-library logs are shown during --debug sessions. Default: false
When --debug is active and this is false, unshackle silences the noisy per-request logs emitted by urllib3, requests, rnet, httpx, and related libraries (e.g. GET ...seg-001.m4s 200) so that unshackle's own structured DEBUG output stays readable. Set to true to see every individual HTTP request, including per-segment downloads.
Has no effect when --debug is not active.
Controls whether unshackle applies its theme background color to the terminal. Default: false
When enabled, the Rich console theme's background color is appended to the foreground styles for both the general color scheme and the ASCII art banner, so the full color palette renders correctly on terminals whose default background differs from the theme. Set to true if the banner or colored output looks off on your terminal.
Check for updates from the GitHub repository on startup. Default: true.
How often to check for updates, in hours. Default: 24.
Enable or disable title metadata caching globally. Default: true.
Title cache duration in seconds. Default: 1800 (30 minutes).
Maximum cache retention in seconds, used as fallback when the upstream API fails. Default: 86400 (24 hours).
When false, replaces non-ASCII characters in output filenames with ASCII equivalents. Default: false.
Optional ipinfo.io token. When set, unshackle uses the ipinfo.io Lite endpoint for IP/geolocation lookups instead of the unauthenticated fallback.
+Optional TMDB API key, used for metadata enrichment and IMDb/TMDb tagging.
+Optional Simkl client ID for metadata lookups.
+Optional Decrypt Labs API key, used by services that integrate with the service.
+The unshackle REST API allows you to control downloads, search services, drive remote downloads from a thin client, and (optionally) co-host the pywidevine/pyplayready CDM. Start the server with unshackle serve and access the interactive Swagger UI at http://localhost:8786/api/docs/.
The server is built on aiohttp (not FastAPI). Implementation lives in unshackle/commands/serve.py and unshackle/core/api/ (routes.py, handlers.py, session_store.py, input_bridge.py, download_manager.py, download_worker.py).
This guide walks through writing a new service adapter, section by section, using the bundled
+reference implementation at
+unshackle/services/EXAMPLE/__init__.py
+as the canonical example. EXAMPLE is deliberately exhaustive - it shows every framework feature
+in one file so you have one place to copy from. It does not run against a real API.
A service is a directory under unshackle/services/ named by its tag (the short code used on
+the CLI - EXAMPLE, or its aliases EX/DOMAIN, in the reference service). The directory needs:
unshackle/services/TAG/
+├── __init__.py # required - the Service subclass
+└── config.yaml # optional - endpoints, headers, client profiles
+The class inside __init__.py must have the same name as the directory (class TAG(Service):
+in services/TAG/__init__.py). unshackle/core/services.py discovers services by globbing
+services/*/__init__.py and looking up a class named after the directory; a mismatch raises
+no class named '<TAG>' found ... the class name must match the directory name at load time, so
+the failure is loud, not silent.
Before the method-by-method breakdown, here's the order the framework calls your service during a +download - your methods are the hooks, the framework drives the loop:
+unshackle dl TAG <title>
+ └─ cli(ctx, title=...) → constructs TAG(ctx, ...)
+ └─ __init__ → super().__init__(ctx) builds session/cache/track_request, resolves geofence proxy
+ 1. authenticate(cookies, credential) login / token exchange (always called; base just loads cookies/credential)
+ 2. get_titles() → Movies / Series / Album (wrapped by get_titles_cached + title_map)
+ · framework applies episode/season filters
+ 3. per title:
+ a. get_tracks(title) → Tracks (parse manifest, post-process, store license data)
+ b. get_chapters(title) → Chapters
+ c. framework selects tracks resolution / range / codec / language (best_available softens misses)
+ d. per track: download → license callback → decrypt → repackage → mux → move to final filename
+Two things follow from this order:
+get_tracks() runs before any track selection, so it must return everything; the framework
+ filters afterward in step ©.get_widevine_license etc.) fire in step (d), long after
+ get_tracks() returned - they only receive challenge, title, and track (nothing else from
+ get_tracks()), so anything they need (license URL, session token) must be stashed during
+ get_tracks().class EXAMPLE(Service):
+ """
+ Reference service for domain.com - ...
+
+ Version: 2.0.0
+ Author: sp4rk.y
+ Date: 2026-06-28
+ Authorization: Cookies + Credentials
+ Geofence: US, UK
+ Robustness:
+ Widevine:
+ L1: 2160p, HDR10, HDR10+, DV
+ L3: 1080p, SDR
+ PlayReady:
+ SL3000: 2160p
+ ClearKey: 1080p (DRM-free fallback)
+
+ Tips:
+ - Input may be a full URL or a bare ID/slug
+ """
+
+ ALIASES = ("EX", "DOMAIN")
+ GEOFENCE = ("US", "UK")
+ TITLE_RE = r"^(?:https?://(?:www\.)?domain\.com/details/)?(?P<title_id>[^/?#]+)"
+ NO_SUBTITLES = False
+ VAULT_TAG = "DIFFERENT_NAME"
+The docstring is the unshackle dl TAG --help output, so it follows Click's conventions:
Version, Author, date, Authorization, Geofence, and Robustness - they're the fields
+ users and other contributors check first when deciding whether a service still works.Class variables, all optional except where noted:
+| Variable | +Purpose | +
|---|---|
ALIASES: tuple[str, ...] |
+Extra CLI tags that resolve to this service, e.g. dl EX ... |
+
GEOFENCE: tuple[str, ...] |
+ISO country codes required; the framework warns/auto-proxies on mismatch | +
TITLE_RE: str |
+Regex with named groups (e.g. (?P<title_id>...)) used in get_titles() to accept either a full URL or a bare ID |
+
NO_SUBTITLES: bool |
+Convention checked via hasattr by dl.py, not declared on the base class - set True if the service has no subtitle tracks, to skip subtitle handling |
+
VAULT_TAG: Optional[str] |
+Store/read keys under a different vault namespace than this service's own tag - lets sibling services (e.g. regional variants of the same backend) share one key vault | +
AUTH_METHODS: tuple[str, ...] |
+Declares which of "cookies"/"credentials" the service accepts. Optional - when unset, unshackle serve's /services endpoint infers it by inspecting authenticate()'s source. Set it explicitly if that inference would guess wrong |
+
See Service Integration & Authentication for the
+full list of self.* helpers available once super().__init__(ctx) has run.
cli() - the Click entry point¶@staticmethod
+@click.command(name="EXAMPLE", short_help="https://domain.com", help=__doc__)
+@click.argument("title", type=str)
+@click.option("-m", "--movie", is_flag=True, default=False, help="Treat the title as a movie.")
+@click.option(
+ "-d", "--device",
+ type=click.Choice(["android_tv", "web", "ios"], case_sensitive=False),
+ default="android_tv",
+ help="Client profile block to use from config.yaml.",
+)
+@click.pass_context
+def cli(ctx: click.Context, **kwargs: Any) -> EXAMPLE:
+ return EXAMPLE(ctx, **kwargs)
+Keep name="EXAMPLE" in @click.command matching the class/directory name - resolution of
+unshackle dl EXAMPLE ... is actually driven by the directory name matching the class name (see
+File layout above), but a mismatched name= produces confusing --help/usage output.
+help=__doc__ reuses the class docstring above as the --help text. Add @click.option entries here for anything a user should be able to toggle per
+download (device/client profile, movie-vs-series disambiguation, region, etc.) - they arrive as
+keyword arguments into __init__.
__init__()¶def __init__(self, ctx: click.Context, title: str, movie: bool, device: str):
+ self.title = title
+ self.movie = movie
+ self.device = device
+
+ super().__init__(ctx)
+
+ self.cdm = ctx.obj.cdm
+ self.is_playready = is_playready_cdm(self.cdm)
+ self.is_widevine = is_widevine_cdm(self.cdm)
+
+ if self.is_playready:
+ self.device = "playready_tv"
+ elif self.is_widevine:
+ self.log.info(" + Widevine CDM detected")
+
+ # The active -p/--profile, used below to key per-profile token caches.
+ self.profile = (ctx.parent.params.get("profile") if ctx.parent else None) or "default"
+
+ if any(r != Video.Range.SDR for r in self.track_request.ranges):
+ self.track_request.codecs = [Video.Codec.HEVC]
+
+ if self.is_widevine and getattr(self.cdm, "security_level", None) == 3:
+ self.track_request.codecs = [Video.Codec.AVC]
+ self.track_request.ranges = [Video.Range.SDR]
+
+ if self.config is None:
+ raise EnvironmentError("config.yaml is missing for this service.")
+Order matters here:
+super().__init__(ctx) only if base-class init needs them; otherwise
+ it doesn't matter, but storing first is the convention every service follows.super().__init__(ctx) wires up self.config, self.log, self.session, self.cache,
+ self.title_cache, self.current_region, and builds self.track_request from the global dl
+ flags. Nothing below this line works without it. (self.request_input is a base-class method,
+ always available - it isn't assigned here.)ctx.obj.cdm and classify it with is_playready_cdm/is_widevine_cdm
+ (unshackle.core.cdm.detect) - never hand-roll isinstance checks, these helpers also handle
+ remote/wrapper CDMs.self.track_request for service-level constraints: e.g. force HEVC when any
+ non-SDR range was requested, or clamp a Widevine L3 box down to AVC/SDR.self.track_request fields: codecs: list[Video.Codec] (empty = no preference),
+ranges: list[Video.Range] (defaults to [SDR]), best_available: bool. Read codec/range
+preferences from self.track_request, not from ctx.parent.params directly - it's the same data,
+but track_request is what _get_tracks_for_variants() and the rest of the framework consult.
self.session and get_session()¶super().__init__(ctx) sets self.session = self.get_session(). The base get_session() is a
+plain requests.Session with config.headers applied and a retry adapter mounted (5 retries,
+backoff on 429/500/502/503/504) - it does not use rnet/TLS-impersonation by default. If a
+service needs TLS fingerprinting to get past bot detection, override get_session():
from unshackle.core.session import session
+
+@staticmethod
+def get_session() -> RnetSession:
+ return session("Chrome131") # any rnet.Impersonate preset name, e.g. OkHttp4_12, Edge101, Safari18
+RnetSession is a drop-in requests.Session replacement (same .get/.post/cookie jar
+interface), so nothing else in the service needs to change. For custom SSL cipher requirements
+instead, mount unshackle.core.utils.sslciphers.SSLCiphers on a plain requests.Session the same
+way.
authenticate()¶def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
+ super().authenticate(cookies, credential)
+ self.session.headers.update({"user-agent": self.config["client"][self.device]["user_agent"]})
+
+ cache = self.cache.get(f"tokens_{self.device}_{self.profile}")
+ if cache and cache.data.get("expires_in", 0) > int(datetime.now().timestamp()):
+ self.log.info(" + Using cached tokens")
+ elif cache and cache.data.get("refresh_token"):
+ refresh = self.session.post(url=self.config["endpoints"]["refresh"], data=...).json()
+ cache.set(data=refresh, expiration=refresh.get("expires_in"))
+ else:
+ if cookies:
+ ... # decode a JWT cookie
+ elif credential:
+ otp = self.request_input("Enter the OTP sent to your device: ")
+ ...
+ else:
+ raise EnvironmentError("Service requires either Cookies or Credentials.")
+ token = self.session.post(url=self.config["endpoints"]["login"], data=body).json()
+ cache.set(data=token, expiration=token.get("expires_in"))
+
+ self.token = cache.data["token"]
+Skip this method entirely for services that need no auth at all. Otherwise:
+super().authenticate(cookies, credential) first - it loads the cookie jar into
+ self.session and stores self.credential.self.cache.get(key) / cache.set(data, expiration=seconds). Key the cache
+ by whatever varies per session (device, profile, or credential.sha1) so multiple profiles
+ don't collide.self.request_input(prompt) instead of bare input() for anything interactive (OTP, 2FA
+ codes) - locally it prompts via the shared Rich console (console.input, so the prompt renders
+ correctly alongside progress/log output), but under serve the attached InputBridge relays the
+ prompt to the remote client instead of hanging the server.EnvironmentError if auth is required but neither cookies nor a credential were given.Credential object past this method - store the derived token instead.search()¶def search(self) -> Generator[SearchResult, None, None]:
+ results = self.session.get(url=self.config["endpoints"]["search"], params={"q": self.title}).json()
+ for result in results["entries"]:
+ yield SearchResult(
+ id_=result["id"],
+ title=result["title"],
+ description=result.get("description"),
+ label="SERIES" if result["programType"] == "series" else result["programType"].upper(),
+ url=result.get("url"),
+ )
+Optional - implement it to support unshackle search TAG <query>. A generator yielding
+SearchResult objects; nothing else consumes its return value, so don't collect to a list first.
get_titles()¶def get_titles(self) -> Titles_T:
+ match = re.match(self.TITLE_RE, self.title)
+ if not match:
+ raise ValueError("Could not parse a title ID - is the URL/ID correct?")
+ title_id = match.group("title_id")
+
+ metadata = self.session.get(url=self.config["endpoints"]["metadata"].format(title_id=title_id)).json()
+ original_lang = Language.find(metadata["languages"][0])
+
+ if self.movie or metadata["programType"] == "movie":
+ return Movies([
+ Movie(
+ id_=metadata["id"],
+ service=self.__class__,
+ name=metadata["title"],
+ year=metadata.get("releaseYear") or None,
+ language=original_lang,
+ data=metadata,
+ )
+ ])
+
+ episodes = [...] # flatten seasons into Episode(...) the same way
+ return Series(episodes)
+Required. Returns Movies, Series, or (for music) Album - never a raw list.
self.title with TITLE_RE first; raise ValueError with a clear message if it doesn't
+ match.language= on each Movie/Episode/Song must be the title's original recorded language,
+ not the user's preferred language - it's the source of truth to_tracks() uses later to flag
+ is_original_lang on every track, which drives -l best/all selection and the "Original" audio
+ label.service=self.__class__, not service=self - a common copy-paste mistake.data= on the title. get_tracks() and get_chapters()
+ read it back as title.data - this is the standard way to avoid a second request later.get_tracks()¶Two shapes, depending on what the service's API gives you:
+Pattern A - separate manifest per codec/range. Use the _get_tracks_for_variants() helper:
def get_tracks(self, title: Title_T) -> Tracks:
+ def _fetch_variant(title, codec, range_):
+ vcodec_str = "H265" if codec == Video.Codec.HEVC else "H264"
+ return self._fetch_dash_manifest(title, vcodec=vcodec_str, range_=range_)
+ return self._get_tracks_for_variants(title, _fetch_variant)
+The helper walks every codec x range combination from self.track_request, handles HYBRID
+(fetches HDR10 and DV separately and merges them), and - when --best-available is set - skips
+combos the service can't deliver instead of raising.
Pattern B - one manifest contains every variant. Just parse and return; dl.py filters by
+the user's selection:
def get_tracks(self, title: Title_T) -> Tracks:
+ playback = self.session.get(url=self.config["endpoints"]["playback"].format(title_id=title.id)).json()
+ return HLS.from_url(url=playback["manifest_url"], session=self.session).to_tracks(language=title.language)
+ISM (Microsoft Smooth Streaming) follows the same shape as HLS here.
Whichever pattern you use, the manifest fetch is also where you post-process tracks before
+returning them - see _fetch_dash_manifest in EXAMPLE for the full set of fixups:
self (e.g. self.license_data = {...}) or on title.data -
+ get_widevine_license() only receives title and track, not the manifest response, so this
+ is the only place to capture license URLs/tokens.video.range - manifests can't always be trusted to signal range correctly; set it
+ explicitly to what you asked for.video.dv_compatible_bitstream = True for HEVC tracks carrying DV RPU NALs in a
+ container that only signals plain HEVC - DVFixup then round-trips the bitstream through
+ dovi_tool before mux so the output is recognised as Dolby Vision.Role/label data (audio.descriptive = True).audio.joc when the API tells you a track is Dolby Atmos explicitly (the framework
+ also detects JOC itself from the bitstream).is_original_lang yourself with is_close_match(sub_lang, [title.language]),
+ the same helper to_tracks() uses internally.Attachment if the API provides one.def get_tracks(self, title: Title_T) -> Tracks:
+ # ... never filter by resolution/codec/language here - return everything,
+ # the framework applies the user's selection after this returns.
+This is the single most common service bug: filtering inside get_tracks(). Don't.
get_chapters()¶def get_chapters(self, title: Title_T) -> Chapters:
+ chapters = []
+ seen = set()
+ for marker in title.data.get("markers", []):
+ ts = marker["start"] # seconds, ms, or "HH:MM:SS.mmm"
+ if ts <= 0 or ts in seen: # skip a marker at 0 and any duplicate timestamp
+ continue
+ seen.add(ts)
+ chapters.append(Chapter(timestamp=ts, name=marker.get("label")))
+ return Chapters(chapters)
+Return Chapters() (or []) if the service has no chapter data - don't skip implementing the
+method. Timestamps accept float seconds, int milliseconds, or "HH:MM:SS.mmm" strings. Prefer
+descriptive names ("Intro", "End Credits") over generic ones ("Chapter 01").
The opening chapter at 00:00:00.000 is created for you - never add your own. The moment you
+add the first Chapter, Chapters.add() injects a Chapter(0) at the start if one isn't already
+present, so every file gets an opening chapter automatically. Two consequences you must handle:
Chapters.add() raises ValueError if a chapter already
+ exists at that exact timestamp, so a marker the API reports at 0 (an intro/recap that starts at
+ the very beginning) collides with the auto-inserted opening chapter, and two markers at the same
+ time collide with each other. Skip any marker at 0 and de-duplicate timestamps before adding,
+ as the seen set above does.A Chapter with no name is valid - it inserts an unnamed chapter break at that timestamp, which
+is the standard way to close out a named range (e.g. mark the end of an "Intro" so the next chapter
+boundary lands there).
def get_widevine_service_certificate(self, **_: Any) -> Optional[str]:
+ return self.config.get("certificate") # or None to use the common privacy cert
+
+def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]:
+ license_url = self.license_data.get("url") or self.config["endpoints"].get("widevine_license")
+ response = self.session.post(url=license_url, data=challenge, ...)
+ response.raise_for_status()
+ return response.content
+
+def get_playready_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]:
+ response = self.session.post(url=self.config["endpoints"]["playready_license"], data=challenge)
+ response.raise_for_status()
+ return response.content
+
+def get_clearkey_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str, dict]]:
+ license_url = self.config["endpoints"].get("clearkey_license")
+ if not license_url:
+ return None # fall back to the manifest-provided Laurl, if any
+ response = self.session.post(url=license_url, data=challenge)
+ response.raise_for_status()
+ return response.json()
+get_widevine_service_certificate() - return the service's privacy cert string to enable
+ privacy-mode license requests, or None to use the common cert.get_widevine_license() - POST challenge (raw CDM challenge bytes) to the license endpoint,
+ return the raw license (bytes), or JSON-unwrap it first if the service wraps it
+ (response.json()["license"]). track is passed in case the service rotates keys per segment
+ and you need its per-segment PSSH.get_playready_license() - same shape as Widevine. If omitted, the base class delegates to
+ get_widevine_license(), so only implement it when the service has a genuinely separate
+ PlayReady endpoint.get_clearkey_license() - only for DASH org.w3.clearkey (W3C EME). challenge is the W3C
+ JSON license request; return the JWK Set as dict, str, or bytes. Three integration levels,
+ simplest first:<Laurl> - implement nothing; the framework POSTs there itself.drm.content_keys[kid] = key_hex on the track's ClearKeyCENC during get_tracks(); the
+ framework skips the license round-trip entirely when every KID is already keyed.A handful of base-class hooks aren't exercised by EXAMPLE because it has nothing interesting to
+do in them, but are there when you need them:
on_segment_downloaded(track, segment: Path), on_track_downloaded(track),
+ on_track_decrypted(track, drm, segment=None), on_track_repacked(track),
+ on_track_multiplex(track). Useful for progress reporting or service-specific cleanup tied to
+ pipeline stages.resolve_server_keys(title) - not declared on Service at all; dl.py checks for it with
+ hasattr and calls it if present. Use it for services where decryption keys need to be resolved
+ server-side ahead of the normal license flow, separate from the per-track license callbacks.get_titles_cached() / apply_title_map() - base-class wrappers around your get_titles()
+ that add the title-metadata cache and title_map config rewriting (see
+ title_map). dl.py calls get_titles_cached(), not
+ get_titles() directly - implement get_titles(), don't override the cached wrapper.config.yaml¶endpoints:
+ base_url: "https://api.example.com"
+ content: "https://api.example.com/content"
+ search: "https://api.example.com/search"
+ license: "https://license.example.com/v1"
+headers:
+ User-Agent: "..."
+client:
+ id: "..."
+device:
+ platform_name: "..."
+No enforced schema - self.config is just whatever YAML you put here, merged with any
+services.<TAG> overrides from the user's unshackle.yaml (see
+Service Integration & Authentication). Conventions:
__init__.py. Use {} or {name} placeholders for
+ dynamic segments and .format(...) at the call site.unshackle.yaml under
+ services.<TAG>, which gets merged into self.config at runtime.client:/device: blocks are a common idiom for services with multiple device profiles
+ (web/android_tv/ios) - index into them with whatever -d/--device option you exposed in cli().Use -d/--debug (before the subcommand) for structured JSONL logging while iterating - see
+Structured Debug Logging.
unshackle emits structured JSON Lines (JSONL) when -d/--debug (a global flag, before the
+subcommand: unshackle -d dl ...) or config.debug is set. Output lands at
+config.directories.logs / unshackle_debug_<service>_<time>.jsonl. The logger is built for
+developers troubleshooting pipeline flow — maximum signal, minimum noise — not end users.
Use the two primitives from unshackle.core.utilities. Both are no-ops when debug logging is
+disabled, so they are always safe to call unguarded — no if debug_logger: needed.
from unshackle.core.utilities import log_event, timed_operation
+
+# One-shot event:
+log_event("myfeature_done", message="Did the thing", context={"count": n})
+
+# Time a block (logs once at the end with duration_ms; ERROR + exception if it raises):
+with timed_operation("myfeature_run", context={"input": str(path)}):
+ do_the_work()
+That's it. Do not write if dl := get_debug_logger(): dl.log(...) in new code — log_event
+replaces that boilerplate.
Route binary calls through the helpers in unshackle.core.utils.subprocess:
run_step(args, *, label=..., output=...) — runs a CLI step and auto-logs a tool_run
+ entry (label, tool, returncode, duration). Prefer this for new tool calls.ffprobe(uri) — auto-logs its tool_run.log_tool_run(label, tool, returncode, *, duration_ms=..., **ctx) — for a direct
+ subprocess.run you can't route through run_step; call it right after the process returns.<area>_<event> lowercase, e.g. manifest_dash_parse, drm_decrypt,
+ mux_complete, vault_get_key, tool_run. Names are plain inline strings (no central registry).jq 'select(.level=="INFO")' to read the end-to-end flow); internals at DEBUG; failures at
+ ERROR. Keep INFO sparse.message that reads on its own; structured data
+ (context, duration_ms, counts, ids) lives in fields, not prose.safe_display_url(url) only — never a full Tracks,
+ MPD/manifest body, or response payload.redact_all =
+ redact_text (mask password/token/secret/auth/cookie keys + URL userinfo/secret query params)
+ → redact_url (collapse any http(s) URL to redacted[.ext], hiding CDN/content/manifest/api
+ locations while keeping the extension, e.g. redacted.mpd) → redact_path (strip local base
+ dirs: install root → <unshackle>, venv → <venv>, home → ~). key fields are also redacted
+ unless config.debug_keys. Pass user data via context=/request=/kwargs so it is sanitized.
+ Net effect: a shared JSONL leaks no account URLs, machine paths, or usernames.
+ Set redact_paths: false in unshackle.yaml to show full absolute paths instead of <unshackle>/
+ <venv>/~ tokens (config-only, no CLI flag). Affects redact_path everywhere it is used —
+ the debug log and the service load-summary detail. Secret/URL redaction is unaffected.manifest_*_parse) is the seam for request-level visibility.# Flow skeleton (one line per milestone):
+jq -r 'select(.level=="INFO") | "\(.operation)\t\(.message)"' unshackle/logs/unshackle_debug_*.jsonl
+
+# Everything for one correlated operation:
+jq 'select(.operation_id=="abc12345")' <log>
+| Symbol | +Module | +Purpose | +
|---|---|---|
log_event(op, *, level, message, **ctx) |
+core.utilities |
+one-shot structured entry | +
timed_operation(op, *, level, message, **ctx) |
+core.utilities |
+context manager; logs once at end with duration_ms (ERROR on raise) |
+
DebugLogger.log_drm_operation / log_vault_query / log_service_call |
+core.utilities |
+typed convenience wrappers (accept message=/level= overrides) |
+
run_step / ffprobe / log_tool_run |
+core.utils.subprocess |
+external-tool tool_run logging |
+
get_debug_logger() |
+core.utilities |
+low-level accessor (rarely needed directly) | +
This document covers configuration options related to downloading and processing media content.
+unshackle ships a single unified downloader at unshackle/core/downloaders/requests.py. The legacy
+aria2c, curl_impersonate, and n_m3u8dl_re backends have been removed; their config blocks no
+longer have any effect.
The unified downloader:
+requests.Session and RnetSession (rnet/BoringSSL TLS impersonation,
+ which replaces the previous curl_cffi backend). When a service exposes its own session via
+ self.session, TLS fingerprinting is preserved on every segment.Content-Length.min(16, cpu_count + 4) worker threads by default for segmented downloads
+ (override via --workers / dl.workers).Range requests (a sibling <file>.!dev control file
+ marks an in-progress download).track.downloader, which defaults to this unified requests downloader.There is no downloader: config key to set anymore. Setting one to a legacy value will emit a
+DeprecationWarning and otherwise be ignored.
Pre-define default options and switches of the dl command.
+The values will be ignored if explicitly set in the CLI call.
The Key is the option name with dashes as underscores (--v-lang -> v_lang). range and list
+work as-is; their internal Python names (range_, list_, suffixed to avoid the builtins) are
+also accepted.
For example to set the default primary language to download to German,
+ +You can also set multiple preferred languages using a list, e.g.,
+ +to set how many tracks to download concurrently to 4 and download threads to 16,
+ +to set --bitrate=CVBR for a specific service,
or to change the output subtitle format from the default (original format) to WebVTT,
+ +dl Keys¶Below is a comprehensive list of keys that can be pre-defined in the dl section. Each corresponds
+to a CLI option on the dl command. CLI arguments always take priority over config values.
Quality and codec:
+| Key | +Type | +Default | +Description | +
|---|---|---|---|
quality |
+int or list | +best | +Resolution(s) to download (e.g., 1080, [1080, 2160]) |
+
vcodec |
+str or list | +any | +Video codec(s): H264, H265, VP9, AV1, VC1 |
+
acodec |
+str or list | +any | +Audio codec(s): AAC, AC3, EC3, AC4, OPUS, FLAC, ALAC, DTS |
+
vbitrate |
+int | +highest | +Video bitrate in kbps | +
abitrate |
+int | +highest | +Audio bitrate in kbps | +
vbitrate_range |
+str | +none | +Video bitrate window in kbps, format MIN-MAX (e.g., 6000-7000) |
+
abitrate_range |
+str | +none | +Audio bitrate window in kbps, format MIN-MAX |
+
real_video_bitrate |
+bool | +false |
+Probe actual media size to compute true video bitrates, overriding the manifest's declared value (-rvb). See Real bitrate probing |
+
real_audio_bitrate |
+bool | +false |
+Same as above for audio tracks (-rab). Slower than video (more renditions) |
+
range (or range_) |
+str or list | +SDR |
+Color range(s): SDR, HDR10, HDR10+, HLG, DV, HYBRID |
+
channels |
+float | +any | +Audio channels (e.g., 5.1, 7.1) |
+
worst |
+bool | +false |
+Select the lowest bitrate track within the specified quality. Requires quality |
+
best_available |
+bool | +false |
+Continue if requested quality is unavailable | +
Language:
+| Key | +Type | +Default | +Description | +
|---|---|---|---|
lang |
+str or list | +orig |
+Language for video and audio (orig = original language) |
+
v_lang |
+list | +[] |
+Language override for video tracks only | +
a_lang |
+list | +[] |
+Language override for audio tracks only | +
s_lang |
+list | +["all"] |
+Language for subtitles | +
require_subs |
+list | +[] |
+Required subtitle languages (skip title if missing) | +
forced_subs |
+bool | +false |
+Include forced subtitle tracks | +
exact_lang |
+bool | +false |
+Exact language matching (no regional variants) | +
latest_episode |
+bool | +false |
+Download only the single most recent episode of a series | +
Track selection:
+| Key | +Type | +Default | +Description | +
|---|---|---|---|
video_only |
+bool | +false |
+Only download video tracks | +
audio_only |
+bool | +false |
+Only download audio tracks | +
subs_only |
+bool | +false |
+Only download subtitle tracks | +
chapters_only |
+bool | +false |
+Only download chapters | +
no_video |
+bool | +false |
+Skip video tracks | +
no_audio |
+bool | +false |
+Skip audio tracks | +
no_subs |
+bool | +false |
+Skip subtitle tracks | +
no_chapters |
+bool | +false |
+Skip chapters | +
no_atmos |
+bool | +false |
+Exclude Dolby Atmos audio tracks | +
audio_description |
+bool | +false |
+Include audio description tracks | +
Output and tagging:
+| Key | +Type | +Default | +Description | +
|---|---|---|---|
tag |
+str | +config default | +Override group tag | +
repack |
+bool | +false |
+Add REPACK tag to output filename | +
sub_format |
+str | +original | +Output subtitle format: srt, vtt, ass, ssa, ttml |
+
no_folder |
+bool | +false |
+Disable folder creation for TV shows | +
no_source |
+bool | +false |
+Remove source tag from filename | +
no_mux |
+bool | +false |
+Do not mux tracks into a container file | +
split_audio |
+bool | +false |
+Create separate output files per audio codec | +
export |
+bool | +false |
+Write a JSON sidecar with manifest URLs, subtitles, per-track KID:KEY, codec/track info | +
Metadata enrichment:
+| Key | +Type | +Default | +Description | +
|---|---|---|---|
tmdb_id |
+int | +null |
+Use specific TMDB ID for tagging | +
imdb_id |
+str | +null |
+Use specific IMDB ID (e.g., tt1375666) |
+
animeapi_id |
+str | +null |
+Anime database ID via AnimeAPI (e.g., mal:12345, anilist:98765) |
+
enrich |
+bool | +false |
+Override show title and year from external source. Requires tmdb_id, imdb_id, or animeapi_id |
+
Download behavior:
+| Key | +Type | +Default | +Description | +
|---|---|---|---|
downloads |
+int | +1 |
+Concurrent track downloads | +
workers |
+int | +min(16, cpu_count + 4) |
+Max threads per track download (segments / ranged parts) | +
slow |
+bool or MIN-MAX |
+false |
+Randomized delay between titles. true uses 60-120s; pass MIN-MAX (e.g., 20-40) for a custom range |
+
no_proxy_download |
+bool | +false |
+Bypass proxy for segment downloads only. Manifest, license, and auth still use proxy | +
skip_dl |
+bool | +false |
+Skip download, only get decryption keys | +
cdm_only |
+bool | +null |
+Only use CDM (true) or only vaults (false) |
+
Some services declare inaccurate bandwidth/BANDWIDTH in their manifests — often
+a peak or nominal figure that is far from the real average. Because track.bitrate
+drives the track listing, sorting, and --vbitrate / --vbitrate-range selection,
+a wrong value picks the wrong track.
-rvb / --real-video-bitrate (and -rab / --real-audio-bitrate for audio)
+probe the actual media size and overwrite track.bitrate with the measured value
+(bytes * 8 / duration) before listing and selection. So -rvb --list shows the
+true numbers, and -rvb --vbitrate-range 6000-7000 selects against them. Without
+the flag, behaviour is unchanged (the manifest value is used).
How it works:
+SegmentBase
+ or services that collapse to a BaseURL) are measured exactly: the whole
+ file size over the track duration.ffprobe; probe failures
+ are non-fatal and leave the manifest bitrate in place.Per-track before→after values are logged at debug level (run with -d); the
+corrected values always appear in the Available Tracks panel.
You can also set per-service dl overrides under the services section (see
+Service Integration & Authentication Configuration). Per-service values beat the
+global dl: section; explicit CLI arguments beat both:
dl:
+ lang: en
+ downloads: 4
+ workers: 16
+
+services:
+ EXAMPLE:
+ dl:
+ v_lang: [en]
+ quality: 1080
+Configuration for audio track selection.
+codec_priority
+ Optional list of audio codec names defining the preferred order when multiple audio
+ tracks share the same bitrate and language. Listed codecs are ranked in the order given.
+ Codecs not in the list retain their bitrate-based ordering and are placed after all
+ listed codecs (i.e. soft priority — nothing is dropped).Atmos tracks still take precedence over codec priority, and audio description tracks + are still moved to the end.
+Valid codec names: AAC, AC3, EC3, AC4, OPUS, OGG, DTS, ALAC, FLAC.
For example,
+ +Or to only prefer a subset (e.g. surround codecs first, everything else falls back to +bitrate order):
+ +When unset, audio tracks are sorted by bitrate alone (with Atmos/descriptive rules still +applied).
+Configuration for subtitle processing and conversion.
+conversion_method
+ Method to use for converting subtitles between formats. Default: "auto""auto" — Smart routing: uses subby for WebVTT/SAMI, pycaption for others."subby" — Always use subby with advanced processing."pycaption" — Use only pycaption library (no SubtitleEdit, no subby)."subtitleedit" — Prefer SubtitleEdit when available, fall back to pycaption."pysubs2" — Use pysubs2 library (supports SRT/SSA/ASS/WebVTT/TTML/SAMI/MicroDVD/MPL2/TMP).sdh_method
+ Method to use for SDH (hearing impaired) stripping. Default: "auto""auto" — Try subby (SRT only), then SubtitleEdit (if available), then subtitle-filter."subby" — Use subby library (SRT only)."subtitleedit" — Use SubtitleEdit tool (Windows only, falls back to subtitle-filter)."filter-subs" — Use subtitle-filter library directly.strip_sdh
+ Automatically create stripped (non-SDH) versions of SDH subtitles. Default: trueconvert_before_strip
+ Auto-convert VTT/other formats to SRT before using subtitle-filter for SDH stripping.
+ Ensures compatibility when subtitle-filter is used as fallback. Default: truepreserve_formatting
+ Preserve original subtitle formatting (tags, positioning, styling).
+ When true, skips pycaption processing for WebVTT files to keep tags like <i>, <b>,
+ positioning intact. Combined with no sub_format setting, ensures subtitles remain in
+ their original format. Default: trueoutput_mode
+ Output mode for subtitles. Default: "mux""mux" — Embed subtitles in MKV container only."sidecar" — Save subtitles as separate files only."both" — Embed in MKV and save as sidecar files.sidecar_format
+ Format for sidecar subtitle files when output_mode is "sidecar" or "both". Default: "srt"
+ Options: srt, vtt, ass, original (keep current format).For example,
+subtitle:
+ conversion_method: auto
+ sdh_method: auto
+ strip_sdh: true
+ convert_before_strip: true
+ preserve_formatting: true
+ output_mode: mux
+ sidecar_format: srt
+Choose what software to use to decrypt DRM-protected content throughout unshackle where needed. +You may provide a single decryption method globally or a mapping of service tags to +decryption methods.
+Options:
+shaka (default) - Shaka Packager - https://github.com/shaka-project/shaka-packagermp4decrypt - mp4decrypt from Bento4 - https://github.com/axiomatic-systems/Bento4Note that Shaka Packager is the traditional method and works with most services. mp4decrypt +is an alternative that may work better with certain services that have specific encryption formats.
+Example mapping:
+ +The default entry is optional. If omitted, shaka will be used for services not listed.
Simple configuration (single method for all services):
+ +This document covers Digital Rights Management (DRM) and Content Decryption Module (CDM) configuration options.
+Pre-define which Widevine or PlayReady device to use for each Service by Service Tag as Key (case-insensitive).
+The value should be a WVD or PRD filename without the file extension. When
+loading the device, unshackle will look in both the WVDs and PRDs directories
+for a matching file.
For example,
+ +You may also specify this device based on the profile used.
+For example,
+EXAMPLE: chromecdm_903_l3
+EXAMPLE2: nexus_6_l1
+EXAMPLE3:
+ john_sd: chromecdm_903_l3
+ jane_uhd: nexus_5_l1
+You can also specify a fallback value to predefine if a match was not made.
+This can be done using default key. This can help reduce redundancy in your specifications.
For example, the following has the same result as the previous example, as well as all other
+services and profiles being pre-defined to use chromecdm_903_l3.
You can also select CDMs based on video resolution using comparison operators (>=, >, <=, <)
+or exact match on the resolution height.
For example,
+EXAMPLE:
+ "<=1080": generic_android_l3 # Use L3 for 1080p and below
+ ">1080": nexus_5_l1 # Use L1 for above 1080p (1440p, 2160p)
+ default: generic_android_l3 # Fallback if no quality match
+You can mix profiles and quality thresholds in the same service:
+EXAMPLE:
+ john: example_l3_profile # Profile-based selection
+ "<=720": example_mobile_l3 # Quality-based selection
+ "1080": example_standard_l3 # Exact match for 1080p
+ ">=1440": example_premium_l1 # Quality-based selection
+ default: example_standard_l3 # Fallback
+Configure remote CDM (Content Decryption Module) APIs to use for decrypting DRM-protected content. +Remote CDMs allow you to use high-security CDMs (L1/L2 for Widevine, SL2000/SL3000 for PlayReady) without +having the physical device files locally.
+unshackle supports multiple types of remote CDM providers:
+The name of each defined remote CDM can be referenced in the cdm configuration as if it was a local device file.
DecryptLabs provides a professional CDM API service with support for multiple device types and intelligent key caching.
+Supported Devices:
+- Widevine: ChromeCDM (L3), L1 (Security Level 1), L2 (Security Level 2)
+- PlayReady: SL2 (SL2000), SL3 (SL3000)
Configuration:
+remote_cdm:
+ # Widevine L1 Device
+ - name: decrypt_labs_l1
+ type: decrypt_labs # Required: identifies as DecryptLabs CDM
+ device_name: L1 # Required: must match exactly (L1, L2, ChromeCDM, SL2, SL3)
+ host: https://keyxtractor.decryptlabs.com
+ secret: YOUR_API_KEY # Your DecryptLabs API key
+
+ # Widevine L2 Device
+ - name: decrypt_labs_l2
+ type: decrypt_labs
+ device_name: L2
+ host: https://keyxtractor.decryptlabs.com
+ secret: YOUR_API_KEY
+
+ # Chrome CDM (L3)
+ - name: decrypt_labs_chrome
+ type: decrypt_labs
+ device_name: ChromeCDM
+ host: https://keyxtractor.decryptlabs.com
+ secret: YOUR_API_KEY
+
+ # PlayReady SL2000
+ - name: decrypt_labs_playready_sl2
+ type: decrypt_labs
+ device_name: SL2
+ device_type: PLAYREADY # Required for PlayReady
+ host: https://keyxtractor.decryptlabs.com
+ secret: YOUR_API_KEY
+
+ # PlayReady SL3000
+ - name: decrypt_labs_playready_sl3
+ type: decrypt_labs
+ device_name: SL3
+ device_type: PLAYREADY
+ host: https://keyxtractor.decryptlabs.com
+ secret: YOUR_API_KEY
+Features: +- Intelligent key caching system (reduces API calls) +- Automatic integration with unshackle's vault system +- Support for both Widevine and PlayReady +- Multiple security levels (L1, L2, L3, SL2000, SL3000)
+Note
+The device_type field determines whether the CDM operates in PlayReady or Widevine mode.
+Setting device_type: PLAYREADY (or using device_name: SL2 / SL3) activates PlayReady mode.
+The security_level field is auto-computed from device_name when not specified (e.g., SL2 defaults
+to 2000, SL3 to 3000, and Widevine devices default to 3). You can override these if needed.
A highly configurable CDM adapter that can work with virtually any third-party CDM API through YAML configuration. +This allows you to integrate custom CDM services without writing code.
+Basic Example:
+remote_cdm:
+ - name: custom_chrome_cdm
+ type: custom_api # Required: identifies as Custom API CDM
+ host: https://your-cdm-api.com
+ timeout: 30 # Optional: request timeout in seconds
+
+ device:
+ name: ChromeCDM
+ type: CHROME # CHROME, ANDROID, PLAYREADY
+ system_id: 27175
+ security_level: 3
+
+ auth:
+ type: bearer # bearer, header, basic, body
+ key: YOUR_API_TOKEN
+
+ endpoints:
+ get_request:
+ path: /get-challenge
+ method: POST
+ decrypt_response:
+ path: /get-keys
+ method: POST
+
+ caching:
+ enabled: true # Enable key caching
+ use_vaults: true # Integrate with vault system
+Full field reference:
+remote_cdm:
+ - name: advanced_custom_api
+ type: custom_api
+ host: https://api.example.com
+ timeout: 30 # Default request timeout (seconds); overridden per-endpoint
+
+ device:
+ name: L1 # Passed as 'scheme' in base request params
+ type: ANDROID # CHROME, ANDROID, or PLAYREADY
+ system_id: 26830 # Widevine system ID (informational; not sent)
+ security_level: 1 # 1–3 for Widevine; 2000/3000 for PlayReady
+
+ # Authentication — applied to every request via session headers or Basic auth
+ auth:
+ type: header # bearer | header | basic | body | query
+ header_name: X-API-Key # used when type=header (default: Authorization)
+ key: YOUR_SECRET_KEY # API key / token / password
+ # bearer_token: TOKEN # explicit token for type=bearer (falls back to 'key')
+ # username: user # required for type=basic
+ # password: pass # required for type=basic
+ custom_headers: # merged into every request
+ User-Agent: Unshackle/3.1.0
+ X-Client-Version: "1.0"
+
+ # Endpoints — both keys are required
+ endpoints:
+ get_request: # first call: obtain challenge or cached keys
+ path: /v2/challenge
+ method: POST
+ timeout: 30 # overrides top-level timeout for this endpoint
+ decrypt_response: # second call: exchange license response for keys
+ path: /v2/decrypt
+ method: POST
+ timeout: 30
+
+ # request_mapping — transform outgoing parameters before each request
+ # Base params sent to get_request: scheme, init_data, [service], [service_certificate]
+ # Base params sent to decrypt_response: scheme, session_id, init_data, license_request, license_response
+ request_mapping:
+ get_request:
+ param_names: # rename base param keys
+ init_data: pssh
+ scheme: device_type
+ static_params: # always-added fixed values
+ api_version: "2.0"
+ conditional_params: # added only when condition is true
+ - condition: "is_playready == True"
+ params:
+ drm_type: playready
+ transforms: # applied after renaming; type values listed below
+ - param: pssh
+ type: base64_encode
+ nested_params: # group named params under a parent key
+ payload:
+ - pssh
+ - device_type
+ exclude_params: # remove unwanted params before sending
+ - service_certificate
+ decrypt_response:
+ param_names:
+ license_request: challenge
+ license_response: license
+
+ # response_mapping — extract and validate fields from API responses
+ response_mapping:
+ get_request:
+ fields: # map standard names to dot-path locations in response JSON
+ challenge: data.challenge
+ session_id: session.id
+ message: status.message
+ response_types: # classify response so CDM can choose the right path
+ - condition: "message_type == 'license-request'"
+ type: license_request # CDM expects challenge + session_id
+ - condition: "message_type == 'cached-keys'"
+ type: cached_keys # CDM skips license round-trip
+ success_conditions: # all must be true; failure raises RequestException
+ - "message == 'success'"
+ error_fields: # inspected when success_conditions fail (default: error, message, details)
+ - error
+ - details
+ transforms: # applied to extracted fields
+ - field: challenge
+ type: base64_decode
+ decrypt_response:
+ fields:
+ keys: data.keys
+ message: status.message
+ key_fields: # map key-object property names to standard names
+ kid: key_id # default: kid
+ key: content_key # default: key
+ type: key_type # default: type (value used as-is; CONTENT treated as content key)
+ success_conditions:
+ - "message == 'success'"
+
+ # caching — vault and API-level key caching
+ caching:
+ enabled: true # master switch (default: true)
+ use_vaults: true # check/write unshackle key vaults (default: true)
+ check_cached_first: true # check vaults before making any API call (default: true)
+Supported transforms types (for both request_mapping and response_mapping):
+base64_encode, base64_decode, hex_encode, hex_decode, json_stringify, json_parse, parse_key_string
Supported authentication types:
+- bearer — Authorization: Bearer <key> (or bearer_token field)
+- header — arbitrary header; set header_name (default Authorization)
+- basic — HTTP Basic auth via username / password
+- body — credentials injected into request body (handled by request_mapping)
+- query — credentials appended to query string (handled by request_mapping)
Connects to a running unshackle serve instance for PlayReady licensing. Uses
+pyplayready.remote.remotecdm.RemoteCdm under the hood. No type field is needed —
+the entry is routed to PlayReady when device_type: PLAYREADY is set (same branching
+logic as the legacy Widevine path).
remote_cdm:
+ - name: playready_remote
+ device_name: my_prd_device # Device name registered on the serve instance
+ device_type: PLAYREADY # Required to select the PlayReady code path
+ system_id: 0 # Not used by pyplayready RemoteCdm; set to 0
+ security_level: 3000 # 2000 = SL2000, 3000 = SL3000
+ host: http://127.0.0.1:8786/playready # Must include the /playready path suffix
+ secret: your-api-secret-key # X-Secret-Key sent to the serve instance
+Note
+The /playready path suffix in host is required — pyplayready.RemoteCdm
+appends its own endpoint paths on top of this base, so omitting the suffix will result
+in 404 errors.
Standard pywidevine serve-compliant remote CDM configuration (backwards compatibility).
+remote_cdm:
+ - name: legacy_chrome_cdm
+ device_name: chrome
+ device_type: CHROME
+ system_id: 27175
+ security_level: 3
+ host: https://domain.com/api
+ secret: secret_key
+Note
+If the type field is not specified, the entry is treated as a legacy pywidevine serve CDM.
API key for DecryptLabs CDM service integration.
+When set, enables the use of DecryptLabs remote CDM services in your remote_cdm configuration.
+This is used specifically for type: "decrypt_labs" entries in the remote CDM list.
For example,
+ +Note
+This is different from the per-CDM secret field in remote_cdm entries. This provides a global
+API key that can be referenced across multiple DecryptLabs CDM configurations. If a remote_cdm entry with
+type: "decrypt_labs" does not have a secret field specified, the global decrypt_labs_api_key will be
+used as a fallback.
Configure which decryption tool to use for DRM-protected content. Default: shaka.
Supported values:
+- shaka - Shaka Packager (default)
+- mp4decrypt - Bento4 mp4decrypt
You can specify a single decrypter for all services:
+ +Or configure per-service with a DEFAULT fallback:
Service keys are case-insensitive (normalized to uppercase internally).
+MonaLisa is a WASM-based DRM system that uses local key extraction and two-stage segment decryption. +Unlike Widevine and PlayReady, MonaLisa does not use a challenge/response flow with a license server. +Instead, the PSSH value (ticket) is provided directly by the service API, and keys are extracted +locally via a WASM module.
+PATH (discovered via binaries.ML_Worker).
+ This is the binary that performs stage-1 decryption.MonaLisa uses per-segment decryption during download (not post-download like Widevine/PlayReady), +so segments are decrypted as they are downloaded.
+Note
+MonaLisa is configured per-service rather than through global config options. Services +that use MonaLisa handle ticket/key retrieval and CDM initialization internally.
+Two distinct ClearKey mechanisms are supported; neither needs a CDM device or any DRM config:
+The key is fetched from (or near) the M3U8 EXT-X-KEY URI and segments are decrypted with
+pure-Python AES-CBC. Fully automatic — nothing to configure.
org.w3.clearkey)¶W3C EME ClearKey for DASH CENC content. The DASH parser recognises the clearkey
+ContentProtection scheme (urn:uuid:e2719d58-a985-b3c9-781a-b030af78d30e), takes the KID from
+cenc:default_KID, and reads the license server URL from the manifest's <Laurl> element when
+present.
License flow: the W3C JSON license request ({"kids": [...], "type": "temporary"}) is POSTed to
+the license server, which returns the content key as a JWK Set. Keys land in the same vault and
+--export paths as Widevine/PlayReady, and decryption uses the same shaka-packager/mp4decrypt
+CENC backends (decryption config option applies).
Service integration (simplest first):
+1. Manifest carries a <Laurl> — works with zero service code.
+2. Custom endpoint/headers — service overrides get_clearkey_license.
+3. Bespoke key delivery — service pre-populates the DRM object's keys in get_tracks.
Key Vaults store your obtained Content Encryption Keys (CEKs) and Key IDs per-service.
+This can help reduce unnecessary License calls even during the first download. This is because a Service may +provide the same Key ID and CEK for both Video and Audio, as well as for multiple resolutions or bitrates.
+You can have as many Key Vaults as you would like. It's nice to share Key Vaults or use a unified Vault on +Teams as sharing CEKs immediately can help reduce License calls drastically.
+Four types of Vaults are in the Core codebase: API, SQLite, MySQL, and HTTP. API and HTTP make HTTP requests to a RESTful API, +whereas SQLite and MySQL directly connect to an SQLite or MySQL Database.
+Note: SQLite and MySQL vaults have to connect directly to the Host/IP. It cannot be in front of a PHP API or such. +Beware that some Hosting Providers do not let you access the MySQL server outside their intranet and may not be +accessible outside their hosting platform.
+Additional behavior:
+no_push (bool): Optional per-vault flag. When true, the vault will not receive pushed keys (writes) but
+ will still be queried and can provide keys for lookups. Useful for read-only/backup vaults.timeout (float): Optional per-vault network timeout in seconds for the remote API and HTTP vaults.
+ Defaults to the global vault_timeout (10 seconds) so an unreachable vault host fails fast instead of
+ hanging the download. Set it on a vault to override the global, e.g. timeout: 5.0. Has no effect on
+ SQLite (local) or MySQL (uses its driver's own connect timeout).Set a global default for all remote vaults at the top level of the config:
+ +API vaults use a specific HTTP request format, therefore API or HTTP Key Vault APIs from other projects or services may +not work in unshackle. The API format can be seen in the API Vault Code.
+- type: API
+ name: "John#0001's Vault" # arbitrary vault name
+ uri: "https://key-vault.example.com" # api base uri (can also be an IP or IP:Port)
+ # uri: "127.0.0.1:80/key-vault"
+ # uri: "https://api.example.com/key-vault"
+ token: "random secret key" # authorization token
+ # no_push: true # optional; make this API vault read-only (lookups only)
+ # timeout: 5.0 # optional; per-vault network timeout (seconds), overrides vault_timeout
+MySQL vaults can be either MySQL or MariaDB servers. I recommend MariaDB. +A MySQL Vault can be on a local or remote network, but I recommend SQLite for local Vaults.
+- type: MySQL
+ name: "John#0001's Vault" # arbitrary vault name
+ host: "127.0.0.1" # host/ip
+ # port: 3306 # port (defaults to 3306)
+ database: vault # database used for unshackle
+ username: jane11
+ password: Doe123
+ # no_push: false # optional; defaults to false
+I recommend giving only a trustable user (or yourself) CREATE permission and then use unshackle to cache at least one CEK +per Service to have it create the tables. If you don't give any user permissions to create tables, you will need to +make tables yourself.
+SQLite Vaults are usually only used for locally stored vaults. This vault may be stored on a mounted Cloud storage +drive, but I recommend using SQLite exclusively as an offline-only vault. Effectively this is your backup vault in +case something happens to your MySQL Vault.
+- type: SQLite
+ name: "My Local Vault" # arbitrary vault name
+ path: "C:/Users/Jane11/Documents/unshackle/data/key_vault.db"
+ # no_push: true # optional; commonly true for local backup vaults
+Note
+You do not need to create the file at the specified path.
+SQLite will create a new SQLite database at that path if one does not exist.
+Try not to accidentally move the db file once created without reflecting the change in the config, or you will end
+up with multiple databases.
If you work on a Team I recommend every team member having their own SQLite Vault even if you all use a MySQL vault +together.
+HTTP Vaults provide flexible HTTP-based key storage with support for multiple API modes. This vault type +is useful for integrating with various third-party key vault APIs.
+- type: HTTP
+ name: "My HTTP Vault"
+ host: "https://vault-api.example.com"
+ api_key: "your_api_key" # or use 'password' field
+ api_mode: "json" # query, json, or decrypt_labs
+ # username: "user" # required for query mode only
+ # no_push: false # optional; defaults to false
+ # timeout: 5.0 # optional; per-vault network timeout (seconds), overrides vault_timeout
+Supported API Modes:
+query - Uses GET requests with query parameters. Requires username field.json - Uses POST requests with JSON payloads. Token-based authentication.decrypt_labs - DecryptLabs API format. Read-only mode (no_push is forced to true).Example configurations:
+# Query mode (requires username)
+- type: HTTP
+ name: "Query Vault"
+ host: "https://api.example.com/keys"
+ username: "myuser"
+ password: "mypassword"
+ api_mode: "query"
+
+# JSON mode
+- type: HTTP
+ name: "JSON Vault"
+ host: "https://api.example.com/vault"
+ api_key: "secret_token"
+ api_mode: "json"
+
+# DecryptLabs mode (read-only)
+- type: HTTP
+ name: "DecryptLabs Cache"
+ host: "https://keyxtractor.decryptlabs.com/cache"
+ api_key: "your_decrypt_labs_api_key"
+ api_mode: "decrypt_labs"
+Note
+The decrypt_labs mode is always read-only and cannot receive pushed keys.
Gluetun provides Docker-managed VPN proxies supporting 50+ VPN providers.
+Docker must be installed and running.
+# Linux
+curl -fsSL https://get.docker.com | sh
+sudo usermod -aG docker $USER # Then log out/in
+
+# Windows/Mac
+# Install Docker Desktop: https://www.docker.com/products/docker-desktop/
+Add to ~/.config/unshackle/unshackle.yaml:
proxy_providers:
+ gluetun:
+ providers:
+ windscribe:
+ vpn_type: openvpn
+ credentials:
+ username: "YOUR_OPENVPN_USERNAME"
+ password: "YOUR_OPENVPN_PASSWORD"
+Use 2-letter country codes directly:
+unshackle dl SERVICE CONTENT --proxy gluetun:windscribe:us
+unshackle dl SERVICE CONTENT --proxy gluetun:windscribe:uk
+Format: gluetun:provider:region
OpenVPN (Recommended): Most providers support OpenVPN with just username and password - the simplest setup.
WireGuard: Requires private keys and varies by provider. See the Gluetun Wiki for provider-specific requirements. Note that vpn_type defaults to wireguard if not specified.
Note
+Use service credentials, NOT your account email/password.
+WireGuard requires private keys instead of username/password. See the Gluetun Wiki for provider-specific WireGuard setup.
+OpenVPN (Recommended)
+Most providers support OpenVPN with just username and password:
+providers:
+ windscribe:
+ vpn_type: openvpn
+ credentials:
+ username: YOUR_OPENVPN_USERNAME
+ password: YOUR_OPENVPN_PASSWORD
+
+ nordvpn:
+ vpn_type: openvpn
+ credentials:
+ username: YOUR_SERVICE_USERNAME
+ password: YOUR_SERVICE_PASSWORD
+WireGuard (Advanced)
+WireGuard can be faster but requires more complex credential setup:
+# NordVPN/ProtonVPN (only private_key needed)
+providers:
+ nordvpn:
+ vpn_type: wireguard
+ credentials:
+ private_key: YOUR_PRIVATE_KEY
+
+# Surfshark/Mullvad/IVPN (private_key AND addresses required)
+ surfshark:
+ vpn_type: wireguard
+ credentials:
+ private_key: YOUR_PRIVATE_KEY
+ addresses: 10.x.x.x/32
+
+# Windscribe (all three credentials required)
+ windscribe:
+ vpn_type: wireguard
+ credentials:
+ private_key: YOUR_PRIVATE_KEY
+ addresses: 10.x.x.x/32
+ preshared_key: YOUR_PRESHARED_KEY
+Most providers use SERVER_COUNTRIES, but some use SERVER_REGIONS:
| Variable | +Providers | +
|---|---|
SERVER_COUNTRIES |
+NordVPN, ProtonVPN, Surfshark, Mullvad, ExpressVPN, and most others | +
SERVER_REGIONS |
+Windscribe, VyprVPN, VPN Secure | +
Unshackle handles this automatically - just use 2-letter country codes.
+You can explicitly map region codes to country names, cities, or hostnames per provider:
+providers:
+ nordvpn:
+ vpn_type: openvpn
+ credentials:
+ username: YOUR_USERNAME
+ password: YOUR_PASSWORD
+ server_countries:
+ us: "United States"
+ uk: "United Kingdom"
+ server_cities:
+ us: "New York"
+ server_hostnames:
+ us: "us1239.nordvpn.com"
+Use a <country><number> region (e.g. us1239) to target a specific server. Unshackle builds the
+hostname automatically per provider:
| Provider | +Hostname format | +
|---|---|
| NordVPN | +us1239.nordvpn.com |
+
| Surfshark | +us-1239.prod.surfshark.com |
+
| ExpressVPN | +us-1239.expressvpn.com |
+
| CyberGhost | +us-s1239.cg-dialup.net |
+
| Other | +us1239 (passed as-is to SERVER_HOSTNAMES) |
+
You can pass additional Gluetun environment variables per provider using extra_env:
providers:
+ nordvpn:
+ vpn_type: openvpn
+ credentials:
+ username: YOUR_USERNAME
+ password: YOUR_PASSWORD
+ extra_env:
+ LOG_LEVEL: debug
+proxy_providers:
+ gluetun:
+ providers: {...}
+ base_port: 8888 # Starting port (default: 8888)
+ auto_cleanup: true # Remove containers on exit (default: true)
+ verify_ip: true # Verify IP matches region (default: true)
+ container_prefix: "unshackle-gluetun" # Docker container name prefix (default: "unshackle-gluetun")
+ auth_user: username # Proxy auth (optional)
+ auth_password: password # Proxy auth (optional)
+docker inspect and reused.[http proxy] listening) and the VPN tunnel to come up (initialization sequence completed or public ip address is) before returning the proxy URI. Bails early on fatal or invalid credentials log lines.verify_ip: true (default), looks up the exit IP via ipinfo.io through the proxy and compares country code to the requested region. Retries 3 times with exponential backoff (1s, 2s, 4s).base_port.--proxy gluetun:nordvpn:us1239 for specific server selection (see table above).qmcgaw/gluetun:latest) is pulled automatically on first use (5 min timeout).docker run. They never appear in process listings.atexit (Ctrl+C still works normally). Disable with auto_cleanup: false to leave them stopped instead.# View containers
+docker ps | grep unshackle-gluetun
+
+# Check logs
+docker logs unshackle-gluetun-nordvpn-us
+
+# Remove all containers
+docker ps -a | grep unshackle-gluetun | awk '{print $1}' | xargs docker rm -f
+Check container logs for specific errors: +
+Common issues:
+- Invalid/missing credentials
+- Windscribe WireGuard requires preshared_key (can be empty string, but must be set in credentials)
+- VPN provider server issues
+- Container startup timeout (default 60 seconds)
ExpressVPN is a standalone HTTPS proxy provider — it does not use Docker or the gluetun sub-provider system. It authenticates via a browser-extension OAuth flow (Keycloak/PKCE) and returns an authenticated https://cat:<token>@<host>:443 proxy URL.
An active ExpressVPN subscription is required. The provider resolves proxy-capable locations through ExpressVPN's own API, so no local VPN client or Docker container is needed.
+The provider uses a token cascade (tried in order):
+cache/global/expressvpn_tokens.json (auto-written after first auth; access and refresh tokens are refreshed automatically when they expire).cookies/vpn/expressvpn.txt (also accepted at cookies/vpns/expressvpn.txt). Must contain the KEYCLOAK_IDENTITY and KEYCLOAK_SESSION cookies from auth.expressvpn.com. Used for a one-time PKCE bootstrap that populates the token cache.account.json — configured via the account_json key (see below).Export cookies from a browser that is logged in to auth.expressvpn.com. Any Netscape-format, JSON array, or name=value file is accepted.
Add to ~/.config/unshackle/unshackle.yaml under proxy_providers:
proxy_providers:
+ expressvpn:
+ region_map:
+ us: "ny" # --proxy expressvpn:us → New York (default city)
+ gb: "london" # --proxy expressvpn:gb → London
+ de: "frankfurt" # --proxy expressvpn:de → Frankfurt
+ es: "madrid" # --proxy expressvpn:es → Madrid
+ mx: # --proxy expressvpn:mx → smart/random MX location
+region_map maps a country code to a default city preset. An empty (null) value enables smart connection (a random location in that country). The preset is only applied when the CLI query contains no city; an explicit city in the query always takes precedence.
| Key | +Default | +Description | +
|---|---|---|
region_map |
+{} |
+Country → default city presets (see above) | +
server_map |
+{} |
+Alias → ExpressVPN location slug or direct hostname | +
cookie_path |
+cookies/vpn/expressvpn.txt |
+Path to exported browser cookies | +
cache_path |
+cache/global/expressvpn_tokens.json |
+Token cache location | +
account_json |
+(none) | +Path to ExpressVPN desktop account.json |
+
refresh_token |
+(none) | +OAuth refresh token (manual override) | +
timeout |
+10.0 |
+HTTP request timeout in seconds | +
unshackle dl SERVICE CONTENT --proxy expressvpn:us # random/smart US location
+unshackle dl SERVICE CONTENT --proxy expressvpn:us-ny # specific city (New York)
+unshackle dl SERVICE CONTENT --proxy expressvpn:us-ny-2 # pinned server #2 in New York
+unshackle dl SERVICE CONTENT --proxy expressvpn:mx # random/smart Mexico location
+unshackle dl SERVICE CONTENT --proxy expressvpn:es-madrid # specific city (Madrid)
+Query format: expressvpn:<country>[-<city>[-<N>]]
country — 2-letter country code (e.g. us, gb, de).city — optional city abbreviation or slug matched against location names using first-letter abbreviation (ny → "New York"), exact slug, prefix, then substring strategies.N — optional 1-based server index (e.g. -2 selects the second server in the matched city). If N exceeds the available server count, a random server is selected.The city part of the query is matched against ExpressVPN location names using these strategies (in priority order):
+ny matches "New York"miami matches "Miami"mia matches "Miami"york matches "New York"After the first successful authentication the token cache is written to cache/global/expressvpn_tokens.json (mode 0600). Subsequent runs refresh expired access/SRT/connection tokens automatically without re-reading cookies.
This document covers network and proxy configuration options for bypassing geofencing and managing connections.
+Enable external proxy provider services. These proxies will be used automatically where needed as defined by the
+Service's GEOFENCE class property, but can also be explicitly used with --proxy. You can specify which provider
+to use by prefixing it with the provider key name, e.g., --proxy basic:de or --proxy nordvpn:de. Some providers
+support specific query formats for selecting a country/server.
Define a mapping of country to proxy to use where required.
+The keys are region Alpha 2 Country Codes. Alpha 2 Country Codes are [a-z]{2} codes, e.g., us, gb, and jp.
+Don't get this mixed up with language codes like en vs. gb, or ja vs. jp.
Do note that each key's value can be a list of strings, or a string. For example,
+us:
+ - "http://john%40email.tld:password123@proxy-us.domain.tld:8080"
+ - "http://jane%40email.tld:password456@proxy-us.domain2.tld:8080"
+de: "https://127.0.0.1:8080"
+Note that if multiple proxies are defined for a region, then by default one will be randomly chosen.
+You can choose a specific one by specifying it's number, e.g., --proxy basic:us2 will choose the
+second proxy of the US list.
Set your NordVPN Service credentials with username and password keys to automate the use of NordVPN as a Proxy
+system where required.
You can also specify specific servers to use per-region with the server_map key.
+Sometimes a specific server works best for a service than others, so hard-coding one for a day or two helps.
You can also select servers by city using the format --proxy nordvpn:us:seattle or --proxy nordvpn:ca:calgary.
For example,
+username: zxqsR7C5CyGwmGb6KSvk8qsZ # example of the login format
+password: wXVHmht22hhRKUEQ32PQVjCZ
+server_map:
+ us: 12 # force US server #12 for US proxies
+The username and password should NOT be your normal NordVPN Account Credentials.
+They should be the Service credentials which can be found on your Nord Account Dashboard.
Once set, you can also specifically opt in to use a NordVPN proxy by specifying --proxy nordvpn:gb or such.
+You can even set a specific server number this way, e.g., --proxy nordvpn:gb2366.
Note that gb is used instead of uk to be more consistent across regional systems.
Enable Proton VPN as a proxy provider. Proton mints short-lived HTTPS proxy credentials per session, so +authentication reuses a Proton account session (obtained via TV login or an exported browser session) rather than +static credentials. Free Proton accounts work, limited to the free-tier exit countries.
+There are two ways to obtain that session — TV login or cookie export. You only need one. They can also coexist: a +cached or cookie session is always tried first, and TV login is only used as a fallback when no session is present.
+Set enable: true and run any command that uses the proxy. On first use, when no session is cached, unshackle
+prints a code and pauses:
https://account.proton.me/vpn/tv/code in a browser where you are signed in to Proton.The minted refresh token is cached at <cache>/vpn/protonvpn.json and refreshes on its own forever after, so
+this is genuinely a one-time step — later runs load straight from the cache without prompting. The prompt is off
+by default (enable: false) so nobody is prompted unexpectedly, and it is skipped automatically when there is no
+interactive terminal (e.g. under serve, cron, or piped input) — in those contexts, use a cookie export or run an
+interactive command once to seed the cache first.
Export the cookies for account.proton.me or account.protonvpn.com (Netscape cookies.txt or a JSON cookie
+list) and save them at <cookies>/vpn/protonvpn.txt. The provider auto-loads when that file exists (no YAML
+required); cookies for other Proton apps (e.g. mail.proton.me) are ignored automatically.
AUTH-<UID> cookie. It is HttpOnly — many one-click cookie-export
+ extensions skip HttpOnly cookies by default, which is the most common reason an export "doesn't work". Use an
+ exporter that includes them.To start clean, delete both <cookies>/vpn/protonvpn.txt and <cache>/vpn/protonvpn.json.
Proton: no session available … — no cookies and no cache were found. Either export cookies, or set
+ enable: true and run from an interactive terminal (the prompt is skipped on non-tty stdin).Proton: session expired; re-export … cookies — a cookie session's access token lapsed and cannot be
+ refreshed (expected for cookie auth). Re-export the cookies, or switch to TV login.Configuration is optional — only add a block to enable TV login or override defaults:
+proxy_providers:
+ protonvpn:
+ enable: true # prompt the one-time TV login when no session is cached (default false)
+ cookie_path: /path/to/protonvpn.txt # optional; defaults to <cookies>/vpn/protonvpn.txt
+ cache_path: /path/to/protonvpn.json # optional; defaults to <cache>/vpn/protonvpn.json
+ timeout: 10 # optional; per-request timeout in seconds (default 10)
+ server_map:
+ stream-us: us:ny # optional alias -> country / country:city / countryNN
+Query formats (after the provider prefix). City uses the NordVPN-style colon, like nordvpn:us:seattle:
--proxy protonvpn:us — random server in the country (the chosen server name is logged)--proxy protonvpn:us:ny / protonvpn:ca:vancouver — by city (name or initials)--proxy protonvpn:de203 — pin Proton server #203 (the number shown in the log, e.g. DE#203)Use uk (Proton's code for the United Kingdom); gb is accepted and mapped to uk automatically. Proton's
+server numbers are not sequential (a country may have #203, #813, …), so pick the number from a previous
+run's log line rather than guessing.
Selection only offers servers your plan can use (paid accounts are detected automatically) and prefers paid
+servers over the free pool, falling back to free only when no paid server is available — a pinned #NN always
+wins. Tor and Secure Core servers are excluded (both are slow for a plain proxy). Secure Core servers use
+port 443, all others 4443 — handled automatically.
Enable Surfshark VPN proxy service using Surfshark Service credentials (not your login password).
+You may pin specific server IDs per region using server_map.
You can also select servers by city using the format --proxy surfsharkvpn:us:seattle.
username: your_surfshark_service_username # https://my.surfshark.com/vpn/manual-setup/main/openvpn
+password: your_surfshark_service_password # service credentials, not account password
+server_map:
+ us: 3844 # force US server #3844
+ gb: 2697 # force GB server #2697
+ au: 4621 # force AU server #4621
+Enable Hola VPN proxy service. Requires the hola-proxy binary to be installed and available in your PATH.
+No configuration is needed under proxy_providers. Hola is loaded automatically when the hola-proxy binary
+is detected.
Once available, use --proxy hola:us or similar to connect through Hola.
Enable Windscribe VPN proxy service using static OpenVPN service credentials.
+Use the service credentials from https://windscribe.com/getconfig/openvpn (not your account login credentials).
+proxy_providers:
+ windscribevpn:
+ username: openvpn_username # From https://windscribe.com/getconfig/openvpn
+ password: openvpn_password # Service credentials, NOT your account password
+You can optionally pin specific servers using server_map:
proxy_providers:
+ windscribevpn:
+ username: openvpn_username
+ password: openvpn_password
+ server_map:
+ us: us-central-096.totallyacdn.com # Force specific US server
+ gb: uk-london-001.totallyacdn.com # Force specific UK server
+Once configured, use --proxy windscribevpn:us or --proxy windscribevpn:gb etc. to connect through Windscribe.
You can also select specific servers by number (e.g., --proxy windscribevpn:sg007) or filter by city
+(e.g., --proxy windscribevpn:ca:toronto).
Docker-managed VPN proxy supporting 50+ VPN providers via Gluetun. See GLUETUN.md for full +configuration and usage details.
+proxy_providers:
+ gluetun:
+ providers:
+ windscribe:
+ vpn_type: openvpn
+ credentials:
+ username: "YOUR_OPENVPN_USERNAME"
+ password: "YOUR_OPENVPN_PASSWORD"
+Usage: --proxy gluetun:windscribe:us
Case-Insensitive dictionary of headers that all Services begin their Request Session state with. +All requests will use these unless changed explicitly or implicitly via a Server response. +These should be sane defaults and anything that would only be useful for some Services should not +be put here.
+Avoid headers like 'Accept-Encoding' as that would be a compatibility header that the underlying +HTTP backend (rnet) will set for you as part of its browser impersonation profile.
+I recommend using,
+Accept-Language: "en-US,en;q=0.8"
+User-Agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
+unshackle uses rnet (Rust + BoringSSL) for HTTP with TLS
+fingerprinting. RnetSession is a drop-in requests.Session replacement and is what
+self.session exposes to services. It supports:
rnet.Impersonate presets (Chrome, Edge, Firefox, Safari,
+ OkHttp, etc.) — picks JA3, ALPN, HTTP/2 SETTINGS and header order to match the chosen client.requests-style data= / json= / headers= kwargs for compatibility.The legacy curl_cffi backend has been removed.
HTTP session settings: which client to impersonate and how the underlying rnet +client behaves.
+ +browser must be an exact rnet.Impersonate preset name (e.g. Chrome131, Chrome124,
+Edge101, Firefox133, Safari18, OkHttp4_12). See the rnet README for the full list.
+Default when unset: Chrome131.
The same section accepts optional rnet client tuning keys, forwarded verbatim:
+network:
+ browser: Chrome131
+ http1_only: true # force HTTP/1.1 instead of negotiated HTTP/2
+ # http2_only: true
+ # pool_max_size: 64 # max total connections in the pool
+ # pool_max_idle_per_host: 32
+ # tcp_nodelay: true
+Renamed from curl_impersonate
This section was previously named curl_impersonate (after the removed backend).
+The old spelling still works but is deprecated; rename it to network.
When to force HTTP/1.1
+HTTP/2 is the right default on large CDNs. Force http1_only: true when a host
+throttles per connection or has a slow origin behind HTTP/2 flow control — in
+benchmarks this gained 30–50% on such hosts, while costing up to 27% on fast
+CDNs. Set it only when measurements support it.
This document covers output file organization and naming configuration options.
+Override the default filenames used across unshackle. +The filenames use various variables that are replaced during runtime.
+The following filenames are available and may be overridden:
+log - Log filenames. Uses {name} and {time} variables.debug_log - Debug log filenames. Uses {service} and {time} variables.config - Service configuration filenames.root_config - Root configuration filename.chapters - Chapter export filenames. Uses {title} and {random} variables.subtitle - Subtitle export filenames. Uses {id} and {language} variables.For example,
+filenames:
+ log: "unshackle_{name}_{time}.log"
+ debug_log: "unshackle_debug_{service}_{time}.jsonl"
+ config: "config.yaml"
+ root_config: "unshackle.yaml"
+ chapters: "Chapters_{title}_{random}.txt"
+ subtitle: "Subtitle_{id}_{language}.srt"
+Configure custom output filename templates for movies, series, songs, and albums.
+This is required in your unshackle.yaml - a warning is shown if not configured.
Add ? suffix to make a variable conditional (omitted when empty): {year?}, {hdr?}, {repack?}
| Variable | +Meaning | +
|---|---|
title |
+Title of the movie, episode, or track | +
year |
+Release year | +
source |
+Service source tag (the uppercase service directory name) | +
tag |
+Group/release tag configured via tag: |
+
edition |
+Edition label (e.g. Extended, Theatrical) |
+
repack |
+REPACK when the release is a repack, otherwise empty |
+
lang_tag |
+Language tag produced by language_tags rules (e.g. NORDiC) |
+
| Variable | +Meaning | +
|---|---|
season |
+Season number (e.g. 1) |
+
episode |
+Episode number (e.g. 1) |
+
season_episode |
+Combined season/episode (e.g. S01E01) |
+
episode_name |
+Episode title | +
date |
+Air date in ISO form (e.g. 2024-06-30), only for daily/sports content |
+
Daily / sports content
+When a service supplies an air date (daily talk shows, news, sports events),
+{season_episode} automatically renders the date using your template's separator
+(e.g. 2024.06.30 for a dotted template, 2024 06 30 for a spaced one). The
+standalone {year} is dropped from the filename — the air date is the sole date —
+so every episode of a show names consistently. Folders still keep the year (e.g.
+Show Name (2010)/2024/). No template change is needed.
Use the {date} variable instead if you want a fixed YYYY-MM-DD form regardless
+of separator. Folders for dated episodes group by air year (e.g. Show Name/2024/).
| Variable | +Meaning | +
|---|---|
quality |
+Resolution label (e.g. 1080p, 2160p) |
+
resolution |
+Raw resolution string | +
video |
+Video codec label (e.g. H.264, H.265) |
+
hdr |
+HDR format label (e.g. HDR, DV) |
+
hfr |
+HFR label when frame rate exceeds normal threshold | +
audio |
+Audio codec label (e.g. AAC, EAC3) |
+
audio_channels |
+Channel count (e.g. 2.0, 5.1) |
+
audio_full |
+Combined codec + channels (e.g. DDP5.1) |
+
atmos |
+Atmos when Dolby Atmos is present, otherwise empty |
+
dual |
+DUAL when two audio languages are present, otherwise empty |
+
multi |
+MULTi when three or more audio languages are present, otherwise empty |
+
| Variable | +Meaning | +
|---|---|
track_number |
+Track number within album | +
disc |
+Disc number | +
track_total |
+Total tracks on disc | +
disc_total |
+Total discs in release | +
artist |
+Track artist | +
album_artist |
+Album-level artist (may differ from track artist) | +
album |
+Album title | +
release_type |
+Release type (e.g. Album, Single, EP) |
+
genre |
+Genre string | +
explicit |
+Explicit when track has explicit content flag, otherwise empty |
+
isrc |
+International Standard Recording Code | +
upc |
+Universal Product Code (album barcode) | +
label |
+Record label name | +
output_template:
+ # Scene-style (dot-separated)
+ movies: '{title}.{year}.{repack?}.{edition?}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{audio_full}.{atmos?}.{hdr?}.{hfr?}.{video}-{tag}'
+ series: '{title}.{year?}.{season_episode}.{episode_name?}.{repack?}.{edition?}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{audio_full}.{atmos?}.{hdr?}.{hfr?}.{video}-{tag}'
+ songs: '{track_number}.{title}.{repack?}.{edition?}.{source?}.WEB-DL.{audio_full}.{atmos?}-{tag}'
+
+ # Plex-friendly (space-separated)
+ # movies: '{title} ({year}) {quality}'
+ # series: '{title} {season_episode} {episode_name?}'
+ # songs: '{track_number}. {title}'
+Example outputs:
+- Scene movies: Example.Movie.2024.1080p.EXAMPLE.WEB-DL.DDP5.1.H.264-TAG
+- Scene movies (REPACK): Example.Movie.2024.REPACK.2160p.EXAMPLE.WEB-DL.DDP5.1.H.265-TAG
+- Scene series: Example.Show.2024.S01E01.Pilot.1080p.EXAMPLE.WEB-DL.DDP5.1.H.264-TAG
+- Plex movies: Example Movie (2024) 1080p
Controls the folder name for downloaded content. Uses the same template variables as the file templates above.
+If not configured, the default folder naming is used:
+- Movies: Title (Year)
+- Series: Derived from the series template with episode-specific variables removed
+- Songs: Artist - Album (Year)
folder accepts either a single string (applies to all title kinds) or a mapping with per-kind
+templates keyed by movies, series, songs, and/or albums. Unknown keys are warned about and ignored.
Use / in a folder template to create nested directories - each segment is sanitized
+independently and joined as real path separators. For example '{source}/Series/{title}.{year?}'
+produces EXAMPLE/Series/The.Show.2024/. Note {source} is the service tag (e.g. EXAMPLE,
+EXAMPLE2), not the display name; it is blank when --no-source is used.
Movies only get their own folder when a movies folder template is set; without one the movie file
+is written directly into the downloads directory (unchanged default behaviour). Series and songs
+always get a folder unless --no-folder is passed.
Useful music variables for album folders: album_artist, album, artist, year, genre, label, release_type, track_total, disc_total.
output_template:
+ movies: '{title}.{year}.{repack?}.{edition?}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{audio_full}.{atmos?}.{hdr?}.{hfr?}.{video}-{tag}'
+ series: '{title}.{year?}.{season_episode}.{episode_name?}.{repack?}.{edition?}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{audio_full}.{atmos?}.{hdr?}.{hfr?}.{video}-{tag}'
+ songs: '{track_number}.{title}.{repack?}.{edition?}.{source?}.WEB-DL.{audio_full}.{atmos?}-{tag}'
+
+ # Scene-style folder (single template, applies to all kinds)
+ folder: '{title}.{year?}.{repack?}.{edition?}.{lang_tag?}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{audio_full}.{atmos?}.{hdr?}.{hfr?}.{video}-{tag}'
+
+ # Plex-friendly folder
+ # folder: '{title} ({year?})'
+
+ # Per-kind folder templates
+ # folder:
+ # movies: '{title} ({year})'
+ # series: '{title} ({year?})'
+ # songs: '{artist} - {album} ({year?})'
+ # albums: '{album_artist} - {album} ({year?})'
+
+ # Nested per-service layout (EXAMPLE/Series/Title.Year/...)
+ # folder:
+ # series: '{source}/Series/{title}.{year?}'
+ # movies: '{source}/Movies/{title}.{year?}'
+ # songs: '{source}/Music/{album_artist}/{album}.{year?}'
+Example outputs:
+- Scene folder: Example.Show.2024.S01.1080p.EXAMPLE.WEB-DL.DDP5.1.H.264-TAG/
+- Plex folder: Example Show (2024)/
+- Nested folder: EXAMPLE/Series/Example.Show.2024/
Automatically adds language-based identifiers (e.g., DANiSH, NORDiC, DKsubs) to output filenames
+based on audio and subtitle track languages. Use {lang_tag?} in your output_template to place the tag.
Rules are evaluated in order; the first matching rule wins. All conditions within a single rule
+must match (AND logic). If no rules match, {lang_tag?} is cleanly removed from the filename.
| Condition | +Type | +Description | +
|---|---|---|
audio |
+string | +Matches if any selected audio track has this language | +
subs_contain |
+string | +Matches if any selected subtitle has this language | +
subs_contain_all |
+list | +Matches if subtitles include ALL listed languages | +
Language matching uses fuzzy matching (e.g., en matches en-US, en-GB).
language_tags:
+ rules:
+ - audio: da
+ tag: DANiSH
+ - audio: sv
+ tag: SWEDiSH
+ - audio: nb
+ tag: NORWEGiAN
+ - audio: en
+ subs_contain_all: [da, sv, nb]
+ tag: NORDiC
+ - audio: en
+ subs_contain: da
+ tag: DKsubs
+
+output_template:
+ movies: '{title}.{year?}.{lang_tag?}.{quality}.{source}.WEB-DL.{audio_full}.{video}-{tag}'
+Example outputs:
+- Danish audio: Example.Show.S01E01.DANiSH.1080p.EXAMPLE.WEB-DL.DDP5.1.H.264-TAG
+- English audio + multiple Nordic subs: Example.Show.S01E01.NORDiC.1080p.EXAMPLE.WEB-DL.DDP5.1.H.264-TAG
+- English audio + Danish subs only: Example.Show.S01E01.DKsubs.1080p.EXAMPLE.WEB-DL.DDP5.1.H.264-TAG
+- No matching languages: Example.Show.S01E01.1080p.EXAMPLE.WEB-DL.DDP5.1.H.264-TAG
language_tags:
+ rules:
+ - audio: nl
+ tag: DUTCH
+ - audio: de
+ tag: GERMAN
+ - audio: fr
+ subs_contain: en
+ tag: ENGFR
+ - audio: fr
+ tag: FRENCH
+Allow Unicode characters in output filenames. When false, Unicode characters are transliterated
+to ASCII equivalents. Default: false.
Group or Username to postfix to the end of download filenames following a dash.
+Use {tag} in your output template to include it.
+For example, tag: "J0HN" will have -J0HN at the end of all download filenames.
Enable/disable tagging downloads with your group name when tag is set. Default: true.
Enable/disable tagging downloaded files with IMDB/TMDB/TVDB identifiers (when available). Default: true.
set_title
+ Set the container title to Show SXXEXX Episode Name or Movie (Year). Default: truemerge_audio
+ Merge all audio tracks into each output file. Default: truetrue: All selected audio tracks are muxed into one MKV per quality.false: Separate MKV per (quality, audio_codec) combination.
+ For example: Title.1080p.AAC.mkv, Title.1080p.EC3.mkv.Note: The --split-audio CLI flag overrides this setting. When --split-audio is passed,
+ merge_audio is effectively set to false for that run.
merge_video
+ Merge video language variants into one file. Default: falsefalse: One MKV per video track (the default behaviour).true: Group the selected video tracks by (resolution, range, codec) and merge
+ each group into one MKV, so only language varies within a file. The player switches
+ between the language tracks. No re-encode, no concatenation.Only the language dimension is collapsed. Different resolutions, ranges
+ (SDR/HDR10/HDR10+/DV/HYBRID) and codecs (H264/H265) always stay in separate files.
+ For example, -r HYBRID,DV,HDR10,SDR --merge-video produces one file per range (never a
+ single combined file), while a title offering English + French video of the same
+ resolution/range/codec produces one file containing both video tracks.
Note: The --merge-video CLI flag overrides this setting. Can be set per service under
+ services.<TAG>.muxing.merge_video. Change group_videos_by_variant in
+ unshackle/commands/dl.py to adjust the grouping.
default_language (dict)
+ Override which track is flagged as the default in the muxed MKV, regardless
+ of the title's original language. Useful when you always want your player to
+ open on a specific language (e.g. always default to Polish audio even on
+ English originals). Only affects the MKV --default-track flag - track
+ selection (-l, --alang, etc.) is unchanged. All keys are optional; each
+ track type falls back to its previous default rule when the configured
+ language isn't present in the manifest.
audio: BCP-47 tag (e.g. pl, en, pt-BR). Wins over is_original_lang.
+ The --original-flag continues to mark the true original-audio track.
video: BCP-47 tag. Wins over the title-language / first-track rule.subtitle: BCP-47 tag. Wins over the "forced sub matching audio" rule.Languages are matched with the same close-match logic used elsewhere
+ (pt matches pt-BR, etc.). Supports per-service overrides like the rest
+ of muxing.
The Chapter Name to use when exporting a Chapter without a Name. +The default is no fallback name at all and no Chapter name will be 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 title.
+ E.g., "Chapter {j}": "Chapter 1", "Intro", "Chapter 2".These are formatted with f-strings, directives are supported.
+For example, "Chapter {i:02}" will result in "Chapter 01".
Override the default directories used across unshackle. +The directories are set to common values by default.
+The following directories are available and may be overridden,
+commands - CLI Command Classes.services - Service Classes.vaults - Vault Classes.fonts - Font files (ttf or otf).downloads - Downloads.temp - Temporary files or conversions during download.cache - Expiring data like Authorization tokens, or other misc data.cookies - Expiring Cookie data.logs - Logs.exports - JSON sidecar exports written when --export is used on dl.wvds - Widevine Devices.prds - PlayReady Devices.dcsl - Device Certificate Status List.Notes:
+services accepts either a single directory or a list of directories to search for service modules.
+ Entries may also be git repo specs to load service packs hosted in a repo (see below).For example,
+ +There are directories not listed that cannot be modified as they are crucial to the operation of unshackle.
+A services entry may be a git repo instead of a local path, letting you host service packs on
+GitHub or any git host (GitLab, Gitea, self-hosted). Local paths and repo specs can be mixed:
directories:
+ services:
+ - https://github.com/you/your-services # https repo (highest priority - listed first)
+ - git@gitlab.com:you/your-services.git # ssh repo (private, via your git auth)
+ - you/your-services@main # owner/repo shorthand + optional @branch
+ - ~/my-local-services # local path (fallback - listed last)
+How it works:
+<your-services-dir>/_repos/<repo-name>/ - the first
+ local services entry, or the bundled unshackle/services if you configured none. Nothing is
+ written to the cache directory.unshackle util refresh-services.git on your PATH. Private repos use your existing git credential helper - unshackle
+ stores no tokens. Git use is read-only on the remote - only clone, fetch, pull, and a
+ local reset are run; nothing is ever pushed._repos/ directly. The two refresh paths
+ treat your edits differently:dl/search run): if the clone has uncommitted
+ changes to tracked files or unpushed local commits, unshackle refuses to refresh and exits,
+ naming the clone, so a background pull never clobbers work in progress. Commit and push it
+ upstream (or revert), then it refreshes normally.unshackle util refresh-services): an explicit "get upstream's latest" - it
+ hard-resets the clone to upstream, discarding local changes. Run it only when you want to
+ throw away local edits.__pycache__) never block the automatic path - a
+ fast-forward pull doesn't touch them.<TAG>/__init__.py service dirs (same layout as
+ unshackle/services/).The full per-duplicate detail is logged only at debug verbosity (unshackle -d ...), one line per
+ duplicate, naming the path that loaded and the path that was ignored:
EXAMPLE: using <unshackle>/services/EXAMPLE/__init__.py, ignoring duplicate <unshackle>/services/_repos/your-repo/EXAMPLE/__init__.py
+Paths are shortened to <unshackle>/<venv>/~ tokens; set redact_paths: false to show full
+ absolute paths (see Debug Logging).
If <your-services-dir> is inside the installed package, a reinstall may remove the clones; they are
+simply re-cloned on next use. On read-only installs, point services at a writable path.
This document covers service-specific configuration, authentication, and metadata integration options.
+Configuration data for each Service. The Service will have the data within this section merged into the per-service
+config.yaml (located in the service's directory) before being provided to the Service class.
Think of this config to be used for more sensitive configuration data, like user or device-specific API keys, IDs,
+device attributes, and so on. A per-service config.yaml file is typically shared and not meant to be modified,
+so use this for any sensitive configuration data.
The Key is the Service Tag, but can take any arbitrary form for its value. It's expected to begin as either a list or +a dictionary.
+For example,
+ +You can override many global configuration options on a per-service basis by nesting them under the
+service tag in the services section. Supported override keys include: dl, subtitle, muxing,
+headers, proxy_map, title_map, and more.
Overrides are merged with global config (not replaced) -- only specified keys are overridden, others +use global defaults.
+Any dl command option can be overridden per service. Use the option name with dashes as underscores
+(--v-lang -> v_lang). range and list work as-is; their internal Python names (range_,
+list_, suffixed to avoid the builtins) are also accepted.
Precedence (highest first):
+--v-lang en)services.<TAG>.dl)dl: configFor example,
+services:
+ RATE_LIMITED_SERVICE:
+ dl:
+ downloads: 2 # Limit concurrent track downloads
+ workers: 4 # Reduce workers to avoid rate limits
+ headers:
+ User-Agent: "..." # Service-specific UA override
+Note: unshackle uses a single unified requests-based downloader. The legacy aria2c,
+n_m3u8dl_re, and curl_impersonate override sections have been removed.
Rewrites service-provided titles before naming and output. Some services name a title differently +from how you want it stored, which can break library matching (e.g. a regional variant reusing the +international name). Keys are the exact title string the service returns; values are the desired +output title.
+ +Episodes are matched on their show title, Movies and Songs on their name. The remap is applied
+after the title cache (so edits take effect without a cache reset) and before any --enrich
+override (so an explicit enrich still wins).
It applies on the local dl path, the import command, and the remote client (dl --remote).
+For remote services the client's title_map is applied to the titles returned by the server,
+so you can rename titles for services you don't have installed locally. The server sends raw
+titles and does not remap, leaving the final name fully under the client's control.
Each service directory under unshackle/services/ exports a class extending
+unshackle.core.service.Service. The class name must match the directory name (the service tag).
Key class variables (defined on Service or by service-level idiom):
ALIASES: tuple[str, ...] — alternative tags accepted on the CLI. Empty by default.GEOFENCE: tuple[str, ...] — ISO country codes the service is available in. Empty == no geofence.TITLE_RE: str — regex (with named groups, e.g. (?P<id>...), (?P<type>...)) used by the
+ service to parse the CLI title argument. Service-level idiom, not declared on the base class.NO_SUBTITLES: bool — service-level idiom indicating the service has no subtitle tracks.self.* helpers available after super().__init__(ctx):
self.session — pre-configured HTTP session (requests.Session, or RnetSession when TLS
+ impersonation is active). Cookies, headers, proxies pre-applied.self.config — merged service config (per-service config.yaml plus the services.<TAG> block
+ from unshackle.yaml).self.log — logging.Logger named for the service class.self.cache — generic Cacher for arbitrary key/value persistence.self.title_cache — specialized TitleCacher for title metadata.self.track_request — TrackRequest built from CLI flags. Fields: codecs: list[Video.Codec],
+ ranges: list[Video.Range] (defaults to [SDR]), best_available: bool. Services may
+ read or rewrite these (e.g. force HEVC for HDR ranges).self.credential — set during authenticate(); None if cookies-only.self.current_region — lowercase ISO country code from proxy/geolocation, or None.self.request_input(prompt: str) -> str — interactive prompt. Falls through to input()
+ locally; under serve, the attached InputBridge relays the prompt to the remote client.Driving CLI flags (parsed into self.track_request):
-v / --vcodec — comma-separated Video.Codec list (e.g. H264,H265).-a / --acodec — comma-separated audio codec list.-r / --range — comma-separated Video.Range list (SDR, HDR10, HDR10+, DV,
+ HYBRID). Defaults to [SDR].-q / --quality — resolution list.--vbitrate-range / --abitrate-range — MIN-MAX kbps windows.Specify login credentials to use for each Service, and optionally per-profile.
+For example,
+EXAMPLE: jane@example.tld:LoremIpsum100 # directly
+EXAMPLE2: # or per-profile, optionally with a default
+ default: jane@example.tld:LoremIpsum99 # <-- used by default if -p/--profile is not used
+ james: james@example.tld:TheFriend97
+ john: john@example.tld:LoremIpsum98
+EXAMPLE3: # the `default` key is not necessary, but no credential will be used by default
+ john: john@example.tld:SecretPassword123
+The value should be in string form, i.e. john@example.tld:password123 or john:password123.
+Any arbitrary values can be used on the left (username/password/phone) and right (password/secret).
+You can also specify these in list form, i.e., ["john@example.tld", ":PasswordWithAColon"].
If you specify multiple credentials with keys like the EXAMPLE2 and EXAMPLE3 example above, then you should
+use a default key or no credential will be loaded automatically unless you use -p/--profile. You
+do not have to use a default key at all.
Please be aware that this information is sensitive and to keep it safe. Do not share your config.
+Automatically extract cookies from your running Firefox installation instead of maintaining a
+cookies/<SERVICE>.txt file. Configured as a top-level dict keyed by service tag; each value is
+a settings dict for that service.
firefox_cookies:
+ EXAMPLE:
+ hosts:
+ - example.com
+ - api.example.com
+ local_storage: false # optional, default false
+Fields:
+hosts — list of host patterns to match against Firefox's cookie store. Each pattern must be at
+ least 3 characters. A pattern matches the exact host and any dot-suffix subdomain
+ (e.g. example.com also matches sub.example.com and .example.com).local_storage — when true, also harvest localStorage entries for the listed hosts from
+ Firefox's webappsstore.sqlite. Default: false.At download time unshackle picks the most recently modified valid Firefox profile (the one
+whose cookies.sqlite has the latest mtime), copies the database to a restricted temporary
+directory (0700), and reads cookies from it without touching the live profile. If extraction
+returns no cookies or fails for any reason, it falls back silently to the normal file-based cookie
+(cookies/<SERVICE>.txt or cookies/<SERVICE>/<profile>.txt).
Notes:
+cookies.sqlite and its WAL file to capture any
+ unflushed writes).local_storage: true is only needed for services that store auth tokens in the browser's
+ localStorage rather than in HTTP cookies.core/utils/firefox_cookie_extractor.py; the fallback logic lives
+ in dl.get_cookie_jar (commands/dl.py).API key for The Movie Database (TMDB). This is used for tagging downloaded files with TMDB, +IMDB and TVDB identifiers. Leave empty to disable automatic lookups.
+To obtain a TMDB API key:
+For example,
+ +Note
+Keep your API key secure and do not share it publicly. This key is used by the core/providers/tmdb.py metadata provider to fetch metadata from TMDB for proper file tagging and ID enrichment.
Client ID for SIMKL API integration. SIMKL is used as a metadata source for improved title matching and tagging, +especially when a TMDB API key is not configured.
+To obtain a SIMKL Client ID:
+For example,
+ +Note
+While optional, having a SIMKL Client ID improves metadata lookup reliability. SIMKL serves as an alternative or fallback metadata source to TMDB. This is used by the core/providers/simkl.py metadata provider.
Optional API token for ipinfo.io. When set, unshackle uses the free authenticated Lite endpoint (https://api.ipinfo.io/lite/me), which has substantially higher rate limits than the anonymous endpoint and returns richer fields (ASN, organization name, continent). Leave empty to use the anonymous ipinfo.io endpoint, with ip-api.in as a final fallback.
To obtain an ipinfo.io token:
+For example,
+ +Note
+The token is only ever sent to api.ipinfo.io as a per-request Authorization header — it is never attached to your session for service requests. Used by core/utils/ip_info.py for region detection and proxy verification.
Enable/disable caching of title metadata to reduce redundant API calls. Default: true.
Cache duration in seconds for title metadata. Default: 1800 (30 minutes).
Maximum retention time in seconds for serving slightly stale cached title metadata when API calls fail.
+Default: 86400 (24 hours). Effective retention is min(title_cache_time + grace, title_cache_max_retention).
Enable structured JSON debug logging for troubleshooting and service development. Default: false.
When enabled (via config or the --debug CLI flag):
+- Creates JSON Lines (.jsonl) log files with complete debugging context
+- Logs: session info, CLI params, service config, CDM details, authentication, titles, tracks metadata,
+ DRM operations, vault queries, errors with stack traces
+- File location: logs/unshackle_debug_{service}_{timestamp}.jsonl
Log decryption keys in debug logs. Default: false.
When true, actual content encryption keys (CEKs) are included in debug log output. Useful for
+debugging key retrieval and decryption issues.
Security note
+Passwords, tokens, cookies, and session tokens are always redacted regardless
+of this setting. Only content keys (content_key, key fields) are affected. Key IDs (kid),
+key counts, and other metadata are always logged.
This document covers subtitle processing and formatting options under the top-level subtitle: key in unshackle.yaml.
This document is the canonical reference for subtitle configuration. unshackle/unshackle-example.yaml is a minimal starter only.
Control subtitle conversion, SDH (hearing-impaired) stripping, formatting preservation, and output behavior.
+conversion_method: Which backend to convert subtitles with. Default: auto.Routing is data-driven (unshackle/core/tracks/subtitle_convert.py): a registry of backends each
+ declares the source→target codec pairs it supports plus a preference rank. For a conversion, the
+ available backends that support the pair are tried in rank order — a real fallback chain. A
+ non-auto value pins that backend first, then still falls back through the chain if it can't
+ handle the pair or errors (pin-then-fallback). A service may also set preferred_conversion_method
+ on its tracks; an explicit conversion_method in config always wins.
auto: Best available backend by rank — SubtitleEdit (if installed) for highest fidelity;
+ otherwise subby for WebVTT/fVTT/SAMI→SRT (adds CommonIssuesFixer cleanup), pysubs2 for SSA/ASS
+ and the broad format set, pycaption as last resort.subby: Prefer subby (CommonIssuesFixer); reads WebVTT/fVTT/SAMI, writes SRT (and TTML/VTT via
+ an SRT intermediate).subtitleedit: Prefer SubtitleEdit / seconv. Highest fidelity — preserves positioning/italics.pycaption: Prefer pycaption. Flattens positioning/italics, writes only SRT/TTML/WebVTT.pysubs2: Prefer pysubs2 (SRT, SSA, ASS, WebVTT, TTML, SAMI, MicroDVD, MPL2, TMP). The only
+ pure-Python backend that reads ASS/SSA, so it is the default for styled SubStation sources.Styled-subtitle protection: ASS/SSA are never automatically downconverted to SRT (the
+ conversion is skipped and the original kept) — SRT cannot carry their positioning/colours/styling.
+ This applies to the default muxed track only; explicit requests still convert: a per-download
+ --sub-format srt for the muxed track, or sidecar_format: srt for sidecars. To keep raw styled
+ sidecars, set sidecar_format: original.
Segmented subtitles (fVTT/WVTT and fTTML/STPP from DASH/HLS) are read directly
+ from the fragmented MP4: fVTT via subby's WVTTConverter, fTTML via pycaption's box parsing. They
+ can be converted from but not to.
SubtitleEdit on Linux/macOS: install the SubtitleEdit 5+ CLI (SeConv / seconv, the
+ self-contained cross-platform build from the SubtitleEdit releases) onto PATH or into
+ unshackle/binaries/. unshackle targets the SubtitleEdit 5+ command syntax. The Windows
+ SubtitleEdit.exe is the GUI app — use the SeConv CLI binary for headless conversion.
sdh_method: How to strip SDH cues. Default: auto.auto: Try subby for SRT first, then SubtitleEdit (when conversion_method is auto/subtitleedit and the binary is available), then subtitle-filter as the final fallback.subby: Use subby's SDHStripper. Only operates on SRT; for other codecs the call returns without stripping.subtitleedit: Use SubtitleEdit's --remove-text-for-hi (SE5 CLI) when the binary is available; otherwise falls through to subtitle-filter.filter-subs: Use the subtitle-filter library directly (rm_fonts, rm_ast, rm_music, rm_effects, rm_names, rm_author).
strip_sdh: Enable/disable automatic SDH stripping for tracks flagged as SDH. Default: true.
convert_before_strip: When falling through to the subtitle-filter path, auto-convert non-SRT subtitles to SRT first for better compatibility. Default: true. Has no effect when SubtitleEdit handles stripping directly.
preserve_formatting: Keep original subtitle tags and positioning during WebVTT processing. When true, sanitized WebVTT is written back without round-tripping through pycaption, preserving tags like <i>, <b>, and line: positioning. Default: true.
output_mode: Controls how subtitles are included in the output. Default: mux.
mux: Embed subtitles in the MKV container only.sidecar: Save subtitles as separate files only (not muxed).both: Embed in the MKV container and save as sidecar files.
sidecar_format: Format for sidecar subtitle files (used when output_mode is sidecar or both). Default: srt.
srt: SubRip.vtt: WebVTT.ass: Advanced SubStation Alpha.original: Keep the subtitle in its current format without conversion.Example:
+subtitle:
+ conversion_method: auto
+ sdh_method: auto
+ strip_sdh: true
+ convert_before_strip: true
+ preserve_formatting: true
+ output_mode: mux
+ sidecar_format: srt
+After download, WebVTT and segmented WebVTT (fVTT/WVTT) tracks pass through a fixed sanitization pipeline before any conversion or muxing:
merge_segmented_webvtt (uses pysubs2 for lenient parsing when conversion_method is auto or pysubs2, otherwise pycaption directly).sanitize_webvtt_timestamps rewrites -HH:MM:SS.mmm cues to 00:00:00.000.sanitize_webvtt_cue_identifiers strips letter+digit IDs (e.g. Q0, S12) on their own line before a timing line, which otherwise confuse parsers like pysubs2.merge_overlapping_webvtt_cues collapses cues with start times within 50 ms and matching end times into a single multi-line cue, ordered by line: percentage (lower % = higher on screen = first line).preserve_formatting is false and the first pycaption parse fails, sanitize_webvtt retries with a WEBVTT header guard, hour-padded timings, and another negative-timestamp pass; if that still fails, the sanitized text is written as-is.sanitize_broken_webvtt and space_webvtt_headers additionally run inside Subtitle.parse() to drop malformed --> lines and reflow merged-segment headers. merge_same_cues and filter_unwanted_cues (drops /whitespace-only cues) run only on the pycaption path.
These behaviors are intentional and have no config knobs — they apply to every WebVTT track regardless of conversion_method.
unshackle/core/utilities.py::sanitize_filename and is governed by output_template, not the subtitle: config block.unshackle/core/tracks/subtitle.py; the conversion backend
+ registry, capability matrix, and ranks live in unshackle/core/tracks/subtitle_convert.py.When api_secret is set in unshackle.yaml, all API requests require the X-Secret-Key header. There is no query-parameter fallback. /api/health is always reachable without authentication. --no-key disables auth entirely (not recommended for public-facing servers).
# unshackle.yaml
+serve:
+ api_secret: "your-master-secret" # falls back to global users map below
+ remote_only: false # also toggleable via --remote-only
+ services: ["EXAMPLE1", "EXAMPLE2"] # optional global service allowlist
+ users:
+ user-secret-1:
+ username: alice
+ devices: ["my_widevine_l3"] # Widevine WVD names this user may use
+ playready_devices: ["my_pr_sl2000"] # PlayReady PRD names; defaults to [] (no access)
+ services: ["EXAMPLE1"] # optional per-user allowlist (intersected with global)
+ user-secret-2:
+ username: bob
+ devices: []
+ playready_devices: []
+config.serve.services is the global allowlist; users.<key>.services further narrows it per key. The effective set is the intersection. Endpoints affected: /api/services, /api/search, /api/list-titles, /api/list-tracks, /api/download, and all /api/session/* routes.
There is no separate "tier" flag. Whether the server can return KID:KEY for a session-mode download depends solely on the device lists configured for the calling user key:
+devices and playready_devices -> server can only proxy CDM challenges; the client must run its own CDM and parse the license.mode: "server_cdm" on /api/session/{id}/license and receive { "keys": { "<track_id>": { "<KID>": "<KEY>" } } } instead of raw license bytes.Per-service CDM type can be pinned via config.cdm (widevine/playready) or per-service cdm_type; otherwise the server picks the type the user has devices for.
dl defaults¶Any flag accepted by /api/download (see the table below) can be declared under serve: in unshackle.yaml and the API will apply it as a default. Request-body values still win. Useful for raising concurrency without changing every client call:
serve:
+ api_secret: "..."
+ users: { ... }
+ downloads: 4 # parallel tracks per download job
+ workers: 16 # threads per track segment fetch
+ best_available: true
+ no_proxy_download: false
+Layering order: built-in defaults < serve.* overrides < service-specific click defaults < request body.
Two security gates under serve: guard capabilities that /api/download clients can request. Both default OFF; an unset or false value causes the server to reject the request with 403 FORBIDDEN.
cdm_overrides — a /api/download job body may include a cdm field to select a specific server-side device. When this gate is off, every such request is rejected. Set to true to allow any device the server has, or to a list of device names to restrict to an explicit subset:
serve:
+ # Allow any server-side device:
+ cdm_overrides: true
+
+ # Or restrict to specific devices:
+ cdm_overrides:
+ - generic_nexus_4464_l3
+allow_job_credentials — a /api/download job body may include a credential (single) or credentials (map) field to authenticate the job with client-supplied secrets instead of the server's configured credentials. When this gate is off, any job containing either field is rejected. Each distinct credential gets its own isolated token cache on the server. Set to true to allow:
Both gates are independent and can be combined. A typical locked-down deployment leaves both unset; a trusted single-client deployment might enable both.
+ + + + + + + + + + + + + +remote_services¶remote_services is a top-level block in unshackle.yaml on the client machine. It tells dl how to reach a remote unshackle serve instance instead of running service code locally. The client handles track selection, download, decrypt, and mux; the server handles auth, manifest parsing, and (optionally) DRM licensing.
| Flag | +Description | +
|---|---|
--remote |
+Use a remote server instead of local service code. Reads remote_services from config. |
+
--server <name> |
+Select a named server when multiple are configured. If only one entry exists it is picked automatically. | +
unshackle dl --remote EXAMPLE1 "https://example.com/show/abc123"
+unshackle dl --remote --server us-server EXAMPLE1 "https://example.com/show/abc123"
+remote_services:
+ my-server:
+ url: "http://192.168.1.100:8786" # base URL of the remote serve instance
+ api_key: "your-secret-key-here" # sent as X-Secret-Key; omit or set "" for --no-key servers
+
+ # server_cdm: the server runs its own CDM and returns KID:KEY pairs (mode: server_cdm).
+ # false (default): client runs its own CDM; license challenges are proxied through the server.
+ server_cdm: false
+
+ # Per-service overrides applied on the client side after the remote session is set up.
+ # Supported keys: decryption, downloader, cdm (and any free-form service config keys).
+ services:
+ EXAMPLE1:
+ decryption: mp4decrypt # override decryption tool for this service (shaka or mp4decrypt)
+server_cdm — when false (default) the client builds CDM challenges locally and calls POST /api/session/{id}/license with mode: "proxy"; the server forwards the challenge to the service's license endpoint and returns raw license bytes. When true the client skips local CDM setup and calls with mode: "server_cdm"; the server performs the full CDM flow and returns { "keys": { "<KID>": "<KEY>" } }. The server-side user key must have a matching device configured (devices / playready_devices) for server_cdm to work.
When more than one server is configured, --server <name> is required:
remote_services:
+ us-server:
+ url: "https://us.example.com:8786"
+ api_key: "us-key"
+ server_cdm: true
+ services:
+ EXAMPLE1:
+ decryption: mp4decrypt
+
+ eu-server:
+ url: "https://eu.example.com:8786"
+ api_key: "eu-key"
+ server_cdm: false
+On session open the client sends any local *.json cache files for the service (tokens, etc.) to the server so it can skip interactive re-auth. On session close the server returns updated cache files; the client writes them back locally so the next remote session can forward them again.
A multi-episode request (wanted: ["S01E01", ...]) is a single job that processes episodes sequentially. During a run the job status updates live: current_title names the episode downloading now, output_files grows as each episode finishes muxing, and track_progress lists per-track progress when downloads > 1 runs tracks concurrently. progress and track counts reset as each episode starts; use output_files against parameters.wanted to derive overall completion.
Jobs are retained for 24 hours after completion (override via top-level download_job_retention_hours in unshackle.yaml). The server runs up to 2 concurrent download jobs by default; override via top-level max_concurrent_downloads. This is independent of serve.downloads, which controls parallel tracks within a single job.
Remote sessions are managed by SessionStore (unshackle/core/api/session_store.py); idle sessions and their InputBridge instances are cleaned up by a background loop started/stopped with the app lifecycle.
Standard endpoints (suppressed in --remote-only mode are marked R):
| Method | +Path | +R | +
|---|---|---|
| GET | +/api/health |
+ok | +
| GET | +/api/services |
+ok | +
| POST | +/api/search |
+ok | +
| POST | +/api/list-titles |
+hidden | +
| POST | +/api/list-tracks |
+hidden | +
| POST | +/api/download |
+hidden | +
| GET | +/api/download/jobs |
+hidden | +
| GET | +/api/download/jobs/{job_id} |
+hidden | +
| DELETE | +/api/download/jobs/{job_id} |
+hidden | +
| POST | +/api/session/create |
+ok | +
| GET | +/api/session/{session_id} |
+ok | +
| DELETE | +/api/session/{session_id} |
+ok | +
| GET | +/api/session/{session_id}/titles |
+ok | +
| POST | +/api/session/{session_id}/tracks |
+ok | +
| POST | +/api/session/{session_id}/segments |
+ok | +
| POST | +/api/session/{session_id}/license |
+ok | +
| GET | +/api/session/{session_id}/prompt |
+ok | +
| POST | +/api/session/{session_id}/prompt |
+ok | +
CDM endpoints (/{wvd}/..., /playready/{prd}/...) are exposed unless --api-only / --remote-only / --no-widevine / --no-playready is set, and use pywidevine / pyplayready's own auth scheme.
Health check with version and update information. Always reachable without auth.
+ +{
+ "status": "ok",
+ "version": "4.0.0",
+ "update_check": {
+ "update_available": false,
+ "current_version": "4.0.0",
+ "latest_version": null
+ }
+}
+List all available streaming services (filtered by the effective allowlist for the caller).
+ +Returns {"services": [...]}. Each entry has tag, aliases, geofence, title_regex, url (from cli.short_help), help (full docstring), and cli_params describing the service-level Click parameters.
Search for titles from a streaming service.
+Required parameters:
+| Parameter | Type | Description |
+| --- | --- | --- |
+| service | string | Service tag |
+| query | string | Search query |
Optional parameters:
+| Parameter | Type | Default | Description |
+| --- | --- | --- | --- |
+| profile | string | null | Profile for credentials/cookies |
+| proxy | string | null | Proxy URI or country code |
+| no_proxy | boolean | false | Disable all proxy use |
curl -X POST http://localhost:8786/api/search \
+ -H "X-Secret-Key: $KEY" \
+ -H "Content-Type: application/json" \
+ -d '{"service": "EXAMPLE1", "query": "example show"}'
+{
+ "results": [
+ {
+ "id": "abc123def456",
+ "title": "Example Show",
+ "description": null,
+ "label": "TV Show",
+ "url": "https://example.com/show/abc123def456"
+ }
+ ],
+ "count": 1
+}
+Get available titles (seasons/episodes/movies) for a service and title ID. Disabled in --remote-only mode.
Required parameters:
+| Parameter | Type | Description |
+| --- | --- | --- |
+| service | string | Service tag |
+| title_id | string | Title ID or URL |
curl -X POST http://localhost:8786/api/list-titles \
+ -H "X-Secret-Key: $KEY" \
+ -H "Content-Type: application/json" \
+ -d '{"service": "EXAMPLE1", "title_id": "abc123def456"}'
+Get video, audio, and subtitle tracks for a title. Disabled in --remote-only mode.
Required parameters:
+| Parameter | Type | Description |
+| --- | --- | --- |
+| service | string | Service tag |
+| title_id | string | Title ID or URL |
Optional parameters:
+| Parameter | Type | Default | Description |
+| --- | --- | --- | --- |
+| wanted | array | all | Episode filter (e.g., ["S01E01"]) |
+| profile | string | null | Profile for credentials/cookies |
+| proxy | string | null | Proxy URI or country code |
+| no_proxy | boolean | false | Disable all proxy use |
Returns video, audio, and subtitle tracks with codec, bitrate, resolution, language, and DRM information.
+Start a download job. Returns immediately with a job ID (HTTP 202). Disabled in --remote-only mode.
Required parameters:
+| Parameter | Type | Description |
+| --- | --- | --- |
+| service | string | Service tag |
+| title_id | string | Title ID or URL |
Quality and codec parameters:
+| Parameter | Type | Default | Description |
+| --- | --- | --- | --- |
+| quality | array[int] | best | Resolution(s) (e.g., [1080, 2160]) |
+| vcodec | string or array | any | Video codec(s): H264, H265/HEVC, VP9, AV1, VC1, VP8 |
+| acodec | string or array | any | Audio codec(s): AAC, AC3, EC3, AC4, OPUS, FLAC, ALAC, DTS, OGG |
+| vbitrate | int | highest | Video bitrate in kbps |
+| abitrate | int | highest | Audio bitrate in kbps |
+| range | array[string] | ["SDR"] | Color range(s): SDR, HDR10, HDR10+, HLG, DV, HYBRID |
+| channels | float | any | Audio channels (e.g., 5.1, 7.1) |
+| no_atmos | boolean | false | Exclude Dolby Atmos tracks |
+| split_audio | boolean | null | Create separate output per audio codec |
+| sub_format | string | null | Output subtitle format: SRT, VTT, ASS, SSA, TTML |
Episode selection:
+| Parameter | Type | Default | Description |
+| --- | --- | --- | --- |
+| wanted | array[string] | all | Episodes (e.g., ["S01E01", "S01E02-S01E05"]) |
+| latest_episode | boolean | false | Download only the most recent episode |
Language parameters:
+| Parameter | Type | Default | Description |
+| --- | --- | --- | --- |
+| lang | array[string] | ["orig"] | Language for video and audio (orig = original) |
+| v_lang | array[string] | [] | Language override for video tracks only |
+| a_lang | array[string] | [] | Language override for audio tracks only |
+| s_lang | array[string] | ["all"] | Language for subtitles |
+| require_subs | array[string] | [] | Required subtitle languages (skip if missing) |
+| forced_subs | boolean | false | Include forced subtitle tracks |
+| exact_lang | boolean | false | Exact language matching (no variants) |
Track selection:
+| Parameter | Type | Default | Description |
+| --- | --- | --- | --- |
+| video_only | boolean | false | Only download video tracks |
+| audio_only | boolean | false | Only download audio tracks |
+| subs_only | boolean | false | Only download subtitle tracks |
+| chapters_only | boolean | false | Only download chapters |
+| no_video | boolean | false | Skip video tracks |
+| no_audio | boolean | false | Skip audio tracks |
+| no_subs | boolean | false | Skip subtitle tracks |
+| no_chapters | boolean | false | Skip chapters |
+| audio_description | boolean | false | Include audio description tracks |
Output and tagging:
+| Parameter | Type | Default | Description |
+| --- | --- | --- | --- |
+| tag | string | null | Override group tag |
+| repack | boolean | false | Add REPACK tag to filename |
+| tmdb_id | int | null | Use specific TMDB ID for tagging |
+| imdb_id | string | null | Use specific IMDB ID (e.g., tt1375666) |
+| animeapi_id | string | null | Anime database ID via AnimeAPI (e.g., mal:12345) |
+| enrich | boolean | false | Override show title and year from external source |
+| no_folder | boolean | false | Disable folder creation for TV shows |
+| no_source | boolean | false | Remove source tag from filename |
+| no_mux | boolean | false | Do not mux tracks into container |
+| output_dir | string | null | Override output directory |
Download behavior:
+| Parameter | Type | Default | Description |
+| --- | --- | --- | --- |
+| profile | string | null | Profile for credentials/cookies |
+| proxy | string | null | Proxy URI or country code |
+| no_proxy | boolean | false | Disable all proxy use |
+| no_proxy_download | boolean | false | Bypass proxy for segment downloads only. Manifest, license, and auth still use proxy |
+| workers | int | null | Max threads per track download |
+| downloads | int | 1 | Concurrent track downloads |
+| slow | boolean or string | null | Add randomized delay between titles. true = 60-120s, or "MIN-MAX" string (e.g., "20-40"). Min must be >= 20 |
+| best_available | boolean | false | Continue if requested quality unavailable |
+| worst | boolean | false | Select the lowest bitrate track within the specified quality. Requires quality |
+| skip_dl | boolean | false | Skip download, only get decryption keys |
+| export | boolean | false | Export manifest, track URLs, keys, and subtitles to JSON in the exports directory |
+| cdm_only | boolean | null | Only use CDM (true) or only vaults (false) |
+| no_cache | boolean | false | Bypass title cache |
+| reset_cache | boolean | false | Clear title cache before fetching |
Example:
+curl -X POST http://localhost:8786/api/download \
+ -H "X-Secret-Key: $KEY" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "service": "EXAMPLE1",
+ "title_id": "abc123def456",
+ "wanted": ["S01E01"],
+ "quality": [1080, 2160],
+ "vcodec": ["H265"],
+ "acodec": ["AAC", "EC3"],
+ "range": ["HDR10", "SDR"],
+ "split_audio": true,
+ "lang": ["en"]
+ }'
+{
+ "job_id": "504db959-80b0-446c-a764-7924b761d613",
+ "status": "queued",
+ "created_time": "2026-02-27T18:00:00.000000"
+}
+List all download jobs with optional filtering and sorting. Disabled in --remote-only mode.
Query parameters:
+| Parameter | Type | Default | Description |
+| --- | --- | --- | --- |
+| status | string | all | Filter by status: queued, downloading, completed, failed, cancelled |
+| service | string | all | Filter by service tag |
+| sort_by | string | created_time | Sort field: created_time, started_time, completed_time, progress, status, service |
+| sort_order | string | desc | Sort order: asc, desc |
+| full | string | false | true to include full job details per job (parameters, timestamps, output_files, error fields) |
Get detailed information about a specific download job including progress, parameters, and error details.
+{
+ "job_id": "504db959-80b0-446c-a764-7924b761d613",
+ "status": "downloading",
+ "created_time": "2026-02-27T18:00:00.000000",
+ "service": "EXAMPLE1",
+ "title_id": "abc123def456",
+ "title": "Example Show",
+ "progress": 74.9,
+ "phase": "downloading video 1080p SDR",
+ "current_title": "S01E04",
+ "completed_tracks": 4,
+ "total_tracks": 13,
+ "active_tracks": ["video 1080p SDR"],
+ "track_progress": [
+ { "label": "video 1080p SDR", "progress": 74.9, "speed": "DASH 265.5 MB/s" }
+ ],
+ "segments_done": 75.0,
+ "segments_total": 100.0,
+ "speed": "DASH 265.5 MB/s",
+ "parameters": { },
+ "started_time": "2026-02-27T18:00:01.000000",
+ "completed_time": null,
+ "output_files": [
+ "/downloads/Example.Show.S01E01.mkv",
+ "/downloads/Example.Show.S01E02.mkv",
+ "/downloads/Example.Show.S01E03.mkv"
+ ],
+ "error_message": null,
+ "error_details": null
+}
+Progress fields:
+| Field | +Type | +Description | +
|---|---|---|
progress |
+number | +Overall progress for the title currently downloading, 0–100. Resets per episode on multi-episode jobs | +
phase |
+string | +Human-readable current step, e.g. downloading video 1080p SDR, muxing |
+
title |
+string | +Human-readable show/movie name, available once the download starts | +
current_title |
+string | +SxxEyy code of the episode downloading now (episodic jobs only) |
+
completed_tracks / total_tracks |
+int | +Track counts for the current title | +
active_tracks |
+array[string] | +Labels of tracks downloading now | +
track_progress |
+array | +Per-track {label, progress, speed} for each concurrently active track |
+
segments_done / segments_total |
+number | +Segment counts of the track downloading now | +
speed |
+string | +Transfer speed of the track downloading now | +
output_files |
+array[string] | +Final file paths, appended as each title finishes muxing | +
The list endpoint (GET /api/download/jobs) returns the same progress fields per job but omits parameters, timestamps, output_files, and error details.
Cancel a queued or running download job, or remove a finished one.
+queued / downloading job: cancelled, returns 200 with {"status": "success", "message": "Job cancelled"}.completed / failed / cancelled job: removed from the manager entirely, returns 204 (no body).404.Remove all completed, failed, and cancelled jobs from the manager. Disabled in --remote-only mode.
Enqueue a new job reusing a finished job's service, title, and original (unredacted) parameters. Only allowed on completed, failed, or cancelled jobs — returns 409 otherwise, 404 if unknown. The original job is left in place.
Creation-time authorization is re-applied at retry time: the job's service must pass the caller's service allowlist (otherwise 400 invalid service), and jobs whose original parameters carried a cdm override or per-job credentials return 403 if serve.cdm_overrides / serve.allow_job_credentials no longer permit them.
Returns 202 with the same shape as POST /api/download:
{
+ "job_id": "b7e9c1f2-...",
+ "status": "queued",
+ "created_time": "2026-02-27T18:00:00.000000"
+}
+Move a queued job to the front of the download queue so a worker picks it up next. Returns 409 if the job is not queued, 404 if unknown.
List named credential profiles per service, usable as the profile parameter on search/list/download. Only services whose credentials are configured as a profile-name: credential mapping appear (a default key is included if present); a service with a single plain (unnamed) credential is omitted entirely. Filtered by the caller's service allowlist. Disabled in --remote-only mode.
Read-only, redacted view of the effective server configuration for a UI settings page. Secrets (api_secret, users, credentials, tokens) are never included; secret-looking keys inside dl are masked with ***. Disabled in --remote-only mode.
{
+ "config": {
+ "dl": { "lang": "orig", "sub_format": "srt", "downloads": 12 },
+ "serve": {
+ "max_concurrent_downloads": 2,
+ "job_retention_hours": 24,
+ "history_limit": 100,
+ "services": null,
+ "remote_only": false,
+ "cdm_overrides": null,
+ "allow_job_credentials": false
+ },
+ "directories": {
+ "downloads": "/srv/downloads",
+ "temp": "/srv/temp",
+ "cache": "/srv/cache"
+ },
+ "services": ["EXAMPLE1", "EXAMPLE2"]
+ }
+}
+| Field | +Description | +
|---|---|
dl |
+Default dl parameters from the config file (redacted) |
+
serve.services |
+Global service allowlist, or null when all services are allowed |
+
serve.cdm_overrides |
+Permitted per-request CDM device names (list), true, or null |
+
services |
+Available service tags, filtered by the caller's allowlist | +
Persistent download history. Every job that reaches a terminal state (completed, failed, cancelled) is appended as one JSON line to api_history.jsonl in the cache directory, surviving restarts and job cleanup. Only the newest serve.history_limit entries are retained (default 100, 0 for unlimited; see Advanced Config). Newest first. Corrupt lines are skipped; a missing file yields an empty list. Entries are filtered by the caller's service allowlist. Disabled in --remote-only mode.
Query parameters:
+| Parameter | Type | Default | Description |
+| --- | --- | --- | --- |
+| limit | int | 100 | Maximum entries returned (must be >= 1) |
+| service | string | all | Filter by service tag (case-insensitive) |
{
+ "history": [
+ {
+ "job_id": "504db959-80b0-446c-a764-7924b761d613",
+ "service": "EXAMPLE1",
+ "title_id": "abc123def456",
+ "title": "Example Show",
+ "status": "completed",
+ "created_time": "2026-02-27T18:00:00.000000",
+ "completed_time": "2026-02-27T18:12:34.000000",
+ "output_files": ["/downloads/Example.Show.S01E01.mkv"],
+ "parameters": { "wanted": ["S01E01"], "quality": [1080], "skip_dl": false },
+ "error_message": null
+ }
+ ],
+ "count": 1
+}
+error_message is redacted with the same secret-scrubbing applied to job responses. parameters is the redacted download selection the job ran with (quality/vcodec/range/wanted/skip_dl, etc.). completed_time is null for jobs cancelled while still queued.
Remove a single history entry by job_id (the file is rewritten without it). Returns 204 on success, 404 if no matching entry exists or it falls outside the caller's service allowlist. Corrupt lines in the history file are preserved.
curl -X DELETE -H "X-Secret-Key: $KEY" "http://localhost:8786/api/history/504db959-80b0-446c-a764-7924b761d613"
+Delete the contents of the cache directory (the directory is recreated empty). freed_bytes is best effort. This also removes api_history.jsonl, so persistent download history is cleared. Returns 409 with the active job ids if any job is currently downloading, since in-flight jobs may be reading the cache. Disabled in --remote-only mode.
Same as clear-cache, for the temp directory. Also refuses with 409 while any job is downloading — active workers write segment files under temp.
Force-sync (git pull, hard reset to upstream) every service repo configured in directories.services. refreshed is true when all repos synced (or none are configured). Disabled in --remote-only mode.
{
+ "refreshed": true,
+ "repos": [
+ { "spec": "https://github.com/example/services", "updated": true, "changes": ["~ EXAMPLE1 (modified)"] }
+ ]
+}
+An empty changes array means the repo was already up to date. A repo that failed to sync has updated: false (and flips refreshed to false).
Install status of the binaries unshackle env check inspects (FFmpeg, MKVToolNix, shaka-packager, etc.). version is a best-effort probe and may be null even when installed. Disabled in --remote-only mode.
All endpoints return consistent error responses:
+{
+ "status": "error",
+ "error_code": "INVALID_PARAMETERS",
+ "message": "Invalid vcodec: XYZ. Must be one of: H264, H265, VP9, AV1, VC1, VP8",
+ "timestamp": "2026-02-27T18:00:00.000000+00:00",
+ "details": { }
+}
+Common error codes:
+INVALID_INPUT -- malformed request bodyINVALID_PARAMETERS -- invalid parameter valuesMISSING_SERVICE -- service tag not providedINVALID_SERVICE -- service not found or not in the caller's allowlistSERVICE_ERROR -- service initialization or runtime errorAUTH_FAILED -- authentication failureNOT_FOUND / TRACK_NOT_FOUND / session not found -- job/session/track/title missingINTERNAL_ERROR -- unexpected server errorWhen --debug-api is enabled, error responses include additional debug_info with tracebacks and stderr output.
Authentication errors from the auth middleware are returned as {"status": 401, "message": "..."} (not the standard error envelope).
# Start the server (no authentication)
+unshackle serve --no-key
+
+# Start with authentication (api_secret in unshackle.yaml)
+unshackle serve
+
+# Serve only the REST API (no pywidevine/pyplayready CDM)
+unshackle serve --api-only
+
+# Serve only the remote-dl session endpoints (CORS/Cloudflare friendly)
+unshackle serve --remote-only
+
+# Disable just one CDM
+unshackle serve --no-widevine
+unshackle serve --no-playready
+
+# Verbose error responses (tracebacks/stderr in JSON)
+unshackle serve --debug-api
+serve flags:
| Flag | +Description | +
|---|---|
-h, --host |
+Bind host (default 127.0.0.1) |
+
-p, --port |
+Bind port (default 8786) |
+
--caddy |
+Also launch Caddy using Caddyfile next to the unshackle config |
+
--api-only |
+REST API only; skip the bundled pywidevine/pyplayready CDM endpoints | +
--no-widevine |
+Disable Widevine CDM endpoints | +
--no-playready |
+Disable PlayReady CDM endpoints | +
--no-key |
+Disable API key authentication entirely | +
--debug-api |
+Include tracebacks/stderr in error responses | +
--debug |
+Enable DEBUG-level logging for API operations | +
--remote-only |
+Expose only /api/health, /api/services, /api/search, and /api/session/* (implies --api-only) |
+
These endpoints back the RemoteService adapter in unshackle/core/remote_service.py. They let a thin dl client (or any consumer) authenticate against a service on the server, fetch titles/tracks/manifests, and either proxy CDM challenges or have the server resolve KID:KEY directly. The dl command's RemoteService adapter replaces the old remote_dl command. These endpoints are the only /api/* routes available in --remote-only mode (in addition to health, services, and search).
Authenticate against a service and open a session. Body fields:
+| Field | +Type | +Description | +
|---|---|---|
service |
+string | +Service tag (required) | +
title_id |
+string | +Title ID/URL (required) | +
credentials |
+object | +Auth credentials forwarded to Service.authenticate |
+
cookies |
+string | +Cookie blob (Netscape or JSON) | +
proxy |
+string | +Proxy URI or country code | +
no_proxy |
+bool | +Force-disable proxies | +
profile |
+string | +Profile name | +
cache |
+object | +Optional pre-warmed title cache payload | +
If the service requires interactive input during authentication, poll GET /api/session/{id}/prompt and submit responses via POST /api/session/{id}/prompt until status is authenticated.
Request:
+{
+ "service": "EXAMPLE1",
+ "title_id": "abc123def456",
+ "credentials": {"username": "alice", "password": "hunter2"},
+ "cookies": "# Netscape HTTP Cookie File\n...",
+ "proxy": "us",
+ "no_proxy": false,
+ "profile": "default",
+ "cache": {}
+}
+Response (202-style; auth runs asynchronously):
+{
+ "session_id": "f1c4a8b2-9c7e-4d2a-bf91-2d3e4f5a6b7c",
+ "service": "EXAMPLE1",
+ "status": "authenticating"
+}
+Returns session metadata. 404 if expired or unknown.
+{
+ "session_id": "f1c4a8b2-9c7e-4d2a-bf91-2d3e4f5a6b7c",
+ "service": "EXAMPLE1",
+ "valid": true,
+ "expires_in": 3600,
+ "track_count": 0,
+ "title_count": 0
+}
+Tears down the session, cancels any pending prompts, and returns any updated per-session cache files (base64-encoded, zlib-compressed) so the client can re-warm next time.
+ +Returns the resolved titles list.
+{
+ "session_id": "f1c4a8b2-9c7e-4d2a-bf91-2d3e4f5a6b7c",
+ "titles": [
+ {
+ "type": "episode",
+ "name": "Pilot",
+ "series_title": "Example Show",
+ "season": 1,
+ "number": 1,
+ "year": 2024,
+ "id": "ep-0001",
+ "language": "en"
+ },
+ {
+ "type": "movie",
+ "name": "Example Movie",
+ "year": 2024,
+ "id": "mov-0001",
+ "language": "en"
+ }
+ ]
+}
+Request:
+ +Response:
+{
+ "title": {
+ "type": "episode",
+ "name": "Pilot",
+ "series_title": "Example Show",
+ "season": 1,
+ "number": 1,
+ "year": 2024,
+ "id": "ep-0001",
+ "language": "en"
+ },
+ "video": [
+ {
+ "id": "v-1080p-h264",
+ "codec": "H264",
+ "codec_display": "H.264",
+ "bitrate": 6000,
+ "width": 1920,
+ "height": 1080,
+ "resolution": "1920x1080",
+ "fps": "23.976",
+ "range": "SDR",
+ "range_display": "SDR",
+ "language": "en",
+ "drm": [
+ {
+ "type": "widevine",
+ "pssh": "AAAAW3Bzc2gAAAAA7e+...",
+ "kids": ["abcdef0123456789abcdef0123456789"],
+ "license_url": "https://license.example.com/widevine"
+ }
+ ],
+ "descriptor": "DASH",
+ "url": "https://cdn.example.com/manifest.mpd"
+ }
+ ],
+ "audio": [
+ {
+ "id": "a-en-eac3",
+ "codec": "EC3",
+ "codec_display": "Dolby Digital Plus",
+ "bitrate": 640,
+ "channels": "5.1",
+ "language": "en",
+ "atmos": false,
+ "descriptive": false,
+ "drm": null,
+ "descriptor": "DASH",
+ "url": "https://cdn.example.com/manifest.mpd"
+ }
+ ],
+ "subtitles": [
+ {
+ "id": "s-en-vtt",
+ "codec": "WebVTT",
+ "language": "en",
+ "forced": false,
+ "sdh": false,
+ "cc": false,
+ "descriptor": "DASH",
+ "url": "https://cdn.example.com/subs/en.vtt"
+ }
+ ],
+ "chapters": [
+ {"timestamp": "00:00:00.000", "name": "Chapter 1"}
+ ],
+ "attachments": [],
+ "manifests": [
+ {
+ "type": "dash",
+ "url": "https://cdn.example.com/manifest.mpd",
+ "data": "eJzNVk1v2zAM/Ss..."
+ }
+ ],
+ "session_headers": {
+ "User-Agent": "Mozilla/5.0 ..."
+ },
+ "session_cookies": {
+ "session": "abc123"
+ },
+ "server_cdm_type": "widevine"
+}
+Request:
+ +Response:
+{
+ "tracks": {
+ "v-1080p-h264": {
+ "descriptor": "DASH",
+ "url": "https://cdn.example.com/manifest.mpd",
+ "drm": [
+ {
+ "type": "widevine",
+ "pssh": "AAAAW3Bzc2gAAAAA7e+...",
+ "kids": ["abcdef0123456789abcdef0123456789"],
+ "license_url": "https://license.example.com/widevine"
+ }
+ ],
+ "headers": {"User-Agent": "Mozilla/5.0 ..."},
+ "cookies": {"session": "abc123"},
+ "data": {}
+ },
+ "a-en-eac3": {
+ "descriptor": "DASH",
+ "url": "https://cdn.example.com/manifest.mpd",
+ "drm": null,
+ "headers": {"User-Agent": "Mozilla/5.0 ..."},
+ "cookies": {"session": "abc123"},
+ "data": {}
+ }
+ }
+}
+Two modes, selected by the mode field.
mode: "proxy" (default) -- forward a client-built CDM challenge to the service's license endpoint.
Request:
+{
+ "mode": "proxy",
+ "track_id": "v-1080p-h264",
+ "challenge": "CAESxQEK...",
+ "drm_type": "widevine",
+ "pssh": "AAAAW3Bzc2gAAAAA7e+..."
+}
+Response:
+ +mode: "server_cdm" -- the server uses its own CDM to license the track and extract keys. Single-track form takes track_id; batch form takes track_ids. Requires the calling user key to have a matching device (devices for Widevine, playready_devices for PlayReady) in unshackle.yaml.
Request (batch):
+{
+ "mode": "server_cdm",
+ "track_ids": ["v-1080p-h264", "a-en-eac3"],
+ "drm_type": "widevine"
+}
+Response:
+{
+ "keys": {
+ "v-1080p-h264": {
+ "abcdef0123456789abcdef0123456789": "00112233445566778899aabbccddeeff"
+ },
+ "a-en-eac3": {
+ "abcdef0123456789abcdef0123456789": "00112233445566778899aabbccddeeff"
+ }
+ },
+ "drm_type": "widevine"
+}
+Polled by the client during interactive authentication (OTP, PIN, device codes). Backed by the InputBridge in unshackle/core/api/input_bridge.py; Service.request_input() blocks server-side until the client posts a response.
Pending input:
+ +Other states:
+ + + +Unblocks the server-side request_input() call.
Request:
+ +Response:
+ + + + + + + + + + + + + + +0&&i[i.length-1])&&(p[0]===6||p[0]===2)){r=0;continue}if(p[0]===3&&(!i||p[1]>i[0]&&p[1]=e.length&&(e=void 0),{value:e&&e[o++],done:!e}}};throw new TypeError(t?"Object is not iterable.":"Symbol.iterator is not defined.")}function K(e,t){var r=typeof Symbol=="function"&&e[Symbol.iterator];if(!r)return e;var o=r.call(e),n,i=[],s;try{for(;(t===void 0||t-- >0)&&!(n=o.next()).done;)i.push(n.value)}catch(a){s={error:a}}finally{try{n&&!n.done&&(r=o.return)&&r.call(o)}finally{if(s)throw s.error}}return i}function B(e,t,r){if(r||arguments.length===2)for(var o=0,n=t.length,i;o