mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 20:15:10 +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:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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"]
|
||||
@@ -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,
|
||||
|
||||
@@ -175,6 +175,7 @@ class Config:
|
||||
"season_episode",
|
||||
"episode_name",
|
||||
"part",
|
||||
"absolute",
|
||||
"date",
|
||||
"quality",
|
||||
"resolution",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user