feat(titles): add optional Episode.part, selected with -w s1e1.2

Opt-in and additive: an episode without a part behaves exactly as before.
This commit is contained in:
imSp4rky
2026-08-04 17:38:30 -06:00
parent 10bfaf6a73
commit 041ba5293b
17 changed files with 946 additions and 44 deletions
+75 -1
View File
@@ -3,10 +3,11 @@ services set via the ``sub_format`` override (must not be rejected as an invalid
from __future__ import annotations
import click
import pytest
from unshackle.core.tracks.subtitle import Subtitle
from unshackle.core.utils.click_types import QUALITY_LIST, SLOW_DELAY_RANGE, SubtitleCodecChoice
from unshackle.core.utils.click_types import QUALITY_LIST, SLOW_DELAY_RANGE, SeasonRange, SubtitleCodecChoice
choice = SubtitleCodecChoice(Subtitle.Codec)
@@ -57,3 +58,76 @@ def test_quality_list_accepts_yaml_native_values(value, expected):
)
def test_slow_delay_range_accepts_bool(value, expected):
assert SLOW_DELAY_RANGE.convert(value, None, None) == expected
# --- SeasonRange ------------------------------------------------------------
def parse(*tokens: str) -> list[str]:
return SeasonRange().parse_tokens(*tokens)
def test_single_episode_token_is_unchanged():
assert parse("S01E01") == ["1x1"]
def test_bare_season_spans_the_whole_episode_range():
"""A bare S01 reads the one match object with a different default per side."""
keys = parse("S01")
assert len(keys) == 1000
assert "1x0" in keys and "1x999" in keys
def test_multi_token_range_is_unchanged():
assert sorted(parse("S02E01", "S02E03-S02E05")) == ["2x1", "2x3", "2x4", "2x5"]
def test_exclusions_are_unchanged():
keys = parse("S01-S05", "-S03", "-S02E01")
assert not any(k.startswith("3x") for k in keys)
assert "2x1" not in keys
assert "2x0" in keys and "2x2" in keys
assert len(keys) == 3999 # 5 seasons less all of S03 less 2x1
@pytest.mark.parametrize("tokens", [("S01E01",), ("S01",), ("S01-S05", "-S03", "-S02E01"), ("S02E03-S02E05",)])
def test_part_less_tokens_never_emit_a_part_or_exclusion_key(tokens):
assert all("." not in k and "!" not in k for k in parse(*tokens))
def test_part_token():
assert parse("S01E01.2") == ["1x1.2"]
def test_part_range_within_one_episode():
assert sorted(parse("S01E01.1-S01E01.3")) == ["1x1.1", "1x1.2", "1x1.3"]
def test_part_exclusion_becomes_a_negative_key():
keys = parse("S01", "-S01E01.2")
assert "!1x1.2" in keys
assert "1x1" in keys # base key stays so the other parts still match
def test_base_exclusion_covers_part_keys():
assert parse("S01E01.1-S01E01.3", "S01E02", "-S01E01") == ["1x2"]
def test_exclusion_removes_duplicate_keys():
# "S01" and "S01E01" both emit 1x1; the exclusion must remove every copy
assert "1x1" not in parse("S01", "S01E01", "-S01E01")
@pytest.mark.parametrize(
"token",
[
"S01E01.0", # parts count from 1
"S01E01.1-S01E02.2", # a part range cannot cross episodes
"S01-S01E01.2", # a part on only one side
"S01E01.3-S01E01.1", # reversed part range
"S01E01.200", # above MAX_PART
],
)
def test_bad_part_tokens_fail(token):
with pytest.raises(click.UsageError):
parse(token)
+199
View File
@@ -0,0 +1,199 @@
"""Tests for multi-part episodes: Episode.part, its naming, sorting and -w matching."""
from __future__ import annotations
import pytest
from unshackle.core.config import config
from unshackle.core.titles.episode import Episode, Series
from unshackle.core.utils.click_types import SeasonRange
class DummyService:
pass
class StubMediaInfo:
"""Minimal MediaInfo stand-in: the template context only reads track lists."""
video_tracks: list = []
audio_tracks: list = []
def make_episode(**overrides) -> Episode:
kwargs = dict(
id_="ep-0001",
service=DummyService,
title="The Show",
season=1,
number=1,
name="The Reckoning",
)
kwargs.update(overrides)
return Episode(**kwargs)
@pytest.fixture
def reset_template():
saved = (config.output_template, config.folder_templates, config.folder_template, config.tag)
config.output_template = {"series": "{title}.{season_episode}.{quality?}.{source}-{tag}"}
config.folder_templates = {}
config.folder_template = ""
config.tag = "GROUP"
yield config
config.output_template, config.folder_templates, config.folder_template, config.tag = saved
# --- validation -------------------------------------------------------------
def test_part_defaults_to_none():
assert make_episode().part is None
def test_digit_string_part_coerces():
assert make_episode(part="2").part == 2
@pytest.mark.parametrize("bad", [0, -1])
def test_non_positive_part_rejected(bad):
with pytest.raises(ValueError):
make_episode(part=bad)
def test_non_int_part_rejected():
with pytest.raises(TypeError):
make_episode(part="x")
def test_bool_part_rejected():
"""bool is an int subclass; True would render as '.True' in keys and filenames."""
with pytest.raises(TypeError):
make_episode(part=True)
def test_old_cache_entry_reads_as_part_less():
"""jsonpickle restores by populating __dict__; an older entry has no 'part' key."""
restored = Episode.__new__(Episode)
restored.__dict__.update(title="The Show", season=1, number=1, name=None, year=None)
assert restored.part is None
# --- display ----------------------------------------------------------------
def test_str_is_unchanged_without_a_part():
without = make_episode()
assert str(without) == "The Show S01E01 The Reckoning"
def test_str_shows_the_selection_syntax():
assert str(make_episode(part=2)) == "The Show S01E01.2 The Reckoning"
def test_str_tells_dated_parts_apart():
# dated content renders the air date instead of SxxExx, so without the suffix both
# parts print the same string while their filenames differ
assert str(make_episode(part=2, air_date="2024-05-06")) == "The Show 2024.05.06.2 The Reckoning"
assert str(make_episode(air_date="2024-05-06")) == "The Show 2024.05.06 The Reckoning"
def test_tree_label_carries_the_part_on_dated_content():
series = Series([make_episode(part=1, air_date="2024-05-06"), make_episode(part=2, air_date="2024-05-06")])
labels = [node.label for node in series.tree(verbose=True).children[0].children]
assert labels == [
"[bold]2024.05.06.1.[/] [bright_black]The Reckoning",
"[bold]2024.05.06.2.[/] [bright_black]The Reckoning",
]
def test_tree_label_carries_the_part():
series = Series([make_episode(part=1), make_episode(part=2)])
labels = [node.label for node in series.tree(verbose=True).children[0].children]
assert labels == ["[bold]1.1.[/] [bright_black]The Reckoning", "[bold]1.2.[/] [bright_black]The Reckoning"]
def test_series_sorts_parts_within_their_episode():
series = Series(
[
make_episode(number=2),
make_episode(number=1, part=2),
make_episode(number=1),
make_episode(number=1, part=1),
]
)
assert [(e.number, e.part) for e in series] == [(1, None), (1, 1), (1, 2), (2, None)]
# --- naming -----------------------------------------------------------------
def test_filename_is_unchanged_without_a_part(reset_template):
assert make_episode().get_filename(StubMediaInfo()) == "The.Show.S01E01.DummyService-GROUP"
def test_dotted_template_folds_the_part_after_the_episode(reset_template):
name = make_episode(part=2).get_filename(StubMediaInfo())
assert name == "The.Show.S01E01.Part.2.DummyService-GROUP"
assert name.endswith("-GROUP") # group tag stays last
def test_spaced_template_uses_spaces(reset_template):
reset_template.output_template = {"series": "{title} {season_episode} {quality?} {source}-{tag}"}
assert make_episode(part=2).get_filename(StubMediaInfo()) == "The Show S01E01 Part 2 DummyService-GROUP"
def test_template_context_leaves_the_season_token_alone(reset_template):
ctx = make_episode(part=2)._build_template_context(StubMediaInfo())
assert ctx["season"] == "S01"
assert ctx["episode"] == "E01.Part.2"
assert ctx["season_episode"] == "S01E01.Part.2"
assert ctx["part"] == 2
def test_part_token_is_empty_without_a_part(reset_template):
assert make_episode()._build_template_context(StubMediaInfo())["part"] == ""
def test_parts_share_one_season_folder(reset_template):
# {season_episode} in a folder template would otherwise carry the part into the path
reset_template.folder_template = "{title}/{season_episode}"
folders = {make_episode(part=p).get_filename(StubMediaInfo(), folder=True) for p in (1, 2)}
assert folders == {"The.Show/S01E01"}
def test_parts_share_one_derived_season_folder(reset_template):
folders = {make_episode(part=p).get_filename(StubMediaInfo(), folder=True) for p in (1, 2)}
assert len(folders) == 1
assert folders == {make_episode().get_filename(StubMediaInfo(), folder=True)}
# --- matching ---------------------------------------------------------------
# the worked key scheme from the design: (token, part-less E1 selected, E1 part 2 selected)
MATCH_TABLE = [
(("S01",), True, True),
(("S01E01",), True, True),
(("S01E01.2",), False, True),
(("S01E01.1-S01E01.3",), False, True),
(("S01", "-S01E01.2"), True, False),
(("S01", "-S01E01"), False, False),
(("S01E02",), False, False),
]
@pytest.mark.parametrize("tokens,wants_plain,wants_part2", MATCH_TABLE)
def test_matches_wanted_table(tokens, wants_plain, wants_part2):
wanted = SeasonRange().parse_tokens(*tokens)
assert make_episode().matches_wanted(wanted) is wants_plain
assert make_episode(part=2).matches_wanted(wanted) is wants_part2
def test_a_part_exclusion_leaves_the_other_parts_alone():
wanted = SeasonRange().parse_tokens("S01", "-S01E01.2")
assert make_episode(part=1).matches_wanted(wanted) is True
assert make_episode(part=3).matches_wanted(wanted) is True
def test_asking_for_a_part_of_an_unsplit_episode_selects_nothing():
assert make_episode().matches_wanted(SeasonRange().parse_tokens("S01E01.2")) is False
+59
View File
@@ -171,3 +171,62 @@ def test_get_episodes_does_not_cache_an_empty_listing(monkeypatch: pytest.Monkey
monkeypatch.setattr(p, "_get", lambda _path, params: None)
assert p.get_episodes(73871, "official") == []
assert p._episodes == {}
# --- apply_tvdb_order over multi-part episodes -------------------------------
class _Svc:
pass
class _FakeProvider:
def __init__(self, mapping: dict) -> None:
self.mapping = mapping
def detect_order(self, _id, _keys) -> str:
return "official"
def get_order_map(self, _id, _order, source_order=None) -> dict:
return self.mapping
def _renumber(monkeypatch: pytest.MonkeyPatch, episodes: list, mapping: dict):
from types import SimpleNamespace
from unshackle.commands.dl import dl
from unshackle.core.titles.episode import Series
monkeypatch.setattr(providers, "get_provider", lambda _name: _FakeProvider(mapping))
errors: list = []
stub = SimpleNamespace(
tvdb_id=73871,
tvdb_order="dvd",
service="STUB",
log=SimpleNamespace(warning=lambda *a, **k: None, info=lambda *a, **k: None, error=lambda *a: errors.append(a)),
)
return dl.apply_tvdb_order(stub, Series(episodes)), errors
def _ep(season: int, number: int, part=None):
from unshackle.core.titles.episode import Episode
return Episode(id_=f"{season}x{number}.{part}", service=_Svc, title="Show", season=season, number=number, part=part)
def test_parts_of_one_episode_renumber_together(monkeypatch: pytest.MonkeyPatch) -> None:
"""Three parts share one (season, number) slot, so they must not read as a collision."""
episodes = [_ep(1, 1, 1), _ep(1, 1, 2), _ep(1, 1, 3), _ep(1, 2)]
mapping = {(1, 1): (2, 1, "Part-ful"), (1, 2): (2, 2, "Whole")}
titles, errors = _renumber(monkeypatch, episodes, mapping)
assert errors == []
assert [(t.season, t.number, t.part) for t in titles] == [(2, 1, 1), (2, 1, 2), (2, 1, 3), (2, 2, None)]
def test_a_real_collision_still_bails(monkeypatch: pytest.MonkeyPatch) -> None:
"""Dedupe must not disable the guard: two distinct episodes on one slot still refuses."""
episodes = [_ep(1, 1), _ep(1, 2)]
mapping = {(1, 2): (1, 1, "Clash")} # (1, 1) is unmapped and keeps the slot (1, 2) moves onto
titles, errors = _renumber(monkeypatch, episodes, mapping)
assert errors # refused, with the "two episodes each" error
assert [(t.season, t.number) for t in titles] == [(1, 1), (1, 2)] # numbering untouched