mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-14 13:05:09 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -158,6 +158,7 @@ class Config:
|
||||
"episode",
|
||||
"season_episode",
|
||||
"episode_name",
|
||||
"date",
|
||||
"quality",
|
||||
"resolution",
|
||||
"source",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user