From e4f6ddec85db8c55d853ba0b001ef7003164e96a Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Tue, 11 Aug 2026 16:42:07 -0600 Subject: [PATCH] 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. --- docs/guide/downloading.md | 6 + docs/guide/output-and-naming.md | 1 + docs/reference/configuration/output.md | 10 +- tests/core/test_episode_absolute.py | 215 +++++++++++++++++++++++++ unshackle/commands/dl.py | 45 ++++++ unshackle/core/config.py | 1 + unshackle/core/titles/episode.py | 16 +- unshackle/services/EXAMPLE/__init__.py | 2 + 8 files changed, 294 insertions(+), 2 deletions(-) create mode 100644 tests/core/test_episode_absolute.py diff --git a/docs/guide/downloading.md b/docs/guide/downloading.md index 0b283302..0488291b 100644 --- a/docs/guide/downloading.md +++ b/docs/guide/downloading.md @@ -698,6 +698,12 @@ source's values, whether or not the service already filled them in. A field the not answer with is left alone, and unshackle logs which fields those were, so a provider with a thin record cannot blank out what the service told you. +`--enrich` also fills in the absolute episode number of each episode from TVDB's absolute +order, for any series that has one and where the service did not supply it. Anime is the +usual beneficiary, but no part of this is limited to anime. This only adds the +[`{absolute}`](../reference/configuration/output.md#output_template) naming variable. The +season and episode numbers are never changed. + !!! warning "`--enrich` replaces the original language, which affects more than the filename" Track selection reads the original language, so replacing it changes which audio is treated as the original. That is the point when a service mislabels it, but it means a diff --git a/docs/guide/output-and-naming.md b/docs/guide/output-and-naming.md index abaed437..6a162529 100644 --- a/docs/guide/output-and-naming.md +++ b/docs/guide/output-and-naming.md @@ -127,6 +127,7 @@ Every variable below is valid in both output and folder templates. Values are de | `season_episode` | Combined | `S01E05` | | `episode_name` | Episode title | `Pilot` | | `part` | Part index of a [split episode](#split-episodes), empty otherwise | `2` | +| `absolute` | Absolute episode number, zero-padded to 3 digits, empty when unknown | `007` | | `date` | ISO air date for daily/dated content | `2024-06-01` | !!! note "Daily & sports content" diff --git a/docs/reference/configuration/output.md b/docs/reference/configuration/output.md index 2a58d909..9623a3f2 100644 --- a/docs/reference/configuration/output.md +++ b/docs/reference/configuration/output.md @@ -35,7 +35,7 @@ folder-kind keys. ??? example "All valid template variables" `title`, `year`, `season`, `episode`, `season_episode`, `episode_name`, `part`, - `date`, `quality`, `resolution`, `source`, `tag`, `track_number`, `artist`, + `absolute`, `date`, `quality`, `resolution`, `source`, `tag`, `track_number`, `artist`, `album_artist`, `album`, `disc`, `track_total`, `disc_total`, `release_type`, `genre`, `explicit`, `isrc`, `upc`, `label`, `audio`, `audio_channels`, `audio_full`, `atmos`, `dual`, `multi`, `dubbed`, `video`, `hdr`, `hfr`, `edition`, `repack`, `lang_tag`, @@ -51,6 +51,14 @@ folder-kind keys. `{season_episode}`, and note that it is always empty in a folder template, since folders do not carry the part. +!!! tip "`{absolute}` counts episodes across seasons" + `absolute` holds the absolute episode number, zero-padded to 3 digits (`007`), and is + empty on titles that have none. It is added to the name and never replaces `{season}` + or `{episode}`. Any series with a TVDB absolute order can use it, anime most often. + Services can supply it, and + [`--enrich`](../../guide/downloading.md#metadata-and-tagging) fills it in from TVDB. + Use `{absolute?}` so the name stays clean when it is unknown. + ## Tagging & naming keys | Key | Type | Default | Description | diff --git a/tests/core/test_episode_absolute.py b/tests/core/test_episode_absolute.py new file mode 100644 index 00000000..d3a76988 --- /dev/null +++ b/tests/core/test_episode_absolute.py @@ -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"] diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 28f17d46..e474dbb0 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -1656,6 +1656,11 @@ class dl: if enrich_lang: t.language = enrich_lang + if isinstance(titles, Series): + self.fill_absolute_numbers( + titles, self.tvdb_id or (enrich_result.external_ids.tvdb_id if enrich_result else None) + ) + if self.tvdb_order and isinstance(titles, Series): titles = self.apply_tvdb_order(titles, title_cacher, cache_title_id, cache_region, cache_account_hash) @@ -3911,6 +3916,46 @@ class dl: per_title = getattr(title, "anime", None) return self.service_anime if per_title is None else bool(per_title) + def fill_absolute_numbers(self, titles: Series, tvdb_id: Optional[int] = None) -> None: + """Fill in missing absolute episode numbers from TVDB's absolute order. + + Additive only: season and number are never written, and an absolute number the + service already set is kept. + """ + tvdb = providers.get_provider("tvdb") + if not tvdb: + self.log.debug("Absolute episode numbers need a tvdb_api_key in your config, skipping.") + return + + if not tvdb_id: + self.log.debug("Could not resolve a TVDB ID, skipping absolute episode numbers.") + return + + keys = list(dict.fromkeys((t.season, t.number) for t in titles)) + source_order = tvdb.detect_order(tvdb_id, keys) # type: ignore[attr-defined] + if source_order == "absolute": + # the service already numbers in absolute order; get_order_map would return the + # empty same-order map, but here the episode numbers are their own fill + mapping = {key: (*key, None) for key in keys} + else: + mapping = tvdb.get_order_map(tvdb_id, "absolute", source_order) # type: ignore[attr-defined] + if not mapping: + self.log.debug("TVDB has no absolute order for series %s, skipping absolute episode numbers.", tvdb_id) + return + + filled = 0 + for title in titles: + if title.absolute is not None: + continue + entry = mapping.get((title.season, title.number)) + if not entry: + continue + title.absolute = entry[1] + filled += 1 + + if filled: + self.log.info("Filled absolute numbers for %d episode(s) from TVDB (ID %s).", filled, tvdb_id) + def apply_tvdb_order( self, titles: Series, diff --git a/unshackle/core/config.py b/unshackle/core/config.py index df5b5f19..265b129d 100644 --- a/unshackle/core/config.py +++ b/unshackle/core/config.py @@ -175,6 +175,7 @@ class Config: "season_episode", "episode_name", "part", + "absolute", "date", "quality", "resolution", diff --git a/unshackle/core/titles/episode.py b/unshackle/core/titles/episode.py index 4b1d5d7a..0edefe8c 100644 --- a/unshackle/core/titles/episode.py +++ b/unshackle/core/titles/episode.py @@ -16,8 +16,10 @@ from unshackle.core.utils.template_formatter import TemplateFormatter, detect_sp class Episode(Title): - # class-level default so Episodes restored from an older title cache read as part-less + # class-level defaults so Episodes restored from an older title cache read as part-less + # and without an absolute number part: Optional[int] = None + absolute: Optional[int] = None def __init__( self, @@ -33,6 +35,7 @@ class Episode(Title): description: Optional[str] = None, air_date: Optional[Union[date, str]] = None, part: Optional[Union[int, str]] = None, + absolute: Optional[Union[int, str]] = None, ) -> None: super().__init__(id_, service, language, data) @@ -65,6 +68,15 @@ class Episode(Title): if part <= 0: raise ValueError(f"Episode part cannot be {part}") + if absolute is not None: + if isinstance(absolute, str) and absolute.isdigit(): + absolute = int(absolute) + # bool is an int subclass; True would render as "001" in filenames + elif isinstance(absolute, bool) or not isinstance(absolute, int): + raise TypeError(f"Expected absolute to be an int, not {absolute!r}") + if absolute <= 0: + raise ValueError(f"Episode absolute cannot be {absolute}") + if name is not None and not isinstance(name, str): raise TypeError(f"Expected name to be a str, not {name!r}") @@ -104,6 +116,7 @@ class Episode(Title): self.description = description self.air_date = air_date self.part = part + self.absolute = absolute def matches_wanted(self, wanted: Collection[str]) -> bool: """Whether a parsed ``-w`` key set selects this episode. @@ -137,6 +150,7 @@ class Episode(Title): context["episode_name"] = self.name or "" context["date"] = "" context["part"] = self.part if include_part and self.part is not None else "" + context["absolute"] = f"{self.absolute:03}" if self.absolute is not None else "" if self.air_date: # daily/sports: air date replaces SxxExx disp = self._air_date_display() diff --git a/unshackle/services/EXAMPLE/__init__.py b/unshackle/services/EXAMPLE/__init__.py index c5aa0897..e9f0463f 100644 --- a/unshackle/services/EXAMPLE/__init__.py +++ b/unshackle/services/EXAMPLE/__init__.py @@ -288,6 +288,8 @@ class EXAMPLE(Service): data=ep, # daily/sports: name by date instead of SxxExx (date or ISO string) # air_date=ep.get("airDate"), + # feeds the {absolute} naming token, --enrich fills it in from TVDB when unset + absolute=ep.get("absoluteNumber"), ) ) # Per-title override of the ANIME class attr, for a mixed catalogue. Leave it