Files
unshackle/tests/test_post_scripts_music.py
T
imSp4rky 68c7d95173 refactor(music)!: make Song an ordinary Title on the generic path
Delete the dead grouped-download block, the planner, and the music service hooks; Song now flows through the one download loop, gets tagged via tag_file, and answers -w with {disc}x{track} keys.

BREAKING CHANGE: GROUP_AUDIO_DOWNLOADS, get_music_track_options and get_music_collection_label are gone. A music service returns one Audio track per quality and lets the framework choose.
2026-08-18 15:05:30 -06:00

266 lines
9.5 KiB
Python

"""Music post-script hooks, which no live service was available to test against.
A Song runs through the same title loop as an Episode, so its hooks 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 hook 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 hook 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 hook 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 hook."""
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_hook_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():
break
import time
time.sleep(0.05)
assert marker.exists(), "the hook 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 hook 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 hook 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 hook 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"]))