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