Files
unshackle/tests/core/test_template_formatter.py
T
imSp4rky b74b64825e fix(templates): use one separator style across a whole folder path
Detect the dot/space style from the full folder template instead of per segment, so `{artist}/{album} ({year})` no longer yields `Some.Artist/Some Album (2025)`. Also drops empty `[]` groups like the existing `()` handling and removes redundant cleanup passes.
2026-07-26 09:48:58 -06:00

293 lines
11 KiB
Python

"""Separator cleanup around omitted conditionals in TemplateFormatter.
An omitted `{var?}` must not leave separator debris behind. Trailing spaces are the
dangerous case: the formatter sanitizes each path segment separately, and Windows
drops a trailing space when it creates a directory but not when it looks one up, so
the download fails with FileNotFoundError [WinError 3]. See #107 and #144.
"""
from __future__ import annotations
import pytest
from unshackle.core.config import config
from unshackle.core.titles.episode import Episode
from unshackle.core.titles.movie import Movie
from unshackle.core.titles.music import Song as MusicSong
from unshackle.core.titles.song import Song
from unshackle.core.utilities import sanitize_filename
from unshackle.core.utils.template_formatter import TemplateFormatter, detect_spacer
@pytest.mark.parametrize(
("template", "context", "expected"),
[
# An omitted conditional inside brackets must take the whole group with it.
("{title} ({year?})", {"title": "Some Movie", "year": ""}, "Some Movie"),
("{title} [{quality?}]", {"title": "Some Movie", "quality": ""}, "Some Movie"),
("{title} ({year?}) [{quality?}]", {"title": "T", "year": "", "quality": ""}, "T"),
# The group survives when the value is present.
("{title} ({year?})", {"title": "Some Movie", "year": "2024"}, "Some Movie (2024)"),
# #107: the dash wins, so the release tag stays attached.
(
"{title}.{year}.{atmos?}-{tag}",
{"title": "Some Movie", "year": "2024", "atmos": "", "tag": "GRP"},
"Some.Movie.2024-GRP",
),
(
"{title}.{year}.{atmos?}-{tag}",
{"title": "Some Movie", "year": "2024", "atmos": "Atmos", "tag": "GRP"},
"Some.Movie.2024.Atmos-GRP",
),
# A conditional between two dashes must not leave a doubled separator.
("{title} - {edition?} - {year}", {"title": "A", "edition": "", "year": "2024"}, "A - 2024"),
# Consecutive omitted conditionals collapse to nothing.
("{a?} {b?} {title}", {"a": "", "b": "", "title": "T"}, "T"),
("{show} S{season}E{ep} {name?}", {"show": "Show", "season": "01", "ep": "02", "name": ""}, "Show S01E02"),
],
)
def test_omitted_conditional_leaves_no_separator_debris(template, context, expected):
assert TemplateFormatter(template).format(context) == expected
@pytest.mark.parametrize(
("template", "context"),
[
("{title} ({year?})", {"title": "Some Movie", "year": ""}),
("{title} [{quality?}]", {"title": "Some Movie", "quality": ""}),
("{title}.{quality?}", {"title": "Some Movie", "quality": ""}),
("{title} - {tag?}", {"title": "Some Movie", "tag": ""}),
],
)
def test_result_is_safe_as_a_windows_path_segment(template, context):
"""Windows strips these when creating a directory but not when looking one up."""
result = TemplateFormatter(template).format(context)
assert result == result.strip(" ."), f"unsafe path segment: {result!r}"
def test_empty_result_falls_back():
assert TemplateFormatter("{title?}").format({"title": ""}) == "untitled"
def test_missing_required_variable_raises():
with pytest.raises(ValueError, match="Missing required template variables"):
TemplateFormatter("{title}.{year}").format({"title": "T"})
@pytest.mark.parametrize(
("raw", "spacer", "expected"),
[
("Some Movie ", " ", "Some Movie"),
(" Some Movie", " ", "Some Movie"),
("Some Movie...", ".", "Some.Movie"),
("S.W.A.T.", ".", "S.W.A.T"),
("...Some Movie...", ".", "Some.Movie"),
],
)
def test_sanitize_filename_strips_unsafe_edges(raw, spacer, expected):
assert sanitize_filename(raw, spacer) == expected
def test_sanitize_filename_keeps_interior_dots():
assert sanitize_filename("Mr. Robot", ".") == "Mr.Robot"
@pytest.mark.parametrize(
("template", "context", "expected"),
[
# Space-separated style.
("{title} ({year})", {"title": "Some Movie", "year": "2024"}, "Some Movie (2024)"),
(
"{title} {year} {quality} {source}",
{"title": "Some Movie", "year": "2024", "quality": "1080p", "source": "WEB-DL"},
"Some Movie 2024 1080p WEB-DL",
),
# Dot-separated style.
(
"{title}.{year}.{quality}.{source}",
{"title": "Some Movie", "year": "2024", "quality": "1080p", "source": "WEB-DL"},
"Some.Movie.2024.1080p.WEB-DL",
),
# Interior dots in the title itself survive both styles.
("{title}.{year}", {"title": "Mr. Robot", "year": "2015"}, "Mr.Robot.2015"),
("{title} ({year})", {"title": "Mr. Robot", "year": "2015"}, "Mr. Robot (2015)"),
("{title}.{year}", {"title": "S.W.A.T.", "year": "2017"}, "S.W.A.T.2017"),
("{title} ({year})", {"title": "S.W.A.T.", "year": "2017"}, "S.W.A.T. (2017)"),
# A decimal inside a codec name is not a separator.
(
"{title}.{year}.{audio}-{tag}",
{"title": "Some Movie", "year": "2024", "audio": "DDP5.1", "tag": "GRP"},
"Some.Movie.2024.DDP5.1-GRP",
),
],
)
def test_separator_styles_keep_interior_spacing(template, context, expected):
assert TemplateFormatter(template).format(context) == expected
class DummyService:
pass
class StubMediaInfo:
"""Minimal MediaInfo stand-in: the template context only reads track lists."""
video_tracks: list = []
audio_tracks: list = []
@pytest.fixture
def folder_config(monkeypatch):
"""Clear the global template config, and restore it after each test."""
monkeypatch.setattr(config, "folder_templates", {})
monkeypatch.setattr(config, "folder_template", "")
monkeypatch.setattr(config, "output_template", {})
return config
def make_movie():
return Movie(id_="movie-0001", service=DummyService, name="Some Movie")
def make_episode(**overrides):
kwargs = dict(id_="episode-0001", service=DummyService, title="Some Show", season=1, number=2, name="Pilot")
kwargs.update(overrides)
return Episode(**kwargs)
def make_song(**overrides):
kwargs = dict(
id_="track-0001",
service=DummyService,
name="Some Track",
artist="Some Artist",
album="Some Album",
track=1,
disc=1,
year=2025,
)
kwargs.update(overrides)
return Song(**kwargs)
def make_album_song(**overrides):
kwargs = dict(
id_="track-0001",
service=DummyService,
name="Some Track",
artist="Some Artist",
album="Some Album",
track=1,
disc=1,
year=2025,
album_artist="Some Artist",
)
kwargs.update(overrides)
return MusicSong(**kwargs)
def assert_safe_path(result: str):
"""No segment may start or end with a space or dot, or Windows loses the directory."""
segments = result.split("/")
assert all(segments), f"empty segment in {result!r}"
assert all(seg == seg.strip(" .") for seg in segments), f"unsafe segment in {result!r}"
@pytest.mark.parametrize(
("kind", "make_title", "template", "expected"),
[
("movies", make_movie, "Movies/{title} ({year?})", "Movies/Some Movie"),
("series", make_episode, "Shows/{title} ({year?})/Season {season}", "Shows/Some Show/Season S01"),
("songs", make_song, "Music/{artist}/{album} ({year?})", "Music/Some Artist/Some Album (2025)"),
("albums", make_album_song, "Music/{album_artist}/{album} ({year?})", "Music/Some Artist/Some Album (2025)"),
],
)
def test_folder_templates_keep_path_separators(folder_config, kind, make_title, template, expected):
"""`/` survives because each segment is formatted separately, but edge spaces do not."""
folder_config.folder_templates = {kind: template}
result = make_title().get_filename(StubMediaInfo(), folder=True)
assert result == expected
assert_safe_path(result)
@pytest.mark.parametrize(
("kind", "make_title", "template"),
[
("movies", make_movie, "{title} ({year?})"),
("series", make_episode, "{title} ({year?})"),
("songs", make_song, "{artist} - {album} [{genre?}]"),
("albums", make_album_song, "{album_artist} - {album} [{genre?}]"),
],
)
def test_folder_names_are_safe_when_conditionals_are_omitted(folder_config, kind, make_title, template):
folder_config.folder_templates = {kind: template}
assert_safe_path(make_title().get_filename(StubMediaInfo(), folder=True))
@pytest.mark.parametrize(
("template", "expected"),
[
("Shows/{title}.{year?}/Season.{season}", "Shows/Some.Show/Season.S01"),
("Shows/{title} {year?}/Season {season}", "Shows/Some Show/Season S01"),
],
)
def test_series_folder_handles_both_separator_styles(folder_config, template, expected):
folder_config.folder_templates = {"series": template}
result = make_episode().get_filename(StubMediaInfo(), folder=True)
assert result == expected
assert_safe_path(result)
@pytest.mark.parametrize(
("template", "expected"),
[
# A segment with one variable has no gap to judge by, so it follows the whole
# path's style instead of falling back to a dot.
("Music/{artist}/{album} ({year?})", "Music/Some Artist/Some Album (2025)"),
("Music/{artist}/{album}.{year?}", "Music/Some.Artist/Some.Album.2025"),
("{artist}/{album}", "Some.Artist/Some.Album"),
],
)
def test_single_variable_segments_follow_the_whole_path(folder_config, template, expected):
folder_config.folder_templates = {"songs": template}
result = make_song().get_filename(StubMediaInfo(), folder=True)
assert result == expected
assert_safe_path(result)
@pytest.mark.parametrize(
("template", "expected"),
[
("{title}.{year}.{quality}", "."),
("{title} {year} {quality}", " "),
("{title} ({year})", " "),
("Movies/{title} ({year?})", " "),
("Shows/{title}.{year?}/Season.{season}", "."),
("{title}", "."), # nothing between two variables, so scene style wins
],
)
def test_detect_spacer(template, expected):
assert detect_spacer(template) == expected
@pytest.mark.parametrize(
("series_template", "expected"),
[
# Deriving a show folder strips the episode tokens, so the template ends on a
# separator once {episode_name?} goes too.
("{title}.{year?}.{season_episode}.{episode_name?}", "Some.Show.S01"),
("{title} {year?} {season_episode} {episode_name?}", "Some Show S01"),
],
)
def test_series_folder_derived_from_output_template(folder_config, series_template, expected):
"""With no folder template set, the show folder comes from the file template."""
folder_config.output_template = {"series": series_template}
result = make_episode().get_filename(StubMediaInfo(), folder=True)
assert result == expected
assert_safe_path(result)
@pytest.mark.parametrize("make_title", [make_movie, make_episode, make_song, make_album_song])
def test_folder_fallbacks_need_no_template(folder_config, make_title):
assert_safe_path(make_title().get_filename(StubMediaInfo(), folder=True))