mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 20:15:10 +00:00
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.
293 lines
11 KiB
Python
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))
|