diff --git a/docs/reference/configuration/post-scripts.md b/docs/reference/configuration/post-scripts.md index bf245b3f..0f3a8de8 100644 --- a/docs/reference/configuration/post-scripts.md +++ b/docs/reference/configuration/post-scripts.md @@ -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 diff --git a/tests/test_post_scripts.py b/tests/test_post_scripts.py index 02274eac..b3eaa45e 100644 --- a/tests/test_post_scripts.py +++ b/tests/test_post_scripts.py @@ -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) diff --git a/unshackle/core/utils/post_scripts.py b/unshackle/core/utils/post_scripts.py index 097af245..d46028c2 100644 --- a/unshackle/core/utils/post_scripts.py +++ b/unshackle/core/utils/post_scripts.py @@ -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"):