Files
unshackle/tests/test_post_scripts_music.py
T
imSp4rky 9f82777394 ci: run the unit suite in parallel and keep it off the network
Test servers shut down without the 0.5s poll wait, unit tests refuse non-loopback lookups, and three load-sensitive tests are steadied. Pushes to dev test Python 3.11 only; the docs job builds with uv.
2026-10-13 19:31:23 -06:00

266 lines
9.6 KiB
Python

"""Music post-scripts, which no live service was available to test against.
A Song runs through the same title loop as an Episode, so its post-scripts come from the same
two functions: ``build_context`` for a file and ``season_context`` for the release folder.
These check what those two feed a script: that a Song builds a usable context, that an
album groups the way a season does, and that the album post-script describes the album rather
than whichever track landed last.
"""
from __future__ import annotations
from pathlib import Path
from types import SimpleNamespace
import pytest
from unshackle.core.utils.post_scripts import build_context, dispatch, season_context, substitute, tokenize
class FakeSong(SimpleNamespace):
"""A Song as the post-script sees it: name/artist/album, no season or episode."""
def build_template_context(self, media_info, show_service=True):
return {
"title": self.name,
"artist": self.artist,
"album": self.album,
"track_number": f"{self.track:02}",
"year": self.year,
"audio": "FLAC",
"video": "",
}
def song(**kwargs):
defaults = dict(
name="Example Track",
artist="Example Artist",
album="Example Album, Live & Remastered",
track=4,
year=1983,
id="s1",
)
return FakeSong(**{**defaults, "id": "s1", **kwargs})
class FakeEpisode(SimpleNamespace):
"""An Episode as the post-script sees it: a show title, a season, and a number."""
def build_template_context(self, media_info, show_service=True):
return {"title": self.title, "season": f"{self.season:02}", "audio": "EAC3", "video": "H.265"}
def episode(**kwargs):
defaults = dict(title="Some Show", season=1, number=5, name="Pilot", year=2024, id="e1")
return FakeEpisode(**{**defaults, **kwargs})
def test_song_context_has_music_fields_and_blank_episode_fields():
context = build_context(
song(),
media_info=object(),
filepath=Path("/dl/Example Artist/04. Example Track.flac"),
service="EXAMPLE",
)
assert context["title"] == "Example Track"
assert context["artist"] == "Example Artist"
assert context["album"] == "Example Album, Live & Remastered"
assert context["track_number"] == "04"
assert context["service"] == "EXAMPLE"
assert context["filename"] == "04. Example Track.flac"
assert context["ext"] == ".flac"
assert context["folder"] == str(Path("/dl/Example Artist"))
assert context["season"] == ""
assert context["episode"] == ""
def test_song_context_survives_without_media_info():
"""The failure path and any pre-mux dispatch pass no MediaInfo."""
context = build_context(song(), None, service="EXAMPLE", error="HTTPError: 403")
assert context["title"] == "Example Track"
assert context["error"] == "HTTPError: 403"
assert context["filepath"] == ""
def test_album_grouping_matches_the_season_grouping_rule():
"""post_script_group's music branch: albums group by (album artist, album)."""
from unshackle.commands.dl import post_script_group
from unshackle.core.titles.music import Song
def real_song(**kwargs):
defaults = dict(
id_="track-0001",
service=type("DummyService", (), {}),
name="Example Track",
artist="Example Artist",
album="Example Album, Live & Remastered",
track=4,
)
return Song(**{**defaults, **kwargs})
a = real_song(track=1, id_="track-0002")
b = real_song(track=2, id_="track-0003")
c = real_song(album="Technique", id_="track-0004")
assert post_script_group(a) == post_script_group(b)
assert post_script_group(a) != post_script_group(c)
assert post_script_group(a)[0] == "album"
@pytest.mark.parametrize("mode", ["season", "run"])
def test_album_and_run_contexts_blank_the_file_variables(mode):
"""Mirrors the blanking dl.py applies before dispatching an album or run post-script."""
base = build_context(
song(),
media_info=object(),
filepath=Path("/dl/Example Artist/04. Example Track.flac"),
service="EXAMPLE",
)
folder = Path("/dl/Example Artist")
if mode == "season":
context = season_context(base, folder)
else:
context = dict.fromkeys(base, "")
context["folder"] = str(folder)
assert context["filepath"] == ""
assert context["sidecars"] == ""
assert context["folder"] == str(folder)
if mode == "run":
assert context["title"] == ""
assert set(context) == set(base)
else:
assert context["album"] == "Example Album, Live & Remastered"
def test_a_music_post_script_command_builds_the_argv_a_script_would_get(monkeypatch, tmp_path):
"""End to end through the real dispatcher: config entry in, argv out."""
from unshackle.core.config import config
marker = tmp_path / "argv.txt"
script = tmp_path / "hook.py"
script.write_text("import sys, pathlib\npathlib.Path(sys.argv[1]).write_text(chr(10).join(sys.argv[2:]))\n")
import sys
command = (
f'"{sys.executable}" "{script}" "{marker}" '
"--album={album} --artist={artist} --track={track_number} --file={filepath}"
)
monkeypatch.setattr(config, "post_scripts", [{"command": command, "mode": "file", "event": "success"}])
context = build_context(
song(),
media_info=object(),
filepath=Path("/dl/Example Artist/04. Example Track.flac"),
service="EXAMPLE",
)
dispatch("success", "file", context)
for _ in range(100):
if marker.exists() and marker.stat().st_size:
break
import time
time.sleep(0.05)
assert marker.exists(), "the post-script process never ran"
argv = marker.read_text().splitlines()
assert "--album=Example Album, Live & Remastered" in argv
assert "--artist=Example Artist" in argv
assert "--track=04" in argv
assert f"--file={Path('/dl/Example Artist/04. Example Track.flac')}" in argv
def test_season_context_blanks_the_file_variables_and_sets_the_folder():
"""An album post-script describes the release folder, so no track's file may leak into it."""
folder = Path("/dl/Example Artist - Example Album, Live & Remastered (1983)")
context = build_context(
song(),
media_info=object(),
filepath=folder / "04. Example Track.flac",
sidecars=[folder / "04. Example Track.lrc"],
service="EXAMPLE",
)
album = season_context(context, folder)
for key in ("filepath", "filename", "ext", "sidecars", "episode", "episode_name"):
assert album[key] == "", f"{key} still describes a file"
assert album["folder"] == str(folder)
assert album["service"] == "EXAMPLE"
assert context["filepath"] != "", "the file context handed in must not be mutated"
def test_season_context_identifies_the_album_not_the_last_track():
"""A Song's ``{title}`` is the song, so blanking only the file fields is not enough.
Guards a regression where an album post-script was handed whichever track landed last:
``{title}`` was a song name and ``{track_number}``/``{isrc}`` were that track's.
"""
folder = Path("/dl/Example Artist - Example Album (1983)")
context = build_context(
song(track=11),
media_info=object(),
filepath=folder / "11. Example Track.flac",
service="EXAMPLE",
)
context["isrc"] = "ZZABC2400001"
context["disc"] = "02"
album = season_context(context, folder)
assert album["title"] == "Example Album, Live & Remastered"
assert album["title_raw"] == "Example Album, Live & Remastered"
assert album["track_number"] == ""
assert album["disc"] == ""
assert album["isrc"] == ""
assert album["artist"] == "Example Artist" # album-level, kept
assert album["year"] == "1983" # album-level, kept
def test_season_context_leaves_a_non_music_context_untouched():
"""The music branch is gated on ``{album}``; a season post-script must not lose its own keys."""
folder = Path("/dl/Some Show/Season 1")
context = build_context(
episode(),
media_info=object(),
filepath=folder / "Some Show S01E05 Pilot.mkv",
service="EXAMPLE",
)
context["disc"] = "sentinel" # only the music branch clears this
season = season_context(context, folder)
assert season["title"] == "Some Show"
assert season["title_raw"] == "Some Show"
assert season["season"] == "1"
assert season["disc"] == "sentinel"
assert season["episode"] == ""
assert season["episode_name"] == ""
assert season["folder"] == str(folder)
def test_a_failed_track_gets_a_track_scoped_failure_context():
"""Failure is per-track, like every other title type, and does not describe the album.
The real dispatch passes no MediaInfo and no filepath, so the context is whatever the
Song itself carries, and it is never run through ``season_context``.
"""
context = build_context(song(track=11), None, service="EXAMPLE", error="HTTPError: 403")
assert context["title"] == "Example Track"
assert context["title_raw"] == "Example Track"
assert context["error"] == "HTTPError: 403"
assert context["filepath"] == ""
assert context["season"] == ""
def test_an_album_name_with_a_comma_stays_one_argument():
"""'Example Album, Live & Remastered' must not split, which is the whole tokenize-first point."""
argv = substitute(tokenize("upload.py --album={album}"), {"album": "Example Album, Live & Remastered"})
assert argv == ["upload.py", "--album=Example Album, Live & Remastered"]
if __name__ == "__main__":
raise SystemExit(pytest.main([__file__, "-q"]))