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 @@ +
  • + + + + + + + + Post-Download Scripts + + + + + + + + +
  • + + + + + + + + + +
  • diff --git a/dev/creating-a-service/index.html b/dev/creating-a-service/index.html index 5b58c550..6fb58759 100644 --- a/dev/creating-a-service/index.html +++ b/dev/creating-a-service/index.html @@ -919,6 +919,8 @@ + + @@ -1178,6 +1180,34 @@ +
  • + + + + + + + + Post-Download Scripts + + + + + + + + +
  • + + + + + + + + + +
  • @@ -2426,9 +2456,9 @@ subtitle work for services that never carry subtitles; leave it off otherwise.--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"
     

    What 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")
     
    1. 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 @@ +
    2. + + + + + + + + Post-Download Scripts + + + + + + + + +
    3. + + + + + + + + + +
    4. @@ -2411,7 +2441,7 @@
    5. Start the server with unshackle serve.
    6. Send that secret in the X-Secret-Key header on every request except GET /api/health.
    -
    A minimal authenticated request
    curl -H "X-Secret-Key: your-secret-key-here" \
    +
    A minimal authenticated request
    curl -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.yaml
    serve:
    -  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.
    +
    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.yaml
    serve:
    -  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 @@
    -
    curl -H "X-Secret-Key: your-secret-key-here" \
    +
    curl -H "X-Secret-Key: your-secret-key-here" \
          http://127.0.0.1:8786/api/services
     
    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 @@
     
    No key needed
    curl http://127.0.0.1:8786/api/health
     
    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:

    {
    -  "status": 401,
    -  "message": "Secret Key is Empty."
    +  "status": 401,
    +  "message": "Secret Key is Empty."
     }
     

    Sending a key that is not in your config:

    {
    -  "status": 401,
    -  "message": "Secret Key is Invalid."
    +  "status": 401,
    +  "message": "Secret Key is Invalid."
     }
     
    @@ -2613,12 +2643,12 @@
    Restricting services
    serve:
    -  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 credentials
    serve:
    -  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 @@
    -
    curl -H "X-Secret-Key: $KEY" http://127.0.0.1:8786/api/services
    +
    curl -H "X-Secret-Key: $KEY" http://127.0.0.1:8786/api/services
     
    {
    -  "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 @@
     
    curl -X POST http://127.0.0.1:8786/api/search \
    -  -H "X-Secret-Key: $KEY" \
    -  -H "Content-Type: application/json" \
    -  -d '{"service": "EXAMPLE", "query": "planet earth"}'
    +  -H "X-Secret-Key: $KEY" \
    +  -H "Content-Type: application/json" \
    +  -d '{"service": "EXAMPLE", "query": "planet earth"}'
     
    {
    -  "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 @@
    curl -X POST http://127.0.0.1:8786/api/list-titles \
    -  -H "X-Secret-Key: $KEY" \
    -  -H "Content-Type: application/json" \
    -  -d '{"service": "EXAMPLE", "title_id": "12345"}'
    +  -H "X-Secret-Key: $KEY" \
    +  -H "Content-Type: application/json" \
    +  -d '{"service": "EXAMPLE", "title_id": "12345"}'
     
    {
    -  "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"]
    +  }'
     
    {
    -  "job_id": "b0f7c8e2-4a1d-4e9c-9b7e-2f3a1c0d5e6f",
    -  "status": "queued",
    -  "created_time": "2026-07-03T12:00:00+00:00"
    +  "job_id": "b0f7c8e2-4a1d-4e9c-9b7e-2f3a1c0d5e6f",
    +  "status": "queued",
    +  "created_time": "2026-07-03T12:00:00+00:00"
     }
     
    @@ -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.

    +

    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"
    +
    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.

    -
    curl -N -H "X-Secret-Key: $KEY" \
    -  "http://127.0.0.1:8786/api/download/jobs/b0f7c8e2-.../events"
    +
    curl -N -H "X-Secret-Key: $KEY" \
    +  "http://127.0.0.1:8786/api/download/jobs/b0f7c8e2-.../events"
     
    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
    -
    { "removed": 3 }
    +
    { "removed": 3 }
     
    @@ -4059,9 +4097,9 @@ is unaffected. See Split epis
    {
    -  "job_id": "9c1e2f3a-...",
    -  "status": "queued",
    -  "created_time": "2026-07-03T12:05:00+00:00"
    +  "job_id": "9c1e2f3a-...",
    +  "status": "queued",
    +  "created_time": "2026-07-03T12:05:00+00:00"
     }
     
    @@ -4108,7 +4146,7 @@ is unaffected. See Split epis
    -
    { "job_id": "b0f7c8e2-...", "position": "front" }
    +
    { "job_id": "b0f7c8e2-...", "position": "front" }
     
    @@ -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
    -
    { "cleared": true, "freed_bytes": 1048576 }
    +
    { "cleared": true, "freed_bytes": 1048576 }
     
    @@ -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
     
    {
    -  "session_id": "d4e5f6a7-...",
    -  "service": "EXAMPLE",
    -  "status": "authenticating"
    +  "session_id": "d4e5f6a7-...",
    +  "service": "EXAMPLE",
    +  "status": "authenticating"
     }
     
    @@ -4408,19 +4446,19 @@ is unaffected. See Split epis
    -
    { "status": "authenticating" }
    +
    { "status": "authenticating" }
     
    -
    { "status": "pending_input", "prompt": "Enter the code sent to your email:" }
    +
    { "status": "pending_input", "prompt": "Enter the code sent to your email:" }
     
    -
    { "status": "authenticated" }
    +
    { "status": "authenticated" }
     
    -
    { "status": "failed", "error": "Invalid credentials" }
    +
    { "status": "failed", "error": "Invalid credentials" }
     
    @@ -4456,7 +4494,7 @@ is unaffected. See Split epis
    -
    { "status": "accepted" }
    +
    { "status": "accepted" }
     
    @@ -4493,8 +4531,8 @@ is unaffected. See Split epis
    {
    -  "session_id": "d4e5f6a7-...",
    -  "titles": [ { "type": "episode", "name": "Pilot", "id": "ep-1" } ]
    +  "session_id": "d4e5f6a7-...",
    +  "titles": [ { "type": "episode", "name": "Pilot", "id": "ep-1" } ]
     }
     
    @@ -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
    -
    { "track_id": "v-1", "challenge": "<base64 CDM challenge>", "drm_type": "widevine" }
    +
    { "track_id": "v-1", "challenge": "<base64 CDM challenge>", "drm_type": "widevine" }
     
    -
    { "license": "<base64 license bytes>" }
    +
    { "license": "<base64 license bytes>" }
     
    @@ -4607,11 +4645,11 @@ is unaffected. See Split epis
    -
    { "keys": { "<kid_hex>": "<key_hex>" } }
    +
    { "keys": { "<kid_hex>": "<key_hex>" } }
     
    -
    { "keys": { "v-1": { "<kid_hex>": "<key_hex>" } }, "drm_type": "widevine" }
    +
    { "keys": { "v-1": { "<kid_hex>": "<key_hex>" } }, "drm_type": "widevine" }
     
    @@ -4693,12 +4731,12 @@ is unaffected. See Split epis
    {
    -  "session_id": "d4e5f6a7-...",
    -  "service": "EXAMPLE",
    -  "valid": true,
    -  "expires_in": 300,
    -  "track_count": 4,
    -  "title_count": 10
    +  "session_id": "d4e5f6a7-...",
    +  "service": "EXAMPLE",
    +  "valid": true,
    +  "expires_in": 300,
    +  "track_count": 4,
    +  "title_count": 10
     }
     
    @@ -4711,8 +4749,8 @@ is unaffected. See Split epis
    {
    -  "status": "ok",
    -  "cache": { "tokens.json": "<base64 zlib bytes>" }
    +  "status": "ok",
    +  "cache": { "tokens.json": "<base64 zlib bytes>" }
     }
     
    @@ -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
    -
    { "status": 401, "message": "Secret Key is Empty." }
    +
    { "status": 401, "message": "Secret Key is Empty." }
     
    -
    { "status": 401, "message": "Secret Key is Invalid." }
    +
    { "status": 401, "message": "Secret Key is Invalid." }
     
    @@ -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.

    Confirm the server is up
    curl http://127.0.0.1:8786/api/health
     
    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 request
    curl -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 download
    curl -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.

    curl 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"
     
  • 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 job
    curl -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.yaml
    serve:
    -  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.yaml
    serve:
    -  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"
    +
    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"
    +
    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"
    +
    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." }
    +
    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 @@
    queued → downloading → completed | failed | cancelled
     

    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"
    +
    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.sh
    while 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.

    -
    curl -N "$BASE/api/download/jobs/$JOB/events" \
    -  -H "X-Secret-Key: $SECRET"
    +
    curl -N "$BASE/api/download/jobs/$JOB/events" \
    +  -H "X-Secret-Key: $SECRET"
     

    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"
    +
    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"
    +
    curl -X POST "$BASE/api/download/jobs/clear-finished" \
    +  -H "X-Secret-Key: $SECRET"
     
    -
    200 OK
    { "removed": 3 }
    +
    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)"
     
    @@ -2643,7 +2673,7 @@ from a browser origin, not when you simply want the local HTTP API.

    quality, best_available, plus arbitrary service CLI options (additionalProperties: true).

    The response returns before authentication finishes:

    -
    { "session_id": "...uuid4...", "service": "EXAMPLE", "status": "authenticating" }
    +
    { "session_id": "...uuid4...", "service": "EXAMPLE", "status": "authenticating" }
     

    Authentication runs on a background thread (asyncio.to_thread(authenticate, ...)). The session starts in AUTHENTICATING; the client must poll the prompt endpoint @@ -2661,10 +2691,10 @@ layer.

    AUTHENTICATING / PENDING_INPUT sessions in the store.

    GET /api/session/{id}/prompt returns one of:

    -
    { "status": "authenticated" }
    -{ "status": "authenticating" }
    -{ "status": "pending_input", "prompt": "Enter code: " }
    -{ "status": "failed", "error": "...message..." }
    +
    { "status": "authenticated" }
    +{ "status": "authenticating" }
    +{ "status": "pending_input", "prompt": "Enter code: " }
    +{ "status": "failed", "error": "...message..." }
     

    A missing session returns 404 SESSION_NOT_FOUND; an IP mismatch returns 403 FORBIDDEN. POST /api/session/{id}/prompt takes { "response": "..." } and @@ -2743,23 +2773,23 @@ returns { "status": "accepted" }; posting with no pending prompt is

    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))..." } }
    +
    { "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.yaml
    output_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.yaml
    cdm:
    -  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:

    unshackle.yaml
    credentials:
    -  EXAMPLE: "email@example.com:your-password"
    +  EXAMPLE: "email@example.com:your-password"
     
  • @@ -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"
    +
    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"
    +
    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
    unshackle wvd add device.wvd
     unshackle wvd parse nexus_6p_1234_l1
     unshackle wvd dump device.wvd ./out_dir
    -unshackle wvd new "Nexus 6P" private_key.pem client_id.bin vmp.bin -t ANDROID -l 3
    +unshackle wvd new "Nexus 6P" private_key.pem client_id.bin vmp.bin -t ANDROID -l 3
     

    @@ -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.yaml
    serve:
    -  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.

    A minimal download
    unshackle dl EXAMPLE 81234567
     
    -
    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 service
    unshackle 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 run
    unshackle dl -q 2160 -r HDR10,DV EXAMPLE '...'
    +
    Grab HDR10 and Dolby Vision in one run
    unshackle 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 uploader
    unshackle 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 label
    unshackle dl --tag MYGRP --repack EXAMPLE 81234567
    +
    Custom group tag and a REPACK label
    unshackle 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 forms
    unshackle dl --proxy us EXAMPLE 81234567
    -unshackle dl --proxy nordvpn:ca EXAMPLE 81234567
    -unshackle dl --proxy 'http://user:pass@host:8080' EXAMPLE 81234567
    +
    Proxy forms
    unshackle 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 each
      unshackle dl --downloads 2 --workers 8 EXAMPLE 81234567
      +
      Two tracks at a time, eight threads each
      unshackle dl --downloads 2 --workers 8 EXAMPLE 81234567
       
      -
      Space out a season download
      unshackle dl -w S01 --slow 30-60 EXAMPLE 81234567
      +
      Space out a season download
      unshackle 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 download
    unshackle dl --skip-dl EXAMPLE 81234567
    +
    Fetch keys only, no download
    unshackle 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 keys
    unshackle dl --skip-dl --export EXAMPLE 81234567
    +
    Export track info and keys
    unshackle 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 naming
    unshackle dl --imdb tt1375666 EXAMPLE 81234567
    +
    Force the right IMDb match, and keep the service's own naming
    unshackle dl --imdb tt1375666 EXAMPLE 81234567
     
    -
    Force the match and take IMDb's title, year and language too
    unshackle dl --imdb tt1375666 --enrich EXAMPLE 81234567
    +
    Force the match and take IMDb's title, year and language too
    unshackle 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:

    -
    Download Futurama in DVD order
    unshackle dl --tvdb-order dvd EXAMPLE 81234567
    +
    Download Futurama in DVD order
    unshackle dl --tvdb-order dvd EXAMPLE 81234567
     

    Available orders are official (aired), dvd, absolute, alternate, and regional. Set tvdb_order in your config to apply one by default.

    @@ -4045,6 +4107,11 @@ authoritative list.

    Muxing behaviour. +--postscript + +Run a command after each output file. Repeatable. + + --proxy / --no-proxy / --no-proxy-download Proxy control. diff --git a/guide/drm-and-cdm/index.html b/guide/drm-and-cdm/index.html index ec4a5f37..1c6c4ab9 100644 --- a/guide/drm-and-cdm/index.html +++ b/guide/drm-and-cdm/index.html @@ -1243,6 +1243,8 @@ + + @@ -1502,6 +1504,34 @@ +
  • + + + + + + + + Post-Download Scripts + + + + + + + + +
  • + + + + + + + + + +
  • @@ -2641,7 +2671,7 @@ Client ID, and VMP are present. A bare name (no extension) is resolved inside th WVDs directory; a path with an extension is used as-is.

  • -
    $ unshackle wvd new "Nexus 6P" ./private_key.pem ./client_id.bin ./vmp.bin -t ANDROID -l 3
    +
    $ unshackle wvd new "Nexus 6P" ./private_key.pem ./client_id.bin ./vmp.bin -t ANDROID -l 3
     

    Builds a .wvd from a PEM private key, a Client ID blob, and an optional FileHashes (VMP) blob. -t/--type is the device type (default Android) and @@ -2725,7 +2755,7 @@ profile.

    comparisons (>=1080, >720, <=576, <480). More specific matches win.

    cdm:
       SOMESERVICE:
    -    ">=1080": sm_g935f_l1
    +    ">=1080": sm_g935f_l1
         default: chromecdm_l3
     
    @@ -2912,7 +2942,7 @@ there is nothing to set up. Such tracks are handled automatically during downloa

    The CENC ClearKey scheme is a proper license flow, but the "license server" simply returns the keys as a JWK Set rather than a DRM-wrapped blob. unshackle builds a W3C EME request of the form:

    -
    {"kids": ["<base64url-kid>", ...], "type": "temporary"}
    +
    {"kids": ["<base64url-kid>", ...], "type": "temporary"}
     

    It sends this to the manifest's license URL (or to whatever a service's ClearKey license hook returns) and reads the JSON Web Key set back. No device file is needed. diff --git a/guide/output-and-naming/index.html b/guide/output-and-naming/index.html index 92197c6c..1979652e 100644 --- a/guide/output-and-naming/index.html +++ b/guide/output-and-naming/index.html @@ -1209,6 +1209,8 @@ + + @@ -1468,6 +1470,34 @@ +

  • + + + + + + + + Post-Download Scripts + + + + + + + + +
  • + + + + + + + + + +
  • @@ -2445,9 +2475,9 @@ The folder name comes from the series folder template and the file

    Output templates

    Filename templates live under the output_template key, keyed by title kind. Each value is a format string built from {variable} placeholders.

    unshackle.yaml
    output_template:
    -  movies: "{title}.{year}.{quality}.{source}.WEB-DL.{audio_full}.{video}-{tag}"
    -  series: "{title}.{season_episode}.{episode_name?}.{quality}.{source}.WEB-DL.{audio_full}.{video}-{tag}"
    -  songs: "{track_number}. {title}"
    +  movies: "{title}.{year}.{quality}.{source}.WEB-DL.{audio_full}.{video}-{tag}"
    +  series: "{title}.{season_episode}.{episode_name?}.{quality}.{source}.WEB-DL.{audio_full}.{video}-{tag}"
    +  songs: "{track_number}. {title}"
     

    The recognized kinds are:

    @@ -2493,7 +2523,7 @@ The folder name comes from the series folder template and the file

    Use ? for anything that isn't always there

    Episode names, editions, tags, HDR labels and audio extras aren't present on every title. Mark them optional so you never get doubled dots or dangling separators:

    -

    series: "{title}.{season_episode}.{episode_name?}.{quality}.{hdr?}.{source}-{tag?}"
    +

    series: "{title}.{season_episode}.{episode_name?}.{quality}.{hdr?}.{source}-{tag?}"
     
    For an episode with no on-screen name, {episode_name?} and its adjacent dot simply disappear.

    @@ -2503,14 +2533,14 @@ For an episode with no on-screen name, {episode_name?} and its adja

    output_template:
    -  movies: "{title}.{year}.{quality}.{source}.WEB-DL.{audio_full}.{video}-{tag}"
    +  movies: "{title}.{year}.{quality}.{source}.WEB-DL.{audio_full}.{video}-{tag}"
     
    The.Movie.2024.1080p.EXAMPLE.WEB-DL.DDP5.1.H.264-TAG.mkv
     

    output_template:
    -  movies: "{title} ({year}) {quality} {source} WEB-DL {audio_full} {video}"
    +  movies: "{title} ({year}) {quality} {source} WEB-DL {audio_full} {video}"
     
    The Movie (2024) 1080p EXAMPLE WEB-DL DDP5.1 H.264.mkv
     

    @@ -2772,19 +2802,19 @@ For an episode with no on-screen name, {episode_name?} and its adja
    unshackle.yaml
    output_template:
    -  movies: "{title}.{year}.{quality}.{source}-{tag}"
    -  series: "{title}.{season_episode}.{quality}.{source}-{tag}"
    -  folder: "{title}.{year}"
    +  movies: "{title}.{year}.{quality}.{source}-{tag}"
    +  series: "{title}.{season_episode}.{quality}.{source}-{tag}"
    +  folder: "{title}.{year}"
     
    unshackle.yaml
    output_template:
    -  movies: "{title}.{year}.{quality}.{source}-{tag}"
    -  series: "{title}.{season_episode}.{quality}.{source}-{tag}"
    +  movies: "{title}.{year}.{quality}.{source}-{tag}"
    +  series: "{title}.{season_episode}.{quality}.{source}-{tag}"
       folder:
    -    movies: "{title} ({year})"
    -    series: "{title} ({year})/Season {season}"
    -    albums: "{artist} - {album} ({year})"
    +    movies: "{title} ({year})"
    +    series: "{title} ({year})/Season {season}"
    +    albums: "{artist} - {album} ({year})"
     
    @@ -2867,7 +2897,7 @@ It can be swapped per release with reference page for the exact list.

    -
    unshackle.yaml
    tag: "MYGROUP"
    +
    unshackle.yaml
    tag: "MYGROUP"
     tag_group_name: true
     tag_imdb_tmdb: true
     
    @@ -2905,10 +2935,10 @@ cannot be matched. See the Matroska tagging spec requires: series/73871 for a show, movies/113 for a film.

    You can override the tag per run without editing config:

    -
    unshackle dl --tag OTHERGROUP EXAMPLE "..."
    +
    unshackle dl --tag OTHERGROUP EXAMPLE "..."
     

    The --repack flag adds the REPACK marker (surfaced through the {repack} variable) to the filename for that run:

    -
    unshackle dl --repack EXAMPLE "..."
    +
    unshackle dl --repack EXAMPLE "..."
     

    Empty tags collapse cleanly

    @@ -2940,11 +2970,11 @@ requires: series/73871 for a show, movies/113 for a fi

    Directory & filename configuration

    The directories key relocates unshackle's working folders. Only the folders listed here can be moved; core package paths are protected and silently ignored if you try to override them.

    unshackle.yaml
    directories:
    -  downloads: "~/Media/unshackle"
    -  temp: "/mnt/fast/unshackle-temp"
    -  cache: "~/.cache/unshackle"
    -  cookies: "~/.config/unshackle/cookies"
    -  logs: "~/.config/unshackle/logs"
    +  downloads: "~/Media/unshackle"
    +  temp: "/mnt/fast/unshackle-temp"
    +  cache: "~/.cache/unshackle"
    +  cookies: "~/.config/unshackle/cookies"
    +  logs: "~/.config/unshackle/logs"
     
    @@ -2992,9 +3022,9 @@ requires: series/73871 for a show, movies/113 for a fi

    Filename patterns

    The filenames key overrides the naming patterns for a handful of internal files. These are not your media output names. They are logs, temp files, and per-service configs.

    unshackle.yaml
    filenames:
    -  log: "unshackle_{name}_{time}.log"
    -  chapters: "Chapters_{title}_{random}.txt"
    -  subtitle: "Subtitle_{id}_{language}.srt"
    +  log: "unshackle_{name}_{time}.log"
    +  chapters: "Chapters_{title}_{random}.txt"
    +  subtitle: "Subtitle_{id}_{language}.srt"
     
    @@ -3039,7 +3069,7 @@ requires: series/73871 for a show, movies/113 for a fi

    Chapters naming

    When a title has chapters, they are written to an OGM chapters file (filenames.chapters) and passed to the muxer. Unnamed chapters use the chapter_fallback_name template:

    -
    unshackle.yaml
    chapter_fallback_name: "Chapter {i:02}"
    +
    unshackle.yaml
    chapter_fallback_name: "Chapter {i:02}"
     

    The fallback supports two placeholders: {i} (chapter number, starting at 1) and {j} (which increments only for unnamed chapters). Standard format directives like {i:02} are allowed.

    Removed & migrated options

    @@ -3054,9 +3084,9 @@ how the optional (?) variables appear and disappear.

    output_template:
    -  movies: "{title}.{year}.{edition?}.{quality}.{source}.WEB-DL.{audio_full}.{atmos?}.{video}.{hdr?}-{tag?}"
    +  movies: "{title}.{year}.{edition?}.{quality}.{source}.WEB-DL.{audio_full}.{atmos?}.{video}.{hdr?}-{tag?}"
       folder:
    -    movies: "{title} ({year})"
    +    movies: "{title} ({year})"
     

    A 4K Atmos HDR download fills every placeholder:

    The Movie (2024)/
    @@ -3074,9 +3104,9 @@ and {hdr?} collapse along with their dots:

    output_template:
    -  series: "{title}.{season_episode}.{episode_name?}.{quality}.{source}.WEB-DL.{audio_full}.{video}-{tag}"
    +  series: "{title}.{season_episode}.{episode_name?}.{quality}.{source}.WEB-DL.{audio_full}.{video}-{tag}"
       folder:
    -    series: "{title} ({year})/Season {season}"
    +    series: "{title} ({year})/Season {season}"
     
    The Show (2023)/Season S02/
       └─ The.Show.S02E04.The.Reckoning.2160p.EXAMPLE.WEB-DL.DDP5.1.H.265-TAG.mkv
    @@ -3094,7 +3124,7 @@ so Season {season} keeps its space even though it holds only one va
     ever set (see dual_multi_mode), and the
     empty ones collapse:

    output_template:
    -  movies: "{title}.{year}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{dubbed?}.{audio_full}.{video}-{tag}"
    +  movies: "{title}.{year}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{dubbed?}.{audio_full}.{video}-{tag}"
     

    Original English audio plus a French dub (-l en,fr):

    The.Movie.2024.1080p.EXAMPLE.WEB-DL.DUAL.DDP5.1.H.264-TAG.mkv
    @@ -3116,7 +3146,7 @@ with langu
     

    Dated content needs no special template. When an episode carries an air date, {season_episode} becomes the date automatically and {episode} and {year} clear:

    output_template:
    -  series: "{title}.{season_episode}.{episode_name?}.{quality}.{source}.WEB-DL.{audio_full}.{video}-{tag}"
    +  series: "{title}.{season_episode}.{episode_name?}.{quality}.{source}.WEB-DL.{audio_full}.{video}-{tag}"
     

    A normal episode and a dated one, same template:

    The.Daily.Show.S28E101.1080p.EXAMPLE.WEB-DL.DDP5.1.H.264-TAG.mkv
    @@ -3129,9 +3159,9 @@ template uses spaces.

    output_template:
    -  songs: "{track_number}. {title}"
    +  songs: "{track_number}. {title}"
       folder:
    -    albums: "{album_artist} - {album} ({year})"
    +    albums: "{album_artist} - {album} ({year})"
     
    The Artist - The Album (2024)/
       └─ 01.Opening.Track.mka
    @@ -3141,9 +3171,9 @@ more spaces between variables (as below) if you want space-separated names.

    A multi-disc album with explicit tracks, using more of the music variables. {disc} is empty on disc 1, so {disc?} and its separator only appear from disc 2 onward:

    output_template:
    -  songs: "{disc?}-{track_number}. {title} {explicit?}"
    +  songs: "{disc?}-{track_number}. {title} {explicit?}"
       folder:
    -    albums: "{album_artist} - {album} ({year}) [{label?}]"
    +    albums: "{album_artist} - {album} ({year}) [{label?}]"
     
    The Artist - The Album (2024) [The Label]/
       ├─ 01. Opening Track.mka
    diff --git a/guide/proxies-and-vpn/index.html b/guide/proxies-and-vpn/index.html
    index 69f8bf96..2a88d2bf 100644
    --- a/guide/proxies-and-vpn/index.html
    +++ b/guide/proxies-and-vpn/index.html
    @@ -1209,6 +1209,8 @@
             
           
             
    +      
    +        
           
             
           
    @@ -1468,6 +1470,34 @@
       
       
       
    +    
  • + + + + + + + + Post-Download Scripts + + + + + + + + +
  • + + + + + + + + + +
  • diff --git a/guide/subtitles/index.html b/guide/subtitles/index.html index 05308da6..2aef1bc9 100644 --- a/guide/subtitles/index.html +++ b/guide/subtitles/index.html @@ -1171,6 +1171,8 @@ + + @@ -1430,6 +1432,34 @@ +
  • + + + + + + + + Post-Download Scripts + + + + + + + + +
  • + + + + + + + + + +
  • @@ -2501,7 +2531,7 @@ variants. If a requested language is missing but others were found, unshackle co with whatever remains and tells you which languages it dropped.

    For strict matching, where es-419 matches only es-419 and never es-ES, add --exact-lang:

    -
    Only Latin-American Spanish, no other Spanish variants
    unshackle dl --s-lang es-419 --exact-lang EXAMPLE 'https://www.example.com/...'
    +
    Only Latin-American Spanish, no other Spanish variants
    unshackle dl --s-lang es-419 --exact-lang EXAMPLE 'https://www.example.com/...'
     

    Requiring languages before downloading

    --require-subs is a gate rather than a filter. It downloads all available @@ -2591,7 +2621,7 @@ TTML to WebVTT for muxing, as noted above). To force a specific output format, u

    --sub-format accepts the common format names and aliases, including srt, vtt, ssa, ass, and ttml. Pass original to explicitly keep the source format and skip all conversion:

    -
    Keep whatever the service delivered
    unshackle dl --sub-format original EXAMPLE 'https://www.example.com/...'
    +
    Keep whatever the service delivered
    unshackle dl --sub-format original EXAMPLE 'https://www.example.com/...'
     

    Conversion only runs when the source format differs from the target, so --sub-format srt on a track that is already SRT is a no-op.

    diff --git a/guide/troubleshooting/index.html b/guide/troubleshooting/index.html index e82b7887..4cf1fec5 100644 --- a/guide/troubleshooting/index.html +++ b/guide/troubleshooting/index.html @@ -1154,6 +1154,8 @@ + + @@ -1413,6 +1415,34 @@ +
  • + + + + + + + + Post-Download Scripts + + + + + + + + +
  • + + + + + + + + + +
  • @@ -2345,7 +2375,7 @@
  • What a log entry looks like

    Every line is a self-contained JSON object. The first line of a session records the environment:

    -
    {"timestamp":"2026-07-03T14:25:30.123456+00:00","session_id":"a1b2c3d4","level":"INFO","operation":"session_start","message":"Debug logging session started","context":{"unshackle_version":"5.3.0","unshackle_code_hash":"1d22a1e","python_version":"3.12.4 ...","platform":"Linux-6.18.5-x86_64","platform_system":"Linux","platform_release":"6.18.5"}}
    +
    {"timestamp":"2026-07-03T14:25:30.123456+00:00","session_id":"a1b2c3d4","level":"INFO","operation":"session_start","message":"Debug logging session started","context":{"unshackle_version":"5.3.0","unshackle_code_hash":"1d22a1e","python_version":"3.12.4 ...","platform":"Linux-6.18.5-x86_64","platform_system":"Linux","platform_release":"6.18.5"}}
     

    Quote unshackle_code_hash in a bug report. It fingerprints the framework source you are running, so it tells us the exact code state even when you did not install from git. A hash that matches no release means the source was edited locally.

    Subsequent lines describe operations: service calls, DRM licence requests, vault lookups, downloader progress, and errors. Common fields include:

    @@ -2406,16 +2436,16 @@

    Reading and filtering the log

    Because each line is independent JSON, jq is the natural tool:

    # Show only error-level entries
    -jq 'select(.level == "ERROR")' unshackle_debug_EXAMPLE_20260703-142530.jsonl
    +jq 'select(.level == "ERROR")' unshackle_debug_EXAMPLE_20260703-142530.jsonl
     
     # List every operation and whether it succeeded
    -jq -c '{operation, success}' unshackle_debug_EXAMPLE_20260703-142530.jsonl
    +jq -c '{operation, success}' unshackle_debug_EXAMPLE_20260703-142530.jsonl
     
     # Pull the traceback out of the first error
    -jq -r 'select(.error) | .error.traceback[]' unshackle_debug_EXAMPLE_20260703-142530.jsonl
    +jq -r 'select(.error) | .error.traceback[]' unshackle_debug_EXAMPLE_20260703-142530.jsonl
     
     # Show only DRM / licensing operations
    -jq 'select(.operation | startswith("drm_"))' unshackle_debug_EXAMPLE_20260703-142530.jsonl
    +jq 'select(.operation | startswith("drm_"))' unshackle_debug_EXAMPLE_20260703-142530.jsonl
     

    The session's binary-tool versions (Shaka Packager, mp4decrypt, mkvmerge, FFmpeg, FFprobe) are logged near the start of a dl run, which is often the fastest way to confirm a muxing or decryption failure is really a wrong-version problem.


    @@ -2512,7 +2542,7 @@

    env clear

    Stale cache or leftover temp files can cause confusing behaviour (an old cached title list, a half-written temp file). env clear wipes those directories safely:

    unshackle env clear cache            # clear the whole cache directory
    -unshackle env clear cache EXAMPLE    # clear just one service's cache subdirectory
    +unshackle env clear cache EXAMPLE    # clear just one service's cache subdirectory
     unshackle env clear temp             # clear the temp working directory
     

    Each command reports how many files it removed and how much space it freed, then recreates the empty directory.

    @@ -2566,8 +2596,8 @@

    Log levels are a flow skeleton, not just severity

    Treat level as structure, not decoration. Emit exactly one INFO milestone per pipeline stage so that jq 'select(.level=="INFO")' reads back the whole end-to-end flow of a run. Everything internal goes to DEBUG; failures go to ERROR. Keeping INFO sparse is the whole point. It is the line a developer skims first to see where a run got to before it broke.

    -
    # Read the pipeline's high-level flow, one line per stage
    -jq 'select(.level == "INFO")' unshackle_debug_EXAMPLE_20260703-142530.jsonl
    +
    # Read the pipeline's high-level flow, one line per stage
    +jq 'select(.level == "INFO")' unshackle_debug_EXAMPLE_20260703-142530.jsonl
     
    diff --git a/guide/vaults/index.html b/guide/vaults/index.html index efec71a2..a6e272eb 100644 --- a/guide/vaults/index.html +++ b/guide/vaults/index.html @@ -1148,6 +1148,8 @@ + + @@ -1407,6 +1409,34 @@ +
  • + + + + + + + + Post-Download Scripts + + + + + + + + +
  • + + + + + + + + + +
  • @@ -2320,7 +2350,7 @@ host: vault.example.com database: unshackle username: reader - password: "s3cret" + password: "s3cret"
  • The type value is matched to a vault backend by name. The four backends that ship with unshackle are SQLite, MySQL, HTTP, and API, described below.

    @@ -2393,7 +2423,7 @@ host: vault.example.com database: unshackle username: unshackle - password: "your-password" + password: "your-password" port: 3306
    @@ -2488,7 +2518,7 @@ host: https://vault.example.com/api api_mode: query username: myuser - password: "your-password" + password: "your-password"

    username is required in query mode. Both the username and password are sent as query parameters on each request.

    @@ -2498,7 +2528,7 @@ name: jsonvault host: https://vault.example.com/rpc api_mode: json - password: "your-api-token" + password: "your-api-token"

    Requests are POSTed as {"method": ..., "params": ..., "token": ...} using methods like GetKey, InsertKey, and GetServices. This mode cannot list all keys in bulk, so a kv copy from a json vault relies on the remote server's own duplicate handling.

    @@ -2508,7 +2538,7 @@ name: decrypt_labs host: https://api.decryptlabs.example/ api_mode: decrypt_labs - api_key: "your-decrypt-labs-key" + api_key: "your-decrypt-labs-key"

    A read-only lookup source. The key is sent in a decrypt-labs-api-key header and the vault refuses all writes.

    @@ -2578,10 +2608,10 @@ - type: API name: myapi uri: https://vault.example.com/api - token: "your-bearer-token" + token: "your-bearer-token" headers: - CF-Access-Client-Id: "CLIENT_ID_HERE" - CF-Access-Client-Secret: "CLIENT_SECRET_HERE" + CF-Access-Client-Id: "CLIENT_ID_HERE" + CF-Access-Client-Secret: "CLIENT_SECRET_HERE"
    diff --git a/index.html b/index.html index 67bc78d7..4e1d2dab 100644 --- a/index.html +++ b/index.html @@ -1019,6 +1019,8 @@ + + @@ -1278,6 +1280,34 @@ +
  • + + + + + + + + Post-Download Scripts + + + + + + + + +
  • + + + + + + + + + +
  • diff --git a/reference/api/config/index.html b/reference/api/config/index.html index 00e0a4d5..e4f73f7c 100644 --- a/reference/api/config/index.html +++ b/reference/api/config/index.html @@ -919,6 +919,8 @@ + + @@ -1178,6 +1180,34 @@ +
  • + + + + + + + + Post-Download Scripts + + + + + + + + +
  • + + + + + + + + + +
  • @@ -2229,7 +2259,7 @@ Falls back to the legacy single-string folder template, then "".

    -
  • 154
    -155
    -156
    -157
    -158
    -159
    +                    
    159
     160
     161
     162
    @@ -2889,66 +2918,58 @@ cannot distinguish the two, and rnet has no unbounded mode either.

    285 286 287 -288 -289 -290 -291
    def __init__(self, ctx: click.Context):
    -    console.print(Padding(Rule(f"[rule.text]Service: {self.__class__.__name__}"), (1, 2)))
    -
    -    self.config = ctx.obj.config
    -
    -    self.log = logging.getLogger(self.__class__.__name__)
    -
    -    self.session = self.get_session()
    -    self.cache = Cacher(self.__class__.__name__)
    -    self.title_cache = TitleCacher(self.__class__.__name__)
    -    self.cache_dir = config.directories.cache / self.__class__.__name__
    +288
    def __init__(self, ctx: click.Context):
    +    console.print(Padding(Rule(f"[rule.text]Service: {self.__class__.__name__}"), (1, 2)))
    +
    +    self.config = ctx.obj.config
    +
    +    self.log = logging.getLogger(self.__class__.__name__)
     
    -    # Store context for cache control flags and credential
    -    self.ctx = ctx
    -    self.credential = None  # Will be set in authenticate()
    -    self.current_region = None  # Will be set based on proxy/geolocation
    -    self._input_bridge: Optional[InputBridge] = None
    -
    -    # Set track request from CLI params - services can read/override in their __init__
    -    vcodec = ctx.parent.params.get("vcodec") if ctx.parent else None
    -    range_ = ctx.parent.params.get("range_") if ctx.parent else None
    -    best_available = ctx.parent.params.get("best_available", False) if ctx.parent else False
    -    self.track_request = TrackRequest(
    -        codecs=list(vcodec) if vcodec else [],
    -        ranges=list(range_) if range_ else [Video.Range.SDR],
    -        best_available=bool(best_available),
    -    )
    -
    -    if not ctx.parent or not ctx.parent.params.get("no_proxy"):
    -        if ctx.parent:
    -            proxy = ctx.parent.params["proxy"]
    -            proxy_query = ctx.parent.params.get("proxy_query")
    -            proxy_provider_name = ctx.parent.params.get("proxy_provider")
    -        else:
    -            proxy = None
    -            proxy_query = None
    -            proxy_provider_name = None
    -
    -        # Check for service-specific proxy mapping
    -        service_name = self.__class__.__name__
    -        service_config_dict = config.services.get(service_name, {})
    -        proxy_map = service_config_dict.get("proxy_map", {})
    -
    -        if proxy_map and proxy_query:
    -            # Build the full proxy query key (e.g., "nordvpn:ca" or "us")
    -            if proxy_provider_name:
    -                full_proxy_key = f"{proxy_provider_name}:{proxy_query}"
    -            else:
    -                full_proxy_key = proxy_query
    -
    -            # Check if there's a mapping for this query
    -            mapped_value = proxy_map.get(full_proxy_key)
    -            if mapped_value:
    -                self.log.info(
    -                    f"Found service-specific proxy mapping: {full_proxy_key} -> {sanitize_proxy_for_log(mapped_value)}"
    -                )
    -                # Query the proxy provider with the mapped value
    +    self.session = self.get_session()
    +    self.cache = Cacher(self.__class__.__name__)
    +    self.title_cache = TitleCacher(self.__class__.__name__)
    +    self.cache_dir = config.directories.cache / self.__class__.__name__
    +
    +    self.ctx = ctx
    +    self.credential = None  # Will be set in authenticate()
    +    self.current_region = None  # Will be set based on proxy/geolocation
    +    self._input_bridge: Optional[InputBridge] = None
    +
    +    # Set track request from CLI params - services can read/override in their __init__
    +    vcodec = ctx.parent.params.get("vcodec") if ctx.parent else None
    +    range_ = ctx.parent.params.get("range_") if ctx.parent else None
    +    best_available = ctx.parent.params.get("best_available", False) if ctx.parent else False
    +    self.track_request = TrackRequest(
    +        codecs=list(vcodec) if vcodec else [],
    +        ranges=list(range_) if range_ else [Video.Range.SDR],
    +        best_available=bool(best_available),
    +    )
    +
    +    if not ctx.parent or not ctx.parent.params.get("no_proxy"):
    +        if ctx.parent:
    +            proxy = ctx.parent.params["proxy"]
    +            proxy_query = ctx.parent.params.get("proxy_query")
    +            proxy_provider_name = ctx.parent.params.get("proxy_provider")
    +        else:
    +            proxy = None
    +            proxy_query = None
    +            proxy_provider_name = None
    +
    +        service_name = self.__class__.__name__
    +        service_config_dict = config.services.get(service_name, {})
    +        proxy_map = service_config_dict.get("proxy_map", {})
    +
    +        if proxy_map and proxy_query:
    +            if proxy_provider_name:
    +                full_proxy_key = f"{proxy_provider_name}:{proxy_query}"
    +            else:
    +                full_proxy_key = proxy_query
    +
    +            mapped_value = proxy_map.get(full_proxy_key)
    +            if mapped_value:
    +                self.log.info(
    +                    f"Found service-specific proxy mapping: {full_proxy_key} -> {sanitize_proxy_for_log(mapped_value)}"
    +                )
                     if proxy_provider_name:
                         # Specific provider requested
                         proxy_provider = next(
    @@ -2960,76 +2981,73 @@ cannot distinguish the two, and rnet has no unbounded mode either.

    if mapped_proxy_uri: proxy = mapped_proxy_uri self.log.info( - f"Using mapped proxy from {proxy_provider.__class__.__name__}: {sanitize_proxy_for_log(proxy)}" + f"Using mapped proxy from {proxy_provider.__class__.__name__}: {sanitize_proxy_for_log(proxy)}" ) else: self.log.warning( - f"Failed to get proxy for mapped value '{sanitize_proxy_for_log(mapped_value)}', using default" + f"Failed to get proxy for mapped value '{sanitize_proxy_for_log(mapped_value)}', using default" ) else: - self.log.warning(f"Proxy provider '{proxy_provider_name}' not found, using default proxy") + self.log.warning(f"Proxy provider '{proxy_provider_name}' not found, using default proxy") else: - # No specific provider, try all providers - for proxy_provider in ctx.obj.proxy_providers: - mapped_proxy_uri = proxy_provider.get_proxy(mapped_value) - if mapped_proxy_uri: - proxy = mapped_proxy_uri - self.log.info( - f"Using mapped proxy from {proxy_provider.__class__.__name__}: {sanitize_proxy_for_log(proxy)}" - ) - break - else: - self.log.warning( - f"No provider could resolve mapped value '{sanitize_proxy_for_log(mapped_value)}', using default" - ) - - if not proxy: - # don't override the explicit proxy set by the user, even if they may be geoblocked - with console.status("Checking if current region is Geoblocked...", spinner="dots"): - if self.GEOFENCE: - # Service has geofence - need fresh IP check to determine if proxy needed - try: - current_region = get_ip_info(self.session)["country"].lower() - if any(x.lower() == current_region for x in self.GEOFENCE): - self.log.info("Service is not Geoblocked in your region") - else: - requested_proxy = self.GEOFENCE[0] # first is likely main region - self.log.info( - f"Service is Geoblocked in your region, getting a Proxy to {requested_proxy}" - ) - for proxy_provider in ctx.obj.proxy_providers: - proxy = proxy_provider.get_proxy(requested_proxy) - if proxy: - self.log.info(f"Got Proxy from {proxy_provider.__class__.__name__}") - break - except Exception as e: - self.log.warning(f"Failed to check geofence: {e}") - current_region = None - else: - self.log.info("Service has no Geofence") - - if proxy: - self.session.proxies.update({"all": proxy}) - # Don't set Proxy-Authorization manually: both rnet (Proxy.all) and - # requests authenticate from the credentials embedded in the proxy URL. - # A manual header here was malformed (no "Basic " scheme) and broke - # plaintext-http forward-proxy requests with HTTP 407. - # Always verify proxy IP - proxies can change exit nodes - try: - proxy_ip_info = get_ip_info(self.session) - self.current_region = proxy_ip_info.get("country", "").lower() if proxy_ip_info else None - except Exception as e: - self.log.warning(f"Failed to verify proxy IP: {e}") - # Fallback to extracting region from proxy config - self.current_region = get_region_from_proxy(proxy) - else: - # No proxy, use cached IP info for title caching (non-critical) - try: - ip_info = get_ip_info(self.session, cached=True) - self.current_region = ip_info.get("country", "").lower() if ip_info else None - except Exception as e: - self.log.debug(f"Failed to get cached IP info: {e}") - self.current_region = None + for proxy_provider in ctx.obj.proxy_providers: + mapped_proxy_uri = proxy_provider.get_proxy(mapped_value) + if mapped_proxy_uri: + proxy = mapped_proxy_uri + self.log.info( + f"Using mapped proxy from {proxy_provider.__class__.__name__}: {sanitize_proxy_for_log(proxy)}" + ) + break + else: + self.log.warning( + f"No provider could resolve mapped value '{sanitize_proxy_for_log(mapped_value)}', using default" + ) + + if not proxy: + # don't override the explicit proxy set by the user, even if they may be geoblocked + with console.status("Checking if current region is Geoblocked...", spinner="dots"): + if self.GEOFENCE: + try: + current_region = get_ip_info(self.session)["country"].lower() + if any(x.lower() == current_region for x in self.GEOFENCE): + self.log.info("Service is not Geoblocked in your region") + else: + requested_proxy = self.GEOFENCE[0] # first is likely main region + self.log.info( + f"Service is Geoblocked in your region, getting a Proxy to {requested_proxy}" + ) + for proxy_provider in ctx.obj.proxy_providers: + proxy = proxy_provider.get_proxy(requested_proxy) + if proxy: + self.log.info(f"Got Proxy from {proxy_provider.__class__.__name__}") + break + except Exception as e: + self.log.warning(f"Failed to check geofence: {e}") + current_region = None + else: + self.log.info("Service has no Geofence") + + if proxy: + self.session.proxies.update({"all": proxy}) + # Don't set Proxy-Authorization manually: both rnet (Proxy.all) and + # requests authenticate from the credentials embedded in the proxy URL. + # A manual header here was malformed (no "Basic " scheme) and broke + # plaintext-http forward-proxy requests with HTTP 407. + # Always verify proxy IP - proxies can change exit nodes + try: + proxy_ip_info = get_ip_info(self.session) + self.current_region = proxy_ip_info.get("country", "").lower() if proxy_ip_info else None + except Exception as e: + self.log.warning(f"Failed to verify proxy IP: {e}") + self.current_region = get_region_from_proxy(proxy) + else: + # No proxy, use cached IP info for title caching (non-critical) + try: + ip_info = get_ip_info(self.session, cached=True) + self.current_region = ip_info.get("country", "").lower() if ip_info else None + except Exception as e: + self.log.debug(f"Failed to get cached IP info: {e}") + self.current_region = None
    @@ -3116,7 +3134,10 @@ the DV video tracks are merged into the HDR10 result.

    Source code in unshackle/core/service.py -
    293
    +              
    290
    +291
    +292
    +293
     294
     295
     296
    @@ -3183,80 +3204,77 @@ the DV video tracks are merged into the HDR10 result.

    357 358 359 -360 -361 -362 -363
    def get_tracks_for_variants(
    -    self,
    -    title: Title_T,
    -    fetch_fn: Callable[..., Tracks],
    -) -> Tracks:
    -    """Call fetch_fn for each codec/range combo in track_request, merge results.
    +360
    def get_tracks_for_variants(
    +    self,
    +    title: Title_T,
    +    fetch_fn: Callable[..., Tracks],
    +) -> Tracks:
    +    """Call fetch_fn for each codec/range combo in track_request, merge results.
    +
    +    Services that need separate API calls per codec/range combo can use this
    +    helper from their get_tracks() implementation.
     
    -    Services that need separate API calls per codec/range combo can use this
    -    helper from their get_tracks() implementation.
    -
    -    The fetch_fn signature should be: (title, codec, range_) -> Tracks
    +    The fetch_fn signature should be: (title, codec, range_) -> Tracks
    +
    +    For HYBRID range, fetch_fn is called with HDR10 and DV separately and
    +    the DV video tracks are merged into the HDR10 result.
     
    -    For HYBRID range, fetch_fn is called with HDR10 and DV separately and
    -    the DV video tracks are merged into the HDR10 result.
    -
    -    Args:
    -        title: The title being processed.
    -        fetch_fn: A callable that fetches tracks for a specific codec/range.
    -    """
    -    all_tracks = Tracks()
    -    first = True
    +    Args:
    +        title: The title being processed.
    +        fetch_fn: A callable that fetches tracks for a specific codec/range.
    +    """
    +    all_tracks = Tracks()
    +    first = True
    +
    +    codecs = self.track_request.codecs or [None]
    +    ranges = self.track_request.ranges or [Video.Range.SDR]
     
    -    codecs = self.track_request.codecs or [None]
    -    ranges = self.track_request.ranges or [Video.Range.SDR]
    -
    -    for range_val in ranges:
    -        if range_val == Video.Range.HYBRID:
    -            # HYBRID: fetch HDR10 first (full tracks), then DV (video only)
    -            for codec_val in codecs:
    -                try:
    -                    hdr_tracks = fetch_fn(title, codec=codec_val, range_=Video.Range.HDR10)
    -                except (ValueError, SystemExit) as e:
    -                    if self.track_request.best_available:
    -                        self.log.warning(f" - HDR10 not available for HYBRID, skipping ({e})")
    -                        continue
    -                    raise
    -                if first:
    -                    all_tracks.add(hdr_tracks, warn_only=True)
    -                    if hdr_tracks.manifest_url and not all_tracks.manifest_url:
    -                        all_tracks.manifest_url = hdr_tracks.manifest_url
    -                    first = False
    -                else:
    -                    for video in hdr_tracks.videos:
    -                        all_tracks.add(video, warn_only=True)
    -
    -                try:
    -                    dv_tracks = fetch_fn(title, codec=codec_val, range_=Video.Range.DV)
    -                    for video in dv_tracks.videos:
    -                        all_tracks.add(video, warn_only=True)
    -                except (ValueError, SystemExit):
    -                    self.log.info(" - No DolbyVision manifest available for HYBRID")
    -        else:
    -            for codec_val in codecs:
    -                try:
    -                    tracks = fetch_fn(title, codec=codec_val, range_=range_val)
    -                except (ValueError, SystemExit) as e:
    -                    if self.track_request.best_available:
    -                        codec_name = codec_val.name if codec_val else "default"
    -                        self.log.warning(f" - {range_val.name}/{codec_name} not available, skipping ({e})")
    -                        continue
    -                    raise
    -                if first:
    -                    all_tracks.add(tracks, warn_only=True)
    -                    if tracks.manifest_url and not all_tracks.manifest_url:
    -                        all_tracks.manifest_url = tracks.manifest_url
    -                    first = False
    -                else:
    -                    for video in tracks.videos:
    -                        all_tracks.add(video, warn_only=True)
    -
    -    return all_tracks
    +    for range_val in ranges:
    +        if range_val == Video.Range.HYBRID:
    +            # HYBRID: fetch HDR10 first (full tracks), then DV (video only)
    +            for codec_val in codecs:
    +                try:
    +                    hdr_tracks = fetch_fn(title, codec=codec_val, range_=Video.Range.HDR10)
    +                except (ValueError, SystemExit) as e:
    +                    if self.track_request.best_available:
    +                        self.log.warning(f" - HDR10 not available for HYBRID, skipping ({e})")
    +                        continue
    +                    raise
    +                if first:
    +                    all_tracks.add(hdr_tracks, warn_only=True)
    +                    if hdr_tracks.manifest_url and not all_tracks.manifest_url:
    +                        all_tracks.manifest_url = hdr_tracks.manifest_url
    +                    first = False
    +                else:
    +                    for video in hdr_tracks.videos:
    +                        all_tracks.add(video, warn_only=True)
    +
    +                try:
    +                    dv_tracks = fetch_fn(title, codec=codec_val, range_=Video.Range.DV)
    +                    for video in dv_tracks.videos:
    +                        all_tracks.add(video, warn_only=True)
    +                except (ValueError, SystemExit):
    +                    self.log.info(" - No DolbyVision manifest available for HYBRID")
    +        else:
    +            for codec_val in codecs:
    +                try:
    +                    tracks = fetch_fn(title, codec=codec_val, range_=range_val)
    +                except (ValueError, SystemExit) as e:
    +                    if self.track_request.best_available:
    +                        codec_name = codec_val.name if codec_val else "default"
    +                        self.log.warning(f" - {range_val.name}/{codec_name} not available, skipping ({e})")
    +                        continue
    +                    raise
    +                if first:
    +                    all_tracks.add(tracks, warn_only=True)
    +                    if tracks.manifest_url and not all_tracks.manifest_url:
    +                        all_tracks.manifest_url = tracks.manifest_url
    +                    first = False
    +                else:
    +                    for video in tracks.videos:
    +                        all_tracks.add(video, warn_only=True)
    +
    +    return all_tracks
     
    @@ -3287,7 +3305,15 @@ from config, cookies, retry handler, and a proxy if available.
    Source code in unshackle/core/service.py -
    373
    +              
    365
    +366
    +367
    +368
    +369
    +370
    +371
    +372
    +373
     374
     375
     376
    @@ -3311,47 +3337,39 @@ from config, cookies, retry handler, and a proxy if available.
     394
     395
     396
    -397
    -398
    -399
    -400
    -401
    -402
    -403
    -404
    -405
    @staticmethod
    -def get_session() -> requests.Session:
    -    """
    -    Creates a Python-requests Session, adds common headers
    -    from config, cookies, retry handler, and a proxy if available.
    -    :returns: Prepared Python-requests Session
    -    """
    -    session = TimeoutSession()
    -    session.headers.update(config.headers)
    -    # Retry / pool policy mirrors RnetSession via the shared constants in session.py:
    -    # same total, forcelist, backoff cap, allowed_methods (incl. POST, so license
    -    # requests retry on both paths), and pool size. Accepted divergence: urllib3's
    -    # backoff_jitter adds an absolute 0..N seconds whereas rnet jitters ±10% of the
    -    # backoff, the only intentional difference, not worth hand-rolling a retry loop.
    -    session.mount(
    -        "https://",
    -        TimeoutHTTPAdapter(
    -            max_retries=Retry(
    -                total=MAX_RETRIES,
    -                backoff_factor=BACKOFF_FACTOR,
    -                backoff_max=MAX_BACKOFF,
    -                backoff_jitter=0.2,
    -                status_forcelist=STATUS_FORCELIST,
    -                allowed_methods=RETRY_METHODS,
    -                respect_retry_after_header=True,
    -            ),
    -            pool_connections=POOL_MAX_SIZE,
    -            pool_maxsize=POOL_MAX_SIZE,
    -            pool_block=True,
    -        ),
    -    )
    -    session.mount("http://", session.adapters["https://"])
    -    return session
    +397
    @staticmethod
    +def get_session() -> requests.Session:
    +    """
    +    Creates a Python-requests Session, adds common headers
    +    from config, cookies, retry handler, and a proxy if available.
    +    :returns: Prepared Python-requests Session
    +    """
    +    session = TimeoutSession()
    +    session.headers.update(config.headers)
    +    # Retry / pool policy mirrors RnetSession via the shared constants in session.py:
    +    # same total, forcelist, backoff cap, allowed_methods (incl. POST, so license
    +    # requests retry on both paths), and pool size. Accepted divergence: urllib3's
    +    # backoff_jitter adds an absolute 0..N seconds whereas rnet jitters ±10% of the
    +    # backoff, the only intentional difference, not worth hand-rolling a retry loop.
    +    session.mount(
    +        "https://",
    +        TimeoutHTTPAdapter(
    +            max_retries=Retry(
    +                total=MAX_RETRIES,
    +                backoff_factor=BACKOFF_FACTOR,
    +                backoff_max=MAX_BACKOFF,
    +                backoff_jitter=0.2,
    +                status_forcelist=STATUS_FORCELIST,
    +                allowed_methods=RETRY_METHODS,
    +                respect_retry_after_header=True,
    +            ),
    +            pool_connections=POOL_MAX_SIZE,
    +            pool_maxsize=POOL_MAX_SIZE,
    +            pool_block=True,
    +        ),
    +    )
    +    session.mount("http://", session.adapters["https://"])
    +    return session
     
    @@ -3383,7 +3401,15 @@ the Cookie jar into the service session.

    Source code in unshackle/core/service.py -
    407
    +              
    399
    +400
    +401
    +402
    +403
    +404
    +405
    +406
    +407
     408
     409
     410
    @@ -3395,35 +3421,27 @@ the Cookie jar into the service session.

    416 417 418 -419 -420 -421 -422 -423 -424 -425 -426 -427
    def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
    -    """
    -    Authenticate the Service with Cookies and/or Credentials (Email/Username and Password).
    -
    -    This is effectively a login() function. Any API calls or object initializations
    -    needing to be made, should be made here. This will be run before any of the
    -    following abstract functions.
    -
    -    You should avoid storing or using the Credential outside this function.
    -    Make any calls you need for any Cookies, Tokens, or such, then use those.
    +419
    def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
    +    """
    +    Authenticate the Service with Cookies and/or Credentials (Email/Username and Password).
    +
    +    This is effectively a login() function. Any API calls or object initializations
    +    needing to be made, should be made here. This will be run before any of the
    +    following abstract functions.
    +
    +    You should avoid storing or using the Credential outside this function.
    +    Make any calls you need for any Cookies, Tokens, or such, then use those.
    +
    +    The Cookie jar should also not be stored outside this function. However, you may load
    +    the Cookie jar into the service session.
    +    """
    +    if cookies is not None:
    +        if not isinstance(cookies, CookieJar):
    +            raise TypeError(f"Expected cookies to be a {CookieJar}, not {cookies!r}.")
    +        self.session.cookies.update(cookies)
     
    -    The Cookie jar should also not be stored outside this function. However, you may load
    -    the Cookie jar into the service session.
    -    """
    -    if cookies is not None:
    -        if not isinstance(cookies, CookieJar):
    -            raise TypeError(f"Expected cookies to be a {CookieJar}, not {cookies!r}.")
    -        self.session.cookies.update(cookies)
    -
    -    # Store credential for cache key generation
    -    self.credential = credential
    +    # Store credential for cache key generation
    +    self.credential = credential
     
    @@ -3452,27 +3470,27 @@ delegates to the bridge which relays the prompt to the remote client.

    Source code in unshackle/core/service.py -
    429
    +              
    def request_input(self, prompt: str) -> str:
    -    """Request interactive input from the user.
    -
    -    When running locally (CLI), prompts via the shared rich console so the
    -    prompt renders correctly alongside Live progress / log handlers.
    -    When running in serve mode with an :class:`InputBridge` attached,
    -    delegates to the bridge which relays the prompt to the remote client.
    -    """
    -    if self._input_bridge is not None:
    -        return self._input_bridge.request_input(prompt)
    -    return prompt_user(prompt)
    +431
    def request_input(self, prompt: str) -> str:
    +    """Request interactive input from the user.
    +
    +    When running locally (CLI), prompts via the shared rich console so the
    +    prompt renders correctly alongside Live progress / log handlers.
    +    When running in serve mode with an :class:`InputBridge` attached,
    +    delegates to the bridge which relays the prompt to the remote client.
    +    """
    +    if self._input_bridge is not None:
    +        return self._input_bridge.request_input(prompt)
    +    return prompt_user(prompt)
     
    @@ -3500,25 +3518,25 @@ Ideally just re-use the title ID argument (i.e. self.title).

    Source code in unshackle/core/service.py -
    def search(self) -> Generator[SearchResult, None, None]:
    -    """
    -    Search by query for titles from the Service.
    -
    -    The query must be taken as a CLI argument by the Service class.
    -    Ideally just re-use the title ID argument (i.e. self.title).
    -
    -    Search results will be displayed in the order yielded.
    -    """
    -    raise NotImplementedError(f"Search functionality has not been implemented by {self.__class__.__name__}")
    +              
    def search(self) -> Generator[SearchResult, None, None]:
    +    """
    +    Search by query for titles from the Service.
    +
    +    The query must be taken as a CLI argument by the Service class.
    +    Ideally just re-use the title ID argument (i.e. self.title).
    +
    +    Search results will be displayed in the order yielded.
    +    """
    +    raise NotImplementedError(f"Search functionality has not been implemented by {self.__class__.__name__}")
     
    @@ -3552,33 +3570,33 @@ Ideally just re-use the title ID argument (i.e. self.title).

    Source code in unshackle/core/service.py -
    452
    +              
    def get_widevine_service_certificate(
    -    self, *, challenge: bytes, title: Title_T, track: AnyTrack
    -) -> Union[bytes, str]:
    -    """
    -    Get the Widevine Service Certificate used for Privacy Mode.
    -
    -    :param challenge: The service challenge, providing this to a License endpoint should return the
    -        privacy certificate that the service uses.
    -    :param title: The current `Title` from get_titles that is being executed. This is provided in
    -        case it has data needed to be used, e.g. for a HTTP request.
    -    :param track: The current `Track` needing decryption. Provided for same reason as `title`.
    -    :return: The Service Privacy Certificate as Bytes or a Base64 string. Don't Base64 Encode or
    -        Decode the data, return as is to reduce unnecessary computations.
    -    """
    +457
    def get_widevine_service_certificate(
    +    self, *, challenge: bytes, title: Title_T, track: AnyTrack
    +) -> Union[bytes, str]:
    +    """
    +    Get the Widevine Service Certificate used for Privacy Mode.
    +
    +    :param challenge: The service challenge, providing this to a License endpoint should return the
    +        privacy certificate that the service uses.
    +    :param title: The current `Title` from get_titles that is being executed. This is provided in
    +        case it has data needed to be used, e.g. for a HTTP request.
    +    :param track: The current `Track` needing decryption. Provided for same reason as `title`.
    +    :return: The Service Privacy Certificate as Bytes or a Base64 string. Don't Base64 Encode or
    +        Decode the data, return as is to reduce unnecessary computations.
    +    """
     
    @@ -3614,7 +3632,15 @@ revoked, disabled, or downgraded.

    Source code in unshackle/core/service.py -
    467
    +              
    459
    +460
    +461
    +462
    +463
    +464
    +465
    +466
    +467
     468
     469
     470
    @@ -3623,32 +3649,24 @@ revoked, disabled, or downgraded.

    473 474 475 -476 -477 -478 -479 -480 -481 -482 -483 -484
    def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]:
    -    """
    -    Get a Widevine License message by sending a License Request (challenge).
    -
    -    This License message contains the encrypted Content Decryption Keys and will be
    -    read by the Cdm and decrypted.
    -
    -    This is a very important request to get correct. A bad, unexpected, or missing
    -    value in the request can cause your key to be detected and promptly banned,
    -    revoked, disabled, or downgraded.
    -
    -    :param challenge: The license challenge from the Widevine CDM.
    -    :param title: The current `Title` from get_titles that is being executed. This is provided in
    -        case it has data needed to be used, e.g. for a HTTP request.
    -    :param track: The current `Track` needing decryption. Provided for same reason as `title`.
    -    :return: The License response as Bytes or a Base64 string. Don't Base64 Encode or
    -        Decode the data, return as is to reduce unnecessary computations.
    -    """
    +476
    def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]:
    +    """
    +    Get a Widevine License message by sending a License Request (challenge).
    +
    +    This License message contains the encrypted Content Decryption Keys and will be
    +    read by the Cdm and decrypted.
    +
    +    This is a very important request to get correct. A bad, unexpected, or missing
    +    value in the request can cause your key to be detected and promptly banned,
    +    revoked, disabled, or downgraded.
    +
    +    :param challenge: The license challenge from the Widevine CDM.
    +    :param title: The current `Title` from get_titles that is being executed. This is provided in
    +        case it has data needed to be used, e.g. for a HTTP request.
    +    :param track: The current `Track` needing decryption. Provided for same reason as `title`.
    +    :return: The License response as Bytes or a Base64 string. Don't Base64 Encode or
    +        Decode the data, return as is to reduce unnecessary computations.
    +    """
     
    @@ -3684,7 +3702,15 @@ revoked, disabled, or downgraded.

    Source code in unshackle/core/service.py -
    486
    +              
    478
    +479
    +480
    +481
    +482
    +483
    +484
    +485
    +486
     487
     488
     489
    @@ -3697,36 +3723,28 @@ revoked, disabled, or downgraded.

    496 497 498 -499 -500 -501 -502 -503 -504 -505 -506 -507
    def get_playready_license(
    -    self, *, challenge: bytes, title: Title_T, track: AnyTrack
    -) -> Optional[Union[bytes, str]]:
    -    """
    -    Get a PlayReady License message by sending a License Request (challenge).
    -
    -    This License message contains the encrypted Content Decryption Keys and will be
    -    read by the CDM and decrypted.
    -
    -    This is a very important request to get correct. A bad, unexpected, or missing
    -    value in the request can cause your key to be detected and promptly banned,
    -    revoked, disabled, or downgraded.
    -
    -    :param challenge: The license challenge from the PlayReady CDM.
    -    :param title: The current `Title` from get_titles that is being executed. This is provided in
    -        case it has data needed to be used, e.g. for a HTTP request.
    -    :param track: The current `Track` needing decryption. Provided for same reason as `title`.
    -    :return: The License response as Bytes or a Base64 string. Don't Base64 Encode or
    -        Decode the data, return as is to reduce unnecessary computations.
    -    """
    -    # Delegates license handling to the Widevine license method by default if a service-specific PlayReady implementation is not provided.
    -    return self.get_widevine_license(challenge=challenge, title=title, track=track)
    +499
    def get_playready_license(
    +    self, *, challenge: bytes, title: Title_T, track: AnyTrack
    +) -> Optional[Union[bytes, str]]:
    +    """
    +    Get a PlayReady License message by sending a License Request (challenge).
    +
    +    This License message contains the encrypted Content Decryption Keys and will be
    +    read by the CDM and decrypted.
    +
    +    This is a very important request to get correct. A bad, unexpected, or missing
    +    value in the request can cause your key to be detected and promptly banned,
    +    revoked, disabled, or downgraded.
    +
    +    :param challenge: The license challenge from the PlayReady CDM.
    +    :param title: The current `Title` from get_titles that is being executed. This is provided in
    +        case it has data needed to be used, e.g. for a HTTP request.
    +    :param track: The current `Track` needing decryption. Provided for same reason as `title`.
    +    :return: The License response as Bytes or a Base64 string. Don't Base64 Encode or
    +        Decode the data, return as is to reduce unnecessary computations.
    +    """
    +    # Delegates license handling to the Widevine license method by default if a service-specific PlayReady implementation is not provided.
    +    return self.get_widevine_license(challenge=challenge, title=title, track=track)
     
    @@ -3762,7 +3780,15 @@ and the license is a JWK Set, e.g. {"keys": [{"kty": "oct", "k": "...", "k
    Source code in unshackle/core/service.py -
    509
    +              
    501
    +502
    +503
    +504
    +505
    +506
    +507
    +508
    +509
     510
     511
     512
    @@ -3773,34 +3799,26 @@ and the license is a JWK Set, e.g. {"keys": [{"kty": "oct", "k": "...", "k
     517
     518
     519
    -520
    -521
    -522
    -523
    -524
    -525
    -526
    -527
    -528
    def get_clearkey_license(
    -    self, *, challenge: bytes, title: Title_T, track: AnyTrack
    -) -> Optional[Union[bytes, str, dict]]:
    -    """
    -    Get a W3C ClearKey License (JWK Set) by sending a License Request (challenge).
    -
    -    Used for DASH `org.w3.clearkey` content. No CDM is involved: the challenge is
    -    the W3C EME JSON license request, e.g. ``{"kids": ["<base64url>"], "type": "temporary"}``,
    -    and the license is a JWK Set, e.g. ``{"keys": [{"kty": "oct", "k": "...", "kid": "..."}]}``.
    -
    -    :param challenge: The JSON license request bytes to POST to the license server.
    -    :param title: The current `Title` from get_titles that is being executed. This is provided in
    -        case it has data needed to be used, e.g. for a HTTP request.
    -    :param track: The current `Track` needing decryption. Provided for same reason as `title`.
    -    :return: The JWK Set license as a dict, JSON str, or raw bytes. Return None (the default)
    -        to let the framework POST the challenge to the manifest-provided Laurl, if any.
    -        Services with no license server can instead pre-populate the DRM object's
    -        `content_keys` in get_tracks.
    -    """
    -    return None
    +520
    def get_clearkey_license(
    +    self, *, challenge: bytes, title: Title_T, track: AnyTrack
    +) -> Optional[Union[bytes, str, dict]]:
    +    """
    +    Get a W3C ClearKey License (JWK Set) by sending a License Request (challenge).
    +
    +    Used for DASH `org.w3.clearkey` content. No CDM is involved: the challenge is
    +    the W3C EME JSON license request, e.g. ``{"kids": ["<base64url>"], "type": "temporary"}``,
    +    and the license is a JWK Set, e.g. ``{"keys": [{"kty": "oct", "k": "...", "kid": "..."}]}``.
    +
    +    :param challenge: The JSON license request bytes to POST to the license server.
    +    :param title: The current `Title` from get_titles that is being executed. This is provided in
    +        case it has data needed to be used, e.g. for a HTTP request.
    +    :param track: The current `Track` needing decryption. Provided for same reason as `title`.
    +    :return: The JWK Set license as a dict, JSON str, or raw bytes. Return None (the default)
    +        to let the framework POST the challenge to the manifest-provided Laurl, if any.
    +        Services with no license server can instead pre-populate the DRM object's
    +        `content_keys` in get_tracks.
    +    """
    +    return None
     
    @@ -3835,33 +3853,33 @@ This can be useful to store information on each title that will be required like
    Source code in unshackle/core/service.py -
    @abstractmethod
    -def get_titles(self) -> Titles_T:
    -    """
    -    Get Titles for the provided title ID.
    -
    -    Return a Movies, Series, or Album objects containing Movie, Episode, or Song title objects respectively.
    -    The returned data must be for the given title ID, or a spawn of the title ID.
    -
    -    At least one object is expected to be returned, or it will presume an invalid Title ID was
    -    provided.
    -
    -    You can use the `data` dictionary class instance attribute of each Title to store data you may need later on.
    -    This can be useful to store information on each title that will be required like any sub-asset IDs, or such.
    -    """
    +              
    @abstractmethod
    +def get_titles(self) -> Titles_T:
    +    """
    +    Get Titles for the provided title ID.
    +
    +    Return a Movies, Series, or Album objects containing Movie, Episode, or Song title objects respectively.
    +    The returned data must be for the given title ID, or a spawn of the title ID.
    +
    +    At least one object is expected to be returned, or it will presume an invalid Title ID was
    +    provided.
    +
    +    You can use the `data` dictionary class instance attribute of each Title to store data you may need later on.
    +    This can be useful to store information on each title that will be required like any sub-asset IDs, or such.
    +    """
     
    @@ -3943,7 +3961,19 @@ fallback to cached data when API calls fail.

    Source code in unshackle/core/service.py -
    549
    +              
    537
    +538
    +539
    +540
    +541
    +542
    +543
    +544
    +545
    +546
    +547
    +548
    +549
     550
     551
     552
    @@ -3973,67 +4003,49 @@ fallback to cached data when API calls fail.

    576 577 578 -579 -580 -581 -582 -583 -584 -585 -586 -587 -588 -589 -590 -591 -592 -593 -594
    def get_titles_cached(self, title_id: str = None) -> Titles_T:
    -    """
    -    Cached wrapper around get_titles() to reduce redundant API calls.
    -
    -    This method checks the cache before calling get_titles() and handles
    -    fallback to cached data when API calls fail.
    -
    -    Args:
    -        title_id: Optional title ID for cache key generation.
    -                 If not provided, will try to extract from service instance.
    -
    -    Returns:
    -        Titles object (Movies, Series, or Album)
    -    """
    -    # Try to get title_id from service instance if not provided
    -    if title_id is None:
    -        # Different services store the title ID in different attributes
    -        if hasattr(self, "title"):
    -            title_id = self.title
    -        elif hasattr(self, "title_id"):
    -            title_id = self.title_id
    -        else:
    -            # If we can't determine title_id, just call get_titles directly
    -            self.log.debug("Cannot determine title_id for caching, bypassing cache")
    -            return self.apply_title_map(self.get_titles())
    -
    -    # Get cache control flags from context
    -    no_cache = False
    -    reset_cache = False
    -    if self.ctx and self.ctx.parent:
    -        no_cache = self.ctx.parent.params.get("no_cache", False)
    -        reset_cache = self.ctx.parent.params.get("reset_cache", False)
    -
    -    # Get account hash for cache key
    -    account_hash = get_account_hash(self.credential)
    -
    -    # Use title cache to get titles with fallback support
    -    titles = self.title_cache.get_cached_titles(
    -        title_id=str(title_id),
    -        fetch_function=self.get_titles,
    -        region=self.current_region,
    -        account_hash=account_hash,
    -        no_cache=no_cache,
    -        reset_cache=reset_cache,
    -    )
    -    return self.apply_title_map(titles)
    +579
    def get_titles_cached(self, title_id: str = None) -> Titles_T:
    +    """
    +    Cached wrapper around get_titles() to reduce redundant API calls.
    +
    +    This method checks the cache before calling get_titles() and handles
    +    fallback to cached data when API calls fail.
    +
    +    Args:
    +        title_id: Optional title ID for cache key generation.
    +                 If not provided, will try to extract from service instance.
    +
    +    Returns:
    +        Titles object (Movies, Series, or Album)
    +    """
    +    if title_id is None:
    +        # Different services store the title ID in different attributes
    +        if hasattr(self, "title"):
    +            title_id = self.title
    +        elif hasattr(self, "title_id"):
    +            title_id = self.title_id
    +        else:
    +            # If we can't determine title_id, just call get_titles directly
    +            self.log.debug("Cannot determine title_id for caching, bypassing cache")
    +            return self.apply_title_map(self.get_titles())
    +
    +    no_cache = False
    +    reset_cache = False
    +    if self.ctx and self.ctx.parent:
    +        no_cache = self.ctx.parent.params.get("no_cache", False)
    +        reset_cache = self.ctx.parent.params.get("reset_cache", False)
    +
    +    # Get account hash for cache key
    +    account_hash = get_account_hash(self.credential)
    +
    +    titles = self.title_cache.get_cached_titles(
    +        title_id=str(title_id),
    +        fetch_function=self.get_titles,
    +        region=self.current_region,
    +        account_hash=account_hash,
    +        no_cache=no_cache,
    +        reset_cache=reset_cache,
    +    )
    +    return self.apply_title_map(titles)
     
    @@ -4061,23 +4073,23 @@ title cache so config edits take effect without a cache reset, and before any
    Source code in unshackle/core/service.py -
    def apply_title_map(self, titles: Titles_T) -> Titles_T:
    -    """
    -    Rewrite service-provided titles using the per-service ``title_map`` config.
    -
    -    ``title_map`` lives under ``services.<TAG>`` in unshackle.yaml. Applied after the
    -    title cache so config edits take effect without a cache reset, and before any
    -    ``--enrich`` override so enrich wins. See ``remap_titles`` for the match rules.
    -    """
    -    return remap_titles(titles, (self.config or {}).get("title_map") or {})
    +              
    def apply_title_map(self, titles: Titles_T) -> Titles_T:
    +    """
    +    Rewrite service-provided titles using the per-service ``title_map`` config.
    +
    +    ``title_map`` lives under ``services.<TAG>`` in unshackle.yaml. Applied after the
    +    title cache so config edits take effect without a cache reset, and before any
    +    ``--enrich`` override so enrich wins. See ``remap_titles`` for the match rules.
    +    """
    +    return remap_titles(titles, (self.config or {}).get("title_map") or {})
     
    @@ -4124,7 +4136,22 @@ downloading any of the Track's stream data, then do.

    Source code in unshackle/core/service.py -
    606
    +              
    591
    +592
    +593
    +594
    +595
    +596
    +597
    +598
    +599
    +600
    +601
    +602
    +603
    +604
    +605
    +606
     607
     608
     609
    @@ -4138,51 +4165,36 @@ downloading any of the Track's stream data, then do.

    617 618 619 -620 -621 -622 -623 -624 -625 -626 -627 -628 -629 -630 -631 -632 -633 -634 -635
    @abstractmethod
    -def get_tracks(self, title: Title_T) -> Tracks:
    -    """
    -    Get Track objects of the Title.
    -
    -    Return a Tracks object, which itself can contain Video, Audio, Subtitle or even Chapters.
    -    Tracks.videos, Tracks.audio, Tracks.subtitles, and Track.chapters should be a List of Track objects.
    -
    -    Each Track in the Tracks should represent a Video/Audio Stream/Representation/Adaptation or
    -    a Subtitle file.
    -
    -    While one Track should only hold information for one stream/downloadable, try to get as many
    -    unique Track objects per stream type so Stream selection by the root code can give you more
    -    options in terms of Resolution, Bitrate, Codecs, Language, e.t.c.
    -
    -    No decision making or filtering of which Tracks get returned should happen here. It can be
    -    considered an error to filter for e.g. resolution, codec, and such. All filtering based on
    -    arguments will be done by the root code automatically when needed.
    -
    -    Make sure you correctly mark which Tracks are encrypted or not, and by which DRM System
    -    via its `drm` property.
    -
    -    If you are able to obtain the Track's KID (Key ID) as a 32 char (16 bit) HEX string, provide
    -    it to the Track's `kid` variable as it will speed up the decryption process later on. It may
    -    or may not be needed, that depends on the service. Generally if you can provide it, without
    -    downloading any of the Track's stream data, then do.
    -
    -    :param title: The current `Title` from get_titles that is being executed.
    -    :return: Tracks object containing Video, Audio, Subtitles, and Chapters, if available.
    -    """
    +620
    @abstractmethod
    +def get_tracks(self, title: Title_T) -> Tracks:
    +    """
    +    Get Track objects of the Title.
    +
    +    Return a Tracks object, which itself can contain Video, Audio, Subtitle or even Chapters.
    +    Tracks.videos, Tracks.audio, Tracks.subtitles, and Track.chapters should be a List of Track objects.
    +
    +    Each Track in the Tracks should represent a Video/Audio Stream/Representation/Adaptation or
    +    a Subtitle file.
    +
    +    While one Track should only hold information for one stream/downloadable, try to get as many
    +    unique Track objects per stream type so Stream selection by the root code can give you more
    +    options in terms of Resolution, Bitrate, Codecs, Language, e.t.c.
    +
    +    No decision making or filtering of which Tracks get returned should happen here. It can be
    +    considered an error to filter for e.g. resolution, codec, and such. All filtering based on
    +    arguments will be done by the root code automatically when needed.
    +
    +    Make sure you correctly mark which Tracks are encrypted or not, and by which DRM System
    +    via its `drm` property.
    +
    +    If you are able to obtain the Track's KID (Key ID) as a 32 char (16 bit) HEX string, provide
    +    it to the Track's `kid` variable as it will speed up the decryption process later on. It may
    +    or may not be needed, that depends on the service. Generally if you can provide it, without
    +    downloading any of the Track's stream data, then do.
    +
    +    :param title: The current `Title` from get_titles that is being executed.
    +    :return: Tracks object containing Video, Audio, Subtitles, and Chapters, if available.
    +    """
     
    @@ -4249,41 +4261,41 @@ option chapter_fallback_name. For example, "Chapter {i:02}" Source code in unshackle/core/service.py -
    637
    +              
    @abstractmethod
    -def get_chapters(self, title: Title_T) -> Chapters:
    -    """
    -    Get Chapters for the Title.
    -
    -    Parameters:
    -        title: The current Title from `get_titles` that is being processed.
    -
    -    You must return a Chapters object containing 0 or more Chapter objects.
    -
    -    You do not need to set a Chapter number or sort/order the chapters in any way as
    -    the Chapters class automatically handles all of that for you. If there's no
    -    descriptive name for a Chapter then do not set a name at all.
    -
    -    You must not set Chapter names to "Chapter {n}" or such. If you (or the user)
    -    wants "Chapter {n}" style Chapter names (or similar) then they can use the config
    -    option `chapter_fallback_name`. For example, `"Chapter {i:02}"` for "Chapter 01".
    -    """
    +639
    @abstractmethod
    +def get_chapters(self, title: Title_T) -> Chapters:
    +    """
    +    Get Chapters for the Title.
    +
    +    Parameters:
    +        title: The current Title from `get_titles` that is being processed.
    +
    +    You must return a Chapters object containing 0 or more Chapter objects.
    +
    +    You do not need to set a Chapter number or sort/order the chapters in any way as
    +    the Chapters class automatically handles all of that for you. If there's no
    +    descriptive name for a Chapter then do not set a name at all.
    +
    +    You must not set Chapter names to "Chapter {n}" or such. If you (or the user)
    +    wants "Chapter {n}" style Chapter names (or similar) then they can use the config
    +    option `chapter_fallback_name`. For example, `"Chapter {i:02}"` for "Chapter 01".
    +    """
     
    @@ -4355,21 +4367,21 @@ option chapter_fallback_name. For example, "Chapter {i:02}" Source code in unshackle/core/service.py -
    def on_segment_downloaded(self, track: AnyTrack, segment: Path) -> None:
    -    """
    -    Called when one of a Track's Segments has finished downloading.
    -
    -    Parameters:
    -        track: The Track object that had a Segment downloaded.
    -        segment: The Path to the Segment that was downloaded.
    -    """
    +              
    def on_segment_downloaded(self, track: AnyTrack, segment: Path) -> None:
    +    """
    +    Called when one of a Track's Segments has finished downloading.
    +
    +    Parameters:
    +        track: The Track object that had a Segment downloaded.
    +        segment: The Path to the Segment that was downloaded.
    +    """
     
    @@ -4425,19 +4437,19 @@ option chapter_fallback_name. For example, "Chapter {i:02}" Source code in unshackle/core/service.py -
    def on_track_downloaded(self, track: AnyTrack) -> None:
    -    """
    -    Called when a Track has finished downloading.
    -
    -    Parameters:
    -        track: The Track object that was downloaded.
    -    """
    +              
    def on_track_downloaded(self, track: AnyTrack) -> None:
    +    """
    +    Called when a Track has finished downloading.
    +
    +    Parameters:
    +        track: The Track object that was downloaded.
    +    """
     
    @@ -4525,23 +4537,23 @@ option chapter_fallback_name. For example, "Chapter {i:02}" Source code in unshackle/core/service.py -
    def on_track_decrypted(self, track: AnyTrack, drm: DRM_T, segment: Optional[m3u8.Segment] = None) -> None:
    -    """
    -    Called when a Track has finished decrypting.
    -
    -    Parameters:
    -        track: The Track object that was decrypted.
    -        drm: The DRM object it decrypted with.
    -        segment: The HLS segment information that was decrypted.
    -    """
    +              
    def on_track_decrypted(self, track: AnyTrack, drm: DRM_T, segment: Optional[m3u8.Segment] = None) -> None:
    +    """
    +    Called when a Track has finished decrypting.
    +
    +    Parameters:
    +        track: The Track object that was decrypted.
    +        drm: The DRM object it decrypted with.
    +        segment: The HLS segment information that was decrypted.
    +    """
     
    @@ -4597,19 +4609,19 @@ option chapter_fallback_name. For example, "Chapter {i:02}" Source code in unshackle/core/service.py -
    + + + + diff --git a/reference/configuration/misc/index.html b/reference/configuration/misc/index.html index 968f594c..6e807808 100644 --- a/reference/configuration/misc/index.html +++ b/reference/configuration/misc/index.html @@ -13,7 +13,7 @@ - + @@ -921,6 +921,8 @@ + + @@ -1181,6 +1183,34 @@ + + +
  • + + + + + + + + Post-Download Scripts + + + + + + + + +
  • + + + + + + + + @@ -2463,7 +2493,7 @@ setting, and KIDs, key counts, and other metadata are always logged either way.<
    def on_track_repacked(self, track: AnyTrack) -> None:
    -    """
    -    Called when a Track has finished repacking.
    -
    -    Parameters:
    -        track: The Track object that was repacked.
    -    """
    +              
    def on_track_repacked(self, track: AnyTrack) -> None:
    +    """
    +    Called when a Track has finished repacking.
    +
    +    Parameters:
    +        track: The Track object that was repacked.
    +    """
     
    @@ -4668,27 +4680,27 @@ MP4 via ffmpeg/mp4box.

    Source code in unshackle/core/service.py -
    def on_track_multiplex(self, track: AnyTrack) -> None:
    -    """
    -    Called when a Track is about to be Multiplexed into a Container.
    -
    -    Note: Right now only MKV containers are multiplexed but in the future
    -    this may also be called when multiplexing to other containers like
    -    MP4 via ffmpeg/mp4box.
    -
    -    Parameters:
    -        track: The Track object that was repacked.
    -    """
    +              
    def on_track_multiplex(self, track: AnyTrack) -> None:
    +    """
    +    Called when a Track is about to be Multiplexed into a Container.
    +
    +    Note: Right now only MKV containers are multiplexed but in the future
    +    this may also be called when multiplexing to other containers like
    +    MP4 via ffmpeg/mp4box.
    +
    +    Parameters:
    +        track: The Track object that was repacked.
    +    """
     
    @@ -4766,13 +4778,13 @@ MP4 via ffmpeg/mp4box.

    135 136 137
    def sanitize_proxy_for_log(uri: Optional[str]) -> Optional[str]:
    -    """
    +    """
         Sanitize a proxy URI for logs by redacting any embedded userinfo (username/password).
     
         Examples:
           - http://user:pass@host:8080 -> http://REDACTED@host:8080
           - socks5h://user@host:1080   -> socks5h://REDACTED@host:1080
    -    """
    +    """
         if uri is None:
             return None
         if not isinstance(uri, str):
    @@ -4783,24 +4795,24 @@ MP4 via ffmpeg/mp4box.

    try: parsed = urlparse(uri) - # Handle schemeless proxies like "user:pass@host:port" - if not parsed.scheme and not parsed.netloc and "@" in uri and "://" not in uri: + # Handle schemeless proxies like "user:pass@host:port" + if not parsed.scheme and not parsed.netloc and "@" in uri and "://" not in uri: # Parse as netloc using a dummy scheme, then strip scheme back out. - dummy = urlparse(f"http://{uri}") + dummy = urlparse(f"http://{uri}") netloc = dummy.netloc - if "@" in netloc: - netloc = f"REDACTED@{netloc.split('@', 1)[1]}" - # urlparse("http://...") sets path to "" for typical netloc-only strings; keep it just in case. - return f"{netloc}{dummy.path}" + if "@" in netloc: + netloc = f"REDACTED@{netloc.split('@', 1)[1]}" + # urlparse("http://...") sets path to "" for typical netloc-only strings; keep it just in case. + return f"{netloc}{dummy.path}" netloc = parsed.netloc - if "@" in netloc: - netloc = f"REDACTED@{netloc.split('@', 1)[1]}" + if "@" in netloc: + netloc = f"REDACTED@{netloc.split('@', 1)[1]}" return urlunparse(parsed._replace(netloc=netloc)) except ValueError: - if "@" in uri: - return f"REDACTED@{uri.split('@', 1)[1]}" + if "@" in uri: + return f"REDACTED@{uri.split('@', 1)[1]}" return uri
    diff --git a/reference/api/titles/index.html b/reference/api/titles/index.html index 6542f49c..0fd2a138 100644 --- a/reference/api/titles/index.html +++ b/reference/api/titles/index.html @@ -919,6 +919,8 @@ + + @@ -1178,6 +1180,34 @@ +
  • + + + + + + + + Post-Download Scripts + + + + + + + + +
  • + + + + + + + + + +
  • @@ -2592,7 +2622,7 @@ takes every part. A dated episode also answers to its ISO air date.
    Album(
         iterable=None,
    -    kind="album",
    +    kind="album",
         title=None,
         artist=None,
         year=None,
    @@ -2653,7 +2683,7 @@ takes every part. A dated episode also answers to its ISO air date.
     
     
    Music(
         iterable=None,
    -    kind="album",
    +    kind="album",
         title=None,
         artist=None,
         year=None,
    @@ -2724,7 +2754,7 @@ takes every part. A dated episode also answers to its ISO air date.
         language=None,
         data=None,
         album_artist=None,
    -    release_type="album",
    +    release_type="album",
         total_tracks=None,
         total_discs=None,
         genre=None,
    diff --git a/reference/api/tracks/index.html b/reference/api/tracks/index.html
    index b82dad3b..25da6c85 100644
    --- a/reference/api/tracks/index.html
    +++ b/reference/api/tracks/index.html
    @@ -919,6 +919,8 @@
             
           
             
    +      
    +        
           
             
           
    @@ -1178,6 +1180,34 @@
       
       
       
    +    
  • + + + + + + + + Post-Download Scripts + + + + + + + + +
  • + + + + + + + + + +
  • @@ -4726,7 +4756,7 @@ it to an integer.

    -
    dumps(fallback_name='')
    +
    dumps(fallback_name='')
     
    @@ -4887,7 +4917,7 @@ any existing data.

    -
    log = logging.getLogger('hybrid')
    +
    log = logging.getLogger('hybrid')
     
    @@ -5707,6 +5737,9 @@ line: position (lower percentage = higher on screen = first line).

    More thorough sanitization of WebVTT files to handle multiple potential issues.

    +

    This is lossy, so use it only as a fallback once normal parsing has failed. Everything before the +WEBVTT header line is discarded and the header itself is reduced to a bare "WEBVTT" line. Negative +timestamps become 00:00:00.000 and timestamps missing the hours field are padded to HH:MM:SS.mmm.

    Parameters:

    @@ -6263,6 +6296,8 @@ or be removed at any point.

    Download and optionally Decrypt this Track.

    +

    A URL-descriptor Video or Audio track with no drm set has its DRM probed from the init data of the +stream and stored on the track, so a service need not declare drm itself.

    @@ -7038,6 +7073,8 @@ selections) stays unflagged and is muxed standalone alongside the hybrid.

    Multiplex all the Tracks into a Matroska Container file.

    +

    A failed mux does not raise. mkvmerge's exit code and error lines come back alongside the +output path, and the caller must check them.

    Parameters:

    diff --git a/reference/configuration/directories/index.html b/reference/configuration/directories/index.html index ff5b031b..a04f1eda 100644 --- a/reference/configuration/directories/index.html +++ b/reference/configuration/directories/index.html @@ -921,6 +921,8 @@ + + @@ -1241,6 +1243,34 @@ +
  • + + + + + + + + Post-Download Scripts + + + + + + + + +
  • + + + + + + + + + +
  • diff --git a/reference/configuration/download/index.html b/reference/configuration/download/index.html index c5406e59..ad3278bc 100644 --- a/reference/configuration/download/index.html +++ b/reference/configuration/download/index.html @@ -921,6 +921,8 @@ + + @@ -1307,6 +1309,34 @@ +
  • + + + + + + + + Post-Download Scripts + + + + + + + + +
  • + + + + + + + + + +
  • @@ -2273,7 +2303,7 @@ default. In other words, config only fills in options you did not set on the com as a list or as one comma-separated string.

    dl:
       s_lang: [all, -es]
    -  a_lang: "orig,-es"
    +  a_lang: "orig,-es"
     

    See Excluding languages for the rules.

  • @@ -2578,7 +2608,7 @@ A rule may test:

    rules: - audio: [ja] subs_contain: [en] - tag: "SUBBED" + tag: "SUBBED"

    dual_multi_mode

      diff --git a/reference/configuration/drm/index.html b/reference/configuration/drm/index.html index 38120f34..bcd056c7 100644 --- a/reference/configuration/drm/index.html +++ b/reference/configuration/drm/index.html @@ -921,6 +921,8 @@ + + @@ -1291,6 +1293,34 @@ +
    • + + + + + + + + Post-Download Scripts + + + + + + + + +
    • + + + + + + + + + +
    • @@ -2063,8 +2093,8 @@ then default.

      profile:

      cdm:
         EXAMPLE1:
      -    ">=1080": android_l1        # by track height
      -    "<1080": chromecdm_l3
      +    ">=1080": android_l1        # by track height
      +    "<1080": chromecdm_l3
           widevine: android_l1        # by DRM system
           playready: sl3000
           default: chromecdm_l3
      diff --git a/reference/configuration/example/index.html b/reference/configuration/example/index.html
      index 39858cd9..a346e0b6 100644
      --- a/reference/configuration/example/index.html
      +++ b/reference/configuration/example/index.html
      @@ -921,6 +921,8 @@
               
             
               
      +      
      +        
             
               
             
      @@ -1183,6 +1185,34 @@
         
         
         
      +    
    • + + + + + + + + Post-Download Scripts + + + + + + + + +
    • + + + + + + + + + +
    • @@ -1899,11 +1929,11 @@ EXAMPLE2: user@example.com:hunter2 output_template: - movies: "{title}.{year}.{quality?}.{source}-{tag}" - series: "{title}.{season_episode}.{quality?}.{source}-{tag}" + movies: "{title}.{year}.{quality?}.{source}-{tag}" + series: "{title}.{season_episode}.{quality?}.{source}-{tag}" folder: - movies: "{title} ({year})" - series: "{title} ({year})" + movies: "{title} ({year})" + series: "{title} ({year})" tag: MYGRP @@ -1916,9 +1946,9 @@ basic: us: http://user:pass@1.2.3.4:8080 -omdb_api_key: "your-omdb-key" -tmdb_api_key: "your-tmdb-key" -tvdb_api_key: "your-tvdb-key" +omdb_api_key: "your-omdb-key" +tmdb_api_key: "your-tmdb-key" +tvdb_api_key: "your-tvdb-key" title_cache_time: 3600 redact_paths: true
    • diff --git a/reference/configuration/index.html b/reference/configuration/index.html index efa90ccd..81cb24c2 100644 --- a/reference/configuration/index.html +++ b/reference/configuration/index.html @@ -921,6 +921,8 @@ + + @@ -1183,6 +1185,34 @@ +
    • + + + + + + + + Post-Download Scripts + + + + + + + + +
    • + + + + + + + + + +
    • @@ -1950,6 +1980,10 @@ default.

  • key_vaults, vault_timeout
    Post-download scriptspost_scripts
    External API keys omdb_api_key, tmdb_api_key, tvdb_api_key, tvdb_pin, tvdb_order, simkl_client_id, metadata_providers, anilist_title_language, disable_metadata, decrypt_labs_api_key, ipinfo_api_key
    + + + + + + + + + + + + + + + + + + + + + + + + +
    KeyDefaultMeaning
    commandrequiredThe command to run, with {variable} placeholders
    eventsuccesssuccess when a file was written, failure when the download failed
    modefilefile, season, or run. See Modes
    +

    An entry whose event or mode is not one of the listed values is skipped and logs a +warning naming the bad value, once per command. An entry whose command cannot be split +into arguments, such as one with an unbalanced quote, is skipped with a warning too. Neither +stops the download or the other entries.

    +

    How the command runs

    +

    The command is split into arguments first, and variables are substituted into those +arguments afterwards. The process runs without a shell.

    +

    This is what keeps a title like Bob"; rm -rf ~ harmless: a value can never turn into an +extra argument or a shell operator, and you never have to think about quoting. It also +means a path containing spaces always arrives as a single argument on every OS.

    +
    +

    Prefix your variables

    +

    Titles, episode names and IDs come from the streaming service, not from you. Tokenizing +first stops a value becoming a new argument, but a value on its own can still be a +whole argument. A bare {title} whose value is --upload-file=/etc/passwd would arrive +as an option to your script, not as text. Write --title={title}, not a bare {title}, +so a service can never forge a flag. unshackle refuses to run a command when a +substituted value would become an option-like token (one that starts with -), and logs +a warning instead.

    +
    +
    +

    No shell features, and name your interpreter

    +

    Pipes, &&, > redirection, ~ and globs do not work, because no shell is involved. +If you want them, make the shell explicit: bash -c "...".

    +

    For the same reason there is no interpreter lookup. Write python /opt/upload.py +rather than /opt/upload.py. unshackle will not guess whether you meant the system +Python or the one in its own virtual environment.

    +

    On Windows, when a fixed part of an argument contains a space, quote the whole +argument ("--out=C:\My Dir\{filename}"), not just the value. Substituted values +never need quoting on any OS.

    +

    On Windows, do not target a .bat or .cmd file directly. Windows runs batch files +through cmd.exe, whose argument quoting differs from every other program and has a +history of letting a crafted value break out (CVE-2024-3566). Call the interpreter and +pass the script as an argument (python upload.py, pwsh -File upload.ps1) instead.

    +
    +

    Scripts are fire and forget. unshackle starts the process, writes the command to the debug +log, and carries on. It does not wait for the script, capture its output, time it out, or +change its own exit code when a script fails. A script keeps running if unshackle exits +first, and Ctrl+C during a season pack does not kill the scripts +already started.

    +
    +

    One process per file

    +

    In file mode a large season pack starts one process per episode with nothing +throttling them. If your script is heavy, queue the work inside it or use mode: season.

    +
    +

    Variables

    +

    Metadata comes from the same naming context that produced the file's name, built per output +file. So {quality} and {hdr} always describe the file being passed. Downloading +-q 1080,2160 -r HDR10,SDR produces four files, each with its own invocation and its own +values.

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    VariableValue
    {filepath}Absolute path of the output file
    {filename}Its filename, with extension
    {folder}Absolute path of its parent folder
    {ext}Its extension, including the dot
    {sidecars}Sidecar subtitle files written for this output, newline separated
    {title}Show, movie or track name, sanitized
    {title_raw}Exactly what the service returned, untouched
    {title_id}The service's ID for the title
    {service}Service tag
    {year} {season} {episode} {episode_name}Season and episode are plain numbers (1, 5)
    {quality} {resolution} {hdr} {hfr} {vcodec} {acodec} {edition} {tag} {lang_tag}As in the filename template, for this file
    {tmdb} {imdb} {tvdb}The tagging IDs in use, otherwise empty. See Tagging IDs
    {error}Failure message. Empty on success
    +

    Every variable from your output_template is available here as well, including +{video} and {audio}, of which {vcodec} and {acodec} are aliases.

    +
    +

    Empty variables become an empty string

    +

    --tmdb={tmdb} with no ID resolved hands your script --tmdb=, so treat an empty +string as absent. Write your script to handle every variable being empty.

    +

    Empty is common, and not only when data is missing:

    +
      +
    • Nothing was resolved. {tmdb} is empty when no metadata provider found the title, + the matching API key is missing, or the title is a movie you gave no ID for.
    • +
    • The title has no such field. Movies have no {season} or {episode}, and music has + neither.
    • +
    • The naming context renders it empty on purpose. {hdr} is empty for an SDR + file, because SDR is the absence of an HDR tag in a filename rather than a tag that + reads SDR. {edition}, {atmos}, {multi}, {dual} and {hfr} behave the same + way. If your script needs the word SDR, derive it from an empty {hdr} yourself.
    • +
    • The hook is a failure hook. A failed download has no output file to read metadata + from, so everything that comes from the naming context is empty. See + Events.
    • +
    +

    There is no {variable?} conditional here, unlike output_template. The token is +always passed, so the argument list your script receives keeps the same shape on every +run and your argument parser does not have to cope with a flag appearing and +disappearing.

    +

    Season and episode are the plain numbers, not the S01E05 form used in filenames. The +padding and the separator belong to the filename template rather than to the data.

    +
    +

    Music variables

    +

    A music download builds its variables from the music naming context, so a hook gets the +release fields instead of the season and episode ones. {season}, {episode} and +{episode_name} are always empty for music.

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    VariableValue
    {artist}Track artist
    {album_artist}Album artist, falling back to the track artist
    {album}Album name
    {track_number} {disc}Zero padded. {disc} is empty on a single-disc release
    {track_total} {disc_total}Zero padded totals, empty when the service did not give them
    {isrc} {upc} {label} {genre} {release_type} {explicit}As the service reported them, empty when absent
    +

    In a file hook {title} is the track name. See Modes for what an album hook +puts in {title}.

    +

    Tagging IDs

    +

    {tmdb}, {imdb} and {tvdb} carry the IDs unshackle is tagging the file with, read at +the moment the hook is dispatched rather than snapshotted at the start of the run. An +episode's TMDB ID is resolved by the title search inside the download loop, so a hook fires +with it already filled in.

    +

    They hold an ID you passed with --tmdb, --imdb or --tvdb, plus a TMDB ID the episode +search resolved. No such search runs for a movie, so a movie hook gets them empty unless you +passed one.

    +

    Sidecar files

    +

    {sidecars} holds the sidecar subtitle files written beside the output, which are the only +extra files a download leaves next to it. Attachments such as fonts are muxed into the +container and temporary artwork is deleted, so neither appears. Expect {sidecars} to be +empty unless subtitle.output_mode is sidecar or both, which is +not the default. A music download writes no sidecars at all.

    +

    Paths are absolute and separated by a newline, because a newline is the only character that +cannot appear in a filename on any supported OS. The whole list arrives as a single +argument, so no quoting is needed in your template.

    +

    When one download writes several outputs (-q 1080,720), each output's folder gets its own +copy of the sidecar files, and each invocation lists the copies sitting beside its own +{filepath}.

    +
    Reading {sidecars} in Python
    subs = [p for p in sys.argv[i].removeprefix("--subs=").split("\n") if p]
    +
    +

    Modes

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    modeRuns{filepath}{sidecars}
    fileOnce per output fileThat fileThat output's sidecars
    seasonOnce per season folder, after that season's last queued titleEmptyEmpty
    runOnce per folder written, at the end of the runEmptyEmpty
    +
    +

    season means everything this run asked for

    +

    A season hook fires when the last title this run queued for that season finishes. +With -w S01-S02 the S01 hook runs while S02 is still downloading. It says nothing +about whether the season is complete on disk. Downloading a single episode fires the +hook for that season as soon as the episode lands.

    +

    A title that fails takes its group with it. If one episode of S01 fails, the S01 hook +does not fire at all, because the run never finished everything it queued for that +season.

    +
    +

    The hook is keyed to the folder, not to the season, so one season landing in several +folders (-q 1080,720 with a quality-dependent folder template) fires it once per folder, +each with its own {folder}. A movie is a group of its own: a season hook fires for a +movie's folder as soon as the movie finishes.

    +

    In run mode every variable except {folder} is empty, because one run can cover several +shows. Only {folder} is meaningful, which makes it useful for starting a library scan +once.

    +

    Music

    +

    Music uses the same three modes, with the album standing in for the season.

    + + + + + + + + + + + + + + + + + + + + + +
    modeRuns
    fileOnce per track, after its metadata has been written
    seasonOnce per album folder, after every queued track of that album has landed
    runOnce per folder written, at the end of the run
    +

    In a file hook {title} and {title_raw} are the track name. In an album hook they are +the album name, and the per-track variables {track_number}, {disc} and {isrc} are +empty, in the same way that {episode} is empty in a season hook.

    +

    Events

    +

    success runs after the file has moved to its final path. failure runs when the download +fails, with {filepath} empty and {error} set. A music failure hook describes the +release rather than a track, because the whole album stops when one track fails.

    +
    +

    failure only fires in file mode

    +

    A failure entry with mode: season or mode: run never runs. Leave mode at its +default on a failure entry.

    +
    +

    A failure hook has no output file to read metadata from, so it carries only what the +title object already knew: {title}, {title_raw}, {title_id}, {year}, {season}, +{episode}, {episode_name}, {service}, the tagging IDs and {error}. Everything that +comes from the naming context, such as {quality}, {hdr}, {artist} and {album}, is +empty.

    +

    A resumed --continue download that finishes runs success as usual. Because unshackle +does not wait for your script, it never sees its exit code, so a failing script is not +reported anywhere. Your script owns its own error handling and logging.

    +

    A success hook always names a file this run wrote. There is no skip-if-exists path in +dl today, so nothing fires for a title that was not downloaded.

    +

    --postscript

    +

    dl accepts --postscript "<command>", repeatable. Every command given this way runs with +event: success and mode: file.

    +
    $ unshackle dl --postscript "python /opt/upload.py {filepath}" SERVICE TITLE_ID
    +
    +

    It replaces the post_scripts config for that run rather than adding to it, so no +configured entry runs while it is in use. That includes your season, run and failure +entries, which have no --postscript equivalent.

    +

    The REST API

    +

    The API never accepts a post-script command. postscript, post_script and post_scripts +in a POST /api/download body are rejected with 400 INVALID_PARAMETERS, because a command +arriving from an HTTP caller would be remote code execution.

    +

    Scripts defined in unshackle.yaml do run for API jobs, music jobs included. Only the +command itself may not cross the network.

    +

    Logging

    +

    The command that was run is logged twice: at DEBUG level on the console, and as a +post_script_dispatch entry in the +structured JSON debug log +when that log is enabled.

    +

    Both are masked the same way, honouring redact_paths, which is on by default. +Debug logs get shared in issue reports, and a hook command line is mostly paths. URL +credentials and token= / api_key= query parameters are also masked.

    +
    +

    Do not hardcode secrets in the command

    +

    An arbitrary secret in the command line, such as -H "Authorization: Bearer <key>", +cannot be recognised and is logged verbatim. Read the secret from a file or an +environment variable inside your script rather than putting it in the command.

    +
    +

    Limitations

    +
      +
    • No success hooks run under --no-mux or --skip-dl, because neither writes a muxed + output. A failure hook still runs under --no-mux if the download itself fails.
    • +
    • failure hooks only run in file mode.
    • +
    • There is no webhook sender. Call curl from a script instead.
    • +
    • Script output is not captured. Redirect it inside your own script if you want a log.
    • +
    + + + + + + + + + + + + + + +
    + + + + + + + + + + + + + + +
    +
    +
    + + + + + + + + + + + + + \ No newline at end of file diff --git a/reference/configuration/services/index.html b/reference/configuration/services/index.html index ceb90411..af1519f3 100644 --- a/reference/configuration/services/index.html +++ b/reference/configuration/services/index.html @@ -921,6 +921,8 @@ + + @@ -1285,6 +1287,34 @@ +
  • + + + + + + + + Post-Download Scripts + + + + + + + + +
  • + + + + + + + + + +
  • @@ -2057,9 +2087,9 @@ rather than in the service's own config.yaml, which holds shared de EXAMPLE1: proxy_map: us: us-nyc - "nordvpn:ca": ca-toronto + "nordvpn:ca": ca-toronto title_map: - "My Show: The Movie": "My Show Presents The Movie" + "My Show: The Movie": "My Show Presents The Movie"

    credentials