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:
imSp4rky
2026-08-11 16:42:07 -06:00
parent 72581ba5d2
commit e4f6ddec85
8 changed files with 294 additions and 2 deletions
+6
View File
@@ -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
+1
View File
@@ -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"
+9 -1
View File
@@ -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 |
+215
View File
@@ -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"]
+45
View File
@@ -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,
+1
View File
@@ -175,6 +175,7 @@ class Config:
"season_episode",
"episode_name",
"part",
"absolute",
"date",
"quality",
"resolution",
+15 -1
View File
@@ -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()
+2
View File
@@ -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