mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-14 21:15:06 +00:00
feat(dl): add absolute episode numbers filled from tvdb absolute order
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.
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
"""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"]
|
||||
Reference in New Issue
Block a user