From 4bc470351b49a5b1935f15d020938d9bc02956f4 Mon Sep 17 00:00:00 2001 From: <> Date: Tue, 18 Aug 2026 14:27:38 +0000 Subject: [PATCH] Deployed 89acfab with MkDocs version: 1.6.1 --- 404.html | 30 + dev/creating-a-service/index.html | 132 +- dev/rest-api/authentication/index.html | 88 +- dev/rest-api/endpoints/index.html | 434 +-- dev/rest-api/errors/index.html | 88 +- dev/rest-api/index.html | 48 +- dev/rest-api/job-lifecycle/index.html | 72 +- dev/rest-api/quickstart/index.html | 240 +- dev/rest-api/remote-sessions/index.html | 64 +- dev/service-architecture/index.html | 50 +- getting-started/configuration-file/index.html | 36 +- getting-started/installation/index.html | 30 + getting-started/quickstart/index.html | 40 +- guide/cli-reference/index.html | 50 +- guide/downloading/index.html | 93 +- guide/drm-and-cdm/index.html | 36 +- guide/output-and-naming/index.html | 102 +- guide/proxies-and-vpn/index.html | 30 + guide/subtitles/index.html | 34 +- guide/troubleshooting/index.html | 46 +- guide/vaults/index.html | 46 +- index.html | 30 + reference/api/config/index.html | 32 +- reference/api/drm/index.html | 30 + reference/api/service/index.html | 1410 ++++----- reference/api/titles/index.html | 36 +- reference/api/tracks/index.html | 41 +- .../configuration/directories/index.html | 30 + reference/configuration/download/index.html | 34 +- reference/configuration/drm/index.html | 34 +- reference/configuration/example/index.html | 44 +- reference/configuration/index.html | 34 + reference/configuration/misc/index.html | 36 +- reference/configuration/network/index.html | 30 + reference/configuration/output/index.html | 46 +- .../configuration/post-scripts/index.html | 2743 +++++++++++++++++ reference/configuration/services/index.html | 36 +- reference/configuration/vaults/index.html | 36 +- search/search_index.json | 2 +- sitemap.xml | 76 +- sitemap.xml.gz | Bin 491 -> 501 bytes 41 files changed, 5226 insertions(+), 1323 deletions(-) create mode 100644 reference/configuration/post-scripts/index.html diff --git a/404.html b/404.html index 68b26178..ae8d2926 100644 --- a/404.html +++ b/404.html @@ -906,6 +906,8 @@ + + @@ -1165,6 +1167,34 @@ +
--proxy, the base class does a live IP check and, if your region is
blocked, fetches a proxy to GEOFENCE[0] from your configured proxy providers.
class EXAMPLE(Service):
- ALIASES = ("EX", "DOMAIN")
- GEOFENCE = ("US", "UK")
- VAULT_TAG = "DIFFERENT_NAME"
+ ALIASES = ("EX", "DOMAIN")
+ GEOFENCE = ("US", "UK")
+ VAULT_TAG = "DIFFERENT_NAME"
Service.__init__ wires up¶When you call super().__init__(ctx), the base class sets up the attributes you
@@ -2523,10 +2553,10 @@ That confusing output is the reason to keep it in sync, not any dispatch
requirement.
@staticmethod
-@click.command(name="EXAMPLE", short_help="https://domain.com", help=__doc__)
-@click.argument("title", type=str)
-@click.option("-m", "--movie", is_flag=True, default=False, help="Treat the title as a movie.")
-@click.option("-d", "--device", type=click.Choice(["android_tv", "web", "ios"]), default="android_tv")
+@click.command(name="EXAMPLE", short_help="https://domain.com", help=__doc__)
+@click.argument("title", type=str)
+@click.option("-m", "--movie", is_flag=True, default=False, help="Treat the title as a movie.")
+@click.option("-d", "--device", type=click.Choice(["android_tv", "web", "ios"]), default="android_tv")
@click.pass_context
def cli(ctx: click.Context, **kwargs: Any) -> EXAMPLE:
return EXAMPLE(ctx, **kwargs)
@@ -2555,15 +2585,15 @@ and stores self.credential. Do all token fetching here; it runs bef
get_titles.
def authenticate(self, cookies=None, credential=None) -> None:
super().authenticate(cookies, credential)
- self.session.headers.update({"user-agent": self.config["client"][self.device]["user_agent"]})
+ self.session.headers.update({"user-agent": self.config["client"][self.device]["user_agent"]})
- cache = self.cache.get(f"tokens_{self.device}_{self.profile}")
- if cache and cache.data.get("expires_in", 0) > int(datetime.now().timestamp()):
- self.log.info(" + Using cached tokens")
+ cache = self.cache.get(f"tokens_{self.device}_{self.profile}")
+ if cache and cache.data.get("expires_in", 0) > int(datetime.now().timestamp()):
+ self.log.info(" + Using cached tokens")
else:
- token = self.session.post(self.config["endpoints"]["login"], data=body).json()
- cache.set(data=token, expiration=token.get("expires_in"))
- self.token = cache.data["token"]
+ token = self.session.post(self.config["endpoints"]["login"], data=body).json()
+ cache.set(data=token, expiration=token.get("expires_in"))
+ self.token = cache.data["token"]
Cookies come from files under directories.cookies; credentials from the
credentials map in unshackle.yaml. For interactive prompts (OTP, captcha)
@@ -2621,19 +2651,19 @@ one title must be returned, or the ID is treated as invalid.
def get_titles(self) -> Titles_T:
match = re.match(self.TITLE_RE, self.title)
if not match:
- raise ValueError("Could not parse a title ID - is the URL/ID correct?")
+ raise ValueError("Could not parse a title ID - is the URL/ID correct?")
metadata = self.session.get(
- self.config["endpoints"]["metadata"].format(title_id=match.group("title_id")),
- params={"token": self.token},
+ self.config["endpoints"]["metadata"].format(title_id=match.group("title_id")),
+ params={"token": self.token},
).json()
- original_lang = Language.find(metadata["languages"][0])
+ original_lang = Language.find(metadata["languages"][0])
return Movies([
Movie(
- id_=metadata["id"],
+ id_=metadata["id"],
service=self.__class__,
- name=metadata["title"],
- year=metadata.get("releaseYear") or None,
+ name=metadata["title"],
+ year=metadata.get("releaseYear") or None,
language=original_lang, # ORIGINAL audio language, not the -l preference
data=metadata, # read later as title.data
)
@@ -2679,8 +2709,8 @@ when the API errors. It also applies the per-service title_map conf
Some services split one logical episode into several separately playable videos, for
example a variety show that ships episode 1 as Episode 1 (Part 1), (Part 2) and
(Part 3). Episode.number stays an int. The optional part carries the index.
-Episode(..., season=1, number=1, part=1, name="The Reckoning")
-Episode(..., season=1, number=1, part=2, name="The Reckoning")
+Episode(..., season=1, number=1, part=1, name="The Reckoning")
+Episode(..., season=1, number=1, part=2, name="The Reckoning")
part counts from 1, contiguous. part=0 is rejected, and -w can only address
@@ -2766,7 +2796,7 @@ codec×range in the TrackRequest, including HYBRID (fe
merge) and --best-available skip-on-error handling:
def get_tracks(self, title: Title_T) -> Tracks:
def fetch_variant(title, codec, range_) -> Tracks:
- vcodec = "H265" if codec == Video.Codec.HEVC else "H264"
+ vcodec = "H265" if codec == Video.Codec.HEVC else "H264"
return self.fetch_dash_manifest(title, vcodec=vcodec, range_=range_)
return self.get_tracks_for_variants(title, fetch_variant)
@@ -2808,8 +2838,8 @@ milliseconds, or a float in seconds.
def get_chapters(self, title: Title_T) -> Chapters:
chapters = Chapters()
seen: set[int] = set()
- for ch in title.data.get("chapters", []):
- chapter = Chapter(timestamp=ch["start"], name=ch.get("name"))
+ for ch in title.data.get("chapters", []):
+ chapter = Chapter(timestamp=ch["start"], name=ch.get("name"))
if chapter.timestamp == 0 or chapter.timestamp in seen:
continue # see the warning below
seen.add(chapter.timestamp)
@@ -2879,13 +2909,13 @@ current title and track:
def get_widevine_license(self, *, challenge: bytes, title, track):
response = self.session.post(
- self.license_data["url"],
+ self.license_data["url"],
data=challenge, # POST the challenge as-is
- headers={"dt-custom-data": self.license_data["data"]},
+ headers={"dt-custom-data": self.license_data["data"]},
)
response.raise_for_status()
try:
- return response.json()["license"] # some services wrap it in JSON
+ return response.json()["license"] # some services wrap it in JSON
except (ValueError, KeyError):
return response.content # others return raw bytes
@@ -2927,11 +2957,11 @@ flagged, banned, or downgraded.
on_track_repacked, on_track_multiplex. Also override search() to yield
SearchResult objects for unshackle search.
def search(self) -> Generator[SearchResult, None, None]:
- results = self.session.get(self.config["endpoints"]["search"],
- params={"q": self.title}).json()
- for r in results["entries"]:
- yield SearchResult(id_=r["id"], title=r["title"],
- description=r.get("description"), url=r.get("url"))
+ results = self.session.get(self.config["endpoints"]["search"],
+ params={"q": self.title}).json()
+ for r in results["entries"]:
+ yield SearchResult(id_=r["id"], title=r["title"],
+ description=r.get("description"), url=r.get("url"))
Minimal service skeleton¶
@@ -2952,22 +2982,22 @@ flagged, banned, or downgraded.
class MYSVC(Service):
- """
+ """
MyService - https://myservice.com
\b
Usage: unshackle dl MYSVC <title-id-or-url>
- """
+ """
- ALIASES = ("MY",)
- GEOFENCE = ("US",)
- TITLE_RE = r"^(?:https?://myservice\.com/watch/)?(?P<id>[\w-]+)"
+ ALIASES = ("MY",)
+ GEOFENCE = ("US",)
+ TITLE_RE = r"^(?:https?://myservice\.com/watch/)?(?P<id>[\w-]+)"
@staticmethod
- @click.command(name="MYSVC", short_help="https://myservice.com", help=__doc__)
- @click.argument("title", type=str)
+ @click.command(name="MYSVC", short_help="https://myservice.com", help=__doc__)
+ @click.argument("title", type=str)
@click.pass_context
- def cli(ctx: click.Context, **kwargs: Any) -> "MYSVC":
+ def cli(ctx: click.Context, **kwargs: Any) -> "MYSVC":
return MYSVC(ctx, **kwargs)
def __init__(self, ctx: click.Context, title: str):
@@ -2981,34 +3011,34 @@ flagged, banned, or downgraded.
def get_titles(self) -> Titles_T:
match = re.match(self.TITLE_RE, self.title)
if not match:
- raise ValueError("Could not parse a title ID.")
+ raise ValueError("Could not parse a title ID.")
meta = self.session.get(
- self.config["endpoints"]["metadata"].format(id=match.group("id"))
+ self.config["endpoints"]["metadata"].format(id=match.group("id"))
).json()
return Movies([
Movie(
- id_=meta["id"],
+ id_=meta["id"],
service=self.__class__,
- name=meta["title"],
- year=meta.get("year") or None,
- language=meta.get("originalLanguage"),
+ name=meta["title"],
+ year=meta.get("year") or None,
+ language=meta.get("originalLanguage"),
data=meta,
)
])
def get_tracks(self, title: Title_T) -> Tracks:
playback = self.session.get(
- self.config["endpoints"]["playback"].format(id=title.id)
+ self.config["endpoints"]["playback"].format(id=title.id)
).json()
return DASH.from_url(
- url=playback["manifest_url"], session=self.session
+ url=playback["manifest_url"], session=self.session
).to_tracks(language=title.language)
def get_chapters(self, title: Title_T) -> Chapters:
return Chapters()
def get_widevine_license(self, *, challenge: bytes, title, track):
- res = self.session.post(self.config["endpoints"]["license"], data=challenge)
+ res = self.session.post(self.config["endpoints"]["license"], data=challenge)
res.raise_for_status()
return res.content
diff --git a/dev/rest-api/authentication/index.html b/dev/rest-api/authentication/index.html
index 96b6808b..a0517b8d 100644
--- a/dev/rest-api/authentication/index.html
+++ b/dev/rest-api/authentication/index.html
@@ -919,6 +919,8 @@
+
+
@@ -1178,6 +1180,34 @@
+ -
+
+
+
+
+
+
+
+ Post-Download Scripts
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
@@ -2411,7 +2441,7 @@
- Start the server with
unshackle serve.
- Send that secret in the
X-Secret-Key header on every request except GET /api/health.
-A minimal authenticated requestcurl -H "X-Secret-Key: your-secret-key-here" \
+A minimal authenticated requestcurl -H "X-Secret-Key: your-secret-key-here" \
http://127.0.0.1:8786/api/services
Configuring authentication¶
@@ -2419,29 +2449,29 @@
A single API key¶
The simplest setup defines one shared secret with api_secret:
unshackle.yamlserve:
- api_secret: "a-long-random-secret-string"
+ api_secret: "a-long-random-secret-string"
This value becomes the one and only key that the server accepts. Requests that present it in the X-Secret-Key header are allowed; everything else is rejected.
The server will not start without a key
If you start unshackle serve with no api_secret configured, and you have not passed --no-key, the command fails immediately with:
-API secret key is not configured. Please add 'api_secret' to the 'serve' section in your config.
+
Choose a long, unpredictable value. The key is the only thing standing between the internet and your CDM and download endpoints.
Multiple keys with users¶
For finer control you can define named users, each with their own key. A user entry can also grant CDM device access and restrict which services that key may touch:
unshackle.yamlserve:
- api_secret: "the-primary-admin-key"
+ api_secret: "the-primary-admin-key"
users:
a-secret-key-for-alice:
username: alice
- devices: ["my_widevine_device"] # Widevine (.wvd) devices this key may use
- playready_devices: ["my_playready_prd"] # PlayReady (.prd) devices this key may use
- services: ["EXAMPLE1", "EXAMPLE2"] # optional per-key service allowlist
+ devices: ["my_widevine_device"] # Widevine (.wvd) devices this key may use
+ playready_devices: ["my_playready_prd"] # PlayReady (.prd) devices this key may use
+ services: ["EXAMPLE1", "EXAMPLE2"] # optional per-key service allowlist
a-secret-key-for-bob:
username: bob
- devices: ["my_widevine_device"]
+ devices: ["my_widevine_device"]
The key of each entry under users (for example a-secret-key-for-alice) is the secret that client sends in X-Secret-Key. The username is a human-readable label used in logs and in job/history metadata.
@@ -2501,24 +2531,24 @@
-
import requests
-BASE = "http://127.0.0.1:8786"
-HEADERS = {"X-Secret-Key": "your-secret-key-here"}
+BASE = "http://127.0.0.1:8786"
+HEADERS = {"X-Secret-Key": "your-secret-key-here"}
-resp = requests.get(f"{BASE}/api/services", headers=HEADERS, timeout=30)
+resp = requests.get(f"{BASE}/api/services", headers=HEADERS, timeout=30)
resp.raise_for_status()
print(resp.json())
-const resp = await fetch("http://127.0.0.1:8786/api/services", {
- headers: { "X-Secret-Key": "your-secret-key-here" },
+const resp = await fetch("http://127.0.0.1:8786/api/services", {
+ headers: { "X-Secret-Key": "your-secret-key-here" },
});
if (!resp.ok) throw new Error(`Server returned ${resp.status}`);
console.log(await resp.json());
@@ -2535,12 +2565,12 @@
Response{
- "status": "ok",
- "version": "5.3.0",
- "update_check": {
- "update_available": null,
- "current_version": "5.3.0",
- "latest_version": null
+ "status": "ok",
+ "version": "5.3.0",
+ "update_check": {
+ "update_available": null,
+ "current_version": "5.3.0",
+ "latest_version": null
}
}
@@ -2552,16 +2582,16 @@
Sending a request with no X-Secret-Key header:
Sending a key that is not in your config:
@@ -2613,12 +2643,12 @@
Restricting servicesserve:
- api_secret: "admin-key"
- services: ["EXAMPLE1", "EXAMPLE2", "EXAMPLE3"] # global ceiling for all keys
+ api_secret: "admin-key"
+ services: ["EXAMPLE1", "EXAMPLE2", "EXAMPLE3"] # global ceiling for all keys
users:
limited-key:
username: limited
- services: ["EXAMPLE1"] # this key ends up allowed only EXAMPLE1
+ services: ["EXAMPLE1"] # this key ends up allowed only EXAMPLE1
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.
CDM overrides and per-job credentials¶
@@ -2645,8 +2675,8 @@
Opting in to per-request CDM and credentialsserve:
- api_secret: "admin-key"
- cdm_overrides: ["my_widevine_device"] # or true to allow any configured device
+ api_secret: "admin-key"
+ cdm_overrides: ["my_widevine_device"] # or true to allow any configured device
allow_job_credentials: true
diff --git a/dev/rest-api/endpoints/index.html b/dev/rest-api/endpoints/index.html
index 92ff8e3e..19cce673 100644
--- a/dev/rest-api/endpoints/index.html
+++ b/dev/rest-api/endpoints/index.html
@@ -919,6 +919,8 @@
+
+
@@ -1178,6 +1180,34 @@
+
+
+
+
+
+
+
+
+ Post-Download Scripts
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -2859,13 +2889,13 @@
{
- "status": "ok",
- "version": "5.3.0",
- "code_hash": "1d22a1e",
- "update_check": {
- "update_available": false,
- "current_version": "5.3.0",
- "latest_version": "5.3.0"
+ "status": "ok",
+ "version": "5.3.0",
+ "code_hash": "1d22a1e",
+ "update_check": {
+ "update_available": false,
+ "current_version": "5.3.0",
+ "latest_version": "5.3.0"
}
}
@@ -2893,36 +2923,36 @@
-
{
- "services": [
+ "services": [
{
- "tag": "EXAMPLE",
- "aliases": ["EX"],
- "geofence": ["US"],
- "title_regex": "^https?://example\\.com/.+",
- "url": "https://example.com",
- "help": "EXAMPLE\nAuthorization: Cookies\nSecurity: FHD@L3",
- "cli_params": [
- {"name": "title", "kind": "argument", "required": true},
+ "tag": "EXAMPLE",
+ "aliases": ["EX"],
+ "geofence": ["US"],
+ "title_regex": "^https?://example\\.com/.+",
+ "url": "https://example.com",
+ "help": "EXAMPLE\nAuthorization: Cookies\nSecurity: FHD@L3",
+ "cli_params": [
+ {"name": "title", "kind": "argument", "required": true},
{
- "name": "movie",
- "kind": "option",
- "opts": ["-m", "--movie"],
- "is_flag": true,
- "default": false,
- "help": "Title is a movie.",
- "type": "boolean",
- "multiple": false
+ "name": "movie",
+ "kind": "option",
+ "opts": ["-m", "--movie"],
+ "is_flag": true,
+ "default": false,
+ "help": "Title is a movie.",
+ "type": "boolean",
+ "multiple": false
}
],
- "needs_auth": true,
- "has_search": true,
- "has_drm": true,
- "auth_methods": ["cookies"]
+ "needs_auth": true,
+ "has_search": true,
+ "has_drm": true,
+ "auth_methods": ["cookies"]
}
]
}
@@ -3058,23 +3088,23 @@
{
- "results": [
+ "results": [
{
- "id": "12345",
- "title": "Planet Earth",
- "description": "A nature documentary series.",
- "label": "Available",
- "url": "https://example.com/title/12345"
+ "id": "12345",
+ "title": "Planet Earth",
+ "description": "A nature documentary series.",
+ "label": "Available",
+ "url": "https://example.com/title/12345"
}
],
- "count": 1
+ "count": 1
}
@@ -3172,26 +3202,26 @@
{
- "titles": [
+ "titles": [
{
- "type": "episode",
- "name": "Pilot",
- "id": "ep-1",
- "language": "en",
- "description": "The first episode.",
- "date": "2020-01-01",
- "cover_url": "https://example.com/cover.jpg",
- "year": 2020,
- "series_title": "Example Show",
- "season": 1,
- "number": 1
+ "type": "episode",
+ "name": "Pilot",
+ "id": "ep-1",
+ "language": "en",
+ "description": "The first episode.",
+ "date": "2020-01-01",
+ "cover_url": "https://example.com/cover.jpg",
+ "year": 2020,
+ "series_title": "Example Show",
+ "season": 1,
+ "number": 1
}
]
}
@@ -3357,41 +3387,41 @@ is unaffected. See Split epis
{
- "title": { "type": "movie", "name": "Example Movie", "id": "12345", "year": 2021 },
- "video": [
+ "title": { "type": "movie", "name": "Example Movie", "id": "12345", "year": 2021 },
+ "video": [
{
- "id": "v-1", "codec": "H265", "codec_display": "HEVC",
- "bitrate": 8000, "width": 3840, "height": 2160,
- "resolution": "2160p", "fps": "24", "range": "HDR10",
- "range_display": "HDR10", "language": "en", "drm": [], "descriptor": "DASH"
+ "id": "v-1", "codec": "H265", "codec_display": "HEVC",
+ "bitrate": 8000, "width": 3840, "height": 2160,
+ "resolution": "2160p", "fps": "24", "range": "HDR10",
+ "range_display": "HDR10", "language": "en", "drm": [], "descriptor": "DASH"
}
],
- "audio": [
+ "audio": [
{
- "id": "a-1", "codec": "EC3", "codec_display": "DD+",
- "bitrate": 640, "channels": "5.1", "language": "en", "is_original": true,
- "atmos": false, "descriptive": false, "drm": [], "descriptor": "DASH"
+ "id": "a-1", "codec": "EC3", "codec_display": "DD+",
+ "bitrate": 640, "channels": "5.1", "language": "en", "is_original": true,
+ "atmos": false, "descriptive": false, "drm": [], "descriptor": "DASH"
}
],
- "subtitles": [
- { "id": "s-1", "codec": "vtt", "language": "en",
- "forced": false, "sdh": false, "cc": false, "descriptor": "DASH" }
+ "subtitles": [
+ { "id": "s-1", "codec": "vtt", "language": "en",
+ "forced": false, "sdh": false, "cc": false, "descriptor": "DASH" }
]
}
{
- "episodes": [
+ "episodes": [
{
- "title": { "type": "episode", "name": "Pilot", "id": "ep-1", "season": 1, "number": 1 },
- "video": [ ... ],
- "audio": [ ... ],
- "subtitles": [ ... ]
+ "title": { "type": "episode", "name": "Pilot", "id": "ep-1", "season": 1, "number": 1 },
+ "video": [ ... ],
+ "audio": [ ... ],
+ "subtitles": [ ... ]
}
],
- "unavailable_episodes": [
- { "title": { "id": "ep-2" }, "error": "..." }
+ "unavailable_episodes": [
+ { "title": { "id": "ep-2" }, "error": "..." }
]
}
@@ -3449,23 +3479,23 @@ is unaffected. See Split epis
curl -X POST http://127.0.0.1:8786/api/download \
- -H "X-Secret-Key: $KEY" \
- -H "Content-Type: application/json" \
- -d '{
- "service": "EXAMPLE",
- "title_id": "12345",
- "quality": [2160],
- "vcodec": "H265",
- "range": ["HDR10"],
- "wanted": ["S01E01"]
- }'
+ -H "X-Secret-Key: $KEY" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "service": "EXAMPLE",
+ "title_id": "12345",
+ "quality": [2160],
+ "vcodec": "H265",
+ "range": ["HDR10"],
+ "wanted": ["S01E01"]
+ }'
@@ -3731,6 +3761,14 @@ is unaffected. See Split epis
Validation. Invalid values return 400 INVALID_PARAMETERS. Notably: vcodec must be one of H264/H265/H.264/H.265/AVC/HEVC/VC1/VC-1/VP8/VP9/AV1; acodec one of AAC/AC3/EC3/EAC3/DD/DD+/AC4/OPUS/FLAC/ALAC/VORBIS/OGG/DTS; range one of SDR/HDR10/HDR10P/DV/HLG/HYBRID (HDR10+ is accepted); bitrate/worker/download counts must be positive integers; at most one of the *_only flags may be set; no_subs cannot be combined with subs_only, no_audio with audio_only, or s_lang with require_subs.
+
+Rejected parameters
+postscript, post_script and post_scripts are never accepted. A body containing any
+of them is rejected with 400 INVALID_PARAMETERS, because a command string arriving from
+an HTTP caller would be remote code execution. Post-download hooks are configured only in
+unshackle.yaml, where they still run for API jobs. See
+Post-download scripts.
+
Gated parameters (developer)
A few keys are accepted but only permitted when the server config opts in, otherwise the request is rejected with 403 FORBIDDEN:
@@ -3824,31 +3862,31 @@ is unaffected. See Split epis
-curl -H "X-Secret-Key: $KEY" \
- "http://127.0.0.1:8786/api/download/jobs?status=downloading&sort_by=progress&sort_order=desc"
+
{
- "jobs": [
+ "jobs": [
{
- "job_id": "b0f7c8e2-...",
- "status": "downloading",
- "created_time": "2026-07-03T12:00:00+00:00",
- "service": "EXAMPLE",
- "title_id": "12345",
- "title": "Example Show S01E01",
- "progress": 42.5,
- "phase": "downloading",
- "current_title": "Episode 1",
- "completed_tracks": 1,
- "total_tracks": 3,
- "active_tracks": ["v-1"],
- "track_progress": [{"label": "Video", "progress": 42.5, "speed": "5.2 MB/s"}],
- "segments_done": 120,
- "segments_total": 300,
- "speed": "5.2 MB/s",
- "skipped_subtitles": []
+ "job_id": "b0f7c8e2-...",
+ "status": "downloading",
+ "created_time": "2026-07-03T12:00:00+00:00",
+ "service": "EXAMPLE",
+ "title_id": "12345",
+ "title": "Example Show S01E01",
+ "progress": 42.5,
+ "phase": "downloading",
+ "current_title": "Episode 1",
+ "completed_tracks": 1,
+ "total_tracks": 3,
+ "active_tracks": ["v-1"],
+ "track_progress": [{"label": "Video", "progress": 42.5, "speed": "5.2 MB/s"}],
+ "segments_done": 120,
+ "segments_total": 300,
+ "speed": "5.2 MB/s",
+ "skipped_subtitles": []
}
]
}
@@ -3950,29 +3988,29 @@ is unaffected. See Split epis
const events = new EventSource(
`http://127.0.0.1:8786/api/download/jobs/${jobId}/events?secret_key=${key}`
);
-events.addEventListener("progress", (e) => console.log(JSON.parse(e.data).progress));
-events.addEventListener("completed", () => events.close());
+events.addEventListener("progress", (e) => console.log(JSON.parse(e.data).progress));
+events.addEventListener("completed", () => events.close());
The header always wins. Send the key in the query parameter or in the header, not both: if an X-Secret-Key header is present, the server checks that header and ignores the query parameter. See Authentication.
-
event: snapshot
-data: {"job_id":"b0f7c8e2-...","status":"downloading","progress":42.5,...}
+data: {"job_id":"b0f7c8e2-...","status":"downloading","progress":42.5,...}
event: progress
-data: {"job_id":"b0f7c8e2-...","status":"downloading","progress":48.0,...}
+data: {"job_id":"b0f7c8e2-...","status":"downloading","progress":48.0,...}
: keep-alive
event: completed
-data: {"job_id":"b0f7c8e2-...","status":"completed","progress":100.0,...}
+data: {"job_id":"b0f7c8e2-...","status":"completed","progress":100.0,...}
@@ -4044,7 +4082,7 @@ is unaffected. See Split epis
@@ -4059,9 +4097,9 @@ is unaffected. See Split epis
@@ -4108,7 +4146,7 @@ is unaffected. See Split epis
-
@@ -4148,9 +4186,9 @@ is unaffected. See Split epis
{
- "profiles": {
- "EXAMPLE": ["default", "premium"],
- "OTHER": ["us-account"]
+ "profiles": {
+ "EXAMPLE": ["default", "premium"],
+ "OTHER": ["us-account"]
}
}
@@ -4163,23 +4201,23 @@ is unaffected. See Split epis
{
- "config": {
- "dl": { "...": "redacted default dl parameters" },
- "serve": {
- "max_concurrent_downloads": 2,
- "job_retention_hours": 24,
- "history_limit": 100,
- "services": null,
- "remote_only": false,
- "cdm_overrides": null,
- "allow_job_credentials": false
+ "config": {
+ "dl": { "...": "redacted default dl parameters" },
+ "serve": {
+ "max_concurrent_downloads": 2,
+ "job_retention_hours": 24,
+ "history_limit": 100,
+ "services": null,
+ "remote_only": false,
+ "cdm_overrides": null,
+ "allow_job_credentials": false
},
- "directories": {
- "downloads": "/home/user/Downloads/unshackle",
- "temp": "/home/user/.unshackle/temp",
- "cache": "/home/user/.unshackle/cache"
+ "directories": {
+ "downloads": "/home/user/Downloads/unshackle",
+ "temp": "/home/user/.unshackle/temp",
+ "cache": "/home/user/.unshackle/cache"
},
- "services": ["EXAMPLE", "OTHER"]
+ "services": ["EXAMPLE", "OTHER"]
}
}
@@ -4217,21 +4255,21 @@ is unaffected. See Split epis
{
- "history": [
+ "history": [
{
- "job_id": "b0f7c8e2-...",
- "service": "EXAMPLE",
- "title_id": "12345",
- "title": "Example Show S01E01",
- "status": "completed",
- "created_time": "2026-07-03T12:00:00+00:00",
- "completed_time": "2026-07-03T12:08:00+00:00",
- "output_files": ["/downloads/Example.S01E01.mkv"],
- "error_message": null,
- "parameters": { "...": "redacted" }
+ "job_id": "b0f7c8e2-...",
+ "service": "EXAMPLE",
+ "title_id": "12345",
+ "title": "Example Show S01E01",
+ "status": "completed",
+ "created_time": "2026-07-03T12:00:00+00:00",
+ "completed_time": "2026-07-03T12:08:00+00:00",
+ "output_files": ["/downloads/Example.S01E01.mkv"],
+ "error_message": null,
+ "parameters": { "...": "redacted" }
}
],
- "count": 1
+ "count": 1
}
@@ -4291,7 +4329,7 @@ is unaffected. See Split epis
@@ -4323,9 +4361,9 @@ is unaffected. See Split epis
{
- "refreshed": true,
- "repos": [
- { "spec": "github.com/example/services", "updated": true, "changes": ["a1b2c3d Update EXAMPLE"] }
+ "refreshed": true,
+ "repos": [
+ { "spec": "github.com/example/services", "updated": true, "changes": ["a1b2c3d Update EXAMPLE"] }
]
}
@@ -4338,9 +4376,9 @@ is unaffected. See Split epis
{
- "checks": [
- { "name": "ffmpeg", "installed": true, "version": "6.1.1", "required": true },
- { "name": "ccextractor", "installed": false, "version": null, "required": false }
+ "checks": [
+ { "name": "ffmpeg", "installed": true, "version": "6.1.1", "required": true },
+ { "name": "ccextractor", "installed": false, "version": null, "required": false }
]
}
@@ -4355,7 +4393,7 @@ is unaffected. See Split epis
Sessions are held in memory. They expire after serve.session_ttl (default 300s), except while awaiting interactive authentication input, which uses a 600s window. A session is bound to the IP that created it, so a request from a different IP is rejected with 403 FORBIDDEN.
Session lifecycle overview¶
-POST /api/session/create → returns session_id, status "authenticating"
+POST /api/session/create → returns session_id, status "authenticating"
GET /api/session/{id}/prompt → poll until authenticated (submit answers via POST)
GET /api/session/{id}/titles → list titles
POST /api/session/{id}/tracks → tracks + chapters (with URLs) for one title
@@ -4369,9 +4407,9 @@ is unaffected. See Split epis
@@ -4408,19 +4446,19 @@ is unaffected. See Split epis
-
-
-
-
@@ -4456,7 +4494,7 @@ is unaffected. See Split epis
@@ -4493,8 +4531,8 @@ is unaffected. See Split epis
@@ -4507,16 +4545,16 @@ is unaffected. See Split epis
{
- "title": { "type": "episode", "name": "Pilot", "id": "ep-1" },
- "video": [ { "id": "v-1", "url": "https://...", "drm": [ ... ] } ],
- "audio": [ { "id": "a-1", "url": "https://..." } ],
- "subtitles": [ { "id": "s-1", "url": "https://..." } ],
- "chapters": [ { "timestamp": "00:00:00.000", "name": "Intro" } ],
- "attachments": [ { "url": "https://...", "name": "font.ttf", "mime_type": "font/ttf", "description": "" } ],
- "manifests": [ { "type": "dash", "url": "https://...", "data": "<base64 zlib xml>" } ],
- "session_headers": { "User-Agent": "..." },
- "session_cookies": { "sid": "..." },
- "server_cdm_type": "widevine"
+ "title": { "type": "episode", "name": "Pilot", "id": "ep-1" },
+ "video": [ { "id": "v-1", "url": "https://...", "drm": [ ... ] } ],
+ "audio": [ { "id": "a-1", "url": "https://..." } ],
+ "subtitles": [ { "id": "s-1", "url": "https://..." } ],
+ "chapters": [ { "timestamp": "00:00:00.000", "name": "Intro" } ],
+ "attachments": [ { "url": "https://...", "name": "font.ttf", "mime_type": "font/ttf", "description": "" } ],
+ "manifests": [ { "type": "dash", "url": "https://...", "data": "<base64 zlib xml>" } ],
+ "session_headers": { "User-Agent": "..." },
+ "session_cookies": { "sid": "..." },
+ "server_cdm_type": "widevine"
}
@@ -4554,8 +4592,8 @@ is unaffected. See Split epis
{
- "tracks": {
- "v-1": { "descriptor": "DASH", "url": "https://...", "drm": [ ... ], "headers": { ... }, "cookies": { ... }, "data": { ... } }
+ "tracks": {
+ "v-1": { "descriptor": "DASH", "url": "https://...", "drm": [ ... ], "headers": { ... }, "cookies": { ... }, "data": { ... } }
}
}
@@ -4594,11 +4632,11 @@ is unaffected. See Split epis
-
@@ -4607,11 +4645,11 @@ is unaffected. See Split epis
-
-
@@ -4693,12 +4731,12 @@ is unaffected. See Split epis
@@ -4711,8 +4749,8 @@ is unaffected. See Split epis
@@ -4723,13 +4761,13 @@ is unaffected. See Split epis
Error responses¶
Every structured error raised inside a handler is serialized to the same JSON envelope:
{
- "status": "error",
- "error_code": "INVALID_PARAMETERS",
- "message": "Invalid video codec: XYZ",
- "timestamp": "2026-07-03T12:00:00+00:00",
- "details": { "field": "vcodec" },
- "retryable": true,
- "debug_info": { "exception_type": "APIError", "traceback": "..." }
+ "status": "error",
+ "error_code": "INVALID_PARAMETERS",
+ "message": "Invalid video codec: XYZ",
+ "timestamp": "2026-07-03T12:00:00+00:00",
+ "details": { "field": "vcodec" },
+ "retryable": true,
+ "debug_info": { "exception_type": "APIError", "traceback": "..." }
}
diff --git a/dev/rest-api/errors/index.html b/dev/rest-api/errors/index.html
index d25621bc..a7bbb732 100644
--- a/dev/rest-api/errors/index.html
+++ b/dev/rest-api/errors/index.html
@@ -919,6 +919,8 @@
+
+
@@ -1178,6 +1180,34 @@
+ -
+
+
+
+
+
+
+
+ Post-Download Scripts
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
@@ -2213,15 +2243,15 @@ Nothing on this page is invented; it reflects the actual server behaviour.
A structured error is always a JSON object with status set to the literal string
"error". The full set of fields:
Error response body{
- "status": "error",
- "error_code": "INVALID_PARAMETERS",
- "message": "vcodec must be one of: H264, H265, ...",
- "timestamp": "2026-07-03T12:34:56.789012+00:00",
- "details": { "field": "vcodec" },
- "retryable": true,
- "debug_info": {
- "exception_type": "APIError",
- "traceback": "..."
+ "status": "error",
+ "error_code": "INVALID_PARAMETERS",
+ "message": "vcodec must be one of: H264, H265, ...",
+ "timestamp": "2026-07-03T12:34:56.789012+00:00",
+ "details": { "field": "vcodec" },
+ "retryable": true,
+ "debug_info": {
+ "exception_type": "APIError",
+ "traceback": "..."
}
}
@@ -2305,12 +2335,12 @@ it is absent, assume the request will keep failing until you change something.
A retryable network error
{
- "status": "error",
- "error_code": "NETWORK_ERROR",
- "message": "Network error occurred: Connection timed out",
- "timestamp": "2026-07-03T12:00:00+00:00",
- "details": { "reason": "network_connectivity" },
- "retryable": true
+ "status": "error",
+ "error_code": "NETWORK_ERROR",
+ "message": "Network error occurred: Connection timed out",
+ "timestamp": "2026-07-03T12:00:00+00:00",
+ "details": { "reason": "network_connectivity" },
+ "retryable": true
}
@@ -2526,11 +2556,11 @@ the message text.
the server returns INVALID_INPUT (400) with the parse error in
details.error:
Malformed request body{
- "status": "error",
- "error_code": "INVALID_INPUT",
- "message": "Invalid JSON request body",
- "timestamp": "2026-07-03T12:00:00+00:00",
- "details": { "error": "Expecting value: line 1 column 1 (char 0)" }
+ "status": "error",
+ "error_code": "INVALID_INPUT",
+ "message": "Invalid JSON request body",
+ "timestamp": "2026-07-03T12:00:00+00:00",
+ "details": { "error": "Expecting value: line 1 column 1 (char 0)" }
}
The authentication error exception¶
@@ -2541,11 +2571,11 @@ body that uses an integer status and no error_code or
-
-
@@ -2571,13 +2601,13 @@ to decode JSON, or the decode will fail on an empty string.
server was started with the --debug-api flag, every structured error gains a
debug_info object:
Error with --debug-api enabled{
- "status": "error",
- "error_code": "INTERNAL_ERROR",
- "message": "An unexpected error occurred: ...",
- "timestamp": "2026-07-03T12:00:00+00:00",
- "debug_info": {
- "exception_type": "KeyError",
- "traceback": "Traceback (most recent call last): ..."
+ "status": "error",
+ "error_code": "INTERNAL_ERROR",
+ "message": "An unexpected error occurred: ...",
+ "timestamp": "2026-07-03T12:00:00+00:00",
+ "debug_info": {
+ "exception_type": "KeyError",
+ "traceback": "Traceback (most recent call last): ..."
}
}
diff --git a/dev/rest-api/index.html b/dev/rest-api/index.html
index 3f8e24af..9d5a46c0 100644
--- a/dev/rest-api/index.html
+++ b/dev/rest-api/index.html
@@ -919,6 +919,8 @@
+
+
@@ -1178,6 +1180,34 @@
+ -
+
+
+
+
+
+
+
+ Post-Download Scripts
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
@@ -2193,12 +2223,12 @@ API key; only
/api/health is exempt from authentication.
Response{
- "status": "ok",
- "version": "5.3.0",
- "update_check": {
- "update_available": false,
- "current_version": "5.3.0",
- "latest_version": "5.3.0"
+ "status": "ok",
+ "version": "5.3.0",
+ "update_check": {
+ "update_available": false,
+ "current_version": "5.3.0",
+ "latest_version": "5.3.0"
}
}
@@ -2426,9 +2456,9 @@ history, config, maintenance) is only available in the full or --api-only<
With the default configuration, send your API secret in the X-Secret-Key header on
every request except /api/health:
An authenticated requestcurl -X POST http://127.0.0.1:8786/api/search \
- -H "X-Secret-Key: your-api-secret" \
- -H "Content-Type: application/json" \
- -d '{"service": "EXAMPLE", "query": "example"}'
+ -H "X-Secret-Key: your-api-secret" \
+ -H "Content-Type: application/json" \
+ -d '{"service": "EXAMPLE", "query": "example"}'
A missing or unknown key returns 401. The full model, including per-key service
allowlists, is covered in Authentication.
diff --git a/dev/rest-api/job-lifecycle/index.html b/dev/rest-api/job-lifecycle/index.html
index 4bf705b4..6f6a1762 100644
--- a/dev/rest-api/job-lifecycle/index.html
+++ b/dev/rest-api/job-lifecycle/index.html
@@ -919,6 +919,8 @@
+
+
@@ -1178,6 +1180,34 @@
+ -
+
+
+
+
+
+
+
+ Post-Download Scripts
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
@@ -2498,19 +2528,19 @@ a state change.
and a
title_id, plus any download options you want to set. On success the server
replies with 202 Accepted and the new job's identity:
Queue a downloadcurl -X POST http://127.0.0.1:8786/api/download \
- -H "X-Secret-Key: your-api-secret" \
- -H "Content-Type: application/json" \
- -d '{
- "service": "EXAMPLE",
- "title_id": "81234567",
- "quality": ["1080"],
- "range": ["HDR10"]
- }'
+ -H "X-Secret-Key: your-api-secret" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "service": "EXAMPLE",
+ "title_id": "81234567",
+ "quality": ["1080"],
+ "range": ["HDR10"]
+ }'
202 Accepted{
- "job_id": "b1e6c7de-6d0a-4f2f-9d1e-3a2b4c5d6e7f",
- "status": "queued",
- "created_time": "2026-07-03T12:00:00.000000"
+ "job_id": "b1e6c7de-6d0a-4f2f-9d1e-3a2b4c5d6e7f",
+ "status": "queued",
+ "created_time": "2026-07-03T12:00:00.000000"
}
The job starts in queued. Workers auto-start on the first job submitted, so there is
@@ -2531,15 +2561,15 @@ defaults, and validation rules are documented on the End
including parameters (with secrets redacted), timing, output files, and error
information if it failed.
GET /api/download/jobs lists every job the manager currently holds. By default
each entry is a summary. Pass full=true to get the same full detail as the
single-job endpoint for every job.
-curl "http://127.0.0.1:8786/api/download/jobs?status=downloading&sort_by=progress&sort_order=desc" \
- -H "X-Secret-Key: your-api-secret"
+curl "http://127.0.0.1:8786/api/download/jobs?status=downloading&sort_by=progress&sort_order=desc" \
+ -H "X-Secret-Key: your-api-secret"
The list can be filtered by status and service, and ordered with sort_by
(created_time, started_time, completed_time, progress, status, or
@@ -2630,9 +2660,9 @@ full detail:
A typical polling loop
JOB=b1e6c7de-6d0a-4f2f-9d1e-3a2b4c5d6e7f
while true; do
- curl -s "http://127.0.0.1:8786/api/download/jobs/$JOB" \
- -H "X-Secret-Key: your-api-secret" \
- | python -c 'import sys,json; d=json.load(sys.stdin); print(d["status"], d["progress"], d.get("phase"))'
+ curl -s "http://127.0.0.1:8786/api/download/jobs/$JOB" \
+ -H "X-Secret-Key: your-api-secret" \
+ | python -c 'import sys,json; d=json.load(sys.stdin); print(d["status"], d["progress"], d.get("phase"))'
sleep 1
done
@@ -2718,7 +2748,7 @@ both stops in-flight work and tidies up afterwards.
not try to decode JSON from it; check the status code first.
Cancel a running jobcurl -X DELETE http://127.0.0.1:8786/api/download/jobs/b1e6c7de-6d0a-4f2f-9d1e-3a2b4c5d6e7f \
- -H "X-Secret-Key: your-api-secret"
+ -H "X-Secret-Key: your-api-secret"
To remove all terminal jobs at once, use POST /api/download/jobs/clear-finished,
which returns {"removed": <count>}.
@@ -2735,9 +2765,9 @@ stays in its terminal state; the response gives you the new job's ID with
INVALID_SERVICE or FORBIDDEN if permissions changed.
202 Accepted, a fresh job{
- "job_id": "c2f7d8ef-7e1b-5a3a-ae2f-4b3c5d6e7f80",
- "status": "queued",
- "created_time": "2026-07-03T12:30:00.000000"
+ "job_id": "c2f7d8ef-7e1b-5a3a-ae2f-4b3c5d6e7f80",
+ "status": "queued",
+ "created_time": "2026-07-03T12:30:00.000000"
}
Prioritising a job¶
diff --git a/dev/rest-api/quickstart/index.html b/dev/rest-api/quickstart/index.html
index ee2b4dae..b2132b94 100644
--- a/dev/rest-api/quickstart/index.html
+++ b/dev/rest-api/quickstart/index.html
@@ -919,6 +919,8 @@
+
+
@@ -1178,6 +1180,34 @@
+ -
+
+
+
+
+
+
+
+ Post-Download Scripts
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
@@ -2231,15 +2261,15 @@
Every request (except the health check) is authenticated with a secret key sent in the X-Secret-Key header. The server reads this key from the serve section of your unshackle.yaml config.
At minimum you must set api_secret:
unshackle.yamlserve:
- api_secret: "choose-a-long-random-secret"
+ api_secret: "choose-a-long-random-secret"
If you want the API to be able to license and download DRM-protected content, you also need to grant CDM devices to the key. Configure that under serve.users, keyed by the secret:
unshackle.yamlserve:
- api_secret: "choose-a-long-random-secret"
+ api_secret: "choose-a-long-random-secret"
users:
choose-a-long-random-secret:
- devices: ["your_widevine_device"] # Widevine .wvd stems
- playready_devices: ["your_playready_device"] # PlayReady .prd stems
+ devices: ["your_widevine_device"] # Widevine .wvd stems
+ playready_devices: ["your_playready_device"] # PlayReady .prd stems
username: api_user
@@ -2317,21 +2347,21 @@
In --api-only mode (without --no-key), the Swagger UI at /api/docs/ requires the X-Secret-Key header just like every other route. Only /api/health is exempt from authentication.
For the rest of this page we will assume the server is reachable at http://127.0.0.1:8786 and store the secret in a shell variable:
-export SECRET="choose-a-long-random-secret"
-export BASE="http://127.0.0.1:8786"
+
Step 3: Confirm the server is alive¶
The health endpoint is the one route that never requires a key. Use it to confirm the server is running and to read its version:
-curl "$BASE/api/health"
+
200 OK{
- "status": "ok",
- "version": "5.3.0",
- "update_check": {
- "update_available": false,
- "current_version": "5.3.0",
- "latest_version": "5.3.0"
+ "status": "ok",
+ "version": "5.3.0",
+ "update_check": {
+ "update_available": false,
+ "current_version": "5.3.0",
+ "latest_version": "5.3.0"
}
}
@@ -2339,29 +2369,29 @@
Step 4: Authenticate a real request¶
Every other endpoint needs the X-Secret-Key header. A quick way to prove your key works is to list the services the server can see:
-curl "$BASE/api/services" \
- -H "X-Secret-Key: $SECRET"
+
200 OK (truncated){
- "services": [
+ "services": [
{
- "tag": "EXAMPLE",
- "aliases": ["EX"],
- "geofence": [],
- "title_regex": null,
- "url": "https://example.com",
- "help": "EXAMPLE service documentation...",
- "needs_auth": true,
- "has_search": true,
- "has_drm": true,
- "auth_methods": ["cookies", "credentials"],
- "cli_params": []
+ "tag": "EXAMPLE",
+ "aliases": ["EX"],
+ "geofence": [],
+ "title_regex": null,
+ "url": "https://example.com",
+ "help": "EXAMPLE service documentation...",
+ "needs_auth": true,
+ "has_search": true,
+ "has_drm": true,
+ "auth_methods": ["cookies", "credentials"],
+ "cli_params": []
}
]
}
If the key is missing or wrong, you get a 401 with a plain body:
-401 Unauthorized{ "status": 401, "message": "Secret Key is Invalid." }
+
Two error shapes exist
@@ -2372,40 +2402,40 @@
Step 5: Submit a download job¶
Downloads are asynchronous. You POST a job to /api/download, the server immediately hands back a job_id, and the actual download runs in the background so your request never blocks.
The only required fields are service and title_id. Everything else mirrors the flags of the dl command and has a sensible default, so a minimal job looks like this:
-curl -X POST "$BASE/api/download" \
- -H "X-Secret-Key: $SECRET" \
- -H "Content-Type: application/json" \
- -d '{
- "service": "EXAMPLE",
- "title_id": "12345"
- }'
+curl -X POST "$BASE/api/download" \
+ -H "X-Secret-Key: $SECRET" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "service": "EXAMPLE",
+ "title_id": "12345"
+ }'
A more realistic job pins the quality and codecs and asks for specific episodes:
-curl -X POST "$BASE/api/download" \
- -H "X-Secret-Key: $SECRET" \
- -H "Content-Type: application/json" \
- -d '{
- "service": "EXAMPLE",
- "title_id": "12345",
- "quality": [1080],
- "vcodec": "H265",
- "range": ["SDR"],
- "wanted": ["S01E01", "S01E02"]
- }'
+curl -X POST "$BASE/api/download" \
+ -H "X-Secret-Key: $SECRET" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "service": "EXAMPLE",
+ "title_id": "12345",
+ "quality": [1080],
+ "vcodec": "H265",
+ "range": ["SDR"],
+ "wanted": ["S01E01", "S01E02"]
+ }'
On success the server responds with 202 Accepted and the identifier you will poll:
202 Accepted{
- "job_id": "3f9c1e0a-8d2b-4e6f-9a1c-77b0f2a1c4d5",
- "status": "queued",
- "created_time": "2026-07-03T12:00:00.000000+00:00"
+ "job_id": "3f9c1e0a-8d2b-4e6f-9a1c-77b0f2a1c4d5",
+ "status": "queued",
+ "created_time": "2026-07-03T12:00:00.000000+00:00"
}
Capture the job_id for the next step:
-JOB=$(curl -s -X POST "$BASE/api/download" \
- -H "X-Secret-Key: $SECRET" \
- -H "Content-Type: application/json" \
- -d '{"service":"EXAMPLE","title_id":"12345","quality":[1080]}' \
- | python -c "import sys, json; print(json.load(sys.stdin)['job_id'])")
+JOB=$(curl -s -X POST "$BASE/api/download" \
+ -H "X-Secret-Key: $SECRET" \
+ -H "Content-Type: application/json" \
+ -d '{"service":"EXAMPLE","title_id":"12345","quality":[1080]}' \
+ | python -c "import sys, json; print(json.load(sys.stdin)['job_id'])")
Frequently used download fields¶
The request body accepts the full download parameter set. A few of the most useful:
@@ -2491,44 +2521,44 @@
The last three are terminal. Poll the job detail endpoint until you reach one of them. This endpoint always returns the full job record:
-curl "$BASE/api/download/jobs/$JOB" \
- -H "X-Secret-Key: $SECRET"
+
While the download is running you will see status: "downloading" and a progress value climbing from 0 toward 100:
200 OK (in progress){
- "job_id": "3f9c1e0a-8d2b-4e6f-9a1c-77b0f2a1c4d5",
- "status": "downloading",
- "progress": 42.7,
- "phase": "downloading",
- "service": "EXAMPLE",
- "title_id": "12345",
- "title": "Example Show S01E01",
- "current_title": "Example Show S01E01",
- "completed_tracks": 1,
- "total_tracks": 3,
- "segments_done": 512,
- "segments_total": 1200,
- "speed": "8.4 MiB/s",
- "created_time": "2026-07-03T12:00:00.000000+00:00",
- "started_time": "2026-07-03T12:00:03.000000+00:00"
+ "job_id": "3f9c1e0a-8d2b-4e6f-9a1c-77b0f2a1c4d5",
+ "status": "downloading",
+ "progress": 42.7,
+ "phase": "downloading",
+ "service": "EXAMPLE",
+ "title_id": "12345",
+ "title": "Example Show S01E01",
+ "current_title": "Example Show S01E01",
+ "completed_tracks": 1,
+ "total_tracks": 3,
+ "segments_done": 512,
+ "segments_total": 1200,
+ "speed": "8.4 MiB/s",
+ "created_time": "2026-07-03T12:00:00.000000+00:00",
+ "started_time": "2026-07-03T12:00:03.000000+00:00"
}
When it finishes you will see status: "completed" and the produced files under output_files:
200 OK (completed){
- "job_id": "3f9c1e0a-8d2b-4e6f-9a1c-77b0f2a1c4d5",
- "status": "completed",
- "progress": 100.0,
- "output_files": ["/path/to/downloads/Example Show S01E01.mkv"],
- "completed_time": "2026-07-03T12:04:31.000000+00:00"
+ "job_id": "3f9c1e0a-8d2b-4e6f-9a1c-77b0f2a1c4d5",
+ "status": "completed",
+ "progress": 100.0,
+ "output_files": ["/path/to/downloads/Example Show S01E01.mkv"],
+ "completed_time": "2026-07-03T12:04:31.000000+00:00"
}
A simple polling loop in shell:
poll.shwhile true; do
- STATUS=$(curl -s "$BASE/api/download/jobs/$JOB" \
- -H "X-Secret-Key: $SECRET" \
- | python -c "import sys, json; print(json.load(sys.stdin)['status'])")
- echo "job status: $STATUS"
- case "$STATUS" in
+ STATUS=$(curl -s "$BASE/api/download/jobs/$JOB" \
+ -H "X-Secret-Key: $SECRET" \
+ | python -c "import sys, json; print(json.load(sys.stdin)['status'])")
+ echo "job status: $STATUS"
+ case "$STATUS" in
completed|failed|cancelled) break ;;
esac
sleep 3
@@ -2541,16 +2571,16 @@
Or let the server push the updates
GET /api/download/jobs/{job_id}/events streams the same job record as Server-Sent Events, and closes the stream when the job ends. It removes the polling loop above. See Endpoints.
-
If a job ends in failed, the full record includes error_message, error_code, and (when the server was started with --debug-api) error_traceback and worker_stderr to help you diagnose it. Sensitive values in these fields are scrubbed before they are returned.
Step 7: Clean up¶
Once you have collected the output you can remove the finished job. DELETE on a job has two behaviors depending on its state:
-curl -X DELETE "$BASE/api/download/jobs/$JOB" \
- -H "X-Secret-Key: $SECRET"
+
- If the job is terminal (completed/failed/cancelled) it is removed from the manager and you get
204 No Content with an empty body.
@@ -2561,10 +2591,10 @@
A 204 response has no body. This applies to terminal-job removal here and to deleting history entries; check the status code before attempting to decode JSON.
To clear every finished job at once instead of one at a time:
-curl -X POST "$BASE/api/download/jobs/clear-finished" \
- -H "X-Secret-Key: $SECRET"
+
-200 OK{ "removed": 3 }
+
Managing the queue¶
@@ -2611,21 +2641,21 @@
Listing supports query parameters for filtering by status or service, sorting via sort_by (created_time, started_time, completed_time, progress, status, service) and sort_order (asc / desc), and requesting full per-job detail with full=true:
-curl "$BASE/api/download/jobs?status=downloading&sort_by=progress&sort_order=desc" \
- -H "X-Secret-Key: $SECRET"
+curl "$BASE/api/download/jobs?status=downloading&sort_by=progress&sort_order=desc" \
+ -H "X-Secret-Key: $SECRET"
200 OK{
- "jobs": [
+ "jobs": [
{
- "job_id": "3f9c1e0a-8d2b-4e6f-9a1c-77b0f2a1c4d5",
- "status": "downloading",
- "service": "EXAMPLE",
- "title_id": "12345",
- "title": "Example Show S01E01",
- "progress": 42.7,
- "phase": "downloading",
- "completed_tracks": 1,
- "total_tracks": 3
+ "job_id": "3f9c1e0a-8d2b-4e6f-9a1c-77b0f2a1c4d5",
+ "status": "downloading",
+ "service": "EXAMPLE",
+ "title_id": "12345",
+ "title": "Example Show S01E01",
+ "progress": 42.7,
+ "phase": "downloading",
+ "completed_tracks": 1,
+ "total_tracks": 3
}
]
}
@@ -2646,11 +2676,11 @@
Error handling¶
Apart from the auth middleware's short form, every error the API returns uses a single structured envelope:
{
- "status": "error",
- "error_code": "INVALID_PARAMETERS",
- "message": "Human-readable description",
- "timestamp": "2026-07-03T12:00:00Z",
- "details": { }
+ "status": "error",
+ "error_code": "INVALID_PARAMETERS",
+ "message": "Human-readable description",
+ "timestamp": "2026-07-03T12:00:00Z",
+ "details": { }
}
details appears only when there is something to add, retryable: true appears only for transient failures, and a debug_info block (exception type and traceback) is included only when the server runs with --debug-api.
diff --git a/dev/rest-api/remote-sessions/index.html b/dev/rest-api/remote-sessions/index.html
index 9d55d09a..c3417104 100644
--- a/dev/rest-api/remote-sessions/index.html
+++ b/dev/rest-api/remote-sessions/index.html
@@ -919,6 +919,8 @@
+
+
@@ -1178,6 +1180,34 @@
+ -
+
+
+
+
+
+
+
+ Post-Download Scripts
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
@@ -2355,15 +2385,15 @@ see Authentication and the endpoint reference i
entry names a server and provides its URL and API key.
unshackle.yaml (client)
remote_services:
my_server:
- url: "https://my-box.example:8786"
- api_key: "your-api-key"
- # Optional: let the server's CDM do the licensing instead of a local CDM
+ url: "https://my-box.example:8786"
+ api_key: "your-api-key"
+ # Optional: let the server's CDM do the licensing instead of a local CDM
server_cdm: false
# Optional: per-service config overrides applied locally (title_map, cdm, etc.)
services:
EXAMPLE:
title_map:
- "0ABCDEF": "The Show (Renamed Locally)"
+ "0ABCDEF": "The Show (Renamed Locally)"
Per-session cache namespacing¶
The create handler builds a Cacher namespaced as:
-_sessions/<pbkdf2_hmac(sha256, X-Secret-Key, "unshackle-session-ns", 100000)[:12]>/<session_id>/<service>
+_sessions/<pbkdf2_hmac(sha256, X-Secret-Key, "unshackle-session-ns", 100000)[:12]>/<session_id>/<service>
Forwarded cache files are written into that directory before authentication.
On DELETE, the handler harvests updated cache files (compressing each with zlib
and base64-encoding, excluding titles_* files) and returns them under a
cache key so the client can persist refreshed tokens:
-{ "status": "ok", "cache": { "tokens": "...base64(zlib(bytes))..." } }
+
Session info response¶
GET /api/session/{id}:
{
- "session_id": "...",
- "service": "EXAMPLE",
- "valid": true,
- "expires_in": 300,
- "track_count": 12,
- "title_count": 40
+ "session_id": "...",
+ "service": "EXAMPLE",
+ "valid": true,
+ "expires_in": 300,
+ "track_count": 12,
+ "title_count": 40
}
diff --git a/dev/service-architecture/index.html b/dev/service-architecture/index.html
index 06bbbbaf..f10fc0c2 100644
--- a/dev/service-architecture/index.html
+++ b/dev/service-architecture/index.html
@@ -919,6 +919,8 @@
+
+
@@ -1178,6 +1180,34 @@
+ -
+
+
+
+
+
+
+
+ Post-Download Scripts
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
@@ -3103,23 +3133,23 @@ prepared, all derived from the Click context (
ctx) that dl
class EXMP(Service):
- ALIASES = ("example",)
- GEOFENCE = ("us",)
+ ALIASES = ("example",)
+ GEOFENCE = ("us",)
def get_titles(self) -> Movies:
- data = self.session.get(f"https://api.example.com/title/{self.title}").json()
+ data = self.session.get(f"https://api.example.com/title/{self.title}").json()
return Movies([
Movie(
- id_=data["id"],
+ id_=data["id"],
service=self.__class__,
- name=data["name"],
- year=data.get("year"),
- language=data.get("language"),
+ name=data["name"],
+ year=data.get("year"),
+ language=data.get("language"),
)
])
- def get_tracks(self, title) -> "Tracks":
- mpd_url = self.session.get(...).json()["manifest"]
+ def get_tracks(self, title) -> "Tracks":
+ mpd_url = self.session.get(...).json()["manifest"]
mpd = self.session.get(mpd_url).text
return DASH.from_text(mpd, mpd_url).to_tracks(language=title.language)
@@ -3127,7 +3157,7 @@ prepared, all derived from the Click context (ctx) that dl return []
def get_widevine_license(self, *, challenge, title, track):
- return self.session.post("https://lic.example.com/wv", data=challenge).content
+ return self.session.post("https://lic.example.com/wv", data=challenge).content
The directory name (EXMP) must exactly match the class name; that stem is the
service tag. Put your service in one of the directories.services paths.
diff --git a/getting-started/configuration-file/index.html b/getting-started/configuration-file/index.html
index 4550bddc..4cf22abf 100644
--- a/getting-started/configuration-file/index.html
+++ b/getting-started/configuration-file/index.html
@@ -1076,6 +1076,8 @@
+
+
@@ -1335,6 +1337,34 @@
+ -
+
+
+
+
+
+
+
+ Post-Download Scripts
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
@@ -2206,7 +2236,7 @@
default: my_device_l3
EXAMPLE: my_device_l1
-# Per-service login details (service tag -> "username:password")
+# Per-service login details (service tag -> "username:password")
credentials:
EXAMPLE: my_email@example.com:hunter2
@@ -2260,6 +2290,10 @@
update_checks, redact_paths, debug, unicode_filenames
+Post-download scripts
+post_scripts
+
+
Paths
directories, filenames
diff --git a/getting-started/installation/index.html b/getting-started/installation/index.html
index 70f8ab2a..c5fd3312 100644
--- a/getting-started/installation/index.html
+++ b/getting-started/installation/index.html
@@ -1121,6 +1121,8 @@
+
+
@@ -1380,6 +1382,34 @@
+ -
+
+
+
+
+
+
+
+ Post-Download Scripts
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
diff --git a/getting-started/quickstart/index.html b/getting-started/quickstart/index.html
index cfb1e783..f00cf1eb 100644
--- a/getting-started/quickstart/index.html
+++ b/getting-started/quickstart/index.html
@@ -1104,6 +1104,8 @@
+
+
@@ -1363,6 +1365,34 @@
+
-
+
+
+
+
+
+
+
+ Post-Download Scripts
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
@@ -2241,8 +2271,8 @@ locations.
That means
output_template is the one setting a first run genuinely requires.
Everything else has a sensible default. A minimal, working config looks like this:
unshackle.yamloutput_template:
- movies: "{title} ({year}) {quality} {source}"
- series: "{title} {season_episode} {episode_name?} {quality} {source}"
+ movies: "{title} ({year}) {quality} {source}"
+ series: "{title} {season_episode} {episode_name?} {quality} {source}"
Each {variable} is filled in from the title and the tracks you downloaded. The
full set of valid variables, including resolution, video, audio, hdr,
@@ -2278,7 +2308,7 @@ Widevine device you already have with:
at it in your config. The cdm map is keyed by service tag, with a default that
covers everything else:
unshackle.yamlcdm:
- default: my_device # the .wvd file's name, without the extension
+ default: my_device # the .wvd file's name, without the extension
EXAMPLE: my_other_device # override for a specific service
PlayReady works the same way with .prd files created and managed by the
@@ -2301,7 +2331,7 @@ contents, and unshackle env info to confirm where WVDs and PRDs are
-
Credentials: store a username and password per service in your config:
@@ -2311,7 +2341,7 @@ depends on the service module.
6. Find a title (optional)¶
If you have a URL or ID already, skip this. Otherwise, search the service for a
title and note the id it prints, since that is what you feed to dl:
-unshackle search EXAMPLE "My Show"
+
You can also list what a service exposes for a given title without downloading:
unshackle dl --list-titles EXAMPLE 81234567 # show seasons/episodes
diff --git a/guide/cli-reference/index.html b/guide/cli-reference/index.html
index 254ca573..c54f3c5e 100644
--- a/guide/cli-reference/index.html
+++ b/guide/cli-reference/index.html
@@ -1425,6 +1425,8 @@
+
+
@@ -1684,6 +1686,34 @@
+ -
+
+
+
+
+
+
+
+ Post-Download Scripts
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
@@ -2893,7 +2923,7 @@
unshackle dl -q 1080 EXAMPLE 81234567
# 4K HDR10, English audio + subs, from a URL
-unshackle dl -q 2160 -r HDR10 -l en EXAMPLE "https://www.example.com/..."
+unshackle dl -q 2160 -r HDR10 -l en EXAMPLE "https://www.example.com/..."
# A season range, HEVC video, EC3 audio
unshackle dl -w S01-S03 -v H.265 -a EC3 SERVICE TITLE-ID
@@ -3173,6 +3203,10 @@ configuration file.
--repack
Add a REPACK tag to the filename.
+
+--postscript
+Run a command after each output file, with {variable} placeholders substituted. Repeatable. Replaces the post_scripts config for this run. See Post-download scripts.
+
Metadata & tagging¶
@@ -3383,9 +3417,9 @@ and keeps the service's numbering unchanged. Pick an order that covers the whole
Example
-unshackle search EXAMPLE "My Show"
-unshackle search -p myprofile EXAMPLE "Another Show"
-unshackle search --proxy nordvpn:ca SERVICE "query"
+
@@ -3660,7 +3694,7 @@ and keeps the service's numbering unchanged. Pick an order that covers the whole
@@ -3797,11 +3831,11 @@ and keeps the service's numbering unchanged. Pick an order that covers the whole
Configure api_secret first
Unless you pass --no-key, serve.api_secret must be set in your config. Requests authenticate with the X-Secret-Key header. Running with --no-key disables authentication entirely and should only be used on a trusted, private network.
unshackle.yamlserve:
- api_secret: "your-api-secret"
+ api_secret: "your-api-secret"
users:
your-secret-key:
- devices: ["device_name"] # Widevine devices
- playready_devices: ["device_name"] # PlayReady devices
+ devices: ["device_name"] # Widevine devices
+ playready_devices: ["device_name"] # PlayReady devices
username: user
diff --git a/guide/downloading/index.html b/guide/downloading/index.html
index 759e7abc..193b766e 100644
--- a/guide/downloading/index.html
+++ b/guide/downloading/index.html
@@ -1124,6 +1124,17 @@
+
+
+ -
+
+
+
+ After the download
+
+
+
+
-
@@ -1476,6 +1487,8 @@
+
+
@@ -1735,6 +1748,34 @@
+
-
+
+
+
+
+
+
+
+ Post-Download Scripts
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
@@ -2800,6 +2841,17 @@
+
+
+ -
+
+
+
+ After the download
+
+
+
+
-
@@ -2964,7 +3016,7 @@ argument.
-Flags belong to dl, the title belongs to the service
unshackle dl -q 1080 -v H.265 -r HDR10 --lang en EXAMPLE 'https://www.example.com/...'
+Flags belong to dl, the title belongs to the serviceunshackle dl -q 1080 -v H.265 -r HDR10 --lang en EXAMPLE 'https://www.example.com/...'
See what a service accepts
@@ -3107,7 +3159,7 @@ either enum names or their values, comma-separ
-Grab HDR10 and Dolby Vision in one rununshackle dl -q 2160 -r HDR10,DV EXAMPLE '...'
+
HYBRID requires dovi_tool
@@ -3635,19 +3687,29 @@ flags change how the output is assembled:
+After the download¶
+--postscript "<command>" runs your own command once per output file, with unshackle's
+metadata substituted into {variable} placeholders. It is repeatable, and it replaces the
+post_scripts config for that run. Nothing runs under --no-mux, since no output is
+written.
+Hand each finished file to an uploaderunshackle dl --postscript "python /opt/upload.py {filepath} --service={service}" EXAMPLE 81234567
+
+For hooks that persist across runs, for the season and run modes, for failure hooks
+and for the full variable list, see
+Post-download scripts.
Naming tags¶
--tag: set the release group tag (overrides the configured tag).
--repack: add a REPACK tag to the output filename.
-Custom group tag and a REPACK labelunshackle dl --tag MYGRP --repack EXAMPLE 81234567
+
Proxies¶
--proxy accepts a full proxy URI, a 2-letter country code (resolved through your
configured proxy providers), or a provider:region form.
-Proxy formsunshackle dl --proxy us EXAMPLE 81234567
-unshackle dl --proxy nordvpn:ca EXAMPLE 81234567
-unshackle dl --proxy 'http://user:pass@host:8080' EXAMPLE 81234567
+Proxy formsunshackle dl --proxy us EXAMPLE 81234567
+unshackle dl --proxy nordvpn:ca EXAMPLE 81234567
+unshackle dl --proxy 'http://user:pass@host:8080' EXAMPLE 81234567
Two related flags:
@@ -3699,9 +3761,9 @@ configured proxy providers), or a provider:region form.
-Two tracks at a time, eight threads eachunshackle dl --downloads 2 --workers 8 EXAMPLE 81234567
+
-Space out a season downloadunshackle dl -w S01 --slow 30-60 EXAMPLE 81234567
+
Keys, vaults, and export¶
By default, unshackle checks your key vaults first and only asks a CDM to license
@@ -3710,12 +3772,12 @@ a key when the vault misses. You can force one side or the other:
--cdm-only: only use the CDM (skip vaults).
--vaults-only: only use key vaults (never license via the CDM); a missing key fails.
-Fetch keys only, no downloadunshackle dl --skip-dl EXAMPLE 81234567
+
--export writes a JSON file, into the configured exports directory, containing track
info and the acquired content keys for each title. This is the format consumed by
unshackle import to reconstruct a download later.
-Export track info and keysunshackle dl --skip-dl --export EXAMPLE 81234567
+
Region is recorded only with a proxy
@@ -3779,9 +3841,9 @@ lookups but keep the IDs you give, set
-Force the right IMDb match, and keep the service's own namingunshackle dl --imdb tt1375666 EXAMPLE 81234567
+Force the right IMDb match, and keep the service's own namingunshackle dl --imdb tt1375666 EXAMPLE 81234567
-Force the match and take IMDb's title, year and language toounshackle dl --imdb tt1375666 --enrich EXAMPLE 81234567
+Force the match and take IMDb's title, year and language toounshackle dl --imdb tt1375666 --enrich EXAMPLE 81234567
What each metadata provider supplies¶
Providers differ in what they answer with, so the fields --enrich can replace depend on
@@ -3895,7 +3957,7 @@ wrong ID can pick the wrong audio track and not only write a wrong name. Without
Futurama in TVDB's alternate (Streaming) order, for example, while TVDB's official order
holds back four season-one episodes to the start of season two. --tvdb-order works out which
order the service used, then renumbers the episodes into the order you asked for:
-