feat(titles): add air_date for date-based episode naming

Optional Episode.air_date names daily/sports content by date instead of SxxExx (no template change). Presentation-only; `{year}` dropped only when it duplicates the date's year. Round-trips via export/import.
This commit is contained in:
imSp4rky
2026-06-30 15:45:57 -06:00
parent 987de239ee
commit 59f3cb4fbe
7 changed files with 183 additions and 8 deletions
+12
View File
@@ -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
+109
View File
@@ -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
+3
View File
@@ -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)
+1
View File
@@ -158,6 +158,7 @@ class Config:
"episode",
"season_episode",
"episode_name",
"date",
"quality",
"resolution",
"source",
+1
View File
@@ -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),
+55 -8
View File
@@ -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
+2
View File
@@ -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)