mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-11 19:45:06 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"):
|
||||
|
||||
Reference in New Issue
Block a user