diff --git a/docs/OUTPUT_CONFIG.md b/docs/OUTPUT_CONFIG.md index 1db277ff..685f73a4 100644 --- a/docs/OUTPUT_CONFIG.md +++ b/docs/OUTPUT_CONFIG.md @@ -57,6 +57,18 @@ Add `?` suffix to make a variable conditional (omitted when empty): `{year?}`, ` | `episode` | Episode number (e.g. `1`) | | `season_episode` | Combined season/episode (e.g. `S01E01`) | | `episode_name` | Episode title | +| `date` | Air date in ISO form (e.g. `2024-06-30`), only for daily/sports content | + +!!! note "Daily / sports content" + When a service supplies an air date (daily talk shows, news, sports events), + `{season_episode}` automatically renders the date using your template's separator + (e.g. `2024.06.30` for a dotted template, `2024 06 30` for a spaced one). The + standalone `{year}` is dropped from the filename — the air date is the sole date — + so every episode of a show names consistently. Folders still keep the year (e.g. + `Show Name (2010)/2024/`). No template change is needed. + + Use the `{date}` variable instead if you want a fixed `YYYY-MM-DD` form regardless + of separator. Folders for dated episodes group by air year (e.g. `Show Name/2024/`). ### Video / audio variables diff --git a/tests/core/test_episode_air_date.py b/tests/core/test_episode_air_date.py new file mode 100644 index 00000000..d14a4629 --- /dev/null +++ b/tests/core/test_episode_air_date.py @@ -0,0 +1,109 @@ +"""Tests for date-based ("daily"/sports) episode naming via Episode.air_date.""" + +from __future__ import annotations + +from datetime import date, datetime + +import pytest + +from unshackle.core.config import config +from unshackle.core.titles.episode import Episode + + +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=2024, + number=181, + name="The Big Race", + year=2024, + ) + kwargs.update(overrides) + return Episode(**kwargs) + + +@pytest.fixture +def reset_template(): + saved = (config.output_template, config.folder_templates, config.folder_template) + config.output_template = {"series": "{title}.{year?}.{season_episode}.{quality}-{tag}"} + config.folder_templates = {} + config.folder_template = "" + yield config + config.output_template, config.folder_templates, config.folder_template = saved + + +def test_air_date_replaces_season_episode_dotted(reset_template): + ep = make_episode(air_date=date(2024, 6, 30)) + ctx = ep._build_template_context(StubMediaInfo()) + assert ctx["season_episode"] == "2024.06.30" + assert ctx["season"] == "2024.06.30" + assert ctx["episode"] == "" + assert ctx["year"] == "" # date carries the year + assert ctx["date"] == "2024-06-30" # ISO regardless of separator + + +def test_air_date_uses_space_separator(reset_template): + reset_template.output_template = {"series": "{title} {season_episode} {quality}-{tag}"} + ep = make_episode(air_date=date(2024, 6, 30)) + assert ep._build_template_context(StubMediaInfo())["season_episode"] == "2024 06 30" + + +def test_iso_string_air_date_coerced(reset_template): + ep = make_episode(air_date="2024-06-30") + assert isinstance(ep.air_date, date) + assert ep._build_template_context(StubMediaInfo())["season_episode"] == "2024.06.30" + + +def test_year_always_dropped_in_file_when_dated(reset_template): + """The air date is the sole date in the filename; a distinct year is dropped too (deterministic).""" + ep = make_episode(year=2010, air_date=date(2013, 10, 30)) + ctx = ep._build_template_context(StubMediaInfo()) + assert ctx["year"] == "" + assert ctx["season_episode"] == "2013.10.30" + + +def test_yearless_title_takes_year_from_date(reset_template): + """A title with no year still names cleanly; the date supplies the year.""" + ep = make_episode(year=None, air_date=date(2026, 6, 28)) + ctx = ep._build_template_context(StubMediaInfo()) + assert ctx["year"] == "" + assert ctx["season_episode"] == "2026.06.28" + + +def test_datetime_normalized_to_date(reset_template): + """A datetime (subclass of date) must not leak its time into the {date} token.""" + ep = make_episode(air_date=datetime(2024, 6, 30, 13, 0, 0)) + assert type(ep.air_date) is date + assert ep._build_template_context(StubMediaInfo())["date"] == "2024-06-30" + + +def test_no_air_date_is_unchanged(reset_template): + """Regression: without air_date, naming is exactly as before (presentation-only field).""" + ep = make_episode() + ctx = ep._build_template_context(StubMediaInfo()) + assert ctx["season_episode"] == "S2024E181" + assert ctx["season"] == "S2024" + assert ctx["episode"] == "E181" + assert ctx["year"] == 2024 + assert ctx["date"] == "" + + +def test_dated_folder_groups_by_year(reset_template): + reset_template.folder_templates = {} + reset_template.folder_template = "" + folder = make_episode(air_date=date(2024, 6, 30)).get_filename(StubMediaInfo(), folder=True) + assert "2024" in folder + assert "S2024" not in folder # season folder is the year, not the faked season diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 22ca512b..efae7526 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -3455,6 +3455,8 @@ class dl: @staticmethod def title_to_meta(title: Title_T) -> dict[str, Any]: """Capture the title fields needed to rebuild a Title for output naming on import.""" + from datetime import date + from unshackle.core.titles import Episode, Movie meta: dict[str, Any] = { @@ -3469,6 +3471,7 @@ class dl: number=title.number, name=title.name, year=title.year, + air_date=title.air_date.isoformat() if isinstance(title.air_date, date) else title.air_date, ) elif isinstance(title, Movie): meta.update(type="movie", name=title.name, year=title.year) diff --git a/unshackle/core/config.py b/unshackle/core/config.py index cfc651ae..b89e40cd 100644 --- a/unshackle/core/config.py +++ b/unshackle/core/config.py @@ -158,6 +158,7 @@ class Config: "episode", "season_episode", "episode_name", + "date", "quality", "resolution", "source", diff --git a/unshackle/core/remote_service.py b/unshackle/core/remote_service.py index 63b9ac37..7d81bf23 100644 --- a/unshackle/core/remote_service.py +++ b/unshackle/core/remote_service.py @@ -297,6 +297,7 @@ def _build_title(info: Dict[str, Any], service_tag: str, fallback_id: str) -> Un name=info.get("name"), year=info.get("year"), language=lang, + air_date=info.get("air_date"), ) return Movie( id_=info.get("id", fallback_id), diff --git a/unshackle/core/titles/episode.py b/unshackle/core/titles/episode.py index d76bfd31..ce64db69 100644 --- a/unshackle/core/titles/episode.py +++ b/unshackle/core/titles/episode.py @@ -1,6 +1,7 @@ import re from abc import ABC from collections import Counter +from datetime import date, datetime from typing import Any, Iterable, Optional, Union from langcodes import Language @@ -27,6 +28,7 @@ class Episode(Title): language: Optional[Union[str, Language]] = None, data: Optional[Any] = None, description: Optional[str] = None, + air_date: Optional[Union[date, str]] = None, ) -> None: super().__init__(id_, service, language, data) @@ -71,12 +73,22 @@ class Episode(Title): if year is not None and year <= 0: raise ValueError(f"Episode year cannot be {year}") + if isinstance(air_date, datetime): + air_date = air_date.date() # avoid leaking time into the {date} token + elif isinstance(air_date, str): + # keep as date when parseable so naming can format it + try: + air_date = date.fromisoformat(air_date[:10]) + except ValueError: + pass + self.title = title self.season = season self.number = number self.name = name self.year = year self.description = description + self.air_date = air_date def _build_template_context(self, media_info: MediaInfo, show_service: bool = True) -> dict: """Build template context dictionary from MediaInfo.""" @@ -87,9 +99,38 @@ class Episode(Title): context["episode"] = f"E{self.number:02}" context["season_episode"] = f"S{self.season:02}E{self.number:02}" context["episode_name"] = self.name or "" + context["date"] = "" + if self.air_date: + # daily/sports: air date replaces SxxExx + disp = self._air_date_display() + context["season"] = disp + context["episode"] = "" + context["season_episode"] = disp + context["year"] = "" # air date is the sole date in the file; folders keep the year + context["date"] = self.air_date.isoformat() if isinstance(self.air_date, date) else str(self.air_date) return context + def _air_date_display(self) -> str: + """Render air_date using the series template's own separator (dots or spaces).""" + if isinstance(self.air_date, date): + sep = config.get_template_separator("series") if config.output_template.get("series") else "." + return f"{self.air_date.year:04}{sep}{self.air_date.month:02}{sep}{self.air_date.day:02}" + return str(self.air_date) + + def _folder_season(self) -> str: + """Season folder label: air year for dated content, else SxxExx-style season.""" + if isinstance(self.air_date, date): + return f"{self.air_date.year:04}" + return f"S{self.season:02}" + def __str__(self) -> str: + if self.air_date: + return "{title}{year} {date} {name}".format( + title=self.title, + year=f" {self.year}" if self.year else "", + date=self._air_date_display(), + name=self.name or "", + ).strip() return "{title}{year} S{season:02}E{number:02} {name}".format( title=self.title, year=f" {self.year}" if self.year else "", @@ -103,7 +144,8 @@ class Episode(Title): template = config.get_folder_template("series") if template: context = self._build_template_context(media_info, show_service) - context["season"] = f"S{self.season:02}" + context["season"] = self._folder_season() + context["year"] = self.year or "" # folders keep the year segments = [ TemplateFormatter(seg).format(context) for seg in re.split(r"[\\/]", template) if seg.strip() ] @@ -123,7 +165,8 @@ class Episode(Title): formatter = TemplateFormatter(derived_template) context = self._build_template_context(media_info, show_service) - context["season"] = f"S{self.season:02}" + context["season"] = self._folder_season() + context["year"] = self.year or "" # folders keep the year folder_name = formatter.format(context) @@ -134,7 +177,7 @@ class Episode(Title): name = f"{self.title}" if self.year: name += f" {self.year}" - name += f" S{self.season:02}" + name += f" {self._folder_season()}" return sanitize_filename(name, " ") formatter = TemplateFormatter(config.output_template["series"]) @@ -168,13 +211,17 @@ class Series(SortedKeyList, ABC): ) for episode in self: if episode.season == season: + label = ( + episode._air_date_display() + if episode.air_date + else str(episode.number).zfill(len(str(episodes))) + ) if episode.name: - season_tree.add( - f"[bold]{str(episode.number).zfill(len(str(episodes)))}.[/] " - f"[bright_black]{episode.name}" - ) + season_tree.add(f"[bold]{label}.[/] [bright_black]{episode.name}") + elif episode.air_date: + season_tree.add(f"[bright_black]{label}") else: - season_tree.add(f"[bright_black]Episode {str(episode.number).zfill(len(str(episodes)))}") + season_tree.add(f"[bright_black]Episode {label}") return tree diff --git a/unshackle/services/EXAMPLE/__init__.py b/unshackle/services/EXAMPLE/__init__.py index 88c5788e..703608f3 100644 --- a/unshackle/services/EXAMPLE/__init__.py +++ b/unshackle/services/EXAMPLE/__init__.py @@ -283,6 +283,8 @@ class EXAMPLE(Service): year=metadata["releaseYear"] if metadata.get("releaseYear", 0) > 0 else None, language=original_lang, data=ep, + # daily/sports: name by date instead of SxxExx (date or ISO string) + # air_date=ep.get("airDate"), ) ) return Series(episodes)