Deployed 8b10b79 with MkDocs version: 1.6.1

This commit is contained in:
2026-08-20 03:01:15 +00:00
parent 01248725da
commit 94f2b09ef6
40 changed files with 1967 additions and 1920 deletions
+59 -59
View File
@@ -1610,10 +1610,10 @@
</li>
<li class="md-nav__item">
<a href="#cdm-device-access-per-key" class="md-nav__link">
<a href="#cdm-device-access-per-api-key" class="md-nav__link">
<span class="md-ellipsis">
CDM device access per key
CDM device access per API key
</span>
</a>
@@ -1676,15 +1676,15 @@
</li>
<li class="md-nav__item">
<a href="#building-on-the-key-access-controls" class="md-nav__link">
<a href="#access-controls-on-top-of-the-api-key" class="md-nav__link">
<span class="md-ellipsis">
Building on the key: access controls
Access controls on top of the API key
</span>
</a>
<nav class="md-nav" aria-label="Building on the key: access controls">
<nav class="md-nav" aria-label="Access controls on top of the API key">
<ul class="md-nav__list">
<li class="md-nav__item">
@@ -1748,15 +1748,15 @@
</li>
<li class="md-nav__item">
<a href="#developer-notes-how-the-check-is-implemented" class="md-nav__link">
<a href="#developer-notes-how-the-server-does-the-check" class="md-nav__link">
<span class="md-ellipsis">
Developer notes: how the check is implemented
Developer notes: how the server does the check
</span>
</a>
<nav class="md-nav" aria-label="Developer notes: how the check is implemented">
<nav class="md-nav" aria-label="Developer notes: how the server does the check">
<ul class="md-nav__list">
<li class="md-nav__item">
@@ -2216,10 +2216,10 @@
</li>
<li class="md-nav__item">
<a href="#cdm-device-access-per-key" class="md-nav__link">
<a href="#cdm-device-access-per-api-key" class="md-nav__link">
<span class="md-ellipsis">
CDM device access per key
CDM device access per API key
</span>
</a>
@@ -2282,15 +2282,15 @@
</li>
<li class="md-nav__item">
<a href="#building-on-the-key-access-controls" class="md-nav__link">
<a href="#access-controls-on-top-of-the-api-key" class="md-nav__link">
<span class="md-ellipsis">
Building on the key: access controls
Access controls on top of the API key
</span>
</a>
<nav class="md-nav" aria-label="Building on the key: access controls">
<nav class="md-nav" aria-label="Access controls on top of the API key">
<ul class="md-nav__list">
<li class="md-nav__item">
@@ -2354,15 +2354,15 @@
</li>
<li class="md-nav__item">
<a href="#developer-notes-how-the-check-is-implemented" class="md-nav__link">
<a href="#developer-notes-how-the-server-does-the-check" class="md-nav__link">
<span class="md-ellipsis">
Developer notes: how the check is implemented
Developer notes: how the server does the check
</span>
</a>
<nav class="md-nav" aria-label="Developer notes: how the check is implemented">
<nav class="md-nav" aria-label="Developer notes: how the server does the check">
<ul class="md-nav__list">
<li class="md-nav__item">
@@ -2424,16 +2424,16 @@
<h1 id="rest-api-authentication">REST API Authentication<a class="headerlink" href="#rest-api-authentication" title="Permanent link">&para;</a></h1>
<p>The unshackle REST API protects every endpoint (with one exception) behind a single API key. Clients present that key in a request header, and the server checks it against the keys defined in your configuration. This page explains how that check works, how to configure keys, how clients send them, and the extra controls that build on top of the key: per-key service allowlists, per-key CDM device access, and session IP binding.</p>
<p>The API is started with the <a href="../../../guide/cli-reference/"><code>unshackle serve</code></a> command. Authentication is on by default: if you start the server without an API key configured, it refuses to launch.</p>
<p>The unshackle REST API protects every endpoint (with one exception) behind a single API key. Clients present that API key in a request header, and the server examines it against the API keys in your configuration. This page explains how that check works, how to configure API keys, and how clients send them. It also explains the extra controls on top of the API key: per-key service allowlists, per-key CDM device access, and remote session IP binding.</p>
<p>You start the API with the <a href="../../../guide/cli-reference/"><code>unshackle serve</code></a> command. Authentication is on by default: if you start the server without an API key configured, it refuses to launch.</p>
<div class="admonition note">
<p class="admonition-title">Which server am I running?</p>
<p><code>unshackle serve</code> can run in two shapes, and each uses a slightly different piece of code to check your key:</p>
<p><code>unshackle serve</code> can operate in two shapes, and each uses a slightly different piece of code to examine your API key:</p>
<ul>
<li>The <strong>integrated server</strong> (the default) exposes the REST API <em>and</em> the pywidevine / pyplayready CDM HTTP endpoints.</li>
<li>The <strong>API-only server</strong> (<code>--api-only</code>, also implied by <code>--remote-only</code>) exposes only the REST API.</li>
</ul>
<p>The header, the config, and the behaviour you see as a client are the same in both. The differences are internal and are called out in the developer notes below.</p>
<p>The header, the config, and the behaviour you see as a client are the same in both. The differences are internal, and the developer notes below give them.</p>
</div>
<h2 id="the-short-version">The short version<a class="headerlink" href="#the-short-version" title="Permanent link">&para;</a></h2>
<ol>
@@ -2451,16 +2451,16 @@
<div class="language-yaml highlight"><span class="filename">unshackle.yaml</span><pre><span></span><code><span id="__span-1-1"><a id="__codelineno-1-1" name="__codelineno-1-1" href="#__codelineno-1-1"></a><span class="nt">serve</span><span class="p">:</span>
</span><span id="__span-1-2"><a id="__codelineno-1-2" name="__codelineno-1-2" href="#__codelineno-1-2"></a><span class="w"> </span><span class="nt">api_secret</span><span class="p">:</span><span class="w"> </span><span class="s">"a-long-random-secret-string"</span>
</span></code></pre></div>
<p>This value becomes the one and only key that the server accepts. Requests that present it in the <code>X-Secret-Key</code> header are allowed; everything else is rejected.</p>
<p>This value becomes the one and only API key that the server accepts. The server allows requests that present it in the <code>X-Secret-Key</code> header. The server rejects everything else.</p>
<div class="admonition warning">
<p class="admonition-title">The server will not start without a key</p>
<p>If you start <code>unshackle serve</code> with no <code>api_secret</code> configured, and you have <em>not</em> passed <code>--no-key</code>, the command fails immediately with:</p>
<div class="language-text highlight"><pre><span></span><code><span id="__span-2-1"><a id="__codelineno-2-1" name="__codelineno-2-1" href="#__codelineno-2-1"></a>API secret key is not configured. Please add 'api_secret' to the 'serve' section in your config.
</span></code></pre></div>
<p>Choose a long, unpredictable value. The key is the only thing standing between the internet and your CDM and download endpoints.</p>
<p>Choose a long, unpredictable value. The API key is the only thing standing between the internet and your CDM and download endpoints.</p>
</div>
<h3 id="multiple-keys-with-users">Multiple keys with <code>users</code><a class="headerlink" href="#multiple-keys-with-users" title="Permanent link">&para;</a></h3>
<p>For finer control you can define named users, each with their own key. A user entry can also grant CDM device access and restrict which services that key may touch:</p>
<p>For finer control you can define named users, each with their own API key. A user entry can also grant CDM device access and restrict which services that API key may touch:</p>
<div class="language-yaml highlight"><span class="filename">unshackle.yaml</span><pre><span></span><code><span id="__span-3-1"><a id="__codelineno-3-1" name="__codelineno-3-1" href="#__codelineno-3-1"></a><span class="nt">serve</span><span class="p">:</span>
</span><span id="__span-3-2"><a id="__codelineno-3-2" name="__codelineno-3-2" href="#__codelineno-3-2"></a><span class="w"> </span><span class="nt">api_secret</span><span class="p">:</span><span class="w"> </span><span class="s">"the-primary-admin-key"</span>
</span><span id="__span-3-3"><a id="__codelineno-3-3" name="__codelineno-3-3" href="#__codelineno-3-3"></a><span class="w"> </span><span class="nt">users</span><span class="p">:</span>
@@ -2473,14 +2473,14 @@
</span><span id="__span-3-10"><a id="__codelineno-3-10" name="__codelineno-3-10" href="#__codelineno-3-10"></a><span class="w"> </span><span class="nt">username</span><span class="p">:</span><span class="w"> </span><span class="l l-Scalar l-Scalar-Plain">bob</span>
</span><span id="__span-3-11"><a id="__codelineno-3-11" name="__codelineno-3-11" href="#__codelineno-3-11"></a><span class="w"> </span><span class="nt">devices</span><span class="p">:</span><span class="w"> </span><span class="p p-Indicator">[</span><span class="s">"my_widevine_device"</span><span class="p p-Indicator">]</span>
</span></code></pre></div>
<p>The key of each entry under <code>users</code> (for example <code>a-secret-key-for-alice</code>) <em>is</em> the secret that client sends in <code>X-Secret-Key</code>. The <code>username</code> is a human-readable label used in logs and in job/history metadata.</p>
<p>The config key of each entry under <code>users</code> (for example <code>a-secret-key-for-alice</code>) <em>is</em> the secret that client sends in <code>X-Secret-Key</code>. The <code>username</code> is a human-readable label used in logs and in job/history metadata.</p>
<div class="admonition info">
<p class="admonition-title">How <code>api_secret</code> and <code>users</code> combine (integrated server)</p>
<p>On the integrated server, the configured <code>api_secret</code> is automatically added as an extra accepted key alongside everyone in <code>users</code>. It is granted access to all loaded Widevine and PlayReady devices and is labelled <code>api_user</code>. So <code>api_secret</code> behaves as an admin-level key in addition to any named users you define.</p>
<p>On the API-only server, only <code>api_secret</code> is accepted: the single-key model. (See the developer notes for the exact difference.)</p>
<p>On the integrated server, the server automatically adds the configured <code>api_secret</code> as an extra accepted API key alongside everyone in <code>users</code>. It gives that API key access to all loaded Widevine and PlayReady devices, and labels it <code>api_user</code>. So <code>api_secret</code> behaves as an admin-level API key in addition to any named users you define.</p>
<p>On the API-only server, the server accepts only <code>api_secret</code>: the single-key model. (See the developer notes for the exact difference.)</p>
</div>
<h3 id="cdm-device-access-per-key">CDM device access per key<a class="headerlink" href="#cdm-device-access-per-key" title="Permanent link">&para;</a></h3>
<p>Under the integrated server, a key can only run DRM licensing through a CDM device that its user entry lists:</p>
<h3 id="cdm-device-access-per-api-key">CDM device access per API key<a class="headerlink" href="#cdm-device-access-per-api-key" title="Permanent link">&para;</a></h3>
<p>Under the integrated server, an API key can do DRM licensing only through a CDM device that its user entry lists:</p>
<table>
<thead>
<tr>
@@ -2501,32 +2501,32 @@
</table>
<div class="admonition warning">
<p class="admonition-title">PlayReady access is opt-in per user</p>
<p>If a user entry omits <code>playready_devices</code>, that key is given <strong>no</strong> PlayReady access, and the server logs a warning at startup. You must list PlayReady devices explicitly for any key that needs them. Widevine and PlayReady device files themselves are auto-loaded from your WVDs and PRDs directories.</p>
<p>If a user entry omits <code>playready_devices</code>, the server gives that API key <strong>no</strong> PlayReady access, and logs a warning at startup. You must name the PlayReady devices explicitly for any API key that needs them. Widevine and PlayReady device files themselves are auto-loaded from your WVDs and PRDs directories.</p>
</div>
<div class="admonition note">
<p class="admonition-title">Device lists <em>are</em> the server-side-decryption switch</p>
<p>There is no separate tier, capability flag, or permission toggle for whether the server will hand back keys (<code>KID:KEY</code>) for a session download. The presence of devices on the calling key decides it. With <strong>empty</strong> <code>devices</code> and <code>playready_devices</code>, the server can only proxy CDM challenges, so the client must run its own CDM. Once those lists are <strong>populated</strong>, the client may request <code>mode:server_cdm</code> and receive keys back. Don't go looking for a tier setting; configuring devices is what enables server-side decryption.</p>
<p>There is no separate tier, capability flag, or permission toggle for whether the server will hand back content keys (<code>KID:KEY</code>) for a remote session download. The presence of devices on the calling API key decides it. With <strong>empty</strong> <code>devices</code> and <code>playready_devices</code>, the server can only proxy CDM challenges, so the client must operate its own CDM. Once those lists are <strong>populated</strong>, the client may request <code>mode:server_cdm</code> and receive content keys back. Do not look for a tier setting. The devices you configure are what enables server-side decryption.</p>
</div>
<h3 id="disabling-authentication">Disabling authentication<a class="headerlink" href="#disabling-authentication" title="Permanent link">&para;</a></h3>
<p>The <code>--no-key</code> flag turns authentication off entirely. Every request is allowed, <code>X-Secret-Key</code> is ignored, and no key needs to be configured.</p>
<p>The <code>--no-key</code> flag turns authentication off entirely. The server allows every request, ignores <code>X-Secret-Key</code>, and needs no API key in your configuration.</p>
<div class="language-bash highlight"><pre><span></span><code><span id="__span-4-1"><a id="__codelineno-4-1" name="__codelineno-4-1" href="#__codelineno-4-1"></a>unshackle<span class="w"> </span>serve<span class="w"> </span>--no-key
</span></code></pre></div>
<div class="admonition danger">
<p class="admonition-title">Only use <code>--no-key</code> on a trusted, isolated network</p>
<p>With <code>--no-key</code> there is nothing stopping any client that can reach the port from listing your services, starting downloads, or using your CDM devices. Never expose a <code>--no-key</code> server to an untrusted network.</p>
<p>With <code>--no-key</code>, any client that can reach the port can show your services, start downloads, and use your CDM devices. Never put a <code>--no-key</code> server on an untrusted network.</p>
</div>
<h2 id="how-clients-present-credentials">How clients present credentials<a class="headerlink" href="#how-clients-present-credentials" title="Permanent link">&para;</a></h2>
<p>Clients authenticate by sending the secret in a single HTTP request header on every call:</p>
<div class="language-text highlight"><pre><span></span><code><span id="__span-5-1"><a id="__codelineno-5-1" name="__codelineno-5-1" href="#__codelineno-5-1"></a>X-Secret-Key: &lt;your-secret&gt;
</span></code></pre></div>
<p>The value must exactly match one of the configured keys (<code>api_secret</code>, or one of the keys under <code>users</code>). There is no login step, no session cookie, and no token exchange for API access. The same static key is sent on each request.</p>
<p>The value must match one of the configured API keys exactly (<code>api_secret</code>, or one of the keys under <code>users</code>). There is no login step, no HTTP session cookie, and no token exchange for API access. The client sends the same static API key on each request.</p>
<div class="admonition info">
<p class="admonition-title">One route also accepts the key as a query parameter</p>
<p>The job event stream, <code>GET /api/download/jobs/{job_id}/events</code>, also accepts the key in a <code>secret_key</code> query parameter. This is for browsers: the <code>EventSource</code> API cannot set request headers. The exception applies to that one route only, and it works in both server modes.</p>
<p>The job event stream, <code>GET /api/download/jobs/{job_id}/events</code>, also accepts the API key in a <code>secret_key</code> query parameter. This is for browsers: the <code>EventSource</code> API cannot set request headers. The exception applies to that one route only, and it works in both server modes.</p>
<div class="language-javascript highlight"><pre><span></span><code><span id="__span-6-1"><a id="__codelineno-6-1" name="__codelineno-6-1" href="#__codelineno-6-1"></a><span class="ow">new</span><span class="w"> </span><span class="nx">EventSource</span><span class="p">(</span><span class="sb">`http://127.0.0.1:8786/api/download/jobs/</span><span class="si">${</span><span class="nx">jobId</span><span class="si">}</span><span class="sb">/events?secret_key=</span><span class="si">${</span><span class="nx">key</span><span class="si">}</span><span class="sb">`</span><span class="p">);</span>
</span></code></pre></div>
<p>The header always wins. If the request has an <code>X-Secret-Key</code> header, the server checks that header and ignores the query parameter, so a wrong header is rejected even when the query parameter holds a valid key.</p>
<p>A key in a URL is easier to leak than a key in a header, because proxies and browser history can record it. Use the header wherever your client can set one.</p>
<p>The header always wins. If the request has an <code>X-Secret-Key</code> header, the server examines that header and ignores the query parameter. The server rejects a wrong header even when the query parameter holds a valid API key.</p>
<p>An API key in a URL is easier to leak than an API key in a header, because proxies and browser history can record it. Use the header wherever your client can set one.</p>
</div>
<div class="tabbed-set tabbed-alternate" data-tabs="1:3"><input checked="checked" id="__tabbed_1_1" name="__tabbed_1" type="radio" /><input id="__tabbed_1_2" name="__tabbed_1" type="radio" /><input id="__tabbed_1_3" name="__tabbed_1" type="radio" /><div class="tabbed-labels"><label for="__tabbed_1_1">curl</label><label for="__tabbed_1_2">Python (requests)</label><label for="__tabbed_1_3">JavaScript (fetch)</label></div>
<div class="tabbed-content">
@@ -2558,10 +2558,10 @@
</div>
<div class="admonition tip">
<p class="admonition-title">The reference client sets it for you</p>
<p>unshackle's own <a href="../../../guide/downloading/">remote download</a> client (<code>RemoteService</code>) sends <code>X-Secret-Key</code> automatically, reading the key from your <code>remote_services</code> config. It also sends a <code>User-Agent</code> of <code>unshackle/&lt;version&gt;</code>. You only deal with the header directly when writing your own client.</p>
<p>unshackle's own <a href="../../../guide/downloading/">remote download</a> client (<code>RemoteService</code>) sends <code>X-Secret-Key</code> automatically, reading the API key from your <code>remote_services</code> config. It also sends a <code>User-Agent</code> of <code>unshackle/&lt;version&gt;</code>. You only deal with the header directly when writing your own client.</p>
</div>
<h3 id="the-health-check-is-always-open">The health check is always open<a class="headerlink" href="#the-health-check-is-always-open" title="Permanent link">&para;</a></h3>
<p>One endpoint is exempt from authentication: <code>GET /api/health</code>. It never requires a key, so you can use it for liveness and version probes without embedding a secret in your monitoring:</p>
<p>One endpoint is exempt from authentication: <code>GET /api/health</code>. It never requires an API key, so you can use it for liveness and version probes without embedding a secret in your monitoring:</p>
<div class="language-bash highlight"><span class="filename">No key needed</span><pre><span></span><code><span id="__span-10-1"><a id="__codelineno-10-1" name="__codelineno-10-1" href="#__codelineno-10-1"></a>curl<span class="w"> </span>http://127.0.0.1:8786/api/health
</span></code></pre></div>
<div class="language-json highlight"><span class="filename">Response</span><pre><span></span><code><span id="__span-11-1"><a id="__codelineno-11-1" name="__codelineno-11-1" href="#__codelineno-11-1"></a><span class="p">{</span>
@@ -2574,9 +2574,9 @@
</span><span id="__span-11-8"><a id="__codelineno-11-8" name="__codelineno-11-8" href="#__codelineno-11-8"></a><span class="w"> </span><span class="p">}</span>
</span><span id="__span-11-9"><a id="__codelineno-11-9" name="__codelineno-11-9" href="#__codelineno-11-9"></a><span class="p">}</span>
</span></code></pre></div>
<p>Every other endpoint (including the Swagger UI at <code>/api/docs/</code> on the API-only server) requires a valid key.</p>
<p>Every other endpoint (including the Swagger UI at <code>/api/docs/</code> on the API-only server) requires a valid API key.</p>
<h2 id="authentication-failures">Authentication failures<a class="headerlink" href="#authentication-failures" title="Permanent link">&para;</a></h2>
<p>When a request is rejected for authentication reasons, the response has an HTTP <code>401 Unauthorized</code> status and a small JSON body.</p>
<p>When the server rejects a request for authentication reasons, the response has an HTTP <code>401 Unauthorized</code> status and a small JSON body.</p>
<div class="tabbed-set tabbed-alternate" data-tabs="2:2"><input checked="checked" id="__tabbed_2_1" name="__tabbed_2" type="radio" /><input id="__tabbed_2_2" name="__tabbed_2" type="radio" /><div class="tabbed-labels"><label for="__tabbed_2_1">Missing header</label><label for="__tabbed_2_2">Wrong key</label></div>
<div class="tabbed-content">
<div class="tabbed-block">
@@ -2588,7 +2588,7 @@
</span></code></pre></div>
</div>
<div class="tabbed-block">
<p>Sending a key that is not in your config:</p>
<p>Sending an API key that is not in your config:</p>
<div class="language-json highlight"><pre><span></span><code><span id="__span-13-1"><a id="__codelineno-13-1" name="__codelineno-13-1" href="#__codelineno-13-1"></a><span class="p">{</span>
</span><span id="__span-13-2"><a id="__codelineno-13-2" name="__codelineno-13-2" href="#__codelineno-13-2"></a><span class="w"> </span><span class="nt">"status"</span><span class="p">:</span><span class="w"> </span><span class="mi">401</span><span class="p">,</span>
</span><span id="__span-13-3"><a id="__codelineno-13-3" name="__codelineno-13-3" href="#__codelineno-13-3"></a><span class="w"> </span><span class="nt">"message"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Secret Key is Invalid."</span>
@@ -2600,14 +2600,14 @@
<div class="admonition warning">
<p class="admonition-title">Auth errors use a different JSON shape than other errors (developer note)</p>
<p>The rest of the API reports errors with a structured body containing <code>error_code</code>, <code>message</code>, <code>timestamp</code>, and other fields (see <a href="../errors/">Errors</a>). The authentication middleware is different: it returns <code>{"status": 401, "message": "..."}</code>, where <code>status</code> is an <strong>integer</strong>, and there is no <code>error_code</code> or <code>timestamp</code>.</p>
<p>A robust client should treat any <code>401</code> as an authentication problem and be prepared to parse <em>both</em> shapes rather than assuming the standard error body is always present.</p>
<p>In your client, treat any <code>401</code> as an authentication problem and parse <em>both</em> shapes, because the standard error body is not always present.</p>
</div>
<h2 id="building-on-the-key-access-controls">Building on the key: access controls<a class="headerlink" href="#building-on-the-key-access-controls" title="Permanent link">&para;</a></h2>
<p>Once a request is authenticated, several additional controls decide what that specific key is allowed to do.</p>
<h2 id="access-controls-on-top-of-the-api-key">Access controls on top of the API key<a class="headerlink" href="#access-controls-on-top-of-the-api-key" title="Permanent link">&para;</a></h2>
<p>After the server authenticates a request, several additional controls decide what that specific API key can do.</p>
<h3 id="per-key-service-allowlists">Per-key service allowlists<a class="headerlink" href="#per-key-service-allowlists" title="Permanent link">&para;</a></h3>
<p>You can restrict which services a key may use. Two layers combine:</p>
<p>You can restrict which services an API key may use. Two layers combine:</p>
<ul>
<li><strong>Global allowlist</strong>: <code>serve.services</code>, applied to every key.</li>
<li><strong>Global allowlist</strong>: <code>serve.services</code>, applied to every API key.</li>
<li><strong>Per-key allowlist</strong>: <code>services</code> inside that key's <code>users</code> entry.</li>
</ul>
<p>The effective allowlist for a request is:</p>
@@ -2650,9 +2650,9 @@
</span><span id="__span-14-6"><a id="__codelineno-14-6" name="__codelineno-14-6" href="#__codelineno-14-6"></a><span class="w"> </span><span class="nt">username</span><span class="p">:</span><span class="w"> </span><span class="l l-Scalar l-Scalar-Plain">limited</span>
</span><span id="__span-14-7"><a id="__codelineno-14-7" name="__codelineno-14-7" href="#__codelineno-14-7"></a><span class="w"> </span><span class="nt">services</span><span class="p">:</span><span class="w"> </span><span class="p p-Indicator">[</span><span class="s">"EXAMPLE1"</span><span class="p p-Indicator">]</span><span class="w"> </span><span class="c1"># this key ends up allowed only EXAMPLE1</span>
</span></code></pre></div>
<p>Endpoints that name a service (listing services, searching, listing titles/tracks, downloading, profiles, history, and remote sessions) all filter against this allowlist. A request for a service outside the key's allowlist is rejected as an invalid service. Service tags are matched after normalization, so casing and aliases resolve consistently.</p>
<p>Endpoints that name a service (listing services, searching, listing titles/tracks, downloading, profiles, history, and remote sessions) all filter against this allowlist. The server rejects a request for a service outside the API key's allowlist as an invalid service. The server matches service tags after normalisation, so casing and aliases give the same result.</p>
<h3 id="cdm-overrides-and-per-job-credentials">CDM overrides and per-job credentials<a class="headerlink" href="#cdm-overrides-and-per-job-credentials" title="Permanent link">&para;</a></h3>
<p>Two download-time capabilities are gated behind server config and, unless enabled, are refused with <code>403 Forbidden</code> even for an authenticated key:</p>
<p>The server config gates two download-time capabilities. Unless you enable them, the server refuses them with <code>403 Forbidden</code>, even for an authenticated API key:</p>
<table>
<thead>
<tr>
@@ -2681,20 +2681,20 @@
</span></code></pre></div>
<div class="admonition tip">
<p class="admonition-title">When to enable these gates</p>
<p>The two gates are fully independent and can be combined. The intended posture is that a locked-down or public-facing deployment leaves <strong>both</strong> unset (they default off and reject with <code>403</code>) so client requests can never steer the server's CDM or feed it their own logins. Enabling them only makes sense for a trusted single-client deployment, where the caller and the operator are effectively the same party.</p>
<p>The two gates are fully independent, and you can combine them. For a locked-down or public-facing deployment, leave <strong>both</strong> unset. They then default off and reject with <code>403</code>, so client requests can never steer the server's CDM or feed it their own logins. Enable them only for a trusted single-client deployment, where the caller and the operator are effectively the same party.</p>
</div>
<div class="admonition note">
<p class="admonition-title">Client-supplied credentials get isolated token caches</p>
<p>When <code>allow_job_credentials</code> is enabled and a download job supplies its own <code>credential</code> / <code>credentials</code>, each distinct credential is given its <strong>own</strong> isolated token cache on the server. Client-supplied logins are never shared or merged with the server's configured-credential caches, so they cannot cross-contaminate or read the server's own token state, and they don't clobber each other.</p>
<p>When you enable <code>allow_job_credentials</code> and a download job supplies its own <code>credential</code> / <code>credentials</code>, the server gives each distinct credential its <strong>own</strong> isolated token cache. The server never shares or merges client-supplied logins with its configured-credential caches. They cannot cross-contaminate or read the server's own token state, and they do not clobber each other.</p>
</div>
<p>See <a href="../endpoints/">Downloads</a> for how these fields are used on the download endpoint.</p>
<p>See <a href="../endpoints/">Downloads</a> for how the download endpoint uses these fields.</p>
<h3 id="session-ip-binding-remote-sessions">Session IP binding (remote sessions)<a class="headerlink" href="#session-ip-binding-remote-sessions" title="Permanent link">&para;</a></h3>
<p>The remote-download session endpoints (<code>/api/session/*</code>) add an extra check on top of the key. When a session is created, the server records the client's IP address. Any later request for that session must come from the <strong>same</strong> IP, or it is rejected with <code>403 Forbidden</code>. This means a session is bound both to the key that created it and to the address it was created from.</p>
<p>The remote-download session endpoints (<code>/api/session/*</code>) add an extra check on top of the API key. When the server creates a remote session, it records the client's IP address. Any later request for that remote session must come from the <strong>same</strong> IP, or the server rejects it with <code>403 Forbidden</code>. Thus the server binds a remote session both to the API key that created it and to the address it came from.</p>
<h2 id="transport-security-https">Transport security (HTTPS)<a class="headerlink" href="#transport-security-https" title="Permanent link">&para;</a></h2>
<p>The API server speaks plain HTTP. Because your key is sent on every request, you should not expose it over an unencrypted connection on an untrusted network. To serve over HTTPS, run a reverse proxy in front of unshackle. The <code>serve</code> command can launch Caddy for you:</p>
<p>The API server speaks plain HTTP. Because the client sends your API key on every request, do not use it over an unencrypted connection on an untrusted network. To serve over HTTPS, operate a reverse proxy in front of unshackle. The <code>serve</code> command can launch Caddy for you:</p>
<div class="language-bash highlight"><pre><span></span><code><span id="__span-16-1"><a id="__codelineno-16-1" name="__codelineno-16-1" href="#__codelineno-16-1"></a>unshackle<span class="w"> </span>serve<span class="w"> </span>--caddy
</span></code></pre></div>
<p>With <code>--caddy</code>, unshackle also starts <code>caddy run</code> using the <code>Caddyfile</code> located next to your unshackle config, letting you terminate TLS and reverse-proxy to the API. Configuring Caddy itself (certificates, domains, upstream) is done in that <code>Caddyfile</code>.</p>
<p>With <code>--caddy</code>, unshackle also starts <code>caddy run</code> using the <code>Caddyfile</code> located next to your unshackle config, letting Caddy end the TLS connection and reverse-proxy to the API. Configuring Caddy itself (certificates, domains, upstream) is done in that <code>Caddyfile</code>.</p>
<h2 id="quick-reference">Quick reference<a class="headerlink" href="#quick-reference" title="Permanent link">&para;</a></h2>
<table>
<thead>
@@ -2743,7 +2743,7 @@
</tbody>
</table>
<hr />
<h2 id="developer-notes-how-the-check-is-implemented">Developer notes: how the check is implemented<a class="headerlink" href="#developer-notes-how-the-check-is-implemented" title="Permanent link">&para;</a></h2>
<h2 id="developer-notes-how-the-server-does-the-check">Developer notes: how the server does the check<a class="headerlink" href="#developer-notes-how-the-server-does-the-check" title="Permanent link">&para;</a></h2>
<div class="admonition note">
<p class="admonition-title">This section is for people writing or debugging the server, not for API consumers.</p>
</div>
@@ -2751,14 +2751,14 @@
<p>The header and config are identical between modes, but the code that enforces them differs:</p>
<ul>
<li>
<p><strong>API-only server</strong> (<code>--api-only</code>, or <code>--remote-only</code> which forces API-only). An <code>api_key_authentication</code> middleware runs on every request. It lets <code>GET /api/health</code> through unconditionally, then reads <code>X-Secret-Key</code>. An empty header returns the <em>"Secret Key is Empty."</em> body; a header that is not a configured user returns <em>"Secret Key is Invalid."</em>. In this mode the accepted-keys map holds your <code>api_secret</code> (labelled <code>api_user</code>, with no devices) plus every key under <code>serve.users</code>, so a per-user key authenticates here exactly as it does on the integrated server, and that user's <code>services</code> allowlist applies as usual. With <code>--no-key</code>, this middleware is simply not installed and the users map is empty.</p>
<p><strong>API-only server</strong> (<code>--api-only</code>, or <code>--remote-only</code> which forces API-only). An <code>api_key_authentication</code> middleware runs on every request. It lets <code>GET /api/health</code> through unconditionally, then reads <code>X-Secret-Key</code>. An empty header returns the <em>"Secret Key is Empty."</em> body. A header that is not a configured user returns <em>"Secret Key is Invalid."</em>. In this mode the accepted-keys map holds your <code>api_secret</code> (labelled <code>api_user</code>, with no devices) plus every key under <code>serve.users</code>. A per-user key therefore authenticates here exactly as it does on the integrated server, and that user's <code>services</code> allowlist applies as usual. With <code>--no-key</code>, this middleware is not installed and the users map is empty.</p>
</li>
<li>
<p><strong>Integrated server</strong> (default). A <code>serve_authentication</code> middleware wraps pywidevine's authentication (and pyplayready's for <code>/playready*</code> paths), and the REST routes live in the same application, guarded by the same check. Here the accepted-keys map is your full <code>serve</code> config: <code>api_secret</code> (auto-added with access to all loaded devices) plus every entry under <code>users</code>, each with its declared <code>devices</code> / <code>playready_devices</code>.</p>
</li>
</ul>
<p>Because the API-only middleware guards <em>all</em> non-health paths, the Swagger UI at <code>/api/docs/</code> is also behind <code>X-Secret-Key</code> there. In <code>--remote-only</code> mode the Swagger UI is not mounted at all.</p>
<p>Both middlewares read the caller's key through the shared <code>request_secret_key()</code> helper in <code>unshackle/core/api/handlers.py</code>, which returns the <code>X-Secret-Key</code> header and falls back to the <code>secret_key</code> query parameter only when the matched route is the job event stream. The integrated server needs one extra branch for this: pywidevine's <code>authentication</code> is header-only, so <code>serve_authentication</code> compares the query-parameter key itself when the header is absent, and delegates to pywidevine in every other case. That is why a present-but-wrong header is rejected in both modes.</p>
<p>Both middlewares read the caller's API key through the shared <code>request_secret_key()</code> helper in <code>unshackle/core/api/handlers.py</code>. That helper returns the <code>X-Secret-Key</code> header, and falls back to the <code>secret_key</code> query parameter only when the matched route is the job event stream. The integrated server needs one extra branch for this. pywidevine's <code>authentication</code> is header-only. So <code>serve_authentication</code> compares the API key from the query parameter itself when the header is absent, and delegates to pywidevine in every other case. That is why both modes reject a present-but-wrong header.</p>
<h3 id="cors-and-the-authorization-header">CORS and the <code>Authorization</code> header<a class="headerlink" href="#cors-and-the-authorization-header" title="Permanent link">&para;</a></h3>
<p>A <code>cors_middleware</code> runs on every response in both modes (and answers <code>OPTIONS</code> preflight requests with an empty response). It emits:</p>
<div class="language-text highlight"><pre><span></span><code><span id="__span-17-1"><a id="__codelineno-17-1" name="__codelineno-17-1" href="#__codelineno-17-1"></a>Access-Control-Allow-Origin: *
@@ -2766,9 +2766,9 @@
</span><span id="__span-17-3"><a id="__codelineno-17-3" name="__codelineno-17-3" href="#__codelineno-17-3"></a>Access-Control-Allow-Headers: Content-Type, X-Secret-Key, Authorization
</span><span id="__span-17-4"><a id="__codelineno-17-4" name="__codelineno-17-4" href="#__codelineno-17-4"></a>Access-Control-Max-Age: 3600
</span></code></pre></div>
<p>The REST API authenticates with <code>X-Secret-Key</code>; <code>Authorization</code> is advertised because the co-hosted pywidevine / pyplayready CDM endpoints use it. <code>PUT</code> appears in the allowed methods list but no REST route uses it; only <code>GET</code>, <code>POST</code>, and <code>DELETE</code> are wired up.</p>
<p>The REST API authenticates with <code>X-Secret-Key</code>. The middleware advertises <code>Authorization</code> because the co-hosted pywidevine / pyplayready CDM endpoints use it. <code>PUT</code> appears in the allowed-methods list, but no REST route uses it. Only <code>GET</code>, <code>POST</code>, and <code>DELETE</code> have routes.</p>
<h3 id="where-device-access-is-read">Where device access is read<a class="headerlink" href="#where-device-access-is-read" title="Permanent link">&para;</a></h3>
<p>The map of accepted keys lives at <code>request.app["config"]["users"]</code>. For DRM operations, a key's permitted Widevine devices come from its <code>devices</code> list and PlayReady devices from <code>playready_devices</code>; the first configured device is used when a request does not (or may not) override it. A server-wide <code>config.cdm[&lt;service&gt;]</code> mapping, if present, takes precedence over the key's device list when resolving which device to use.</p>
<p>The map of accepted API keys lives at <code>request.app["config"]["users"]</code>. For DRM operations, an API key's permitted Widevine devices come from its <code>devices</code> list, and PlayReady devices from <code>playready_devices</code>. The server uses the first configured device when a request does not (or may not) override it. A server-wide <code>config.cdm[&lt;service&gt;]</code> mapping, if present, takes precedence over the API key's device list when the server chooses which device to use.</p>
+75 -75
View File
@@ -1887,10 +1887,10 @@
<ul class="md-nav__list">
<li class="md-nav__item">
<a href="#session-lifecycle-overview" class="md-nav__link">
<a href="#remote-session-lifecycle-overview" class="md-nav__link">
<span class="md-ellipsis">
Session lifecycle overview
Remote session lifecycle overview
</span>
</a>
@@ -2680,10 +2680,10 @@
<ul class="md-nav__list">
<li class="md-nav__item">
<a href="#session-lifecycle-overview" class="md-nav__link">
<a href="#remote-session-lifecycle-overview" class="md-nav__link">
<span class="md-ellipsis">
Session lifecycle overview
Remote session lifecycle overview
</span>
</a>
@@ -2854,27 +2854,27 @@
<h1 id="rest-api-endpoints">REST API Endpoints<a class="headerlink" href="#rest-api-endpoints" title="Permanent link">&para;</a></h1>
<p>This is the full endpoint reference for the unshackle REST API. It covers every route: its method and path, the request body it accepts, the response shape it returns, and the status codes it can produce. Routes are grouped into general, download jobs, server management, and remote-dl sessions.</p>
<p>Start the server with <a href="../../../guide/cli-reference/"><code>unshackle serve</code></a>. By default it binds to <code>127.0.0.1:8786</code>, and every route lives under the <code>/api/</code> prefix. Authentication, server modes, and the <code>X-Secret-Key</code> header are covered in detail on the <a href="../authentication/">Authentication</a> page. Read that first if you have not already. For an end-to-end walkthrough, see the <a href="../quickstart/">Quickstart</a>.</p>
<p>This is the full endpoint reference for the unshackle REST API. It covers every route: its method and path, the request body it accepts, the response shape it returns, and the status codes it can give. This page groups the routes into general, download jobs, server management, and remote-dl sessions.</p>
<p>Start the server with <a href="../../../guide/cli-reference/"><code>unshackle serve</code></a>. By default it binds to <code>127.0.0.1:8786</code>, and every route lives under the <code>/api/</code> prefix. The <a href="../authentication/">Authentication</a> page gives authentication, server modes, and the <code>X-Secret-Key</code> header in detail. Read that first if you have not already. For an end-to-end walkthrough, see the <a href="../quickstart/">Quickstart</a>.</p>
<div class="admonition note">
<p class="admonition-title">Base URL</p>
<p>All examples below assume a base URL of <code>http://127.0.0.1:8786</code>. Replace the host and port to match your own <code>unshackle serve -h &lt;host&gt; -p &lt;port&gt;</code> invocation.</p>
<p>All examples below assume a base URL of <code>http://127.0.0.1:8786</code>. Replace the host and port so that they match your own <code>unshackle serve -h &lt;host&gt; -p &lt;port&gt;</code> invocation.</p>
</div>
<div class="admonition tip">
<p class="admonition-title">Interactive docs</p>
<p>A live Swagger UI is mounted at <strong><code>/api/docs/</code></strong> whenever the server is not running in <code>--remote-only</code> mode. Its title is "Unshackle REST API" and its version tracks the installed package version. In API-only mode with a key configured, the Swagger UI itself sits behind <code>X-Secret-Key</code>; only <code>/api/health</code> is exempt.</p>
<p>The server mounts a live Swagger UI at <strong><code>/api/docs/</code></strong> whenever it does not operate in <code>--remote-only</code> mode. Its title is "Unshackle REST API" and its version tracks the installed package version. In API-only mode with an API key configured, the Swagger UI itself sits behind <code>X-Secret-Key</code>. Only <code>/api/health</code> is exempt.</p>
</div>
<h2 id="conventions">Conventions<a class="headerlink" href="#conventions" title="Permanent link">&para;</a></h2>
<ul>
<li><strong>Authentication.</strong> Every request except <code>GET /api/health</code> requires the <code>X-Secret-Key</code> header when a key is configured. Starting the server with <code>--no-key</code> disables the check entirely. See <a href="../authentication/">Authentication</a>.</li>
<li><strong>Content type.</strong> Request bodies are JSON (<code>Content-Type: application/json</code>); all success responses are JSON unless a <code>204 No Content</code> is documented.</li>
<li><strong>CORS.</strong> Every response carries permissive CORS headers, and <code>OPTIONS</code> preflight requests are answered automatically.</li>
<li><strong>Authentication.</strong> Every request except <code>GET /api/health</code> requires the <code>X-Secret-Key</code> header when you configure an API key. <code>--no-key</code> disables the check entirely. See <a href="../authentication/">Authentication</a>.</li>
<li><strong>Content type.</strong> Request bodies are JSON (<code>Content-Type: application/json</code>). All success responses are JSON unless this page documents a <code>204 No Content</code>.</li>
<li><strong>CORS.</strong> Every response carries permissive CORS headers, and the server answers <code>OPTIONS</code> preflight requests automatically.</li>
<li><strong>Compression.</strong> JSON responses of 256 bytes or more are gzip-compressed when the client sends <code>Accept-Encoding: gzip</code>.</li>
<li><strong>Service allowlist.</strong> Service-facing endpoints are filtered by the effective allowlist for your API key (the intersection of the global <code>serve.services</code> list and your per-key list). Services you are not allowed to use are treated as unknown.</li>
<li><strong>Service allowlist.</strong> The effective allowlist for your API key filters the service-facing endpoints (the intersection of the global <code>serve.services</code> list and your per-key list). The server treats services you cannot use as unknown.</li>
</ul>
<div class="admonition warning">
<p class="admonition-title">Error responses have two shapes</p>
<p>Structured application errors (raised inside a handler) return <code>{"status": "error", "error_code": "...", "message": "...", "timestamp": "..."}</code>. The authentication middleware, however, returns a <strong>different</strong> shape: <code>{"status": 401, "message": "..."}</code> with an integer <code>status</code> and no <code>error_code</code>. Clients must handle both. The full error model is documented in the <a href="#error-responses">Error responses</a> section at the bottom of this page.</p>
<p>Structured application errors (raised inside a handler) return <code>{"status": "error", "error_code": "...", "message": "...", "timestamp": "..."}</code>. The authentication middleware, however, returns a <strong>different</strong> shape: <code>{"status": 401, "message": "..."}</code> with an integer <code>status</code> and no <code>error_code</code>. Clients must accept both shapes. The <a href="#error-responses">Error responses</a> section at the bottom of this page gives the full error model.</p>
</div>
<hr />
<h2 id="general-endpoints">General endpoints<a class="headerlink" href="#general-endpoints" title="Permanent link">&para;</a></h2>
@@ -2902,7 +2902,7 @@
</div>
</div>
</div>
<p><code>code_hash</code> fingerprints the framework source the server is running, so it identifies the code itself rather than the release. It is the same value for a git checkout and for an installed package built from that checkout, and it changes if anything under <code>unshackle/core</code>, <code>unshackle/commands</code>, <code>unshackle/utils</code>, or <code>unshackle/vaults</code> is edited. Service modules are not included. The value is <code>null</code> only when the source files cannot be read.</p>
<p><code>code_hash</code> fingerprints the framework source the server operates, so it names the code itself rather than the release. It is the same value for a git checkout and for an installed package built from that checkout. It changes when you edit anything under <code>unshackle/core</code>, <code>unshackle/commands</code>, <code>unshackle/utils</code>, or <code>unshackle/vaults</code>. Service modules are not included. unshackle sets the value to <code>null</code> only when it cannot read the source files.</p>
<p>If the update check fails (for example, no network), <code>update_available</code> and <code>latest_version</code> are <code>null</code> while <code>current_version</code> still reports the installed version.</p>
<table>
<thead>
@@ -2919,7 +2919,7 @@
</tbody>
</table>
<h3 id="get-apiservices"><code>GET /api/services</code><a class="headerlink" href="#get-apiservices" title="Permanent link">&para;</a></h3>
<p>List the streaming services available on this server, filtered by your allowlist. Each entry describes the service's tag, matching rules, capability flags, and its CLI parameters (useful for building a UI that accepts service-specific options).</p>
<p>Show the streaming services available on this server, filtered by your allowlist. Each entry gives the service's tag, matching rules, capability flags, and its CLI parameters (useful when you make a UI that accepts service-specific options).</p>
<div class="tabbed-set tabbed-alternate" data-tabs="2:2"><input checked="checked" id="__tabbed_2_1" name="__tabbed_2" type="radio" /><input id="__tabbed_2_2" name="__tabbed_2" type="radio" /><div class="tabbed-labels"><label for="__tabbed_2_1">Request</label><label for="__tabbed_2_2">Response <code>200</code></label></div>
<div class="tabbed-content">
<div class="tabbed-block">
@@ -3034,7 +3034,7 @@
</tbody>
</table>
<h3 id="post-apisearch"><code>POST /api/search</code><a class="headerlink" href="#post-apisearch" title="Permanent link">&para;</a></h3>
<p>Search a service for titles by query string. The service must implement <code>search()</code> (see <code>has_search</code> above).</p>
<p>Find titles in a service by query string. The service must have a <code>search()</code> method (see <code>has_search</code> above).</p>
<p><strong>Request body</strong></p>
<table>
<thead>
@@ -3142,12 +3142,12 @@
<tr>
<td><code>502</code></td>
<td><code>SERVICE_ERROR</code></td>
<td>Service failed to initialize, or does not support search.</td>
<td>Service failed to initialise, or does not support search.</td>
</tr>
</tbody>
</table>
<h3 id="post-apilist-titles"><code>POST /api/list-titles</code><a class="headerlink" href="#post-apilist-titles" title="Permanent link">&para;</a></h3>
<p>Resolve a title ID into its list of titles (episodes, movie, etc.) without downloading anything.</p>
<p>Get the list of titles behind a title ID, for example episodes or a movie, without downloading anything.</p>
<p><strong>Request body</strong></p>
<table>
<thead>
@@ -3197,7 +3197,7 @@
</tr>
</tbody>
</table>
<p>Service-specific CLI options may also be passed as additional keys.</p>
<p>You can also pass service-specific CLI options as additional keys.</p>
<div class="tabbed-set tabbed-alternate" data-tabs="4:2"><input checked="checked" id="__tabbed_4_1" name="__tabbed_4" type="radio" /><input id="__tabbed_4_2" name="__tabbed_4" type="radio" /><div class="tabbed-labels"><label for="__tabbed_4_1">Request</label><label for="__tabbed_4_2">Response <code>200</code></label></div>
<div class="tabbed-content">
<div class="tabbed-block">
@@ -3229,13 +3229,13 @@
</div>
</div>
</div>
<p>Each serialized title carries <code>type</code> (<code>"episode"</code>, <code>"movie"</code>, or <code>"other"</code>), <code>name</code>, <code>id</code>, <code>language</code>, <code>description</code>, <code>date</code>, and <code>cover_url</code>. Episodes and movies add <code>year</code>; episodes additionally add <code>series_title</code>, <code>season</code>, and <code>number</code>.</p>
<p>Each serialized title carries <code>type</code> (<code>"episode"</code>, <code>"movie"</code>, or <code>"other"</code>), <code>name</code>, <code>id</code>, <code>language</code>, <code>description</code>, <code>date</code>, and <code>cover_url</code>. Episodes and movies add <code>year</code>. Episodes also add <code>series_title</code>, <code>season</code>, and <code>number</code>.</p>
<div class="admonition warning">
<p class="admonition-title">A song serializes as <code>"other"</code></p>
<p>A music track has no <code>type</code> of its own. It is sent as <code>"other"</code>, and the response
<p>A song has no <code>type</code> of its own. The server sends it as <code>"other"</code>, and the response
carries none of its music fields: no artist, no album, no track or disc number, and no
<code>year</code>. <code>name</code> holds the track name. To read the disc and track numbers of a release,
run the CLI with <code>--list-titles</code>. Selecting music tracks over the API still works,
<code>year</code>. <code>name</code> holds the song name. To read the disc and track numbers of a release,
operate the CLI with <code>--list-titles</code>. You can still select songs over the API,
through the <code>wanted</code> parameter.</p>
</div>
<p>These keys are sent only when the title carries them, so a title without them serializes exactly as before:</p>
@@ -3259,7 +3259,7 @@ through the <code>wanted</code> parameter.</p>
<td><code>air_date</code></td>
<td>string</td>
<td>episode</td>
<td>ISO air date of <a href="../../../guide/downloading/#daily-and-date-based-content">dated content</a>. unshackle names the episode by date instead of <code>SxxExx</code>.</td>
<td>ISO air date of <a href="../../../guide/downloading/#daily-and-date-based-content">daily and date-based content</a>. unshackle names the episode by date instead of <code>SxxExx</code>.</td>
</tr>
<tr>
<td><code>absolute</code></td>
@@ -3285,8 +3285,8 @@ through the <code>wanted</code> parameter.</p>
<p class="admonition-title"><code>part</code> on a split episode</p>
<p>A service that splits one episode into several separately playable videos reports each
one as its own episode. Those titles share a <code>season</code> and <code>number</code> and add a <code>part</code>
integer counting from 1. No other title carries the key, so a client that ignores it
is unaffected. See <a href="../../creating-a-service/#split-episodes">Split episodes</a>.</p>
integer counting from 1. No other title carries the <code>part</code> field, so a client can
ignore it safely. See <a href="../../creating-a-service/#split-episodes">Split episodes</a>.</p>
</div>
<table>
<thead>
@@ -3330,10 +3330,10 @@ is unaffected. See <a href="../../creating-a-service/#split-episodes">Split epis
</tbody>
</table>
<h3 id="post-apilist-tracks"><code>POST /api/list-tracks</code><a class="headerlink" href="#post-apilist-tracks" title="Permanent link">&para;</a></h3>
<p>List the video, audio, and subtitle tracks for a title. For series, you can scope to specific episodes.</p>
<p>Show the video, audio, and subtitle tracks for a title. For series, you can scope to specific episodes.</p>
<div class="admonition tip">
<p class="admonition-title">Spotting the original-language audio</p>
<p>Each audio track carries <code>is_original</code>, which marks the track the default <code>lang</code> of <code>orig</code> selects. Unshackle resolves it with the same language matcher the downloader uses, so it stays correct for regional variants a client cannot separate from tag text alone (<code>pt</code> marks <code>pt-BR</code> rather than <code>pt-PT</code>). Services need not report a title's language, so every track can legitimately come back <code>false</code>.</p>
<p>Each audio track carries <code>is_original</code>, which marks the track the default <code>lang</code> of <code>orig</code> selects. unshackle finds it with the same language matcher the downloader uses. Thus it stays correct for regional variants a client cannot separate from tag text alone (<code>pt</code> marks <code>pt-BR</code> rather than <code>pt-PT</code>). Services need not report a title's language, so every track can legitimately come back <code>false</code>.</p>
</div>
<p><strong>Request body</strong></p>
<table>
@@ -3362,7 +3362,7 @@ is unaffected. See <a href="../../creating-a-service/#split-episodes">Split epis
<td><code>wanted</code></td>
<td>string / string[]</td>
<td>no</td>
<td>Episode/season range (e.g. <code>"S01E01-S01E03"</code>), or a music track selector (e.g. <code>"1-5"</code>, <code>"1,3,7"</code>, or <code>"2x3"</code> for disc 2 track 3). A list of selectors is accepted too.</td>
<td>Episode/season range (e.g. <code>"S01E01-S01E03"</code>), or a song selector (e.g. <code>"1-5"</code>, <code>"1,3,7"</code>, or <code>"2x3"</code> for disc 2 track 3). A list of selectors is accepted too.</td>
</tr>
<tr>
<td><code>season</code></td>
@@ -3390,7 +3390,7 @@ is unaffected. See <a href="../../creating-a-service/#split-episodes">Split epis
</tr>
</tbody>
</table>
<p>When both <code>season</code> and <code>episode</code> are given, they are combined into a <code>"{season}x{episode}"</code> selector. Adding <code>part</code> narrows that to <code>"{season}x{episode}.{part}"</code>, one part of a <a href="../../creating-a-service/#split-episodes">split episode</a>; without it you get every part of the episode. <code>part</code> is read only when <code>season</code> and <code>episode</code> are both given, and <code>wanted</code> takes precedence over all three. To scope a part by range, put it in <code>wanted</code> instead (<code>"S01E01.2"</code>).</p>
<p>When you give both <code>season</code> and <code>episode</code>, the server combines them into a <code>"{season}x{episode}"</code> selector. <code>part</code> narrows that to <code>"{season}x{episode}.{part}"</code>, one part of a <a href="../../creating-a-service/#split-episodes">split episode</a>. Without <code>part</code> you get every part of the episode. The server reads <code>part</code> only when you give both <code>season</code> and <code>episode</code>, and <code>wanted</code> takes precedence over all three. To scope a part by range, put it in <code>wanted</code> instead (<code>"S01E01.2"</code>).</p>
<div class="tabbed-set tabbed-alternate" data-tabs="5:2"><input checked="checked" id="__tabbed_5_1" name="__tabbed_5" type="radio" /><input id="__tabbed_5_2" name="__tabbed_5" type="radio" /><div class="tabbed-labels"><label for="__tabbed_5_1">Single title / movie: Response <code>200</code></label><label for="__tabbed_5_2">Multiple episodes: Response <code>200</code></label></div>
<div class="tabbed-content">
<div class="tabbed-block">
@@ -3436,7 +3436,7 @@ is unaffected. See <a href="../../creating-a-service/#split-episodes">Split epis
</div>
</div>
</div>
<p>Tracks are sorted by bitrate, descending. Episodes that fail to resolve are collected into <code>unavailable_episodes</code> rather than failing the whole request. Track objects here do <strong>not</strong> include a download <code>url</code>; segment URLs are only exposed through the remote-dl session endpoints.</p>
<p>The server sorts tracks by bitrate, descending. Episodes the server cannot get go into <code>unavailable_episodes</code>, rather than failing the whole request. Track objects here do <strong>not</strong> include a download <code>url</code>. Only the remote session endpoints give segment URLs.</p>
<p>The <code>drm</code> array on a track is a list of <code>{type, pssh?, kids?, content_keys?, license_url?}</code> objects.</p>
<table>
<thead>
@@ -3476,13 +3476,13 @@ is unaffected. See <a href="../../creating-a-service/#split-episodes">Split epis
</table>
<hr />
<h2 id="download-jobs">Download jobs<a class="headerlink" href="#download-jobs" title="Permanent link">&para;</a></h2>
<p>Downloads are asynchronous. <code>POST /api/download</code> enqueues a job and returns immediately with a <code>job_id</code>; the server runs each job in a background worker subprocess. You then poll the job endpoints for progress and results.</p>
<p>Downloads are asynchronous. <code>POST /api/download</code> enqueues a job and returns immediately with a <code>job_id</code>. The server runs each job in a background job worker subprocess. You then poll the job endpoints for progress and results.</p>
<div class="admonition info">
<p class="admonition-title">Job states</p>
<p>A job moves through <code>queued</code> → <code>downloading</code> → one of the terminal states <code>completed</code>, <code>failed</code>, or <code>cancelled</code>. Only queued jobs can be prioritized; only terminal jobs can be retried or removed.</p>
<p>A job moves through <code>queued</code> → <code>downloading</code> → one of the terminal states <code>completed</code>, <code>failed</code>, or <code>cancelled</code>. You can prioritise only queued jobs. You can retry or remove only terminal jobs.</p>
</div>
<h3 id="post-apidownload"><code>POST /api/download</code><a class="headerlink" href="#post-apidownload" title="Permanent link">&para;</a></h3>
<p>Create a download job. Requires <code>service</code> and <code>title_id</code>; every other field is an optional download parameter with a server-side default. On success this returns <strong><code>202 Accepted</code></strong>, not <code>200</code>.</p>
<p>Make a download job. It requires <code>service</code> and <code>title_id</code>. Every other field is an optional download parameter with a server-side default. On success this returns <strong><code>202 Accepted</code></strong>, not <code>200</code>.</p>
<div class="tabbed-set tabbed-alternate" data-tabs="6:2"><input checked="checked" id="__tabbed_6_1" name="__tabbed_6" type="radio" /><input id="__tabbed_6_2" name="__tabbed_6" type="radio" /><div class="tabbed-labels"><label for="__tabbed_6_1">Request</label><label for="__tabbed_6_2">Response <code>202</code></label></div>
<div class="tabbed-content">
<div class="tabbed-block">
@@ -3566,7 +3566,7 @@ is unaffected. See <a href="../../creating-a-service/#split-episodes">Split epis
<td><code>channels</code></td>
<td>number</td>
<td><code>null</code></td>
<td>Audio channel count.</td>
<td>Audio channel layout.</td>
</tr>
<tr>
<td><code>no_atmos</code></td>
@@ -3734,7 +3734,7 @@ is unaffected. See <a href="../../creating-a-service/#split-episodes">Split epis
<td><code>tmdb_id</code> / <code>imdb_id</code> / <code>tvdb_id</code> / <code>anilist_id</code></td>
<td>-</td>
<td><code>null</code></td>
<td>External ID overrides. Each resolves its metadata directly instead of by a title search, and is used for tagging. Set <code>enrich</code> to also take the title, year and original language. Give at most one of <code>tmdb_id</code>, <code>imdb_id</code> and <code>tvdb_id</code>, since unshackle resolves the others from it. Sending two returns <code>400</code>. <code>anilist_id</code> still combines with one of them. <code>tmdb_id</code> and <code>tvdb_id</code> must be positive integers, <code>imdb_id</code> must look like <code>tt1375666</code>, and <code>anilist_id</code> must be a positive integer or a string like <code>mal:12345</code>, or the request returns <code>400</code>. An ID whose provider is unconfigured, such as <code>tmdb_id</code> with no <code>tmdb_api_key</code>, fails the job rather than returning <code>400</code>. <code>anilist_id</code> needs no key.</td>
<td>External ID overrides. Each resolves its metadata directly instead of by a title search, and is used for tagging. Set <code>enrich</code> to also take the title, year and original language. Give at most one of <code>tmdb_id</code>, <code>imdb_id</code> and <code>tvdb_id</code>, since unshackle resolves the others from it. Sending two returns <code>400</code>. <code>anilist_id</code> still combines with one of them. <code>tmdb_id</code> and <code>tvdb_id</code> must be positive integers, <code>imdb_id</code> must look like <code>tt1375666</code>, and <code>anilist_id</code> must be a positive integer or a string like <code>mal:12345</code>, or the request returns <code>400</code>. An ID whose provider is unconfigured, such as <code>tmdb_id</code> with no <code>tmdb_api_key</code>, fails the job rather than returning <code>400</code>. <code>anilist_id</code> needs no API key.</td>
</tr>
<tr>
<td><code>tvdb_order</code></td>
@@ -3768,23 +3768,23 @@ is unaffected. See <a href="../../creating-a-service/#split-episodes">Split epis
</tr>
</tbody>
</table>
<p><strong>Validation.</strong> Invalid values return <code>400 INVALID_PARAMETERS</code>. Notably: <code>vcodec</code> must be one of H264/H265/H.264/H.265/AVC/HEVC/VC1/VC-1/VP8/VP9/AV1; <code>acodec</code> one of AAC/AC3/EC3/EAC3/DD/DD+/AC4/OPUS/FLAC/ALAC/VORBIS/OGG/DTS; <code>range</code> one of SDR/HDR10/HDR10P/DV/HLG/HYBRID (<code>HDR10+</code> is accepted); bitrate/worker/download counts must be positive integers; at most one of the <code>*_only</code> flags may be set; <code>no_subs</code> cannot be combined with <code>subs_only</code>, <code>no_audio</code> with <code>audio_only</code>, or <code>s_lang</code> with <code>require_subs</code>.</p>
<p><strong>Validation.</strong> Invalid values return <code>400 INVALID_PARAMETERS</code>. <code>vcodec</code> must be one of H264/H265/H.264/H.265/AVC/HEVC/VC1/VC-1/VP8/VP9/AV1. <code>acodec</code> must be one of AAC/AC3/EC3/EAC3/DD/DD+/AC4/OPUS/FLAC/ALAC/VORBIS/OGG/DTS. <code>range</code> must be one of SDR/HDR10/HDR10P/DV/HLG/HYBRID, and <code>HDR10+</code> is also valid. The bitrate, download worker, and download counts must be positive integers. You may set at most one of the <code>*_only</code> flags. You cannot combine <code>no_subs</code> with <code>subs_only</code>, <code>no_audio</code> with <code>audio_only</code>, or <code>s_lang</code> with <code>require_subs</code>.</p>
<div class="admonition danger">
<p class="admonition-title">Rejected parameters</p>
<p><code>postscript</code>, <code>post_script</code> and <code>post_scripts</code> are never accepted. A body containing any
of them is rejected with <code>400 INVALID_PARAMETERS</code>, because a command string arriving from
an HTTP caller would be remote code execution. Post-download hooks are configured only in
<code>unshackle.yaml</code>, where they still run for API jobs. See
of them gets <code>400 INVALID_PARAMETERS</code>, because a command string that arrives from
an HTTP caller would be remote code execution. You configure post-download hooks only in
<code>unshackle.yaml</code>, where they still operate for API jobs. See
<a href="../../../reference/configuration/post-scripts/">Post-download scripts</a>.</p>
</div>
<div class="admonition warning">
<p class="admonition-title">Gated parameters (developer)</p>
<p>A few keys are accepted but only permitted when the server config opts in, otherwise the request is rejected with <code>403 FORBIDDEN</code>:</p>
<p>The server accepts a few keys, but permits them only when the server config opts in. Otherwise the server rejects the request with <code>403 FORBIDDEN</code>:</p>
<ul>
<li><code>cdm</code> (per-request CDM device name): allowed only if <code>serve.cdm_overrides</code> is <code>true</code> or a list containing that device name.</li>
<li><code>credential</code> (<code>"user:pass"</code>) and <code>credentials</code> (a map): allowed only if <code>serve.allow_job_credentials</code> is truthy (off by default).</li>
</ul>
<p>Server-side, effective parameters are merged as defaults → <code>serve:</code> config overrides → service-specific defaults → your request body.</p>
<p>On the server, unshackle merges the effective parameters in this order: defaults → <code>serve:</code> config overrides → service-specific defaults → your request body.</p>
</div>
<table>
<thead>
@@ -3823,7 +3823,7 @@ an HTTP caller would be remote code execution. Post-download hooks are configure
</tbody>
</table>
<h3 id="get-apidownloadjobs"><code>GET /api/download/jobs</code><a class="headerlink" href="#get-apidownloadjobs" title="Permanent link">&para;</a></h3>
<p>List download jobs, with optional filtering and sorting.</p>
<p>Show download jobs, with optional filtering and sorting.</p>
<p><strong>Query parameters</strong></p>
<table>
<thead>
@@ -3976,30 +3976,30 @@ an HTTP caller would be remote code execution. Post-download hooks are configure
</tr>
<tr>
<td><code>completed</code></td>
<td>The job finished. The server then closes the stream.</td>
<td>The job finished. The server then closes the event stream.</td>
</tr>
<tr>
<td><code>failed</code></td>
<td>The job failed. The server then closes the stream.</td>
<td>The job failed. The server then closes the event stream.</td>
</tr>
<tr>
<td><code>cancelled</code></td>
<td>The job was cancelled. The server then closes the stream.</td>
<td>The job was cancelled. The server then closes the event stream.</td>
</tr>
</tbody>
</table>
<p>The server sends a <code>: keep-alive</code> comment each 15 seconds while the job is quiet, to keep proxies from closing an idle connection. Ignore these lines.</p>
<p>If the job is already in a terminal state, the server sends the <code>snapshot</code> event, then the terminal event, and closes the stream immediately.</p>
<p>If the job is already in a terminal state, the server sends the <code>snapshot</code> event, then the terminal event, and closes the event stream immediately.</p>
<div class="admonition tip">
<p class="admonition-title">Authentication from a browser</p>
<p><code>EventSource</code> cannot set headers. For this endpoint only, the server also accepts the key in the <code>secret_key</code> query parameter. This works in both server modes, the default integrated server and <code>--api-only</code>:</p>
<p><code>EventSource</code> cannot set headers. For this endpoint only, the server also accepts the API key in the <code>secret_key</code> query parameter. This works in both server modes, the default integrated server and <code>--api-only</code>:</p>
<div class="language-javascript highlight"><pre><span></span><code><span id="__span-15-1"><a id="__codelineno-15-1" name="__codelineno-15-1" href="#__codelineno-15-1"></a><span class="kd">const</span><span class="w"> </span><span class="nx">events</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="ow">new</span><span class="w"> </span><span class="nx">EventSource</span><span class="p">(</span>
</span><span id="__span-15-2"><a id="__codelineno-15-2" name="__codelineno-15-2" href="#__codelineno-15-2"></a><span class="w"> </span><span class="sb">`http://127.0.0.1:8786/api/download/jobs/</span><span class="si">${</span><span class="nx">jobId</span><span class="si">}</span><span class="sb">/events?secret_key=</span><span class="si">${</span><span class="nx">key</span><span class="si">}</span><span class="sb">`</span>
</span><span id="__span-15-3"><a id="__codelineno-15-3" name="__codelineno-15-3" href="#__codelineno-15-3"></a><span class="p">);</span>
</span><span id="__span-15-4"><a id="__codelineno-15-4" name="__codelineno-15-4" href="#__codelineno-15-4"></a><span class="nx">events</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s2">"progress"</span><span class="p">,</span><span class="w"> </span><span class="p">(</span><span class="nx">e</span><span class="p">)</span><span class="w"> </span><span class="p">=&gt;</span><span class="w"> </span><span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="nb">JSON</span><span class="p">.</span><span class="nx">parse</span><span class="p">(</span><span class="nx">e</span><span class="p">.</span><span class="nx">data</span><span class="p">).</span><span class="nx">progress</span><span class="p">));</span>
</span><span id="__span-15-5"><a id="__codelineno-15-5" name="__codelineno-15-5" href="#__codelineno-15-5"></a><span class="nx">events</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s2">"completed"</span><span class="p">,</span><span class="w"> </span><span class="p">()</span><span class="w"> </span><span class="p">=&gt;</span><span class="w"> </span><span class="nx">events</span><span class="p">.</span><span class="nx">close</span><span class="p">());</span>
</span></code></pre></div>
<p>The header always wins. Send the key in the query parameter <em>or</em> in the header, not both: if an <code>X-Secret-Key</code> header is present, the server checks that header and ignores the query parameter. See <a href="../authentication/#how-clients-present-credentials">Authentication</a>.</p>
<p>The header always wins. Send the API key in the query parameter <em>or</em> in the header, not both. If an <code>X-Secret-Key</code> header is present, the server examines that header and ignores the query parameter. See <a href="../authentication/#how-clients-present-credentials">Authentication</a>.</p>
</div>
<div class="tabbed-set tabbed-alternate" data-tabs="8:2"><input checked="checked" id="__tabbed_8_1" name="__tabbed_8" type="radio" /><input id="__tabbed_8_2" name="__tabbed_8" type="radio" /><div class="tabbed-labels"><label for="__tabbed_8_1">Request</label><label for="__tabbed_8_2">Response <code>200</code></label></div>
<div class="tabbed-content">
@@ -4052,7 +4052,7 @@ an HTTP caller would be remote code execution. Post-download hooks are configure
</ul>
<div class="admonition warning">
<p class="admonition-title">Warning</p>
<p>A <code>204</code> response has no body. Do not attempt to parse JSON from it.</p>
<p>A <code>204</code> response has no body. Do not try to parse JSON from it.</p>
</div>
<table>
<thead>
@@ -4099,7 +4099,7 @@ an HTTP caller would be remote code execution. Post-download hooks are configure
<p>Enqueue a <strong>new</strong> job that reuses a terminal job's service, title, and parameters. The service allowlist and parameter gates are re-checked. Returns <code>202</code> with the new job's ID.</p>
<div class="admonition warning">
<p class="admonition-title">Retry reuses the original, unredacted parameters</p>
<p>Retry replays the finished job's stored parameters exactly as they were submitted, including any secrets such as <code>credential</code>/<code>credentials</code>, not the redacted <code>parameters</code> view that <code>GET /api/download/jobs</code> and <code>GET /api/history</code> expose. This is deliberate: it lets a retried job reproduce a credentialed download that the redacted job record alone never could. It also means anyone who can retry a job can re-run it with its original credentials, even though those credentials are masked everywhere else in the API.</p>
<p>A retry replays the finished job's stored parameters exactly as the original client submitted them, including any secrets such as <code>credential</code>/<code>credentials</code>. It does not use the redacted <code>parameters</code> view that <code>GET /api/download/jobs</code> and <code>GET /api/history</code> give. This is deliberate: it lets a retried job reproduce a credentialed download that the redacted job record alone never could. The API masks those credentials everywhere else. But anyone who can retry a job can operate it again with the original credentials.</p>
</div>
<div class="tabbed-set tabbed-alternate" data-tabs="10:1"><input checked="checked" id="__tabbed_10_1" name="__tabbed_10" type="radio" /><div class="tabbed-labels"><label for="__tabbed_10_1">Response <code>202</code></label></div>
<div class="tabbed-content">
@@ -4189,7 +4189,7 @@ an HTTP caller would be remote code execution. Post-download hooks are configure
<h2 id="server-management">Server management<a class="headerlink" href="#server-management" title="Permanent link">&para;</a></h2>
<p>Endpoints for profiles, effective configuration, download history, environment checks, and maintenance actions.</p>
<h3 id="get-apiprofiles"><code>GET /api/profiles</code><a class="headerlink" href="#get-apiprofiles" title="Permanent link">&para;</a></h3>
<p>List the named credential profiles configured per service (usable as the <code>profile</code> parameter elsewhere). Only services whose credentials are a mapping of profile name to credential are listed; services with a single unnamed credential are omitted. Filtered by your allowlist.</p>
<p>Show the named credential profiles configured per service (usable as the <code>profile</code> parameter elsewhere). The response holds only services whose credentials are a mapping of profile name to credential. It leaves out services with a single unnamed credential. Your allowlist filters the response.</p>
<div class="tabbed-set tabbed-alternate" data-tabs="12:1"><input checked="checked" id="__tabbed_12_1" name="__tabbed_12" type="radio" /><div class="tabbed-labels"><label for="__tabbed_12_1">Response <code>200</code></label></div>
<div class="tabbed-content">
<div class="tabbed-block">
@@ -4204,7 +4204,7 @@ an HTTP caller would be remote code execution. Post-download hooks are configure
</div>
</div>
<h3 id="get-apiconfig"><code>GET /api/config</code><a class="headerlink" href="#get-apiconfig" title="Permanent link">&para;</a></h3>
<p>Return a read-only, redacted view of the effective server configuration, suitable for a settings page in a UI. Secrets are never emitted: keys matching <code>secret</code>, <code>password</code>, <code>token</code>, <code>api_key</code>, or <code>credential</code>, and any userinfo in proxy URLs, are masked.</p>
<p>Return a read-only, redacted view of the effective server configuration, suitable for a settings page in a UI. The server never sends secrets: it masks every config key whose name contains <code>secret</code>, <code>password</code>, <code>token</code>, <code>api_key</code>, or <code>credential</code>, and any userinfo in proxy URLs.</p>
<div class="tabbed-set tabbed-alternate" data-tabs="13:1"><input checked="checked" id="__tabbed_13_1" name="__tabbed_13" type="radio" /><div class="tabbed-labels"><label for="__tabbed_13_1">Response <code>200</code></label></div>
<div class="tabbed-content">
<div class="tabbed-block">
@@ -4233,7 +4233,7 @@ an HTTP caller would be remote code execution. Post-download hooks are configure
</div>
</div>
<h3 id="get-apihistory"><code>GET /api/history</code><a class="headerlink" href="#get-apihistory" title="Permanent link">&para;</a></h3>
<p>Read the persisted history of jobs that reached a terminal state, newest first. History is stored in <code>api_history.jsonl</code> in the cache directory; corrupt lines are skipped and a missing file yields an empty list.</p>
<p>Read the persisted history of jobs that reached a terminal state, newest first. unshackle stores the history in <code>api_history.jsonl</code> in the cache directory. It skips corrupt lines, and a missing file yields an empty list.</p>
<p><strong>Query parameters</strong></p>
<table>
<thead>
@@ -4328,11 +4328,11 @@ an HTTP caller would be remote code execution. Post-download hooks are configure
</tbody>
</table>
<h3 id="post-apimaintenanceclear-cache-and-post-apimaintenanceclear-temp"><code>POST /api/maintenance/clear-cache</code> and <code>POST /api/maintenance/clear-temp</code><a class="headerlink" href="#post-apimaintenanceclear-cache-and-post-apimaintenanceclear-temp" title="Permanent link">&para;</a></h3>
<p>Delete and recreate the cache or temp directory. Neither takes a request body. Both are blocked with <code>409 CONFLICT</code> if any job is currently <code>downloading</code> (the <code>details</code> include the offending <code>active_jobs</code>).</p>
<p>The <code>409</code> guard is about active file I/O, not an arbitrary lock: an in-flight job may still be <em>reading</em> cached title data under the cache directory, and active download workers are <em>writing</em> segment files under temp. Removing either directory mid-download would corrupt those running jobs, so the endpoints refuse until nothing is downloading.</p>
<p>Delete and recreate the cache or temp directory. Neither takes a request body. The server blocks both with <code>409 CONFLICT</code> if any job is currently <code>downloading</code> (the <code>details</code> include the offending <code>active_jobs</code>).</p>
<p>The <code>409</code> guard is about active file I/O, not an arbitrary lock. An in-flight job may still be <em>reading</em> cached title data under the cache directory, and active download workers are <em>writing</em> segment files under temp. The removal of either directory mid-download would corrupt those jobs, so the endpoints refuse while a download is in progress.</p>
<div class="admonition warning">
<p class="admonition-title">clear-cache also wipes download history</p>
<p>The persistent history read by <a href="#get-apihistory"><code>GET /api/history</code></a> lives in <code>api_history.jsonl</code> <strong>inside the cache directory</strong>. Because clear-cache deletes and recreates that whole directory, calling it also erases your entire download history, not just cached title data. If you want to keep history, do not clear the cache; remove individual entries with <a href="#delete-apihistoryjob_id"><code>DELETE /api/history/{job_id}</code></a> instead.</p>
<p>The persistent history read by <a href="#get-apihistory"><code>GET /api/history</code></a> lives in <code>api_history.jsonl</code> <strong>inside the cache directory</strong>. Because clear-cache deletes and recreates that whole directory, calling it also erases your entire download history, not only cached title data. If you want to keep history, do not clear the cache. Remove individual entries with <a href="#delete-apihistoryjob_id"><code>DELETE /api/history/{job_id}</code></a> instead.</p>
</div>
<div class="tabbed-set tabbed-alternate" data-tabs="15:1"><input checked="checked" id="__tabbed_15_1" name="__tabbed_15" type="radio" /><div class="tabbed-labels"><label for="__tabbed_15_1">Response <code>200</code></label></div>
<div class="tabbed-content">
@@ -4364,7 +4364,7 @@ an HTTP caller would be remote code execution. Post-download hooks are configure
</tbody>
</table>
<h3 id="post-apimaintenancerefresh-services"><code>POST /api/maintenance/refresh-services</code><a class="headerlink" href="#post-apimaintenancerefresh-services" title="Permanent link">&para;</a></h3>
<p>git-pull every service repository configured under <code>directories.services</code>. <code>refreshed</code> is <code>true</code> when all repos updated successfully (or none are configured).</p>
<p>git-pull every service repository configured under <code>directories.services</code>. <code>refreshed</code> becomes <code>true</code> when all repos updated successfully, or when you configure no repository.</p>
<div class="tabbed-set tabbed-alternate" data-tabs="16:1"><input checked="checked" id="__tabbed_16_1" name="__tabbed_16" type="radio" /><div class="tabbed-labels"><label for="__tabbed_16_1">Response <code>200</code></label></div>
<div class="tabbed-content">
<div class="tabbed-block">
@@ -4397,10 +4397,10 @@ an HTTP caller would be remote code execution. Post-download hooks are configure
<h2 id="remote-dl-sessions">Remote-dl sessions<a class="headerlink" href="#remote-dl-sessions" title="Permanent link">&para;</a></h2>
<div class="admonition note">
<p class="admonition-title">Developer feature</p>
<p>The <code>/api/session/*</code> endpoints power unshackle's <strong>remote download</strong> mode, where a thin local client authenticates once against a remote server, then fetches titles, tracks, and segments and proxies DRM licensing back through it. These endpoints are exposed in <code>--remote-only</code> mode, and they are what the built-in <code>RemoteService</code> client drives. Most end users never call them directly.</p>
<p>The <code>/api/session/*</code> endpoints power unshackle's <strong>remote download</strong> mode. A thin local client authenticates once against a remote server, then fetches titles, tracks, and segments, and proxies DRM licensing back through it. The server mounts these endpoints in <code>--remote-only</code> mode, and they are what the built-in <code>RemoteService</code> client drives. Most end users never call them directly.</p>
</div>
<p>Sessions are held in memory. They expire after <code>serve.session_ttl</code> (default 300s), except while awaiting interactive authentication input, which uses a 600s window. A session is bound to the IP that created it, so a request from a different IP is rejected with <code>403 FORBIDDEN</code>.</p>
<h3 id="session-lifecycle-overview">Session lifecycle overview<a class="headerlink" href="#session-lifecycle-overview" title="Permanent link">&para;</a></h3>
<p>The server holds remote sessions in memory. They expire after <code>serve.session_ttl</code> (default 300s), except while awaiting interactive authentication input, which uses a 600s window. A remote session binds to the IP that made it, so the server rejects a request from a different IP with <code>403 FORBIDDEN</code>.</p>
<h3 id="remote-session-lifecycle-overview">Remote session lifecycle overview<a class="headerlink" href="#remote-session-lifecycle-overview" title="Permanent link">&para;</a></h3>
<div class="language-text highlight"><pre><span></span><code><span id="__span-27-1"><a id="__codelineno-27-1" name="__codelineno-27-1" href="#__codelineno-27-1"></a>POST /api/session/create → returns session_id, status "authenticating"
</span><span id="__span-27-2"><a id="__codelineno-27-2" name="__codelineno-27-2" href="#__codelineno-27-2"></a>GET /api/session/{id}/prompt → poll until authenticated (submit answers via POST)
</span><span id="__span-27-3"><a id="__codelineno-27-3" name="__codelineno-27-3" href="#__codelineno-27-3"></a>GET /api/session/{id}/titles → list titles
@@ -4410,7 +4410,7 @@ an HTTP caller would be remote code execution. Post-download hooks are configure
</span><span id="__span-27-7"><a id="__codelineno-27-7" name="__codelineno-27-7" href="#__codelineno-27-7"></a>DELETE /api/session/{id} → tear down, harvest updated cache
</span></code></pre></div>
<h3 id="post-apisessioncreate"><code>POST /api/session/create</code><a class="headerlink" href="#post-apisessioncreate" title="Permanent link">&para;</a></h3>
<p>Create a session for a service and title. Authentication runs asynchronously in the background. This call returns immediately with <code>status: "authenticating"</code>, and you then poll the prompt endpoint. The body accepts <code>service</code> and <code>title_id</code> (both required) plus a broad set of optional keys (<code>additionalProperties</code> is allowed): <code>credentials</code> (<code>{username, password, extra?}</code>), <code>cookies</code> (base64 of zlib-compressed Netscape cookie file), <code>proxy</code>, <code>no_proxy</code>, <code>profile</code>, <code>cache</code> (map of forwarded cache files), <code>client_region</code>, <code>cdm_type</code>, <code>range_</code>, <code>vcodec</code>, <code>quality</code>, <code>best_available</code>, and any service CLI options.</p>
<p>Make a remote session for a service and title. Authentication runs asynchronously in the background. This call returns immediately with <code>status: "authenticating"</code>, and you then poll the prompt endpoint. The body accepts <code>service</code> and <code>title_id</code> (both required). It also accepts a broad set of optional keys, because the body allows <code>additionalProperties</code>. These are <code>credentials</code> (<code>{username, password, extra?}</code>), <code>cookies</code> (base64 of zlib-compressed Netscape cookie file), <code>proxy</code>, <code>no_proxy</code>, <code>profile</code>, <code>cache</code> (map of forwarded cache files), <code>client_region</code>, <code>cdm_type</code>, <code>range_</code>, <code>vcodec</code>, <code>quality</code>, <code>best_available</code>, and any service CLI options.</p>
<div class="tabbed-set tabbed-alternate" data-tabs="18:1"><input checked="checked" id="__tabbed_18_1" name="__tabbed_18" type="radio" /><div class="tabbed-labels"><label for="__tabbed_18_1">Response <code>200</code></label></div>
<div class="tabbed-content">
<div class="tabbed-block">
@@ -4488,7 +4488,7 @@ an HTTP caller would be remote code execution. Post-download hooks are configure
<tr>
<td><code>403</code></td>
<td><code>FORBIDDEN</code></td>
<td>Request IP differs from the session creator.</td>
<td>Request IP differs from the remote session creator.</td>
</tr>
<tr>
<td><code>404</code></td>
@@ -4534,7 +4534,7 @@ an HTTP caller would be remote code execution. Post-download hooks are configure
</tbody>
</table>
<h3 id="get-apisessionsession_idtitles"><code>GET /api/session/{session_id}/titles</code><a class="headerlink" href="#get-apisessionsession_idtitles" title="Permanent link">&para;</a></h3>
<p>Fetch the titles for an authenticated session.</p>
<p>Fetch the titles for an authenticated remote session.</p>
<div class="tabbed-set tabbed-alternate" data-tabs="21:1"><input checked="checked" id="__tabbed_21_1" name="__tabbed_21" type="radio" /><div class="tabbed-labels"><label for="__tabbed_21_1">Response <code>200</code></label></div>
<div class="tabbed-content">
<div class="tabbed-block">
@@ -4546,9 +4546,9 @@ an HTTP caller would be remote code execution. Post-download hooks are configure
</div>
</div>
</div>
<p>Requires an authenticated session; <code>404 SESSION_NOT_FOUND</code> otherwise.</p>
<p>Requires an authenticated remote session. Otherwise it returns <code>404 SESSION_NOT_FOUND</code>.</p>
<h3 id="post-apisessionsession_idtracks"><code>POST /api/session/{session_id}/tracks</code><a class="headerlink" href="#post-apisessionsession_idtracks" title="Permanent link">&para;</a></h3>
<p>Get tracks and chapters for a specific title within the session. Body: <code>{ "title_id": "..." }</code> (required). Unlike <code>list-tracks</code>, track objects here <strong>include</strong> download URLs, along with session headers/cookies and manifest data the client needs to download directly.</p>
<p>Get tracks and chapters for a specific title within the remote session. Body: <code>{ "title_id": "..." }</code> (required). Unlike <code>list-tracks</code>, track objects here <strong>include</strong> download URLs, along with session headers/cookies and manifest data the client needs to download directly.</p>
<div class="tabbed-set tabbed-alternate" data-tabs="22:1"><input checked="checked" id="__tabbed_22_1" name="__tabbed_22" type="radio" /><div class="tabbed-labels"><label for="__tabbed_22_1">Response <code>200</code></label></div>
<div class="tabbed-content">
<div class="tabbed-block">
@@ -4595,7 +4595,7 @@ an HTTP caller would be remote code execution. Post-download hooks are configure
</tbody>
</table>
<h3 id="post-apisessionsession_idsegments"><code>POST /api/session/{session_id}/segments</code><a class="headerlink" href="#post-apisessionsession_idsegments" title="Permanent link">&para;</a></h3>
<p>Resolve the download URL, DRM info, headers, cookies, and raw track data for selected tracks. Body: <code>{ "track_ids": ["v-1", "a-1"] }</code> (required).</p>
<p>Get the download URL, DRM info, headers, cookies, and raw track data for the selected tracks. Body: <code>{ "track_ids": ["v-1", "a-1"] }</code> (required).</p>
<div class="tabbed-set tabbed-alternate" data-tabs="23:1"><input checked="checked" id="__tabbed_23_1" name="__tabbed_23" type="radio" /><div class="tabbed-labels"><label for="__tabbed_23_1">Response <code>200</code></label></div>
<div class="tabbed-content">
<div class="tabbed-block">
@@ -4635,7 +4635,7 @@ an HTTP caller would be remote code execution. Post-download hooks are configure
</tbody>
</table>
<h3 id="post-apisessionsession_idlicense"><code>POST /api/session/{session_id}/license</code><a class="headerlink" href="#post-apisessionsession_idlicense" title="Permanent link">&para;</a></h3>
<p>Acquire DRM keys. Two modes are supported via the <code>mode</code> field.</p>
<p>Get the content keys for the DRM. The <code>mode</code> field selects one of two modes.</p>
<p><strong>Proxy mode</strong> (<code>mode: "proxy"</code>, the default) forwards the client's CDM <code>challenge</code> to the service's license endpoint and returns the raw license bytes:</p>
<div class="tabbed-set tabbed-alternate" data-tabs="24:2"><input checked="checked" id="__tabbed_24_1" name="__tabbed_24" type="radio" /><input id="__tabbed_24_2" name="__tabbed_24" type="radio" /><div class="tabbed-labels"><label for="__tabbed_24_1">Request</label><label for="__tabbed_24_2">Response <code>200</code></label></div>
<div class="tabbed-content">
@@ -4709,7 +4709,7 @@ an HTTP caller would be remote code execution. Post-download hooks are configure
<tr>
<td><code>400</code></td>
<td><code>INVALID_INPUT</code></td>
<td>Missing challenge/PSSH, or no CDM device configured for the key.</td>
<td>Missing challenge/PSSH, or no CDM device configured for the API key.</td>
</tr>
<tr>
<td><code>400</code></td>
@@ -4734,7 +4734,7 @@ an HTTP caller would be remote code execution. Post-download hooks are configure
</tbody>
</table>
<h3 id="get-apisessionsession_id"><code>GET /api/session/{session_id}</code><a class="headerlink" href="#get-apisessionsession_id" title="Permanent link">&para;</a></h3>
<p>Return session metadata and validity.</p>
<p>Return remote session metadata and validity.</p>
<div class="tabbed-set tabbed-alternate" data-tabs="26:1"><input checked="checked" id="__tabbed_26_1" name="__tabbed_26" type="radio" /><div class="tabbed-labels"><label for="__tabbed_26_1">Response <code>200</code></label></div>
<div class="tabbed-content">
<div class="tabbed-block">
@@ -4750,9 +4750,9 @@ an HTTP caller would be remote code execution. Post-download hooks are configure
</div>
</div>
</div>
<p><code>404 SESSION_NOT_FOUND</code> if the session does not exist or has expired.</p>
<p><code>404 SESSION_NOT_FOUND</code> if the remote session does not exist or has expired.</p>
<h3 id="delete-apisessionsession_id"><code>DELETE /api/session/{session_id}</code><a class="headerlink" href="#delete-apisessionsession_id" title="Permanent link">&para;</a></h3>
<p>Tear down a session. Its input bridge is cancelled, any updated cache files are harvested, and the session plus its cache directory are deleted.</p>
<p>Tear down a remote session. The server cancels its input bridge, harvests any updated cache files, and deletes the remote session and its cache directory.</p>
<div class="tabbed-set tabbed-alternate" data-tabs="27:1"><input checked="checked" id="__tabbed_27_1" name="__tabbed_27" type="radio" /><div class="tabbed-labels"><label for="__tabbed_27_1">Response <code>200</code></label></div>
<div class="tabbed-content">
<div class="tabbed-block">
@@ -4764,10 +4764,10 @@ an HTTP caller would be remote code execution. Post-download hooks are configure
</div>
</div>
</div>
<p>The <code>cache</code> field is present only if the session produced cache files. <code>404 SESSION_NOT_FOUND</code> if the session does not exist.</p>
<p>The <code>cache</code> field is present only if the remote session produced cache files. <code>404 SESSION_NOT_FOUND</code> if the remote session does not exist.</p>
<hr />
<h2 id="error-responses">Error responses<a class="headerlink" href="#error-responses" title="Permanent link">&para;</a></h2>
<p>Every structured error raised inside a handler is serialized to the same JSON envelope:</p>
<p>The server serialises every structured error raised inside a handler to the same JSON envelope:</p>
<div class="language-json highlight"><pre><span></span><code><span id="__span-43-1"><a id="__codelineno-43-1" name="__codelineno-43-1" href="#__codelineno-43-1"></a><span class="p">{</span>
</span><span id="__span-43-2"><a id="__codelineno-43-2" name="__codelineno-43-2" href="#__codelineno-43-2"></a><span class="w"> </span><span class="nt">"status"</span><span class="p">:</span><span class="w"> </span><span class="s2">"error"</span><span class="p">,</span>
</span><span id="__span-43-3"><a id="__codelineno-43-3" name="__codelineno-43-3" href="#__codelineno-43-3"></a><span class="w"> </span><span class="nt">"error_code"</span><span class="p">:</span><span class="w"> </span><span class="s2">"INVALID_PARAMETERS"</span><span class="p">,</span>
@@ -4781,7 +4781,7 @@ an HTTP caller would be remote code execution. Post-download hooks are configure
<ul>
<li><code>details</code> appears only when non-empty.</li>
<li><code>retryable</code> appears only when the error is retryable (e.g. network errors, rate limiting).</li>
<li><code>debug_info</code> appears only when the server was started with <code>--debug-api</code>.</li>
<li><code>debug_info</code> appears only when you start the server with <code>--debug-api</code>.</li>
</ul>
<div class="admonition warning">
<p class="admonition-title">Authentication errors differ</p>
+43 -43
View File
@@ -1755,10 +1755,10 @@
</li>
<li class="md-nav__item">
<a href="#how-errors-are-produced" class="md-nav__link">
<a href="#how-the-server-produces-errors" class="md-nav__link">
<span class="md-ellipsis">
How errors are produced
How the server produces errors
</span>
</a>
@@ -2149,10 +2149,10 @@
</li>
<li class="md-nav__item">
<a href="#how-errors-are-produced" class="md-nav__link">
<a href="#how-the-server-produces-errors" class="md-nav__link">
<span class="md-ellipsis">
How errors are produced
How the server produces errors
</span>
</a>
@@ -2225,19 +2225,19 @@
<h1 id="error-responses">Error Responses<a class="headerlink" href="#error-responses" title="Permanent link">&para;</a></h1>
<p>Every endpoint of the unshackle REST API reports failures in a single, predictable
shape. Whether a request was rejected because a field was missing, a service refused
to authenticate, or the download worker crashed, the server answers with a JSON body
shape. The cause can be a missing field, a service that refuses to authenticate, or a
download worker that crashed. In each case the server answers with a JSON body
that carries a machine-readable <strong>error code</strong>, a human-readable <strong>message</strong>, and a
matching <strong>HTTP status</strong>. This page documents that format and catalogues every error
code the server can emit.</p>
<p>If you are building a client against the API, read this page alongside the endpoint
reference. Each endpoint lists the specific codes it raises, and the meanings of
those codes are defined here.</p>
code the server can send.</p>
<p>If you write a client against the API, read this page alongside the endpoint
reference. Each endpoint lists the specific codes it raises, and this page gives the
meanings of those codes.</p>
<div class="admonition note">
<p class="admonition-title">Where this comes from</p>
<p>The error format is defined in <code>unshackle/core/api/errors.py</code>, and the wrapper
<p><code>unshackle/core/api/errors.py</code> defines the error format, and the wrapper
that turns raised errors into responses lives in <code>unshackle/core/api/routes.py</code>.
Nothing on this page is invented; it reflects the actual server behaviour.</p>
Nothing on this page is invented. It reflects the actual server behaviour.</p>
</div>
<h2 id="the-standard-error-shape">The standard error shape<a class="headerlink" href="#the-standard-error-shape" title="Permanent link">&para;</a></h2>
<p>A structured error is always a JSON object with <code>status</code> set to the literal string
@@ -2322,7 +2322,7 @@ included when it is non-empty, so a client must treat a missing <code>details</c
extra information". Some concrete examples you may encounter:</p>
<ul>
<li>A validation failure may include the offending field or the list of valid values.</li>
<li>A "title not found" error on a remote-dl session includes <code>available_titles</code>.</li>
<li>A "title not found" error on a remote session includes <code>available_titles</code>.</li>
<li>A maintenance conflict includes <code>active_jobs</code>, the jobs blocking the operation.</li>
<li>Categorised exceptions add a <code>reason</code> tag, such as <code>"network_connectivity"</code> or
<code>"geofence_restriction"</code>.</li>
@@ -2330,8 +2330,8 @@ extra information". Some concrete examples you may encounter:</p>
<h3 id="the-retryable-hint">The <code>retryable</code> hint<a class="headerlink" href="#the-retryable-hint" title="Permanent link">&para;</a></h3>
<p>The <code>retryable</code> field appears <strong>only when it is <code>true</code></strong>. It is set for conditions
the server believes are transient: network errors, rate limiting, and temporary
service unavailability. When you see it, backing off and retrying is reasonable; when
it is absent, assume the request will keep failing until you change something.</p>
service unavailability. When you see it, you can back off and try the request again.
When it is absent, assume the request will keep failing until you change something.</p>
<div class="admonition example">
<p class="admonition-title">A retryable network error</p>
<div class="language-json highlight"><pre><span></span><code><span id="__span-1-1"><a id="__codelineno-1-1" name="__codelineno-1-1" href="#__codelineno-1-1"></a><span class="p">{</span>
@@ -2387,12 +2387,12 @@ but the defaults hold for the overwhelming majority of responses.</p>
<tr>
<td><code>FORBIDDEN</code></td>
<td>403</td>
<td>The action is not allowed. Raised by server-side gates (per-key restrictions on CDM or credential overrides) and by session IP binding.</td>
<td>The action is not allowed. Raised by server-side gates (per-key restrictions on CDM or credential overrides) and by remote session IP binding.</td>
</tr>
<tr>
<td><code>GEOFENCE</code></td>
<td>403</td>
<td>The content is not available in the applicable region.</td>
<td>The title is not available in the applicable region.</td>
</tr>
<tr>
<td><code>NOT_FOUND</code></td>
@@ -2417,7 +2417,7 @@ but the defaults hold for the overwhelming majority of responses.</p>
<tr>
<td><code>TRACK_NOT_FOUND</code></td>
<td>404</td>
<td>A referenced track ID is not present in the session.</td>
<td>A referenced track ID is not present in the remote session.</td>
</tr>
<tr>
<td><code>CONFLICT</code></td>
@@ -2483,7 +2483,7 @@ but the defaults hold for the overwhelming majority of responses.</p>
<p>If a code is ever emitted without a mapped status, the server falls back to
<code>500</code>. In practice every code above has an explicit mapping.</p>
</div>
<h2 id="how-errors-are-produced">How errors are produced<a class="headerlink" href="#how-errors-are-produced" title="Permanent link">&para;</a></h2>
<h2 id="how-the-server-produces-errors">How the server produces errors<a class="headerlink" href="#how-the-server-produces-errors" title="Permanent link">&para;</a></h2>
<p>Understanding where an error comes from helps interpret it.</p>
<p><strong>Explicitly raised errors.</strong> Most endpoints raise a structured <code>APIError</code> directly.
For example, a missing <code>service</code> field yields <code>INVALID_INPUT</code>, and a disallowed
@@ -2503,42 +2503,42 @@ The classification is keyword-based, checked in order:</p>
</thead>
<tbody>
<tr>
<td>auth, login, credential, unauthorized, forbidden, token</td>
<td><code>auth</code>, <code>login</code>, <code>credential</code>, <code>unauthorized</code>, <code>forbidden</code>, <code>token</code></td>
<td><code>AUTH_FAILED</code> (401)</td>
<td>no</td>
</tr>
<tr>
<td>connection, timeout, network, unreachable, socket, dns, resolve (or <code>ConnectionError</code>, <code>TimeoutError</code>, <code>URLError</code>, <code>SSLError</code>)</td>
<td><code>connection</code>, <code>timeout</code>, <code>network</code>, <code>unreachable</code>, <code>socket</code>, <code>dns</code>, <code>resolve</code> (or <code>ConnectionError</code>, <code>TimeoutError</code>, <code>URLError</code>, <code>SSLError</code>)</td>
<td><code>NETWORK_ERROR</code> (503)</td>
<td>yes</td>
</tr>
<tr>
<td>geofence, region, "not available in", territory</td>
<td><code>geofence</code>, <code>region</code>, <code>not available in</code>, <code>territory</code></td>
<td><code>GEOFENCE</code> (403)</td>
<td>no</td>
</tr>
<tr>
<td>"not found", 404, "does not exist", "invalid id"</td>
<td><code>not found</code>, <code>404</code>, <code>does not exist</code>, <code>invalid id</code></td>
<td><code>NOT_FOUND</code> (404)</td>
<td>no</td>
</tr>
<tr>
<td>rate limit, too many requests, 429, throttle</td>
<td><code>rate limit</code>, <code>too many requests</code>, <code>429</code>, <code>throttle</code></td>
<td><code>RATE_LIMITED</code> (429)</td>
<td>yes</td>
</tr>
<tr>
<td>drm, license, widevine, playready, decrypt</td>
<td><code>drm</code>, <code>license</code>, <code>widevine</code>, <code>playready</code>, <code>decrypt</code></td>
<td><code>DRM_ERROR</code> (502)</td>
<td>no</td>
</tr>
<tr>
<td>"service unavailable", 503, maintenance, "temporarily unavailable"</td>
<td><code>service unavailable</code>, <code>503</code>, <code>maintenance</code>, <code>temporarily unavailable</code></td>
<td><code>SERVICE_UNAVAILABLE</code> (503)</td>
<td>yes</td>
</tr>
<tr>
<td>invalid, malformed, validation (or <code>ValueError</code>, <code>ValidationError</code>)</td>
<td><code>invalid</code>, <code>malformed</code>, <code>validation</code> (or <code>ValueError</code>, <code>ValidationError</code>)</td>
<td><code>INVALID_INPUT</code> (400)</td>
<td>no</td>
</tr>
@@ -2564,8 +2564,8 @@ the server returns <code>INVALID_INPUT</code> (400) with the parse error in
</span><span id="__span-2-7"><a id="__codelineno-2-7" name="__codelineno-2-7" href="#__codelineno-2-7"></a><span class="p">}</span>
</span></code></pre></div>
<h2 id="the-authentication-error-exception">The authentication error exception<a class="headerlink" href="#the-authentication-error-exception" title="Permanent link">&para;</a></h2>
<p>There is one place where the response does <strong>not</strong> follow the standard shape: the API
key authentication middleware. When you send a request to a protected endpoint
<p>There is one place where the response does <strong>not</strong> follow the standard shape: the
API key authentication middleware. When you send a request to a protected endpoint
without a valid <code>X-Secret-Key</code> header, the middleware short-circuits with a legacy
body that uses an integer <code>status</code> and no <code>error_code</code> or <code>timestamp</code>.</p>
<div class="tabbed-set tabbed-alternate" data-tabs="1:2"><input checked="checked" id="__tabbed_1_1" name="__tabbed_1" type="radio" /><input id="__tabbed_1_2" name="__tabbed_1" type="radio" /><div class="tabbed-labels"><label for="__tabbed_1_1">Missing key</label><label for="__tabbed_1_2">Invalid key</label></div>
@@ -2582,7 +2582,7 @@ body that uses an integer <code>status</code> and no <code>error_code</code> or
</div>
<div class="admonition warning">
<p class="admonition-title">Handle both shapes</p>
<p>A robust client must expect two error formats: the standard structured error
<p>A client must expect two error formats: the standard structured error
(string <code>status</code> of <code>"error"</code>, with <code>error_code</code>), and this authentication error
(integer <code>status</code>, no <code>error_code</code>). The <code>GET /api/health</code> endpoint is exempt
from authentication, so it never returns the key-error shape.</p>
@@ -2593,12 +2593,12 @@ from authentication, so it never returns the key-error shape.</p>
deleting a history entry.</p>
<div class="admonition warning">
<p class="admonition-title">Do not parse <code>204</code> bodies</p>
<p>A <code>204</code> response has no body. Clients must check the status code before attempting
to decode JSON, or the decode will fail on an empty string.</p>
<p>A <code>204</code> response has no body. Clients must examine the status code before they
decode JSON, or the decode will fail on an empty string.</p>
</div>
<h2 id="debug-information">Debug information<a class="headerlink" href="#debug-information" title="Permanent link">&para;</a></h2>
<p>By default, error responses never include tracebacks or internal diagnostics. If the
server was started with the <code>--debug-api</code> flag, every structured error gains a
<p>By default, error responses never include tracebacks or internal diagnostics. If you
start the server with the <code>--debug-api</code> flag, every structured error gains a
<code>debug_info</code> object:</p>
<div class="language-json highlight"><span class="filename">Error with --debug-api enabled</span><pre><span></span><code><span id="__span-5-1"><a id="__codelineno-5-1" name="__codelineno-5-1" href="#__codelineno-5-1"></a><span class="p">{</span>
</span><span id="__span-5-2"><a id="__codelineno-5-2" name="__codelineno-5-2" href="#__codelineno-5-2"></a><span class="w"> </span><span class="nt">"status"</span><span class="p">:</span><span class="w"> </span><span class="s2">"error"</span><span class="p">,</span>
@@ -2613,7 +2613,7 @@ server was started with the <code>--debug-api</code> flag, every structured erro
</span></code></pre></div>
<p>The <code>debug_info</code> object always carries <code>exception_type</code> and, for exception-derived
errors, a formatted <code>traceback</code>. Some endpoints add extra diagnostics such as
captured worker stderr.</p>
captured job worker stderr.</p>
<div class="admonition warning">
<p class="admonition-title">Developer use only</p>
<p><code>--debug-api</code> exposes internal tracebacks in responses. Enable it only for local
@@ -2622,16 +2622,16 @@ debugging, never on a server reachable by untrusted clients.</p>
<h2 id="client-handling-recommendations">Client handling recommendations<a class="headerlink" href="#client-handling-recommendations" title="Permanent link">&para;</a></h2>
<p>For anyone writing a consumer of the API, a durable strategy is:</p>
<ol>
<li><strong>Read the HTTP status first.</strong> It tells you the broad class of outcome and lets
you handle the <code>204</code>/no-body and authentication-error cases before touching the
<li><strong>Read the HTTP status first.</strong> It tells you the broad class of outcome, and it
lets you find the <code>204</code>/no-body and authentication-error cases before you read the
body.</li>
<li><strong>Branch on <code>error_code</code>.</strong> For structured errors, this is the stable identifier
to switch on. Do not match on <code>message</code>.</li>
<li><strong>Respect <code>retryable</code>.</strong> When it is <code>true</code>, back off and retry; the reference
client retries <code>429</code>, <code>500</code>, <code>502</code>, <code>503</code>, and <code>504</code> with exponential backoff. When
absent, fix the request rather than repeating it.</li>
<li><strong>Fall back gracefully.</strong> Unrecognised codes should be surfaced using the
<code>message</code> plus the HTTP status.</li>
to switch on. Do not use <code>message</code> as the identifier.</li>
<li><strong>Respect <code>retryable</code>.</strong> When it is <code>true</code>, back off and try the request again. The
reference client retries <code>429</code>, <code>500</code>, <code>502</code>, <code>503</code>, and <code>504</code> with exponential
backoff. When absent, fix the request rather than repeating it.</li>
<li><strong>Fall back gracefully.</strong> For an unrecognised code, show the <code>message</code> and the
HTTP status.</li>
</ol>
<div class="admonition note">
<p class="admonition-title">Reference consumer</p>
+24 -24
View File
@@ -2067,14 +2067,14 @@
<h1 id="rest-api-overview">REST API Overview<a class="headerlink" href="#rest-api-overview" title="Permanent link">&para;</a></h1>
<p>unshackle ships with a built-in HTTP server. Alongside the command-line tool, you
can run <code>unshackle serve</code> and drive the same download engine over a REST API:
can operate <code>unshackle serve</code> and drive the same download engine over a REST API:
searching services, listing titles and tracks, queueing downloads, watching live
progress, and browsing history, all as JSON over HTTP.</p>
<p>This page explains what the API is for, how to start the server, where it listens,
and how the endpoints are organised. Later pages cover each area in depth.</p>
<div class="admonition info">
<p class="admonition-title">Who this section is for</p>
<p>The REST API is aimed at people building automation, dashboards, or remote
<p>The REST API is for people who make automation, dashboards, or remote
clients on top of unshackle. If you only download the occasional title, the
<a href="../../guide/cli-reference/">command-line tool</a> does everything the API does
and is simpler to use. Reach for the API when you want a long-running service, a
@@ -2084,20 +2084,20 @@ job queue, or a web front-end.</p>
<p>The server exposes the same capabilities as the <code>dl</code> command, but as discrete HTTP
calls you can script against:</p>
<ul>
<li><strong>Discovery</strong>: list the services available on the server, run a service's search,
<li><strong>Discovery</strong>: show the services available on the server, use a service's search,
and enumerate the titles and tracks behind a title ID.</li>
<li><strong>Downloading</strong>: submit a download as a background <em>job</em>, then follow its status and
live progress (by polling, or by subscribing to its Server-Sent Events stream),
cancel it, retry it, or bump it to the front of the queue.</li>
<li><strong>History and housekeeping</strong>: read the log of finished jobs, inspect the server's
effective (redacted) configuration, check external tool versions, and clear caches
effective (redacted) configuration, examine external tool versions, and clear caches
or temp files.</li>
<li><strong>Remote-download sessions</strong>: a thin local client can authenticate against a
service <em>on the server</em>, then pull titles, tracks, and segment info back, and proxy
DRM licensing. This is how one machine with the credentials and CDMs can serve
downloads to another. See <a href="remote-sessions/">Remote Sessions</a>.</li>
</ul>
<p>Because every download runs as a queued job in a separate worker process, the API is
<p>Because every download runs as a queued job in a separate job worker process, the API is
well suited to running unattended: submit work, come back later, and read the result.</p>
<h2 id="running-the-server">Running the server<a class="headerlink" href="#running-the-server" title="Permanent link">&para;</a></h2>
<p>Start the server with the <code>serve</code> command:</p>
@@ -2181,24 +2181,24 @@ how the <code>X-Secret-Key</code> header works.</p>
<div class="admonition danger">
<p class="admonition-title">Binding to a public interface</p>
<p><code>--no-key</code> disables authentication completely. Anyone who can reach the port can
search, download, and read your configuration. Only combine it with a host like
find titles, download them, and read your configuration. Only combine it with a host like
<code>127.0.0.1</code>, or keep the default key-based auth when binding to <code>0.0.0.0</code>. Put a
TLS-terminating reverse proxy (Caddy, nginx) in front for anything internet-facing.</p>
</div>
<h3 id="server-modes">Server modes<a class="headerlink" href="#server-modes" title="Permanent link">&para;</a></h3>
<p>The set of routes that is actually mounted depends on how you start the server:</p>
<p>The routes the server mounts depend on how you start the server:</p>
<div class="tabbed-set tabbed-alternate" data-tabs="1:3"><input checked="checked" id="__tabbed_1_1" name="__tabbed_1" type="radio" /><input id="__tabbed_1_2" name="__tabbed_1" type="radio" /><input id="__tabbed_1_3" name="__tabbed_1" type="radio" /><div class="tabbed-labels"><label for="__tabbed_1_1">Default (CDM + REST)</label><label for="__tabbed_1_2"><code>--api-only</code></label><label for="__tabbed_1_3"><code>--remote-only</code></label></div>
<div class="tabbed-content">
<div class="tabbed-block">
<p>Both the pywidevine/pyplayready CDM endpoints and the full REST API are mounted.
This is the mode used when you run <code>unshackle serve</code> with no mode flags.</p>
<p>The server mounts both the pywidevine/pyplayready CDM endpoints and the full REST API.
This is the mode you get when you operate <code>unshackle serve</code> with no mode flags.</p>
</div>
<div class="tabbed-block">
<p>Only the REST API is mounted; the CDM HTTP endpoints are omitted. Authentication
uses the single configured API key (or none, with <code>--no-key</code>).</p>
<p>The server mounts only the REST API. It does not mount the CDM HTTP endpoints.
Authentication uses the single configured API key (or none, with <code>--no-key</code>).</p>
</div>
<div class="tabbed-block">
<p>Only the remote-session subset of routes is exposed (health, services, search,
<p>The server mounts only the remote-session subset of routes (health, services, search,
and everything under <code>/api/session/*</code>). This mode implies <code>--api-only</code>, and the
interactive Swagger UI is <strong>not</strong> mounted.</p>
</div>
@@ -2214,12 +2214,12 @@ health check is <code>GET http://127.0.0.1:8786/api/health</code>.</p>
<div class="admonition tip">
<p class="admonition-title">Interactive API docs</p>
<p>Unless you started the server with <code>--remote-only</code>, an interactive Swagger UI is
available at <a href="http://127.0.0.1:8786/api/docs/"><code>/api/docs/</code></a>. It is generated from
the same route table the server actually serves, so it stays in sync with the
running build. Note that in <code>--api-only</code> mode the docs page is itself behind the
API key; only <code>/api/health</code> is exempt from authentication.</p>
available at <a href="http://127.0.0.1:8786/api/docs/"><code>/api/docs/</code></a>. The server generates
it from the same route table it serves, so it always matches the version you
operate. Note that in <code>--api-only</code> mode the docs page is itself behind the
API key. Only <code>/api/health</code> is exempt from authentication.</p>
</div>
<p>A quick smoke test that needs no key:</p>
<p>A quick smoke test that needs no API key:</p>
<div class="language-shell highlight"><span class="filename">Confirm the server is up</span><pre><span></span><code><span id="__span-3-1"><a id="__codelineno-3-1" name="__codelineno-3-1" href="#__codelineno-3-1"></a>curl<span class="w"> </span>http://127.0.0.1:8786/api/health
</span></code></pre></div>
<div class="language-json highlight"><span class="filename">Response</span><pre><span></span><code><span id="__span-4-1"><a id="__codelineno-4-1" name="__codelineno-4-1" href="#__codelineno-4-1"></a><span class="p">{</span>
@@ -2234,7 +2234,7 @@ API key; only <code>/api/health</code> is exempt from authentication.</p>
</span></code></pre></div>
<h2 id="endpoint-map">Endpoint map<a class="headerlink" href="#endpoint-map" title="Permanent link">&para;</a></h2>
<p>Every REST path uses JSON request and response bodies and lives under <code>/api</code>. The
table below is a map of the surface; the <a href="endpoints/">Endpoints</a> page documents each
table below is a map of the surface. The <a href="endpoints/">Endpoints</a> page gives each
request and response in full.</p>
<h3 id="discovery">Discovery<a class="headerlink" href="#discovery" title="Permanent link">&para;</a></h3>
<table>
@@ -2249,7 +2249,7 @@ request and response in full.</p>
<tr>
<td><code>GET</code></td>
<td><code>/api/health</code></td>
<td>Liveness and version check. Never requires a key.</td>
<td>Liveness and version check. Never requires an API key.</td>
</tr>
<tr>
<td><code>GET</code></td>
@@ -2407,7 +2407,7 @@ request and response in full.</p>
<tr>
<td><code>GET</code></td>
<td><code>/api/session/{session_id}</code></td>
<td>Session info and time-to-live.</td>
<td>Remote session info and time-to-live.</td>
</tr>
<tr>
<td><code>DELETE</code></td>
@@ -2460,13 +2460,13 @@ every request except <code>/api/health</code>:</p>
</span><span id="__span-5-3"><a id="__codelineno-5-3" name="__codelineno-5-3" href="#__codelineno-5-3"></a><span class="w"> </span>-H<span class="w"> </span><span class="s2">"Content-Type: application/json"</span><span class="w"> </span><span class="se">\</span>
</span><span id="__span-5-4"><a id="__codelineno-5-4" name="__codelineno-5-4" href="#__codelineno-5-4"></a><span class="w"> </span>-d<span class="w"> </span><span class="s1">'{"service": "EXAMPLE", "query": "example"}'</span>
</span></code></pre></div>
<p>A missing or unknown key returns <code>401</code>. The full model, including per-key service
allowlists, is covered in <a href="authentication/">Authentication</a>.</p>
<p>A missing or unknown API key returns <code>401</code>. <a href="authentication/">Authentication</a> gives
the full model, including per-key service allowlists.</p>
<h2 id="where-to-go-next">Where to go next<a class="headerlink" href="#where-to-go-next" title="Permanent link">&para;</a></h2>
<div class="grid cards">
<ul>
<li><strong><a href="quickstart/">Quick Start</a></strong>: start the server, set a key, and run your first
end-to-end download over the API.</li>
<li><strong><a href="quickstart/">Quick Start</a></strong>: start the server, set an API key, and operate your
first end-to-end download over the API.</li>
<li><strong><a href="authentication/">Authentication</a></strong>: the <code>X-Secret-Key</code> header, <code>--no-key</code>,
per-key service allowlists, and CORS.</li>
<li><strong><a href="endpoints/">Endpoints</a></strong>: every request body, query parameter, and response
+92 -89
View File
@@ -1776,15 +1776,15 @@
</li>
<li class="md-nav__item">
<a href="#under-the-hood-the-workermanager-model" class="md-nav__link">
<a href="#under-the-hood-the-job-worker-and-manager-model" class="md-nav__link">
<span class="md-ellipsis">
Under the hood: the worker/manager model
Under the hood: the job worker and manager model
</span>
</a>
<nav class="md-nav" aria-label="Under the hood: the worker/manager model">
<nav class="md-nav" aria-label="Under the hood: the job worker and manager model">
<ul class="md-nav__list">
<li class="md-nav__item">
@@ -1821,10 +1821,10 @@
</li>
<li class="md-nav__item">
<a href="#how-cancellation-actually-works" class="md-nav__link">
<a href="#how-cancellation-works" class="md-nav__link">
<span class="md-ellipsis">
How cancellation actually works
How cancellation works
</span>
</a>
@@ -2331,15 +2331,15 @@
</li>
<li class="md-nav__item">
<a href="#under-the-hood-the-workermanager-model" class="md-nav__link">
<a href="#under-the-hood-the-job-worker-and-manager-model" class="md-nav__link">
<span class="md-ellipsis">
Under the hood: the worker/manager model
Under the hood: the job worker and manager model
</span>
</a>
<nav class="md-nav" aria-label="Under the hood: the worker/manager model">
<nav class="md-nav" aria-label="Under the hood: the job worker and manager model">
<ul class="md-nav__list">
<li class="md-nav__item">
@@ -2376,10 +2376,10 @@
</li>
<li class="md-nav__item">
<a href="#how-cancellation-actually-works" class="md-nav__link">
<a href="#how-cancellation-works" class="md-nav__link">
<span class="md-ellipsis">
How cancellation actually works
How cancellation works
</span>
</a>
@@ -2435,22 +2435,22 @@
<h1 id="download-job-lifecycle">Download Job Lifecycle<a class="headerlink" href="#download-job-lifecycle" title="Permanent link">&para;</a></h1>
<p>When you submit a download over the REST API, the server does not block the request
until the file is written. Instead it creates a <strong>job</strong>, hands it back to you with an
until it writes the file. Instead it creates a <strong>job</strong>, hands it back to you with an
ID, and works through it in the background. Everything you do afterwards (watching
progress, cancelling, retrying, reading the result) is done by referring to that job
ID.</p>
<p>This page explains the states a job moves through, how live progress is reported, how
to cancel or retry, and, in clearly marked developer sections, how the worker and
manager behind the queue actually run the download.</p>
<p>This page explains the states a job moves through, how the server reports live
progress, and how to cancel or retry. Clearly marked developer sections tell you how
the job worker and the manager behind the queue operate the download.</p>
<div class="admonition info">
<p class="admonition-title">Who this page is for</p>
<p>The first half is for anyone driving the API: the states, the fields you can read,
and the calls that move a job around. The <strong>Under the hood</strong> sections near the end
describe the internal worker/manager model and are aimed at contributors and people
debugging the server. You do not need them to use the API.</p>
give the internal job worker and manager model, for contributors and for people
who debug the server. You do not need them to use the API.</p>
</div>
<p>For the full request and response shape of each endpoint, see
<a href="../endpoints/">Endpoints</a>. For how errors are reported, see
<a href="../endpoints/">Endpoints</a>. For how the server reports errors, see
<a href="../errors/">Error Responses</a>.</p>
<h2 id="the-five-states">The five states<a class="headerlink" href="#the-five-states" title="Permanent link">&para;</a></h2>
<p>A job is always in exactly one of five states, exposed as the <code>status</code> field:</p>
@@ -2464,11 +2464,11 @@ debugging the server. You do not need them to use the API.</p>
<tbody>
<tr>
<td><code>queued</code></td>
<td>Created and waiting for a free worker. No download has started yet.</td>
<td>Created and waiting for a free job worker. No download has started yet.</td>
</tr>
<tr>
<td><code>downloading</code></td>
<td>A worker has picked the job up and the download is in progress.</td>
<td>A job worker has picked the job up and the download is in progress.</td>
</tr>
<tr>
<td><code>completed</code></td>
@@ -2485,8 +2485,8 @@ debugging the server. You do not need them to use the API.</p>
</tbody>
</table>
<p>The last three (<code>completed</code>, <code>failed</code>, and <code>cancelled</code>) are <strong>terminal</strong>: once a
job reaches one of them it never changes state again. Terminal jobs can be removed,
retried, or read from history, but they will not resume.</p>
job reaches one of them it never changes state again. You can remove, retry, or read
terminal jobs from history, but they will not resume.</p>
<h2 id="how-a-job-moves-between-states">How a job moves between states<a class="headerlink" href="#how-a-job-moves-between-states" title="Permanent link">&para;</a></h2>
<pre class="mermaid"><code>stateDiagram-v2
[*] --&gt; queued: POST /api/download
@@ -2506,7 +2506,7 @@ retried, or read from history, but they will not resume.</p>
<ul>
<li><strong><code>queued</code> → <code>downloading</code></strong> happens automatically. As soon as one of the server's
workers is free, it takes the next job off the queue and starts it. You do not
trigger this; you only observe it.</li>
trigger this. You only observe it.</li>
<li><strong><code>downloading</code> → <code>completed</code></strong> is the happy path: the download engine wrote all the
requested files and reported success.</li>
<li><strong><code>downloading</code> → <code>failed</code></strong> is any error during the download: a service failure, a
@@ -2520,11 +2520,11 @@ retried, or read from history, but they will not resume.</p>
<div class="admonition note">
<p class="admonition-title">Priority does not change status</p>
<p>Moving a job to the front of the queue with the priority endpoint reorders <em>when</em>
it will start, but it stays <code>queued</code> until a worker actually picks it up. It is not
it will start, but it stays <code>queued</code> until a job worker picks it up. It is not
a state change.</p>
</div>
<h2 id="creating-a-job">Creating a job<a class="headerlink" href="#creating-a-job" title="Permanent link">&para;</a></h2>
<p>A download is queued with <code>POST /api/download</code>. The request needs at least a <code>service</code>
<p>You queue a download with <code>POST /api/download</code>. The request needs at least a <code>service</code>
and a <code>title_id</code>, plus any download options you want to set. On success the server
replies with <strong><code>202 Accepted</code></strong> and the new job's identity:</p>
<div class="language-shell highlight"><span class="filename">Queue a download</span><pre><span></span><code><span id="__span-0-1"><a id="__codelineno-0-1" name="__codelineno-0-1" href="#__codelineno-0-1"></a>curl<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span>http://127.0.0.1:8786/api/download<span class="w"> </span><span class="se">\</span>
@@ -2543,14 +2543,15 @@ replies with <strong><code>202 Accepted</code></strong> and the new job's identi
</span><span id="__span-1-4"><a id="__codelineno-1-4" name="__codelineno-1-4" href="#__codelineno-1-4"></a><span class="w"> </span><span class="nt">"created_time"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2026-07-03T12:00:00.000000"</span>
</span><span id="__span-1-5"><a id="__codelineno-1-5" name="__codelineno-1-5" href="#__codelineno-1-5"></a><span class="p">}</span>
</span></code></pre></div>
<p>The job starts in <code>queued</code>. Workers auto-start on the first job submitted, so there is
nothing else to switch on. Keep the <code>job_id</code>; every later call is addressed to it.</p>
<p>The job starts in <code>queued</code>. The job workers auto-start on the first job you submit, so
you switch nothing else on. Keep the <code>job_id</code>. Every later call uses it.</p>
<div class="admonition tip">
<p class="admonition-title">The full parameter set</p>
<p><code>POST /api/download</code> accepts the same download options as the <code>dl</code> command
(quality, codecs, language selection, <code>wanted</code> episode ranges, and many more), with
server-side defaults filling in anything you omit. The complete list, their
defaults, and validation rules are documented on the <a href="../endpoints/">Endpoints</a> page.</p>
server-side defaults filling in anything you do not write. The
<a href="../endpoints/">Endpoints</a> page gives the complete list, their defaults, and the
validation rules.</p>
</div>
<h2 id="reading-a-jobs-status-and-progress">Reading a job's status and progress<a class="headerlink" href="#reading-a-jobs-status-and-progress" title="Permanent link">&para;</a></h2>
<p>There are two ways to look at jobs.</p>
@@ -2571,7 +2572,7 @@ single-job endpoint for every job.</p>
<div class="language-shell highlight"><pre><span></span><code><span id="__span-3-1"><a id="__codelineno-3-1" name="__codelineno-3-1" href="#__codelineno-3-1"></a>curl<span class="w"> </span><span class="s2">"http://127.0.0.1:8786/api/download/jobs?status=downloading&amp;sort_by=progress&amp;sort_order=desc"</span><span class="w"> </span><span class="se">\</span>
</span><span id="__span-3-2"><a id="__codelineno-3-2" name="__codelineno-3-2" href="#__codelineno-3-2"></a><span class="w"> </span>-H<span class="w"> </span><span class="s2">"X-Secret-Key: your-api-secret"</span>
</span></code></pre></div>
<p>The list can be filtered by <code>status</code> and <code>service</code>, and ordered with <code>sort_by</code>
<p>You can filter the list by <code>status</code> and <code>service</code>, and order it with <code>sort_by</code>
(<code>created_time</code>, <code>started_time</code>, <code>completed_time</code>, <code>progress</code>, <code>status</code>, or
<code>service</code>) and <code>sort_order</code> (<code>asc</code> / <code>desc</code>). See <a href="../endpoints/">Endpoints</a> for the
query-parameter reference.</p>
@@ -2670,7 +2671,7 @@ full detail:</p>
<div class="admonition tip">
<p class="admonition-title">Or subscribe instead of polling</p>
<p><code>GET /api/download/jobs/{job_id}/events</code> pushes the same job object as Server-Sent
Events, and closes the stream when the job ends. See
Events, and closes the event stream when the job ends. See
<a href="../endpoints/#get-apidownloadjobsjob_idevents">Endpoints</a>.</p>
</div>
<div class="admonition note">
@@ -2692,13 +2693,13 @@ batch as a whole. To derive true overall completion, compare the growing
<h2 id="terminal-states-in-detail">Terminal states in detail<a class="headerlink" href="#terminal-states-in-detail" title="Permanent link">&para;</a></h2>
<h3 id="completed">Completed<a class="headerlink" href="#completed" title="Permanent link">&para;</a></h3>
<p>A <code>completed</code> job has its <code>output_files</code> populated with the paths of the muxed
results, and <code>progress</code> is <code>100</code>. This is the state you are waiting for.</p>
results, and <code>progress</code> is <code>100</code>. This is the state you want.</p>
<h3 id="failed">Failed<a class="headerlink" href="#failed" title="Permanent link">&para;</a></h3>
<p>A <code>failed</code> job carries the error. <code>error_message</code> is the short human message,
<code>error_code</code> is one of the API's structured error codes (for example <code>NETWORK_ERROR</code>,
<code>DRM_ERROR</code>, <code>AUTH_FAILED</code>, or <code>DOWNLOAD_ERROR</code>), and <code>error_details</code> gives more
context. When the server was started with <code>--debug-api</code>, <code>error_traceback</code> and
<code>worker_stderr</code> are also filled in for diagnosis.</p>
context. When you start the server with <code>--debug-api</code>, <code>error_traceback</code> and
<code>worker_stderr</code> are also filled in, to help you find the cause.</p>
<div class="admonition tip">
<p class="admonition-title">Secrets are scrubbed from error text</p>
<p>A service can echo a credential or a proxy URL into its output. The server masks
@@ -2708,8 +2709,8 @@ and <code>worker_stderr</code> before returning them, so it is safe to log these
<p>The error-code catalogue and how each maps to an HTTP status is on the
<a href="../errors/">Error Responses</a> page.</p>
<h3 id="cancelled">Cancelled<a class="headerlink" href="#cancelled" title="Permanent link">&para;</a></h3>
<p>A <code>cancelled</code> job was stopped on request before finishing. It may have partial output,
but it is not considered complete. See below.</p>
<p>The server stopped a <code>cancelled</code> job on request, before it finished. It may have
partial output, but it is not considered complete. See below.</p>
<h2 id="cancelling-a-job">Cancelling a job<a class="headerlink" href="#cancelling-a-job" title="Permanent link">&para;</a></h2>
<p>Send <code>DELETE /api/download/jobs/{job_id}</code>. The behaviour depends on the job's current
state, and this is important because the response differs:</p>
@@ -2745,7 +2746,7 @@ both stops in-flight work and tidies up afterwards.</p>
<div class="admonition warning">
<p class="admonition-title">Do not parse the body of a 204</p>
<p>Removing a terminal job returns <code>204 No Content</code> with an empty body. A client must
not try to decode JSON from it; check the status code first.</p>
not try to decode JSON from it. Examine the status code first.</p>
</div>
<div class="language-shell highlight"><span class="filename">Cancel a running job</span><pre><span></span><code><span id="__span-5-1"><a id="__codelineno-5-1" name="__codelineno-5-1" href="#__codelineno-5-1"></a>curl<span class="w"> </span>-X<span class="w"> </span>DELETE<span class="w"> </span>http://127.0.0.1:8786/api/download/jobs/b1e6c7de-6d0a-4f2f-9d1e-3a2b4c5d6e7f<span class="w"> </span><span class="se">\</span>
</span><span id="__span-5-2"><a id="__codelineno-5-2" name="__codelineno-5-2" href="#__codelineno-5-2"></a><span class="w"> </span>-H<span class="w"> </span><span class="s2">"X-Secret-Key: your-api-secret"</span>
@@ -2754,14 +2755,15 @@ not try to decode JSON from it; check the status code first.</p>
which returns <code>{"removed": &lt;count&gt;}</code>.</p>
<h2 id="retrying-a-job">Retrying a job<a class="headerlink" href="#retrying-a-job" title="Permanent link">&para;</a></h2>
<p><code>POST /api/download/jobs/{job_id}/retry</code> re-runs a finished job by creating a <strong>new</strong>
job with the same service, title, and parameters. The original job is untouched and
stays in its terminal state; the response gives you the new job's ID with <code>202</code>.</p>
job with the same service, title, and parameters. The retry does not change the
original job. It stays in its terminal state, and the response gives you the new job's
ID with <code>202</code>.</p>
<ul>
<li>Only terminal jobs can be retried. Retrying a <code>queued</code> or <code>downloading</code> job returns
<li>You can retry only terminal jobs. A retry of a <code>queued</code> or <code>downloading</code> job returns
<code>409</code> with error code <code>CONFLICT</code>.</li>
<li>A missing job returns <code>404</code> (<code>JOB_NOT_FOUND</code>).</li>
<li>The service allowlist and any download gates are re-checked for the new job, exactly
as they were for the original submission, so a retry can be rejected with
<li>The server examines the service allowlist and any download gates again for the new
job, exactly as for the original submission. Thus the server can reject a retry with
<code>INVALID_SERVICE</code> or <code>FORBIDDEN</code> if permissions changed.</li>
</ul>
<div class="language-json highlight"><span class="filename">202 Accepted, a fresh job</span><pre><span></span><code><span id="__span-6-1"><a id="__codelineno-6-1" name="__codelineno-6-1" href="#__codelineno-6-1"></a><span class="p">{</span>
@@ -2772,35 +2774,36 @@ stays in its terminal state; the response gives you the new job's ID with <code>
</span></code></pre></div>
<h2 id="prioritising-a-job">Prioritising a job<a class="headerlink" href="#prioritising-a-job" title="Permanent link">&para;</a></h2>
<p><code>POST /api/download/jobs/{job_id}/priority</code> moves a <strong>queued</strong> job to the front of the
line, so it will be the next one a free worker picks up.</p>
line, so it will be the next one a free job worker picks up.</p>
<ul>
<li>The job must be <code>queued</code>. Prioritising a job in any other state returns <code>409</code>
(<code>CONFLICT</code>); a missing job returns <code>404</code>.</li>
(<code>CONFLICT</code>). A missing job returns <code>404</code>.</li>
<li>On success the response is <code>{"job_id": "&lt;id&gt;", "position": "front"}</code>.</li>
</ul>
<p>This only reorders the queue; the job's status stays <code>queued</code> until a worker starts
it.</p>
<p>This only reorders the queue. The job's status stays <code>queued</code> until a job worker
starts it.</p>
<h2 id="history-and-retention">History and retention<a class="headerlink" href="#history-and-retention" title="Permanent link">&para;</a></h2>
<p>Every job that reaches a terminal state is also written once to a persistent history
log, so it survives even after the job is removed from the live list.</p>
<p>The server also writes every job that reaches a terminal state once to a persistent
history log. The job survives there even after the server removes it from the live
list.</p>
<ul>
<li>Read it with <code>GET /api/history</code> (newest first; supports <code>limit</code> and <code>service</code>
<li>Read it with <code>GET /api/history</code> (newest first, with <code>limit</code> and <code>service</code>
filters).</li>
<li>Delete a single entry with <code>DELETE /api/history/{job_id}</code> (returns <code>204</code>).</li>
</ul>
<p>The live in-memory jobs are separate from this on-disk history. A background cleanup
task periodically drops old terminal jobs from the live list based on a retention
period; the history file keeps them for later reference. See <a href="../endpoints/">Endpoints</a>
period. The history file keeps them for later reference. See <a href="../endpoints/">Endpoints</a>
for the history response shape.</p>
<div class="admonition note">
<p class="admonition-title">Why the history file lives under the cache directory</p>
<p><code>api_history.jsonl</code> is placed under the cache directory on purpose, so it stays out
of the config/data tree. It is disposable runtime state, not user configuration or
<p>unshackle puts <code>api_history.jsonl</code> under the cache directory on purpose, so it
stays out of the config/data tree. It is disposable runtime state, not user configuration or
curated data, which is exactly why clearing the cache is a safe operation against
it.</p>
</div>
<hr />
<h2 id="under-the-hood-the-workermanager-model">Under the hood: the worker/manager model<a class="headerlink" href="#under-the-hood-the-workermanager-model" title="Permanent link">&para;</a></h2>
<h2 id="under-the-hood-the-job-worker-and-manager-model">Under the hood: the job worker and manager model<a class="headerlink" href="#under-the-hood-the-job-worker-and-manager-model" title="Permanent link">&para;</a></h2>
<div class="admonition info">
<p class="admonition-title">Developer section</p>
<p>Everything below describes server internals. It is useful for contributors and for
@@ -2809,19 +2812,19 @@ anyone debugging the server, but it is not needed to <em>use</em> the API.</p>
<h3 id="the-manager-and-its-queue">The manager and its queue<a class="headerlink" href="#the-manager-and-its-queue" title="Permanent link">&para;</a></h3>
<p>A single process-wide <strong>download queue manager</strong> owns all jobs. It holds them in
memory keyed by ID, backs the wait list with a FIFO queue, and keeps a separate
priority deque for jobs that have been bumped to the front. On the first job, it starts
a fixed pool of worker tasks plus one cleanup task; starting is idempotent, so later
jobs reuse the same pool.</p>
priority deque for the jobs it bumped to the front. On the first job, it starts
a fixed pool of job worker tasks plus one cleanup task. The start is idempotent, so
later jobs reuse the same pool.</p>
<ul>
<li><strong>Concurrency</strong> is capped by <code>max_concurrent_downloads</code> (default <strong>2</strong>). That many
jobs can be in <code>downloading</code> at once; the rest wait in <code>queued</code>.</li>
<li><strong>Retention</strong> is governed by <code>job_retention_hours</code> (default <strong>24</strong>). An hourly
<li><strong>Concurrency</strong>: <code>max_concurrent_downloads</code> (default <strong>2</strong>) caps it. That many
jobs can be in <code>downloading</code> at once. The rest wait in <code>queued</code>.</li>
<li><strong>Retention</strong>: <code>job_retention_hours</code> (default <strong>24</strong>) controls it. An hourly
cleanup task removes terminal jobs whose completion time (or, if never started,
creation time) is older than the retention window.</li>
</ul>
<div class="admonition note">
<p class="admonition-title">Two concurrency knobs that are easy to conflate</p>
<p><code>max_concurrent_downloads</code> caps how many separate download <strong>jobs</strong> run at once.
<p><code>max_concurrent_downloads</code> caps how many separate download <strong>jobs</strong> operate at once.
The separate <code>serve.downloads</code> setting controls how many <strong>tracks</strong> download in
parallel <em>within</em> a single job. They are independent, and raising one does not affect
the other.</p>
@@ -2831,53 +2834,53 @@ the other.</p>
<p>The manager reads these as top-level config attributes
(<code>config.max_concurrent_downloads</code> and <code>config.download_job_retention_hours</code>).
Those keys are not part of the standard config schema, so in practice the built-in
defaults of <strong>2</strong> and <strong>24</strong> apply. The live values are surfaced by
<a href="../endpoints/"><code>GET /api/config</code></a>. The related <code>serve.history_limit</code> (default
defaults of <strong>2</strong> and <strong>24</strong> apply. <a href="../endpoints/"><code>GET /api/config</code></a> gives the
live values. The related <code>serve.history_limit</code> (default
<strong>100</strong>) instead controls how many entries the on-disk history file keeps.</p>
</div>
<p>Each worker loop drains the priority deque first, then the FIFO queue. A job that was
promoted has its stale FIFO copy skipped so it does not run twice. When a worker takes
a job it flips it to <code>downloading</code>, stamps <code>started_time</code>, runs it, and on finish
stamps <code>completed_time</code> and records history.</p>
<p>Each job worker loop drains the priority deque first, then the FIFO queue. The loop
skips the stale FIFO copy of a promoted job, so it does not do the job twice. When
a job worker takes a job it flips it to <code>downloading</code>, stamps <code>started_time</code>, does the
download, and on finish stamps <code>completed_time</code> and records history.</p>
<h3 id="each-job-runs-in-its-own-subprocess">Each job runs in its own subprocess<a class="headerlink" href="#each-job-runs-in-its-own-subprocess" title="Permanent link">&para;</a></h3>
<p>The manager does <strong>not</strong> run the download in the server's event loop or even in the
<p>The manager does <strong>not</strong> operate the download in the server's event loop or even in the
server process. For each job it spawns a separate Python subprocess:</p>
<div class="language-text highlight"><pre><span></span><code><span id="__span-7-1"><a id="__codelineno-7-1" name="__codelineno-7-1" href="#__codelineno-7-1"></a>python -m unshackle.core.api.download_worker &lt;payload.json&gt; &lt;result.json&gt; &lt;progress.json&gt;
</span></code></pre></div>
<p>Three temporary files bridge the parent and the worker:</p>
<p>Three temporary files bridge the parent and the job worker:</p>
<ul>
<li><strong><code>payload.json</code></strong>: written by the manager before launch; contains the job ID,
<li><strong><code>payload.json</code></strong>: the manager writes it before launch. It contains the job ID,
service, title ID, and merged parameters.</li>
<li><strong><code>progress.json</code></strong>: written continuously by the worker as the download proceeds.</li>
<li><strong><code>result.json</code></strong>: written by the worker at the end.</li>
<li><strong><code>progress.json</code></strong>: the job worker writes it continuously as the download proceeds.</li>
<li><strong><code>result.json</code></strong>: the job worker writes it at the end.</li>
</ul>
<p>Running each download out-of-process isolates the server from crashes, memory growth,
and blocking work inside the download engine, and makes hard cancellation (killing the
process) reliable.</p>
<p>The worker's exit codes are <code>0</code> for success, <code>1</code> for a download error, and <code>2</code> for bad
<p>The job worker's exit codes are <code>0</code> for success, <code>1</code> for a download error, and <code>2</code> for bad
arguments. Its <code>result.json</code> is either <code>{"status": "success", "output_files": [...]}</code>
or <code>{"status": "error", "message", "error_details", "error_code", "traceback"}</code>. If the
process exits non-zero, or the result is missing or not a success, the parent marks the
job <code>failed</code> and adopts the worker's <code>error_code</code> (or derives one by categorising the
job <code>failed</code> and adopts the job worker's <code>error_code</code> (or derives one by categorising the
exception).</p>
<h3 id="how-progress-reaches-the-api">How progress reaches the API<a class="headerlink" href="#how-progress-reaches-the-api" title="Permanent link">&para;</a></h3>
<p>While the worker runs, the manager polls <code>progress.json</code> every <strong>0.5 s</strong> and copies the
<p>While the job worker operates, the manager polls <code>progress.json</code> every <strong>0.5 s</strong> and copies the
fields it finds onto the live <code>DownloadJob</code>. That is what the <code>GET</code> endpoints return.</p>
<p>Inside the worker, progress is aggregated across all selected tracks. Each track's
completion fraction is weighted by its bitrate, so video and audio dominate the bar and
subtitles barely move it (subtitles get a small fixed weight). Track fractions are kept
<strong>monotonic</strong>: the percentage only ever climbs. The download portion is scaled to fill
<code>0</code> to <code>90</code>; the remaining <code>90</code> to <code>100</code> is driven by the download engine as it repackages
and muxes. A track counts as done when it reaches a terminal state such as
<p>The job worker aggregates progress across all selected tracks. It weights each track's
completion fraction by its bitrate, so video and audio dominate the bar and subtitles
barely move it (subtitles get a small fixed weight). Track fractions are kept
<strong>monotonic</strong>: the percentage only ever climbs. The job worker scales the download
portion to fill <code>0</code> to <code>90</code>. The download engine drives the remaining <code>90</code> to <code>100</code> as
it repackages and muxes. A track counts as done when it reaches a terminal state such as
<code>Downloaded</code>, <code>Decrypted</code>, or a skipped subtitle.</p>
<h3 id="how-cancellation-actually-works">How cancellation actually works<a class="headerlink" href="#how-cancellation-actually-works" title="Permanent link">&para;</a></h3>
<h3 id="how-cancellation-works">How cancellation works<a class="headerlink" href="#how-cancellation-works" title="Permanent link">&para;</a></h3>
<p>Each job carries a cancellation event. When you <code>DELETE</code> a live job:</p>
<ul>
<li>If it is <strong><code>queued</code></strong>, it flips straight to <code>cancelled</code>, its history is recorded
immediately (it never reaches a worker), and the worker loop skips it if it later
pops it off the queue.</li>
<li>If it is <strong><code>queued</code></strong>, it flips straight to <code>cancelled</code>, the manager records its
history immediately (it never reaches a job worker), and the job worker loop skips it
if it later pops it off the queue.</li>
<li>If it is <strong><code>downloading</code></strong>, the manager sets the cancellation event and flips the job
to <code>cancelled</code>. The polling loop notices the event, <code>terminate()</code>s the worker
to <code>cancelled</code>. The polling loop notices the event, <code>terminate()</code>s the job worker
subprocess (escalating to <code>kill()</code> if it does not exit within 5 seconds), and raises
a cancellation so the task unwinds. The subprocess model is what makes this a clean,
immediate stop rather than a cooperative one.</li>
@@ -2886,11 +2889,11 @@ and muxes. A track counts as done when it reaches a terminal state such as
<p>Job parameters may carry secrets: a raw <code>user:pass</code> credential, or a proxy URL with
embedded userinfo. Whenever the manager serializes a job (for an API response or the
history file), it masks the sensitive keys (<code>credential</code>, <code>credentials</code>, <code>password</code>,
<code>token</code>, <code>api_key</code>) and strips userinfo from proxy URLs. Free-text fields that a
service might have leaked a secret into (<code>error_message</code>, <code>error_details</code>,
<code>error_traceback</code>, <code>worker_stderr</code>, and worker stdout/stderr in the logs) are scrubbed
of the job's known secret values, longest-match first, before they are ever returned or
logged.</p>
<code>token</code>, <code>api_key</code>) and strips userinfo from proxy URLs. The manager also scrubs
free-text fields that a service might have leaked a secret into (<code>error_message</code>,
<code>error_details</code>, <code>error_traceback</code>, <code>worker_stderr</code>, and job worker stdout/stderr in the
logs). It removes the job's known secret values, longest-match first, before it returns
or logs them.</p>
<h2 id="see-also">See also<a class="headerlink" href="#see-also" title="Permanent link">&para;</a></h2>
<ul>
<li><a href="../endpoints/">Endpoints</a>: the full request/response reference for every job endpoint.</li>
+31 -31
View File
@@ -1570,10 +1570,10 @@
</li>
<li class="md-nav__item">
<a href="#step-3-confirm-the-server-is-alive" class="md-nav__link">
<a href="#step-3-make-sure-that-the-server-is-alive" class="md-nav__link">
<span class="md-ellipsis">
Step 3: Confirm the server is alive
Step 3: Make sure that the server is alive
</span>
</a>
@@ -2109,10 +2109,10 @@
</li>
<li class="md-nav__item">
<a href="#step-3-confirm-the-server-is-alive" class="md-nav__link">
<a href="#step-3-make-sure-that-the-server-is-alive" class="md-nav__link">
<span class="md-ellipsis">
Step 3: Confirm the server is alive
Step 3: Make sure that the server is alive
</span>
</a>
@@ -2234,11 +2234,11 @@
<h1 id="rest-api-quick-start">REST API Quick Start<a class="headerlink" href="#rest-api-quick-start" title="Permanent link">&para;</a></h1>
<p>Unshackle ships with a built-in REST API that exposes the same download engine you drive from the command line. It is served by the <code>unshackle serve</code> command and lets you queue downloads, poll their progress, browse services, and manage history over HTTP instead of the terminal.</p>
<p>The full loop runs end to end: start the server, authenticate, submit a download job, and poll it to completion, using real <code>curl</code> commands built directly from the API routes.</p>
<p>Unshackle ships with a built-in REST API that exposes the same download engine you drive from the command line. The <code>unshackle serve</code> command serves it and lets you queue downloads, poll their progress, browse services, and manage history over HTTP instead of the terminal.</p>
<p>The full loop runs end to end: start the server, authenticate, submit a download job, and poll it to completion. The <code>curl</code> commands here come directly from the API routes.</p>
<div class="admonition note">
<p class="admonition-title">Who this page is for</p>
<p>This is a <strong>developer</strong> guide. It assumes you are comfortable with HTTP, JSON, and a tool like <code>curl</code>. If you just want to download something, the <a href="../../../">command line</a> is simpler. The REST API is meant for building tools, UIs, and automation on top of unshackle.</p>
<p>This is a <strong>developer</strong> guide. It assumes you are comfortable with HTTP, JSON, and a tool like <code>curl</code>. If you only want to download something, the <a href="../../../">command line</a> is simpler. The REST API is meant for building tools, UIs, and automation on top of unshackle.</p>
</div>
<hr />
<h2 id="before-you-begin">Before you begin<a class="headerlink" href="#before-you-begin" title="Permanent link">&para;</a></h2>
@@ -2258,12 +2258,12 @@
</div>
<hr />
<h2 id="step-1-configure-an-api-secret">Step 1: Configure an API secret<a class="headerlink" href="#step-1-configure-an-api-secret" title="Permanent link">&para;</a></h2>
<p>Every request (except the health check) is authenticated with a secret key sent in the <code>X-Secret-Key</code> header. The server reads this key from the <code>serve</code> section of your <code>unshackle.yaml</code> config.</p>
<p>The server authenticates every request (except the health check) with an API key that you send in the <code>X-Secret-Key</code> header. The server reads this API key from the <code>serve</code> section of your <code>unshackle.yaml</code> config.</p>
<p>At minimum you must set <code>api_secret</code>:</p>
<div class="language-yaml highlight"><span class="filename">unshackle.yaml</span><pre><span></span><code><span id="__span-2-1"><a id="__codelineno-2-1" name="__codelineno-2-1" href="#__codelineno-2-1"></a><span class="nt">serve</span><span class="p">:</span>
</span><span id="__span-2-2"><a id="__codelineno-2-2" name="__codelineno-2-2" href="#__codelineno-2-2"></a><span class="w"> </span><span class="nt">api_secret</span><span class="p">:</span><span class="w"> </span><span class="s">"choose-a-long-random-secret"</span>
</span></code></pre></div>
<p>If you want the API to be able to license and download DRM-protected content, you also need to grant CDM devices to the key. Configure that under <code>serve.users</code>, keyed by the secret:</p>
<p>If you want the API to license and download DRM-protected titles, you must also grant CDM devices to the API key. Configure that under <code>serve.users</code>, keyed by the secret:</p>
<div class="language-yaml highlight"><span class="filename">unshackle.yaml</span><pre><span></span><code><span id="__span-3-1"><a id="__codelineno-3-1" name="__codelineno-3-1" href="#__codelineno-3-1"></a><span class="nt">serve</span><span class="p">:</span>
</span><span id="__span-3-2"><a id="__codelineno-3-2" name="__codelineno-3-2" href="#__codelineno-3-2"></a><span class="w"> </span><span class="nt">api_secret</span><span class="p">:</span><span class="w"> </span><span class="s">"choose-a-long-random-secret"</span>
</span><span id="__span-3-3"><a id="__codelineno-3-3" name="__codelineno-3-3" href="#__codelineno-3-3"></a><span class="w"> </span><span class="nt">users</span><span class="p">:</span>
@@ -2278,14 +2278,14 @@
</div>
<div class="admonition tip">
<p class="admonition-title">Disabling auth for local testing</p>
<p>Passing <code>--no-key</code> to <code>serve</code> turns authentication off entirely: every request is allowed and the <code>X-Secret-Key</code> header is ignored. This is convenient for a throwaway local test, but never expose a <code>--no-key</code> server to a network you do not fully trust.</p>
<p><code>--no-key</code> turns authentication off entirely: the server allows every request and ignores the <code>X-Secret-Key</code> header. This is convenient for a throwaway local test, but never put a <code>--no-key</code> server on a network you do not fully trust.</p>
</div>
<hr />
<h2 id="step-2-start-the-server">Step 2: Start the server<a class="headerlink" href="#step-2-start-the-server" title="Permanent link">&para;</a></h2>
<p>Start the REST API with the <code>serve</code> command:</p>
<div class="language-shell highlight"><pre><span></span><code><span id="__span-4-1"><a id="__codelineno-4-1" name="__codelineno-4-1" href="#__codelineno-4-1"></a>unshackle<span class="w"> </span>serve<span class="w"> </span>--api-only
</span></code></pre></div>
<p>By default the server binds to <code>127.0.0.1:8786</code>. The <code>--api-only</code> flag serves just the REST API and omits the Widevine/PlayReady CDM HTTP endpoints; drop it if you also want to expose those.</p>
<p>By default the server binds to <code>127.0.0.1:8786</code>. The <code>--api-only</code> flag serves only the REST API and omits the Widevine/PlayReady CDM HTTP endpoints. Drop it when you also want those endpoints.</p>
<p>Common options:</p>
<table>
<thead>
@@ -2344,15 +2344,15 @@
</span></code></pre></div>
<div class="admonition note">
<p class="admonition-title">Swagger UI is also behind auth</p>
<p>In <code>--api-only</code> mode (without <code>--no-key</code>), the Swagger UI at <code>/api/docs/</code> requires the <code>X-Secret-Key</code> header just like every other route. Only <code>/api/health</code> is exempt from authentication.</p>
<p>In <code>--api-only</code> mode (without <code>--no-key</code>), the Swagger UI at <code>/api/docs/</code> requires the <code>X-Secret-Key</code> header, the same as every other route. Only <code>/api/health</code> is exempt from authentication.</p>
</div>
<p>For the rest of this page we will assume the server is reachable at <code>http://127.0.0.1:8786</code> and store the secret in a shell variable:</p>
<p>For the rest of this page we will assume the server is reachable at <code>http://127.0.0.1:8786</code> and store the secret in an environment variable:</p>
<div class="language-shell highlight"><pre><span></span><code><span id="__span-6-1"><a id="__codelineno-6-1" name="__codelineno-6-1" href="#__codelineno-6-1"></a><span class="nb">export</span><span class="w"> </span><span class="nv">SECRET</span><span class="o">=</span><span class="s2">"choose-a-long-random-secret"</span>
</span><span id="__span-6-2"><a id="__codelineno-6-2" name="__codelineno-6-2" href="#__codelineno-6-2"></a><span class="nb">export</span><span class="w"> </span><span class="nv">BASE</span><span class="o">=</span><span class="s2">"http://127.0.0.1:8786"</span>
</span></code></pre></div>
<hr />
<h2 id="step-3-confirm-the-server-is-alive">Step 3: Confirm the server is alive<a class="headerlink" href="#step-3-confirm-the-server-is-alive" title="Permanent link">&para;</a></h2>
<p>The health endpoint is the one route that never requires a key. Use it to confirm the server is running and to read its version:</p>
<h2 id="step-3-make-sure-that-the-server-is-alive">Step 3: Make sure that the server is alive<a class="headerlink" href="#step-3-make-sure-that-the-server-is-alive" title="Permanent link">&para;</a></h2>
<p>The health endpoint is the one route that never requires an API key. Use it to make sure that the server operates, and to read its version:</p>
<div class="language-shell highlight"><pre><span></span><code><span id="__span-7-1"><a id="__codelineno-7-1" name="__codelineno-7-1" href="#__codelineno-7-1"></a>curl<span class="w"> </span><span class="s2">"</span><span class="nv">$BASE</span><span class="s2">/api/health"</span>
</span></code></pre></div>
<div class="language-json highlight"><span class="filename">200 OK</span><pre><span></span><code><span id="__span-8-1"><a id="__codelineno-8-1" name="__codelineno-8-1" href="#__codelineno-8-1"></a><span class="p">{</span>
@@ -2368,7 +2368,7 @@
<p>The <code>update_check</code> block is best effort. If unshackle cannot reach the update source, <code>update_available</code> and <code>latest_version</code> come back as <code>null</code> rather than failing the request.</p>
<hr />
<h2 id="step-4-authenticate-a-real-request">Step 4: Authenticate a real request<a class="headerlink" href="#step-4-authenticate-a-real-request" title="Permanent link">&para;</a></h2>
<p>Every other endpoint needs the <code>X-Secret-Key</code> header. A quick way to prove your key works is to list the services the server can see:</p>
<p>Every other endpoint needs the <code>X-Secret-Key</code> header. A quick way to prove your API key works is to show the services the server can see:</p>
<div class="language-shell highlight"><pre><span></span><code><span id="__span-9-1"><a id="__codelineno-9-1" name="__codelineno-9-1" href="#__codelineno-9-1"></a>curl<span class="w"> </span><span class="s2">"</span><span class="nv">$BASE</span><span class="s2">/api/services"</span><span class="w"> </span><span class="se">\</span>
</span><span id="__span-9-2"><a id="__codelineno-9-2" name="__codelineno-9-2" href="#__codelineno-9-2"></a><span class="w"> </span>-H<span class="w"> </span><span class="s2">"X-Secret-Key: </span><span class="nv">$SECRET</span><span class="s2">"</span>
</span></code></pre></div>
@@ -2390,12 +2390,12 @@
</span><span id="__span-10-16"><a id="__codelineno-10-16" name="__codelineno-10-16" href="#__codelineno-10-16"></a><span class="w"> </span><span class="p">]</span>
</span><span id="__span-10-17"><a id="__codelineno-10-17" name="__codelineno-10-17" href="#__codelineno-10-17"></a><span class="p">}</span>
</span></code></pre></div>
<p>If the key is missing or wrong, you get a <code>401</code> with a plain body:</p>
<p>If the API key is missing or wrong, you get a <code>401</code> with a plain body:</p>
<div class="language-json highlight"><span class="filename">401 Unauthorized</span><pre><span></span><code><span id="__span-11-1"><a id="__codelineno-11-1" name="__codelineno-11-1" href="#__codelineno-11-1"></a><span class="p">{</span><span class="w"> </span><span class="nt">"status"</span><span class="p">:</span><span class="w"> </span><span class="mi">401</span><span class="p">,</span><span class="w"> </span><span class="nt">"message"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Secret Key is Invalid."</span><span class="w"> </span><span class="p">}</span>
</span></code></pre></div>
<div class="admonition warning">
<p class="admonition-title">Two error shapes exist</p>
<p>The authentication middleware returns the short <code>{"status": 401, "message": "..."}</code> shape shown above. Every <strong>other</strong> error the API produces uses the richer structured shape (<code>error_code</code>, <code>timestamp</code>, <code>details</code>, ...) described in <a href="#error-handling">Error handling</a>. A robust client should handle both.</p>
<p>The authentication middleware returns the short <code>{"status": 401, "message": "..."}</code> shape shown above. Every <strong>other</strong> error the API produces uses the richer structured shape (<code>error_code</code>, <code>timestamp</code>, <code>details</code>, ...) described in <a href="#error-handling">Error handling</a>. Parse both shapes in your client.</p>
</div>
<p>The <code>tag</code> value for the service you want is the identifier you pass as <code>service</code> in the calls below.</p>
<hr />
@@ -2513,7 +2513,7 @@
<p>Validation is strict: invalid codecs, subtitle formats, dynamic-range names, non-positive counts, or mutually exclusive flags (for example both <code>video_only</code> and <code>audio_only</code>) come back as <code>400</code> with error code <code>INVALID_PARAMETERS</code>. See the full reference on the <a href="../endpoints/">download reference page</a> for every accepted field.</p>
<div class="admonition note">
<p class="admonition-title">Gated fields</p>
<p>Passing a per-request CDM (<code>cdm</code>) or inline credentials (<code>credential</code> / <code>credentials</code>) is refused with <code>403 FORBIDDEN</code> unless the server operator has explicitly opted in via <code>serve.cdm_overrides</code> and <code>serve.allow_job_credentials</code>. Both are off by default.</p>
<p>The server refuses a per-request CDM (<code>cdm</code>) or inline credentials (<code>credential</code> / <code>credentials</code>) with <code>403 FORBIDDEN</code>, unless the server operator has explicitly opted in with <code>serve.cdm_overrides</code> and <code>serve.allow_job_credentials</code>. Both are off by default.</p>
</div>
<hr />
<h2 id="step-6-poll-the-job-to-completion">Step 6: Poll the job to completion<a class="headerlink" href="#step-6-poll-the-job-to-completion" title="Permanent link">&para;</a></h2>
@@ -2524,7 +2524,7 @@
<div class="language-shell highlight"><pre><span></span><code><span id="__span-17-1"><a id="__codelineno-17-1" name="__codelineno-17-1" href="#__codelineno-17-1"></a>curl<span class="w"> </span><span class="s2">"</span><span class="nv">$BASE</span><span class="s2">/api/download/jobs/</span><span class="nv">$JOB</span><span class="s2">"</span><span class="w"> </span><span class="se">\</span>
</span><span id="__span-17-2"><a id="__codelineno-17-2" name="__codelineno-17-2" href="#__codelineno-17-2"></a><span class="w"> </span>-H<span class="w"> </span><span class="s2">"X-Secret-Key: </span><span class="nv">$SECRET</span><span class="s2">"</span>
</span></code></pre></div>
<p>While the download is running you will see <code>status: "downloading"</code> and a <code>progress</code> value climbing from <code>0</code> toward <code>100</code>:</p>
<p>While the download runs you will see <code>status: "downloading"</code> and a <code>progress</code> value climbing from <code>0</code> toward <code>100</code>:</p>
<div class="language-json highlight"><span class="filename">200 OK (in progress)</span><pre><span></span><code><span id="__span-18-1"><a id="__codelineno-18-1" name="__codelineno-18-1" href="#__codelineno-18-1"></a><span class="p">{</span>
</span><span id="__span-18-2"><a id="__codelineno-18-2" name="__codelineno-18-2" href="#__codelineno-18-2"></a><span class="w"> </span><span class="nt">"job_id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"3f9c1e0a-8d2b-4e6f-9a1c-77b0f2a1c4d5"</span><span class="p">,</span>
</span><span id="__span-18-3"><a id="__codelineno-18-3" name="__codelineno-18-3" href="#__codelineno-18-3"></a><span class="w"> </span><span class="nt">"status"</span><span class="p">:</span><span class="w"> </span><span class="s2">"downloading"</span><span class="p">,</span>
@@ -2570,12 +2570,12 @@
</div>
<div class="admonition tip">
<p class="admonition-title">Or let the server push the updates</p>
<p><code>GET /api/download/jobs/{job_id}/events</code> streams the same job record as Server-Sent Events, and closes the stream when the job ends. It removes the polling loop above. See <a href="../endpoints/#get-apidownloadjobsjob_idevents">Endpoints</a>.</p>
<p><code>GET /api/download/jobs/{job_id}/events</code> streams the same job record as Server-Sent Events, and closes the event stream when the job ends. It removes the polling loop above. See <a href="../endpoints/#get-apidownloadjobsjob_idevents">Endpoints</a>.</p>
<div class="language-bash highlight"><pre><span></span><code><span id="__span-21-1"><a id="__codelineno-21-1" name="__codelineno-21-1" href="#__codelineno-21-1"></a>curl<span class="w"> </span>-N<span class="w"> </span><span class="s2">"</span><span class="nv">$BASE</span><span class="s2">/api/download/jobs/</span><span class="nv">$JOB</span><span class="s2">/events"</span><span class="w"> </span><span class="se">\</span>
</span><span id="__span-21-2"><a id="__codelineno-21-2" name="__codelineno-21-2" href="#__codelineno-21-2"></a><span class="w"> </span>-H<span class="w"> </span><span class="s2">"X-Secret-Key: </span><span class="nv">$SECRET</span><span class="s2">"</span>
</span></code></pre></div>
</div>
<p>If a job ends in <code>failed</code>, the full record includes <code>error_message</code>, <code>error_code</code>, and (when the server was started with <code>--debug-api</code>) <code>error_traceback</code> and <code>worker_stderr</code> to help you diagnose it. Sensitive values in these fields are scrubbed before they are returned.</p>
<p>If a job ends in <code>failed</code>, the full record includes <code>error_message</code>, <code>error_code</code>, and (when you start the server with <code>--debug-api</code>) <code>error_traceback</code> and <code>worker_stderr</code> to help you find the cause. The server scrubs sensitive values in these fields before it returns them.</p>
<hr />
<h2 id="step-7-clean-up">Step 7: Clean up<a class="headerlink" href="#step-7-clean-up" title="Permanent link">&para;</a></h2>
<p>Once you have collected the output you can remove the finished job. <code>DELETE</code> on a job has two behaviors depending on its state:</p>
@@ -2583,12 +2583,12 @@
</span><span id="__span-22-2"><a id="__codelineno-22-2" name="__codelineno-22-2" href="#__codelineno-22-2"></a><span class="w"> </span>-H<span class="w"> </span><span class="s2">"X-Secret-Key: </span><span class="nv">$SECRET</span><span class="s2">"</span>
</span></code></pre></div>
<ul>
<li>If the job is <strong>terminal</strong> (completed/failed/cancelled) it is removed from the manager and you get <strong><code>204 No Content</code></strong> with an empty body.</li>
<li>If the job is still <strong>queued or downloading</strong> it is cancelled instead, and you get <code>200</code> with <code>{"status": "success", "message": "Job cancelled"}</code>.</li>
<li>If the job is <strong>terminal</strong> (completed/failed/cancelled), the manager removes it. You get <strong><code>204 No Content</code></strong> with an empty body.</li>
<li>If the job is still <strong>queued or downloading</strong>, the manager cancels it instead. You get <code>200</code> with <code>{"status": "success", "message": "Job cancelled"}</code>.</li>
</ul>
<div class="admonition warning">
<p class="admonition-title">Do not parse JSON on 204</p>
<p>A <code>204</code> response has no body. This applies to terminal-job removal here and to deleting history entries; check the status code before attempting to decode JSON.</p>
<p>A <code>204</code> response has no body. This applies to terminal-job removal here and to deleting history entries. Examine the status code before you decode JSON.</p>
</div>
<p>To clear every finished job at once instead of one at a time:</p>
<div class="language-shell highlight"><pre><span></span><code><span id="__span-23-1"><a id="__codelineno-23-1" name="__codelineno-23-1" href="#__codelineno-23-1"></a>curl<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span><span class="s2">"</span><span class="nv">$BASE</span><span class="s2">/api/download/jobs/clear-finished"</span><span class="w"> </span><span class="se">\</span>
@@ -2640,7 +2640,7 @@
</tr>
</tbody>
</table>
<p>Listing supports query parameters for filtering by <code>status</code> or <code>service</code>, sorting via <code>sort_by</code> (<code>created_time</code>, <code>started_time</code>, <code>completed_time</code>, <code>progress</code>, <code>status</code>, <code>service</code>) and <code>sort_order</code> (<code>asc</code> / <code>desc</code>), and requesting full per-job detail with <code>full=true</code>:</p>
<p>Listing supports query parameters for filtering by <code>status</code> or <code>service</code>, sorting with <code>sort_by</code> (<code>created_time</code>, <code>started_time</code>, <code>completed_time</code>, <code>progress</code>, <code>status</code>, <code>service</code>) and <code>sort_order</code> (<code>asc</code> / <code>desc</code>), and requesting full per-job detail with <code>full=true</code>:</p>
<div class="language-shell highlight"><pre><span></span><code><span id="__span-25-1"><a id="__codelineno-25-1" name="__codelineno-25-1" href="#__codelineno-25-1"></a>curl<span class="w"> </span><span class="s2">"</span><span class="nv">$BASE</span><span class="s2">/api/download/jobs?status=downloading&amp;sort_by=progress&amp;sort_order=desc"</span><span class="w"> </span><span class="se">\</span>
</span><span id="__span-25-2"><a id="__codelineno-25-2" name="__codelineno-25-2" href="#__codelineno-25-2"></a><span class="w"> </span>-H<span class="w"> </span><span class="s2">"X-Secret-Key: </span><span class="nv">$SECRET</span><span class="s2">"</span>
</span></code></pre></div>
@@ -2660,15 +2660,15 @@
</span><span id="__span-26-14"><a id="__codelineno-26-14" name="__codelineno-26-14" href="#__codelineno-26-14"></a><span class="w"> </span><span class="p">]</span>
</span><span id="__span-26-15"><a id="__codelineno-26-15" name="__codelineno-26-15" href="#__codelineno-26-15"></a><span class="p">}</span>
</span></code></pre></div>
<p>Retrying reuses the original job's service, title, and parameters to enqueue a brand new job (returning <code>202</code> with a fresh <code>job_id</code>); it only works on terminal jobs and returns <code>409 CONFLICT</code> otherwise. Prioritizing only works on jobs still in the <code>queued</code> state.</p>
<p>A retry reuses the original job's service, title, and parameters to enqueue a brand new job, and returns <code>202</code> with a fresh <code>job_id</code>. A retry works only on terminal jobs, and returns <code>409 CONFLICT</code> otherwise. A priority change works only on jobs still in the <code>queued</code> state.</p>
<hr />
<h2 id="beyond-a-single-download">Beyond a single download<a class="headerlink" href="#beyond-a-single-download" title="Permanent link">&para;</a></h2>
<p>The download loop above is the core of the API, but the same server exposes more:</p>
<ul>
<li><strong>Discovery</strong>: <code>POST /api/search</code> finds titles by query, and <code>POST /api/list-titles</code> / <code>POST /api/list-tracks</code> inspect what a title offers before you commit to downloading it. See <a href="../endpoints/">list titles and tracks</a>.</li>
<li><strong>Discovery</strong>: <code>POST /api/search</code> finds titles by query, and <code>POST /api/list-titles</code> / <code>POST /api/list-tracks</code> inspect what a title offers before you commit to downloading it. See <a href="../endpoints/">titles and tracks</a>.</li>
<li><strong>History</strong>: <code>GET /api/history</code> reads a persisted log of jobs that reached a terminal state (newest first), and <code>DELETE /api/history/{job_id}</code> removes a single entry. See <a href="../endpoints/">history</a>.</li>
<li><strong>Introspection</strong>: <code>GET /api/profiles</code> lists configured credential profiles per service, <code>GET /api/config</code> returns a redacted view of the effective server config, and <code>GET /api/env/check</code> reports which external binaries are installed.</li>
<li><strong>Maintenance</strong>: <code>POST /api/maintenance/clear-cache</code>, <code>.../clear-temp</code>, and <code>.../refresh-services</code> perform housekeeping (the cache/temp clears are blocked with <code>409</code> while a download is active).</li>
<li><strong>Introspection</strong>: <code>GET /api/profiles</code> lists configured credential profiles per service, <code>GET /api/config</code> returns a redacted view of the effective server config, and <code>GET /api/env/check</code> reports which external binaries the machine has.</li>
<li><strong>Maintenance</strong>: <code>POST /api/maintenance/clear-cache</code>, <code>.../clear-temp</code>, and <code>.../refresh-services</code> do housekeeping (the server blocks the cache and temp clears with <code>409</code> while a download is active).</li>
<li><strong>Remote sessions</strong>: the <code>/api/session/*</code> endpoints drive the thin-client "remote download" flow, where the server authenticates and licenses on behalf of a local client. See <a href="../remote-sessions/">remote sessions</a>.</li>
</ul>
<p>The interactive <strong>Swagger UI at <code>/api/docs/</code></strong> documents every route and lets you try them in the browser (send your <code>X-Secret-Key</code> there too).</p>
@@ -2683,7 +2683,7 @@
</span><span id="__span-27-6"><a id="__codelineno-27-6" name="__codelineno-27-6" href="#__codelineno-27-6"></a><span class="w"> </span><span class="nt">"details"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="p">}</span>
</span><span id="__span-27-7"><a id="__codelineno-27-7" name="__codelineno-27-7" href="#__codelineno-27-7"></a><span class="p">}</span>
</span></code></pre></div>
<p><code>details</code> appears only when there is something to add, <code>retryable: true</code> appears only for transient failures, and a <code>debug_info</code> block (exception type and traceback) is included only when the server runs with <code>--debug-api</code>.</p>
<p><code>details</code> appears only when the server has more to add. <code>retryable: true</code> appears only for transient failures. The server includes a <code>debug_info</code> block (exception type and traceback) only when it runs with <code>--debug-api</code>.</p>
<p>The <code>error_code</code> maps to an HTTP status. The ones you will meet most often:</p>
<table>
<thead>
+98 -96
View File
@@ -1710,10 +1710,10 @@
</li>
<li class="md-nav__item">
<a href="#session-lifecycle-and-expiry" class="md-nav__link">
<a href="#remote-session-lifecycle-and-expiry" class="md-nav__link">
<span class="md-ellipsis">
Session lifecycle and expiry
Remote session lifecycle and expiry
</span>
</a>
@@ -1744,10 +1744,10 @@
<ul class="md-nav__list">
<li class="md-nav__item">
<a href="#session-endpoints" class="md-nav__link">
<a href="#remote-session-endpoints" class="md-nav__link">
<span class="md-ellipsis">
Session endpoints
Remote session endpoints
</span>
</a>
@@ -1799,10 +1799,10 @@
</li>
<li class="md-nav__item">
<a href="#session-info-response" class="md-nav__link">
<a href="#remote-session-info-response" class="md-nav__link">
<span class="md-ellipsis">
Session info response
Remote session info response
</span>
</a>
@@ -2187,10 +2187,10 @@
</li>
<li class="md-nav__item">
<a href="#session-lifecycle-and-expiry" class="md-nav__link">
<a href="#remote-session-lifecycle-and-expiry" class="md-nav__link">
<span class="md-ellipsis">
Session lifecycle and expiry
Remote session lifecycle and expiry
</span>
</a>
@@ -2221,10 +2221,10 @@
<ul class="md-nav__list">
<li class="md-nav__item">
<a href="#session-endpoints" class="md-nav__link">
<a href="#remote-session-endpoints" class="md-nav__link">
<span class="md-ellipsis">
Session endpoints
Remote session endpoints
</span>
</a>
@@ -2276,10 +2276,10 @@
</li>
<li class="md-nav__item">
<a href="#session-info-response" class="md-nav__link">
<a href="#remote-session-info-response" class="md-nav__link">
<span class="md-ellipsis">
Session info response
Remote session info response
</span>
</a>
@@ -2334,7 +2334,7 @@
<h1 id="remote-service-sessions">Remote Service Sessions<a class="headerlink" href="#remote-service-sessions" title="Permanent link">&para;</a></h1>
<p>A <strong>remote service session</strong> lets one machine do the hard, service-specific work
<p>A <strong>remote session</strong> lets one machine do the hard, service-specific work
(authenticating to a streaming service, listing titles, resolving tracks and
manifests, and, optionally, DRM licensing) while another machine runs the actual
download, decryption, and muxing locally.</p>
@@ -2342,15 +2342,15 @@ download, decryption, and muxing locally.</p>
A thin local client (<code>RemoteService</code>) talks to an unshackle <code>serve</code> instance over
HTTP. The server keeps an authenticated service instance alive between requests so
the client can authenticate once and then make many follow-up calls against the
same session.</p>
same remote session.</p>
<div class="admonition note">
<p class="admonition-title">Why you'd use this</p>
<ul>
<li>The server has service accounts, cookies, or a CDM you don't want to copy to
<li>The server has service accounts, cookies, or a CDM you do not want to copy to
every machine.</li>
<li>You want to run downloads on a fast/local machine but keep credentials and
licensing centralized.</li>
<li>A service is region-locked to where the server lives, and the client isn't.</li>
<li>You want to do downloads on a fast/local machine but keep credentials and
licensing centralised.</li>
<li>A service is region-locked to where the server lives, and the client is not.</li>
</ul>
</div>
<hr />
@@ -2374,7 +2374,7 @@ selection (downloading segments, decrypting, and muxing) runs locally.</p>
<p>The <code>RemoteService</code> adapter lives inside the ordinary <code>dl</code> command and replaces
the old standalone <code>remote_dl</code> command. If you have prior scripts or docs that
invoke <code>remote_dl</code>, use <code>dl</code> with a configured remote server instead. The
functionality now rides on <code>dl</code> via <code>RemoteService</code>.</p>
functionality now rides on <code>dl</code> through <code>RemoteService</code>.</p>
</div>
<p>For the auth-facing endpoints, header format, and error shapes referenced below,
see <a href="../authentication/">Authentication</a> and the endpoint reference in
@@ -2429,14 +2429,14 @@ entry names a server and provides its URL and API key.</p>
<div class="admonition warning">
<p class="admonition-title">Port default</p>
<p>The <code>unshackle serve</code> default port is <strong><code>8786</code></strong> (not <code>8080</code>). Use the port
your server actually binds. Some older example snippets show <code>8080</code>, which is
your server binds. Some older example snippets show <code>8080</code>, which is
not the default.</p>
</div>
<h3 id="selecting-a-server">Selecting a server<a class="headerlink" href="#selecting-a-server" title="Permanent link">&para;</a></h3>
<p>If exactly one server is configured, unshackle uses it implicitly:</p>
<p>If you configure exactly one server, unshackle uses it implicitly:</p>
<div class="language-bash highlight"><pre><span></span><code><span id="__span-2-1"><a id="__codelineno-2-1" name="__codelineno-2-1" href="#__codelineno-2-1"></a>unshackle<span class="w"> </span>dl<span class="w"> </span>EXAMPLE<span class="w"> </span>0ABCDEF
</span></code></pre></div>
<p>If <strong>more than one</strong> server is configured, you must pick one with <code>--server</code>:</p>
<p>If you configure <strong>more than one</strong> server, you must pick one with <code>--server</code>:</p>
<div class="language-bash highlight"><pre><span></span><code><span id="__span-3-1"><a id="__codelineno-3-1" name="__codelineno-3-1" href="#__codelineno-3-1"></a>unshackle<span class="w"> </span>dl<span class="w"> </span>--server<span class="w"> </span>my_server<span class="w"> </span>EXAMPLE<span class="w"> </span>0ABCDEF
</span></code></pre></div>
<p>With no <code>remote_services</code> configured at all, unshackle raises a clear error telling
@@ -2444,17 +2444,17 @@ you to add the block shown above.</p>
<hr />
<h2 id="what-happens-during-a-download">What happens during a download<a class="headerlink" href="#what-happens-during-a-download" title="Permanent link">&para;</a></h2>
<p>From your point of view the command looks like an ordinary download. Under the
hood the client walks a session through its lifecycle.</p>
hood the client walks a remote session through its lifecycle.</p>
<div class="tabbed-set tabbed-alternate" data-tabs="1:5"><input checked="checked" id="__tabbed_1_1" name="__tabbed_1" type="radio" /><input id="__tabbed_1_2" name="__tabbed_1" type="radio" /><input id="__tabbed_1_3" name="__tabbed_1" type="radio" /><input id="__tabbed_1_4" name="__tabbed_1" type="radio" /><input id="__tabbed_1_5" name="__tabbed_1" type="radio" /><div class="tabbed-labels"><label for="__tabbed_1_1">1. Create + authenticate</label><label for="__tabbed_1_2">2. Interactive prompts (if needed)</label><label for="__tabbed_1_3">3. Titles + tracks</label><label for="__tabbed_1_4">4. Licensing</label><label for="__tabbed_1_5">5. Download + close</label></div>
<div class="tabbed-content">
<div class="tabbed-block">
<p>The client calls <code>POST /api/session/create</code>, forwarding whatever it can so the
server doesn't have to prompt:</p>
server does not have to prompt:</p>
<ul>
<li>Local <strong>credentials</strong> for the service/profile (<code>{username, password, extra?}</code>)</li>
<li>Local <strong>cookies</strong>, compressed and base64-encoded</li>
<li>A resolved <strong>proxy</strong>, or, if you didn't set one, your detected
<strong>client region</strong> so the server can auto-proxy to match</li>
<li>A resolved <strong>proxy</strong>, or, if you did not set one, your detected
<strong>client region</strong> so the server can auto-proxy to match it</li>
<li>Track-selection hints (<code>range_</code>, <code>vcodec</code>, <code>quality</code>, <code>best_available</code>) so
the server fetches the right manifests</li>
<li>Your local per-service <strong>cache files</strong> (e.g. refreshed tokens)</li>
@@ -2464,7 +2464,7 @@ runs in the background on the server.</p>
</div>
<div class="tabbed-block">
<p>Some services need a one-time code, PIN, or device confirmation. When the
server's auth flow asks for input, the session enters <code>pending_input</code>. The
server's auth flow asks for input, the remote session enters <code>pending_input</code>. The
client polls, displays the prompt to you locally, collects your answer, and
sends it back. The server's auth thread resumes with your response.</p>
<div class="language-text highlight"><pre><span></span><code><span id="__span-4-1"><a id="__codelineno-4-1" name="__codelineno-4-1" href="#__codelineno-4-1"></a>Enter the 6-digit code sent to your email: 483920
@@ -2472,30 +2472,30 @@ sends it back. The server's auth thread resumes with your response.</p>
</div>
<div class="tabbed-block">
<p>Once authenticated, the client fetches the title list, then the tracks for the
chosen title. The tracks come back with playback URLs, and DASH/ISM manifests
are shipped as compressed XML so the client can re-parse them locally for
chosen title. The tracks come back with playback URLs, and the server ships
DASH/ISM manifests as compressed XML so the client can re-parse them locally for
downloading. Any <code>session_headers</code> / <code>session_cookies</code> the server used are
merged into the client's local HTTP session.</p>
</div>
<div class="tabbed-block">
<p>For DRM content the client either <strong>proxies</strong> its CDM challenge through the
server, or, if <code>server_cdm: true</code>, asks the server to run the full CDM flow
<p>For a DRM-protected title the client either <strong>proxies</strong> its CDM challenge through
the server, or, if <code>server_cdm: true</code>, asks the server to do the full CDM flow
and return the content keys directly. See
<a href="#server-side-vs-proxied-cdm">Server-side vs. proxied CDM</a> below.</p>
</div>
<div class="tabbed-block">
<p>The client downloads, decrypts, and muxes locally. On completion it deletes
the session. If the server has updated cache files (e.g. a refreshed token),
they're returned on delete and saved locally so the <strong>next</strong> session can skip
interactive auth.</p>
the remote session. If the server has updated cache files (for example a refreshed
token), the delete returns them and the client saves them locally, so the <strong>next</strong>
remote session can skip interactive auth.</p>
</div>
</div>
</div>
<div class="admonition tip">
<p class="admonition-title">Renaming remote titles locally</p>
<p>You can rename titles for a remote service you don't have installed locally by
<p>You can rename titles for a remote service you do not have installed locally by
adding a <code>title_map</code> under that service in your client <code>remote_services.&lt;name&gt;.services</code>
config. The server sends raw titles; your local map wins.</p>
config. The server sends raw titles. Your local map wins.</p>
<p>This is deliberate: the server does <strong>no</strong> <code>title_map</code> remapping of its own and
sends titles exactly as the service returns them. All remapping happens on the
client, applied to the titles the server sends back. It keeps the final output
@@ -2512,27 +2512,27 @@ services you have no local install of.</p>
to <code>POST /api/session/{id}/license</code>, the server forwards it to the service, and
the raw license bytes come back for your local CDM to parse.</p>
<ul>
<li>Keeps your CDM local; the server just relays the license request.</li>
<li>Keeps your CDM local. The server only relays the license request.</li>
<li>Used when <code>server_cdm</code> is <code>false</code> (the default).</li>
</ul>
</div>
<div class="tabbed-block">
<p>The <strong>server's CDM</strong> does everything. The client sends track IDs (or a PSSH),
the server checks its key vaults, loads the device configured for your API key,
runs the CDM flow, and returns <code>KID:KEY</code> pairs directly. No CDM is needed on the
client.</p>
runs the CDM flow, and returns <code>KID:KEY</code> pairs directly. The client needs no
CDM.</p>
<ul>
<li>Enable with <code>server_cdm: true</code> in the server entry.</li>
<li>The server tells the client which DRM type it actually used
<li>The server tells the client which DRM type it used
(<code>widevine</code> or <code>playready</code>).</li>
</ul>
</div>
</div>
</div>
<hr />
<h2 id="session-lifecycle-and-expiry">Session lifecycle and expiry<a class="headerlink" href="#session-lifecycle-and-expiry" title="Permanent link">&para;</a></h2>
<p>Sessions live in an <strong>in-memory store on the server</strong> and expire on a timer. You
generally never touch this directly, but it explains behavior you might observe.</p>
<h2 id="remote-session-lifecycle-and-expiry">Remote session lifecycle and expiry<a class="headerlink" href="#remote-session-lifecycle-and-expiry" title="Permanent link">&para;</a></h2>
<p>Remote sessions live in an <strong>in-memory store on the server</strong> and expire on a timer. You
generally never touch this directly, but it explains behaviour you might observe.</p>
<table>
<thead>
<tr>
@@ -2566,9 +2566,10 @@ generally never touch this directly, but it explains behavior you might observe.
</table>
<div class="admonition note">
<p class="admonition-title">Auth is never rushed</p>
<p>A session that is still <code>authenticating</code> or waiting on <code>pending_input</code> is <strong>not</strong>
subject to the short 300s TTL; it gets the full 600s auth window. This gives you
time to enter an interactive code without the session vanishing mid-prompt.</p>
<p>A remote session that is still <code>authenticating</code> or waiting on <code>pending_input</code> is
<strong>not</strong> subject to the short 300s TTL. It gets the full 600s auth window. This gives
you time to enter an interactive code without the remote session vanishing
mid-prompt.</p>
</div>
<p>The TTL and max-session limits are read from server config:</p>
<div class="language-yaml highlight"><span class="filename">unshackle.yaml (server)</span><pre><span></span><code><span id="__span-5-1"><a id="__codelineno-5-1" name="__codelineno-5-1" href="#__codelineno-5-1"></a><span class="nt">serve</span><span class="p">:</span>
@@ -2578,17 +2579,18 @@ time to enter an interactive code without the session vanishing mid-prompt.</p>
<hr />
<h2 id="security-properties-worth-knowing">Security properties worth knowing<a class="headerlink" href="#security-properties-worth-knowing" title="Permanent link">&para;</a></h2>
<ul>
<li><strong>API key required.</strong> Every session request carries <code>X-Secret-Key</code>; requests
without a valid key are rejected (health check aside). See
<li><strong>API key required.</strong> Every remote session request carries <code>X-Secret-Key</code>. The
server rejects requests without a valid API key (health check aside). See
<a href="../authentication/">Authentication</a>.</li>
<li><strong>IP binding.</strong> A session records the creator's IP at create time. If a later
request for the same session comes from a different IP, the server returns
<code>403 FORBIDDEN</code>. Sessions are not portable between hosts.</li>
<li><strong>Namespaced, isolated cache.</strong> Each session gets its own cache directory,
namespaced by a hash of the API key and the session ID, so sessions can't read
each other's cached tokens. The directory is deleted when the session ends.</li>
<li><strong>Secrets are scrubbed from logs.</strong> Session IDs, service tags, and other user
values are sanitized before logging.</li>
<li><strong>IP binding.</strong> A remote session records the creator's IP when the server makes it.
If a later request for the same remote session comes from a different IP, the server
returns <code>403 FORBIDDEN</code>. Remote sessions are not portable between hosts.</li>
<li><strong>Namespaced, isolated cache.</strong> Each remote session gets its own cache directory,
namespaced by a hash of the API key and the remote session ID, so remote sessions
cannot read each other's cached tokens. The server deletes the directory when the
remote session ends.</li>
<li><strong>The server redacts secrets in logs.</strong> It redacts remote session IDs, service tags,
and other user values before it writes them to the log.</li>
</ul>
<hr />
<h2 id="developer-reference">Developer reference<a class="headerlink" href="#developer-reference" title="Permanent link">&para;</a></h2>
@@ -2597,17 +2599,17 @@ time to enter an interactive code without the session vanishing mid-prompt.</p>
<p>The material below documents the server-side implementation and the HTTP
contract. End users configuring a client can stop at the sections above.</p>
</div>
<h3 id="session-endpoints">Session endpoints<a class="headerlink" href="#session-endpoints" title="Permanent link">&para;</a></h3>
<p>All routes are exposed even in <code>--remote-only</code> server mode. Paths use the
<code>session_id</code> returned by create.</p>
<h3 id="remote-session-endpoints">Remote session endpoints<a class="headerlink" href="#remote-session-endpoints" title="Permanent link">&para;</a></h3>
<p>The server mounts all these routes, even in <code>--remote-only</code> mode. Paths use the
<code>session_id</code> that <code>create</code> returns.</p>
<div class="admonition note">
<p class="admonition-title">Why <code>--remote-only</code> exists</p>
<p><code>--remote-only</code> narrows the server to just the health, services, search, and
session subset (and emits CORS headers) precisely so it is safe to sit behind
<p><code>--remote-only</code> narrows the server to only the health, services, search, and
remote session subset (and emits CORS headers), precisely so it is safe to sit behind
Cloudflare or serve cross-origin browser clients. That trimmed surface, rather
than the full <code>--api-only</code> mode, is what makes a CORS/Cloudflare-fronted
deployment practical: reach for it when the server is public-facing or accessed
from a browser origin, not when you simply want the local HTTP API.</p>
from a browser origin, not when you want only the local HTTP API.</p>
</div>
<table>
<thead>
@@ -2661,7 +2663,7 @@ from a browser origin, not when you simply want the local HTTP API.</p>
<tr>
<td><code>DELETE</code></td>
<td><code>/api/session/{id}</code></td>
<td>Close the session, return updated cache, clean up</td>
<td>Close the remote session, return updated cache, clean up</td>
</tr>
</tbody>
</table>
@@ -2676,8 +2678,8 @@ from a browser origin, not when you simply want the local HTTP API.</p>
<div class="language-json highlight"><pre><span></span><code><span id="__span-6-1"><a id="__codelineno-6-1" name="__codelineno-6-1" href="#__codelineno-6-1"></a><span class="p">{</span><span class="w"> </span><span class="nt">"session_id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"...uuid4..."</span><span class="p">,</span><span class="w"> </span><span class="nt">"service"</span><span class="p">:</span><span class="w"> </span><span class="s2">"EXAMPLE"</span><span class="p">,</span><span class="w"> </span><span class="nt">"status"</span><span class="p">:</span><span class="w"> </span><span class="s2">"authenticating"</span><span class="w"> </span><span class="p">}</span>
</span></code></pre></div>
<p>Authentication runs on a background thread (<code>asyncio.to_thread(authenticate, ...)</code>).
The session starts in <code>AUTHENTICATING</code>; the client must poll the prompt endpoint
until it reaches <code>authenticated</code> (or <code>failed</code>).</p>
The remote session starts in <code>AUTHENTICATING</code>. The client must poll the prompt
endpoint until it reaches <code>authenticated</code> (or <code>failed</code>).</p>
<h3 id="interactive-auth-the-inputbridge">Interactive auth: the <code>InputBridge</code><a class="headerlink" href="#interactive-auth-the-inputbridge" title="Permanent link">&para;</a></h3>
<p>When the service calls its input function during <code>authenticate()</code> on the server
thread, an <code>InputBridge</code> pauses that thread and exposes the prompt to the HTTP
@@ -2686,7 +2688,7 @@ layer.</p>
<li><code>AuthStatus</code> values: <code>authenticating</code>, <code>pending_input</code>, <code>authenticated</code>, <code>failed</code>.</li>
<li><code>InputBridge.request_input(prompt, timeout=600)</code> blocks the sync auth thread on a
<code>threading.Event</code> until <code>submit_response()</code> or <code>cancel()</code> fires.</li>
<li>A timeout raises <code>TimeoutError</code> and marks the session <code>FAILED</code>.</li>
<li>A timeout raises <code>TimeoutError</code> and marks the remote session <code>FAILED</code>.</li>
<li><code>AUTH_INPUT_TIMEOUT = 600.0</code> seconds. This is also the TTL granted to
<code>AUTHENTICATING</code> / <code>PENDING_INPUT</code> sessions in the store.</li>
</ul>
@@ -2696,28 +2698,28 @@ layer.</p>
</span><span id="__span-7-3"><a id="__codelineno-7-3" name="__codelineno-7-3" href="#__codelineno-7-3"></a><span class="p">{</span><span class="w"> </span><span class="nt">"status"</span><span class="p">:</span><span class="w"> </span><span class="s2">"pending_input"</span><span class="p">,</span><span class="w"> </span><span class="nt">"prompt"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Enter code: "</span><span class="w"> </span><span class="p">}</span>
</span><span id="__span-7-4"><a id="__codelineno-7-4" name="__codelineno-7-4" href="#__codelineno-7-4"></a><span class="p">{</span><span class="w"> </span><span class="nt">"status"</span><span class="p">:</span><span class="w"> </span><span class="s2">"failed"</span><span class="p">,</span><span class="w"> </span><span class="nt">"error"</span><span class="p">:</span><span class="w"> </span><span class="s2">"...message..."</span><span class="w"> </span><span class="p">}</span>
</span></code></pre></div>
<p>A missing session returns <code>404 SESSION_NOT_FOUND</code>; an IP mismatch returns
<p>A missing remote session returns <code>404 SESSION_NOT_FOUND</code>. An IP mismatch returns
<code>403 FORBIDDEN</code>. <code>POST /api/session/{id}/prompt</code> takes <code>{ "response": "..." }</code> and
returns <code>{ "status": "accepted" }</code>; posting with no pending prompt is an
returns <code>{ "status": "accepted" }</code>. A post with no pending prompt is an
<code>INVALID_INPUT</code> error.</p>
<h3 id="sessionstore-internals"><code>SessionStore</code> internals<a class="headerlink" href="#sessionstore-internals" title="Permanent link">&para;</a></h3>
<p>Source: <code>unshackle/core/api/session_store.py</code>. A singleton obtained via
<p>Source: <code>unshackle/core/api/session_store.py</code>. A singleton you get from
<code>get_session_store()</code>.</p>
<ul>
<li><strong>Config-driven limits.</strong> <code>serve.session_ttl</code> (default <code>300</code>) and
<code>serve.max_sessions</code> (default <code>100</code>) are read as properties, so config changes
take effect without recreating the store.</li>
<li><strong><code>create()</code></strong> evicts the least-recently-accessed session when at capacity,
then stores a new <code>SessionEntry</code> (defaulting <code>auth_status</code> to <code>AUTHENTICATED</code>;
the create handler overrides it to <code>AUTHENTICATING</code>).</li>
<li><strong><code>get()</code></strong> refreshes <code>last_accessed</code> via <code>touch()</code>. It returns <code>None</code> (and
deletes the entry) if an authenticated session has been idle longer than the TTL.
Sessions in <code>AUTHENTICATING</code> / <code>PENDING_INPUT</code> are exempt from TTL expiry.</li>
<li><strong><code>cleanup_expired()</code></strong> runs every 60s: authenticated sessions expire at <code>ttl</code>,
in-flight-auth sessions expire at <code>AUTH_INPUT_TIMEOUT</code>. Removing a session
cancels its <code>InputBridge</code> and deletes its cache directory (pruning empty parent
dirs up to, but not including, the cache root).</li>
<li><strong><code>cancel_all_bridges()</code></strong> is called on server shutdown to unblock any waiting
<li><strong><code>create()</code></strong> evicts the least-recently-accessed remote session when at capacity,
then stores a new <code>SessionEntry</code>. It defaults <code>auth_status</code> to <code>AUTHENTICATED</code>, and
the <code>create</code> handler overrides it to <code>AUTHENTICATING</code>.</li>
<li><strong><code>get()</code></strong> refreshes <code>last_accessed</code> through <code>touch()</code>. It returns <code>None</code> (and
deletes the entry) if an authenticated remote session has been idle longer than the
TTL. Remote sessions in <code>AUTHENTICATING</code> / <code>PENDING_INPUT</code> are exempt from TTL expiry.</li>
<li><strong><code>cleanup_expired()</code></strong> runs every 60s: authenticated remote sessions expire at
<code>ttl</code>, in-flight-auth remote sessions expire at <code>AUTH_INPUT_TIMEOUT</code>. The removal of
a remote session cancels its <code>InputBridge</code> and deletes its cache directory (pruning
empty parent dirs up to, but not including, the cache root).</li>
<li>The server calls <strong><code>cancel_all_bridges()</code></strong> on shutdown, to unblock any waiting
auth threads.</li>
</ul>
<p><code>SessionEntry</code> fields:</p>
@@ -2772,16 +2774,16 @@ returns <code>{ "status": "accepted" }</code>; posting with no pending prompt is
</tbody>
</table>
<h3 id="per-session-cache-namespacing">Per-session cache namespacing<a class="headerlink" href="#per-session-cache-namespacing" title="Permanent link">&para;</a></h3>
<p>The create handler builds a <code>Cacher</code> namespaced as:</p>
<p>The <code>create</code> handler builds a <code>Cacher</code> namespaced as:</p>
<div class="language-text highlight"><pre><span></span><code><span id="__span-8-1"><a id="__codelineno-8-1" name="__codelineno-8-1" href="#__codelineno-8-1"></a>_sessions/&lt;pbkdf2_hmac(sha256, X-Secret-Key, "unshackle-session-ns", 100000)[:12]&gt;/&lt;session_id&gt;/&lt;service&gt;
</span></code></pre></div>
<p>Forwarded <code>cache</code> files are written into that directory before authentication.
<p>The handler writes forwarded <code>cache</code> files into that directory before authentication.
On <code>DELETE</code>, the handler harvests updated cache files (compressing each with zlib
and base64-encoding, <strong>excluding</strong> <code>titles_*</code> files) and returns them under a
<code>cache</code> key so the client can persist refreshed tokens:</p>
<code>cache</code> field, so the client can keep refreshed tokens:</p>
<div class="language-json highlight"><pre><span></span><code><span id="__span-9-1"><a id="__codelineno-9-1" name="__codelineno-9-1" href="#__codelineno-9-1"></a><span class="p">{</span><span class="w"> </span><span class="nt">"status"</span><span class="p">:</span><span class="w"> </span><span class="s2">"ok"</span><span class="p">,</span><span class="w"> </span><span class="nt">"cache"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nt">"tokens"</span><span class="p">:</span><span class="w"> </span><span class="s2">"...base64(zlib(bytes))..."</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="p">}</span>
</span></code></pre></div>
<h3 id="session-info-response">Session info response<a class="headerlink" href="#session-info-response" title="Permanent link">&para;</a></h3>
<h3 id="remote-session-info-response">Remote session info response<a class="headerlink" href="#remote-session-info-response" title="Permanent link">&para;</a></h3>
<p><code>GET /api/session/{id}</code>:</p>
<div class="language-json highlight"><pre><span></span><code><span id="__span-10-1"><a id="__codelineno-10-1" name="__codelineno-10-1" href="#__codelineno-10-1"></a><span class="p">{</span>
</span><span id="__span-10-2"><a id="__codelineno-10-2" name="__codelineno-10-2" href="#__codelineno-10-2"></a><span class="w"> </span><span class="nt">"session_id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"..."</span><span class="p">,</span>
@@ -2795,11 +2797,11 @@ and base64-encoding, <strong>excluding</strong> <code>titles_*</code> files) and
<div class="admonition warning">
<p class="admonition-title"><code>expires_in</code> is the configured TTL, not time remaining</p>
<p>The <code>expires_in</code> value reports the store's configured <code>session_ttl</code>, not the
seconds left before this specific session expires.</p>
seconds left before this specific remote session expires.</p>
</div>
<h3 id="reference-client-remoteservice">Reference client: <code>RemoteService</code><a class="headerlink" href="#reference-client-remoteservice" title="Permanent link">&para;</a></h3>
<p>Source: <code>unshackle/core/remote_service.py</code>. This is the canonical consumer of the
session API and a good template for any client.</p>
remote session API and a good template for any client.</p>
<ul>
<li><strong><code>RemoteClient.request</code></strong> sets <code>X-Secret-Key</code> and <code>User-Agent: unshackle/&lt;version&gt;</code>,
uses a 120s timeout for <code>POST</code> and 30s for <code>GET</code>/<code>DELETE</code>, and treats any
@@ -2808,10 +2810,10 @@ session API and a good template for any client.</p>
<li><strong>Retries.</strong> The download-side HTTP session mounts an adapter with
<code>Retry(total=5, backoff_factor=0.2, status_forcelist=[429, 500, 502, 503, 504])</code>.</li>
<li><strong>Flow.</strong> <code>authenticate()</code> → <code>create</code> (+ poll <code>prompt</code> every 2s up to a 600s
deadline, answering <code>pending_input</code> prompts; with stdin closed the client logs an
deadline, answering <code>pending_input</code> prompts. With stdin closed the client logs an
error and raises <code>SystemExit(1)</code> instead of posting an empty answer) → <code>get_titles()</code> → <code>get_tracks()</code>
(merging returned <code>session_headers</code>/<code>session_cookies</code>, re-parsing <code>manifests</code>) →
license via proxy or <code>server_cdm</code> → <code>close()</code> (<code>DELETE</code>, saving any returned
get the licence in proxy mode or <code>server_cdm</code> mode → <code>close()</code> (<code>DELETE</code>, saving any returned
<code>cache</code>).</li>
<li><strong>Server resolution.</strong> <code>resolve_server()</code> reads <code>config.remote_services.&lt;name&gt;</code>
into <code>{url, api_key, services, server_cdm}</code>, injecting the <code>server_cdm</code> flag into
@@ -2821,22 +2823,22 @@ session API and a good template for any client.</p>
<h2 id="troubleshooting">Troubleshooting<a class="headerlink" href="#troubleshooting" title="Permanent link">&para;</a></h2>
<div class="admonition example">
<p class="admonition-title">\"Could not connect to remote server ... Is it running?\"</p>
<p>The client couldn't reach the URL. Confirm <code>unshackle serve</code> is running on the
server, the <code>url</code>/port in <code>remote_services</code> are correct (default port <code>8786</code>),
<p>The client could not reach the URL. Make sure that <code>unshackle serve</code> operates on
the server, the <code>url</code>/port in <code>remote_services</code> are correct (default port <code>8786</code>),
and any firewall or reverse proxy allows the connection.</p>
</div>
<div class="admonition example">
<p class="admonition-title"><code>403 FORBIDDEN</code> mid-download</p>
<p>The request came from a different IP than the one that created the session.
Sessions are IP-bound. Don't move between networks (or NAT egress IPs) during a
<p>The request came from a different IP than the one that made the remote session.
Remote sessions are IP-bound. Do not move between networks (or NAT egress IPs) during a
remote download.</p>
</div>
<div class="admonition example">
<p class="admonition-title">Auth times out or the prompt never resolves</p>
<p>Interactive auth allows up to 600s. If you miss that window the session is
marked <code>failed</code> and cleaned up; just re-run the download. If the server never
prompts you, check that credentials/cookies forwarded from the client are valid
for the service.</p>
<p>Interactive auth allows up to 600s. If you miss that window, the server marks the
remote session <code>failed</code> and cleans it up. Operate the download again. If the server
never prompts you, make sure that the credentials and cookies the client forwarded
are valid for the service.</p>
</div>
<div class="admonition example">
<p class="admonition-title">\"Multiple remote services configured. Use --server ...\"</p>