mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 12:05:07 +00:00
Episode gains an additive absolute field services can set, an {absolute}
naming token (3-wide), and an --enrich fill from TVDB's absolute order
that never rewrites season or episode numbers.
216 lines
7.6 KiB
Python
216 lines
7.6 KiB
Python
"""Absolute episode numbering: Episode.absolute, the {absolute} token, and the enrich fill."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from unshackle.core import providers
|
|
from unshackle.core.config import config
|
|
from unshackle.core.titles.episode import Episode, Series
|
|
|
|
|
|
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}.{absolute}.{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_absolute_defaults_to_none():
|
|
assert make_episode().absolute is None
|
|
|
|
|
|
def test_int_absolute_is_kept():
|
|
assert make_episode(absolute=27).absolute == 27
|
|
|
|
|
|
def test_digit_string_absolute_coerces():
|
|
assert make_episode(absolute="27").absolute == 27
|
|
|
|
|
|
@pytest.mark.parametrize("bad", [0, -1])
|
|
def test_non_positive_absolute_rejected(bad):
|
|
with pytest.raises(ValueError):
|
|
make_episode(absolute=bad)
|
|
|
|
|
|
@pytest.mark.parametrize("bad", ["x", "1.5", 1.5, []])
|
|
def test_non_int_absolute_rejected(bad):
|
|
with pytest.raises(TypeError):
|
|
make_episode(absolute=bad)
|
|
|
|
|
|
def test_bool_absolute_rejected():
|
|
"""bool is an int subclass; True would render as '001' in filenames."""
|
|
with pytest.raises(TypeError):
|
|
make_episode(absolute=True)
|
|
|
|
|
|
def test_old_cache_entry_reads_as_absolute_less():
|
|
"""jsonpickle restores by populating __dict__; an older entry has no 'absolute' key."""
|
|
restored = Episode.__new__(Episode)
|
|
restored.__dict__.update(title="The Show", season=1, number=1, name=None, year=None)
|
|
assert restored.absolute is None
|
|
|
|
|
|
# --- naming -----------------------------------------------------------------
|
|
|
|
|
|
def test_absolute_token_is_three_wide(reset_template):
|
|
assert make_episode(absolute=7)._build_template_context(StubMediaInfo())["absolute"] == "007"
|
|
|
|
|
|
def test_absolute_token_does_not_truncate_past_three_digits(reset_template):
|
|
assert make_episode(absolute=1042)._build_template_context(StubMediaInfo())["absolute"] == "1042"
|
|
|
|
|
|
def test_absolute_token_is_empty_without_a_value(reset_template):
|
|
assert make_episode()._build_template_context(StubMediaInfo())["absolute"] == ""
|
|
|
|
|
|
def test_filename_renders_the_absolute_number(reset_template):
|
|
assert make_episode(absolute=7).get_filename(StubMediaInfo()) == "The.Show.S01E01.007.DummyService-GROUP"
|
|
|
|
|
|
def test_optional_absolute_token_drops_out(reset_template):
|
|
reset_template.output_template = {"series": "{title}.{season_episode}.{absolute?}.{source}-{tag}"}
|
|
assert make_episode().get_filename(StubMediaInfo()) == "The.Show.S01E01.DummyService-GROUP"
|
|
assert make_episode(absolute=7).get_filename(StubMediaInfo()) == "The.Show.S01E01.007.DummyService-GROUP"
|
|
|
|
|
|
def test_absolute_is_a_known_template_variable(reset_template, recwarn):
|
|
reset_template._validate_output_templates()
|
|
assert not [w for w in recwarn.list if "absolute" in str(w.message)]
|
|
|
|
|
|
def test_str_and_sorting_ignore_the_absolute_number():
|
|
assert str(make_episode(absolute=7)) == "The Show S01E01 The Reckoning"
|
|
series = Series([make_episode(number=2, absolute=1), make_episode(number=1, absolute=2)])
|
|
assert [e.number for e in series] == [1, 2]
|
|
|
|
|
|
# --- the enrich fill --------------------------------------------------------
|
|
|
|
|
|
class _FakeProvider:
|
|
def __init__(self, mapping: dict, source_order: str = "official") -> None:
|
|
self.mapping = mapping
|
|
self.source_order = source_order
|
|
self.orders: list = []
|
|
|
|
def detect_order(self, _id, _keys) -> str:
|
|
return self.source_order
|
|
|
|
def get_order_map(self, _id, order, source_order=None) -> dict:
|
|
self.orders.append(order)
|
|
return self.mapping
|
|
|
|
|
|
def _fill(
|
|
monkeypatch: pytest.MonkeyPatch, episodes: list, mapping: dict, tvdb_id=73871, provider=True, order="official"
|
|
):
|
|
from unshackle.commands.dl import dl
|
|
|
|
fake = _FakeProvider(mapping, order) if provider else None
|
|
monkeypatch.setattr(providers, "get_provider", lambda _name: fake)
|
|
logged: list = []
|
|
logger = SimpleNamespace(
|
|
debug=lambda *a, **k: logged.append(("debug", a)),
|
|
info=lambda *a, **k: logged.append(("info", a)),
|
|
warning=lambda *a, **k: logged.append(("warning", a)),
|
|
)
|
|
stub = SimpleNamespace(tvdb_id=tvdb_id, log=logger)
|
|
titles = Series(episodes)
|
|
dl.fill_absolute_numbers(stub, titles, tvdb_id)
|
|
return titles, fake, logged
|
|
|
|
|
|
MAPPING = {(1, 1): (1, 1, "One"), (1, 2): (1, 2, "Two"), (2, 1): (1, 3, "Three")}
|
|
|
|
|
|
def test_fill_populates_missing_absolute_numbers(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
episodes = [make_episode(season=1, number=1), make_episode(season=1, number=2), make_episode(season=2, number=1)]
|
|
titles, fake, _ = _fill(monkeypatch, episodes, MAPPING)
|
|
assert [t.absolute for t in titles] == [1, 2, 3]
|
|
assert fake.orders == ["absolute"]
|
|
|
|
|
|
def test_a_service_already_in_absolute_order_fills_from_its_own_numbers(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""get_order_map returns {} for the same-order case; the numbers are their own fill."""
|
|
episodes = [make_episode(season=1, number=26), make_episode(season=1, number=27)]
|
|
titles, fake, _ = _fill(monkeypatch, episodes, {}, order="absolute")
|
|
assert [t.absolute for t in titles] == [26, 27]
|
|
assert fake.orders == []
|
|
|
|
|
|
def test_fill_never_touches_season_or_number(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
episodes = [make_episode(season=2, number=1)]
|
|
titles, _, _ = _fill(monkeypatch, episodes, MAPPING)
|
|
assert [(t.season, t.number) for t in titles] == [(2, 1)]
|
|
|
|
|
|
def test_a_service_set_absolute_number_wins(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
episodes = [make_episode(season=1, number=1, absolute=99), make_episode(season=1, number=2)]
|
|
titles, _, _ = _fill(monkeypatch, episodes, MAPPING)
|
|
assert [t.absolute for t in titles] == [99, 2]
|
|
|
|
|
|
def test_an_unmapped_episode_keeps_no_absolute_number(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
episodes = [make_episode(season=9, number=9)]
|
|
titles, _, _ = _fill(monkeypatch, episodes, MAPPING)
|
|
assert titles[0].absolute is None
|
|
|
|
|
|
def test_fill_skips_silently_without_an_api_key(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
episodes = [make_episode(season=1, number=1)]
|
|
titles, _, logged = _fill(monkeypatch, episodes, MAPPING, provider=False)
|
|
assert titles[0].absolute is None
|
|
assert [level for level, _ in logged] == ["debug"]
|
|
|
|
|
|
def test_fill_skips_silently_without_a_tvdb_id(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
episodes = [make_episode(season=1, number=1)]
|
|
titles, _, logged = _fill(monkeypatch, episodes, MAPPING, tvdb_id=None)
|
|
assert titles[0].absolute is None
|
|
assert [level for level, _ in logged] == ["debug"]
|
|
|
|
|
|
def test_fill_skips_silently_when_tvdb_has_no_absolute_order(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
episodes = [make_episode(season=1, number=1)]
|
|
titles, _, logged = _fill(monkeypatch, episodes, {})
|
|
assert titles[0].absolute is None
|
|
assert [level for level, _ in logged] == ["debug"]
|