fix(post-scripts): expose {part} and all title-owned variables on failure

Build the failure context through the title's template builder with an empty MediaInfo, and blank season_episode, absolute and date in season mode. Document {part} and add a docs-to-code parity test.
This commit is contained in:
imSp4rky
2026-09-20 13:32:20 -06:00
parent b8f00f6b95
commit b182ed97f2
3 changed files with 90 additions and 16 deletions
+10 -7
View File
@@ -111,6 +111,8 @@ values.
| `{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`) |
| `{part}` | Part number of a split episode as a plain number (`1` for `Part.1`). Empty when the episode has no part |
| `{season_episode}` `{absolute}` `{date}` | As in the filename template: `{season_episode}` keeps the part suffix (`S01E05.Part.1`) while `{episode}` drops it |
| `{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](#tagging-ids) |
| `{error}` | Failure message. Empty on success |
@@ -126,14 +128,14 @@ Every variable from your `output_template` is available here as well, including
- No metadata provider found an ID. `{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 title has no such field. Movies have no `{season}`, `{episode}` or `{part}`, and
music has none of them.
- **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 post-script is a `failure` one. A failed download has no output file to read metadata
from, so everything that comes from the naming context is empty. See
from, so everything that comes from the output file is empty. See
[Events](#events).
There is no `{variable?}` conditional here, unlike `output_template`. The token is
@@ -147,7 +149,7 @@ Every variable from your `output_template` is available here as well, including
### Music variables
A music download builds its variables from the music naming context, so a post-script gets the
release fields instead of the season and episode ones. `{season}`, `{episode}` and
release fields instead of the season and episode ones. `{season}`, `{episode}`, `{part}` and
`{episode_name}` are always empty for music.
| Variable | Value |
@@ -257,9 +259,10 @@ A music `failure` post-script therefore describes the track that failed, not the
A `failure` post-script 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.
`{episode}`, `{part}`, `{season_episode}`, `{absolute}`, `{date}`, `{episode_name}`, the music
release fields such as `{artist}` and `{album}`, `{service}`, the tagging IDs and `{error}`.
Everything that comes from the output file, such as `{quality}`, `{hdr}`, `{vcodec}` and
`{acodec}`, is empty.
A resumed `--continue-downloads` download that finishes runs `success` as usual. unshackle
sees your script's exit code only when that entry sets `wait: true`, and even then it only
+55 -2
View File
@@ -2,6 +2,8 @@
from __future__ import annotations
import re
import sys
from pathlib import Path
from types import SimpleNamespace
@@ -213,13 +215,13 @@ def _dispatch_seconds(monkeypatch, entry: dict) -> float:
def test_wait_true_waits_for_the_script_and_logs_the_exit_code(monkeypatch, caplog):
caplog.set_level("DEBUG", logger="post-script")
slow = 'python -c "import time,sys; time.sleep(0.3); sys.exit(3)"'
slow = f'"{sys.executable}" -c "import time,sys; time.sleep(0.3); sys.exit(3)"'
assert _dispatch_seconds(monkeypatch, {"command": slow, "wait": True}) >= 0.3
assert "Post-script exited 3" in caplog.text
def test_wait_defaults_to_fire_and_forget(monkeypatch):
slow = 'python -c "import time; time.sleep(0.3)"'
slow = f'"{sys.executable}" -c "import time; time.sleep(0.3)"'
assert _dispatch_seconds(monkeypatch, {"command": slow}) < 0.3
@@ -232,3 +234,54 @@ def test_no_postscript_override_silences_every_event_and_mode(monkeypatch):
for mode in MODES:
dispatch(event, mode, {"filepath": "/x/y.mkv"}, NO_POST_SCRIPTS)
assert spawned == []
def _episode(part):
from unshackle.core.titles.episode import Episode
class Svc:
pass
return Episode(id_="episode-id", service=Svc, title="T", season=1, number=5, part=part, name="Ep")
def test_part_is_a_plain_number_on_success_and_failure():
"""{part} agrees on both paths, and the failure path carries every title-owned variable."""
title = _episode(1)
success = build_context(title, SimpleNamespace(video_tracks=[], audio_tracks=[]), service="SVC")
failure = build_context(title, None, service="SVC", error="Boom")
for context in (success, failure):
assert context["part"] == "1"
assert context["episode"] == "5"
assert context["season_episode"] == "S01E05.Part.1"
assert failure["quality"] == ""
assert build_context(_episode(None), None, service="SVC")["part"] == ""
def test_season_context_blanks_every_episode_identity_field():
context = build_context(_episode(1), None, service="SVC")
season = ps.season_context(context, Path("/out"))
assert season["season"] == "1"
for key in ("episode", "part", "season_episode", "absolute", "date", "episode_name"):
assert season[key] == ""
def test_every_documented_variable_is_one_build_context_produces():
"""The variable tables in docs/reference/configuration/post-scripts.md must not drift from the code."""
from unshackle.core.titles.song import Song
doc = Path(__file__).resolve().parents[1] / "docs" / "reference" / "configuration" / "post-scripts.md"
documented = {
name
for line in doc.read_text(encoding="utf-8").splitlines()
if line.startswith("| `{")
for name in re.findall(r"`\{(\w+)\}`", line.split("|")[1])
}
assert documented, "no variable table rows found"
class Svc:
pass
song = Song(id_="song-id", service=Svc, name="Tr", artist="Ar", album="Al", track=1)
produced = set(build_context(_episode(1), None, service="SVC")) | set(build_context(song, None, service="SVC"))
assert documented <= produced, sorted(documented - produced)
+25 -7
View File
@@ -20,6 +20,7 @@ import re
import shlex
import subprocess
from pathlib import Path
from types import SimpleNamespace
from typing import Any, Iterator, Optional, Sequence
from unshackle.core.config import config
@@ -38,6 +39,9 @@ SIDECAR_SEPARATOR = "\n"
NO_POST_SCRIPTS: tuple[str, ...] = ("",)
"""``dl --no-postscript``: an override list that replaces the config and holds no command to run."""
_NO_MEDIA = SimpleNamespace(video_tracks=[], audio_tracks=[])
"""Stands in for MediaInfo on the failure path: the title's template builder needs the two track lists."""
_warned_entries: set[str] = set()
@@ -107,12 +111,13 @@ def build_context(
"""
context: dict[str, str] = {}
if media_info is not None:
try:
for key, value in title.build_template_context(media_info, show_service=True).items():
context[key] = "" if value is None else str(value)
except Exception as e: # noqa: BLE001 - a naming quirk must never stop the download
log.debug("Could not build post-script metadata context: %s", e)
if media_info is None:
media_info = _NO_MEDIA
try:
for key, value in title.build_template_context(media_info, show_service=True).items():
context[key] = "" if value is None else str(value)
except Exception as e: # noqa: BLE001 - a naming quirk must never stop the download
log.debug("Could not build post-script metadata context: %s", e)
context["vcodec"] = context.get("video", "")
context["acodec"] = context.get("audio", "")
@@ -121,6 +126,8 @@ def build_context(
number = getattr(title, "number", None)
context["season"] = "" if season is None else str(season)
context["episode"] = "" if number is None else str(number)
part = getattr(title, "part", None)
context["part"] = "" if part is None else str(part)
context["episode_name"] = str(getattr(title, "name", "") or "") if season is not None else ""
context["title_raw"] = str(getattr(title, "title", None) or getattr(title, "name", "") or "")
context.setdefault("title", context["title_raw"].replace("$", "S"))
@@ -146,7 +153,18 @@ def build_context(
def season_context(context: dict[str, str], folder: Path) -> dict[str, str]:
"""Blank the file-level variables so a season/album post-script describes the folder."""
out = dict(context)
for key in ("filepath", "filename", "ext", "sidecars", "episode", "episode_name"):
for key in (
"filepath",
"filename",
"ext",
"sidecars",
"episode",
"part",
"episode_name",
"season_episode",
"absolute",
"date",
):
out[key] = ""
if out.get("album"): # music: the album stands in for the season
for key in ("track_number", "disc", "isrc"):