mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 12:05:07 +00:00
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.
266 lines
9.6 KiB
Python
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"]))
|