feat(titles): add a sports output and folder template kind

A sport episode uses output_template.sports with a {date} in the template's separator style; the flag survives --remote, the REST API and export.
This commit is contained in:
imSp4rky
2026-11-03 08:43:05 -07:00
parent 3027592e31
commit b710b5b0c9
9 changed files with 180 additions and 21 deletions
+1
View File
@@ -287,6 +287,7 @@ These keys are sent only when the title carries them, so a title without them se
| `air_date` | string | episode | ISO air date of [daily and date-based content](../../guide/downloading.md#daily-and-date-based-content). unshackle names the episode by date instead of `SxxExx`. |
| `absolute` | integer | episode | Absolute episode number across all seasons. |
| `daily` | boolean | episode | The episode is daily/date-based. Set from the title, or from the service's `DAILY` class attribute. |
| `sport` | boolean | episode | The episode is a sports event, so it uses the `sports` output template when one is set. Sent only when true. |
| `anime` | boolean | episode, movie | The title is anime, so metadata lookups prefer AniList. Set from the title, or from the service's `ANIME` class attribute. |
!!! info "`part` on a split episode"
+29 -2
View File
@@ -168,6 +168,33 @@ The same holds for a folder template of your own: `{season}` never picks up the
For custom templates there is also a standalone [`{part}`](../reference/configuration/output.md#output_template) variable. You rarely want it, see the caveat on that page.
### Sports events {#sports-events}
A service can mark an episode as a sports event. That episode then uses the `sports` output template and the `sports` folder template, if you set them. Without them it uses the `series` templates, as before.
```yaml title="unshackle.yaml"
output_template:
sports: "{title}.{year?}.{date?}.{episode_name}.{quality}.{source}.WEB-DL.{audio_full}.{video}-{tag}"
folder:
sports: "{title}.{year?}"
```
A `sports` template can use every series variable, with two differences:
- `{date}` holds the air date in the separator style of the template (`2026.11.01`), and is empty when the event has no air date.
- `{year}` is empty when the event has an air date, because the date holds the year.
An event with a year and no air date, and an event with an air date:
```text
League.2026/
└─ League.2026.City.Grand.Prix.Practice.One.1080p.EXAMPLE.WEB-DL.AAC2.0.H.264-TAG.mkv
Club.League.2026/
└─ Club.League.2026.11.01.Home.Club.vs.Away.Club.1080p.EXAMPLE.WEB-DL.AAC2.0.H.264-TAG.mkv
```
A `sports` folder template groups by `{year}`, or by the air year when the event has no year. `{season}`, `{episode}` and `{season_episode}` are not useful in a `sports` template. Use `-w S01E02` to select an event: the season and episode numbers stay on the title.
### Audio
| Variable | Meaning | Example |
@@ -222,12 +249,12 @@ Folder templates sit under a special `folder` config key inside `output_template
albums: "{artist} - {album} ({year})"
```
The per-kind folder config keys are `movies`, `series`, `songs`, and `albums`. Any other config key gives a startup warning. Folder templates permit path separators (`/` or `\`). unshackle formats each segment independently, so you can assemble nested directory structures like `Show/Season 01`. unshackle reads the separator style from the whole template, so it spaces every segment alike.
The per-kind folder config keys are `movies`, `series`, `sports`, `songs`, and `albums`. Any other config key gives a startup warning. Folder templates permit path separators (`/` or `\`). unshackle formats each segment independently, so you can assemble nested directory structures like `Show/Season 01`. unshackle reads the separator style from the whole template, so it spaces every segment alike.
**Fallback behaviour:**
- A per-kind folder template wins if present. If there is none, unshackle uses the single `folder` string. If there is no `folder` string either, unshackle falls back to a built-in default.
- Built-in defaults when no folder template is set. Movies get no folder at all unless a `movies` folder template (or a single-string `folder` template) exists. Series fall back to a folder *derived* from the `series` output template (stripping `{episode}`, `{episode_name}`, and collapsing `{season_episode}` down to `{season}`). Music albums fall back to `{artist} - {album} ({year})`. If the song has no year, the folder name is only `{artist} - {album}`.
- Built-in defaults when no folder template is set. Movies get no folder at all unless a `movies` folder template (or a single-string `folder` template) exists. Series fall back to a folder *derived* from the `series` output template (stripping `{episode}`, `{episode_name}`, and collapsing `{season_episode}` down to `{season}`). A sports event with no `sports` folder template uses the `series` folder template, or derives its folder from the `sports` output template when there is no folder template. Music albums fall back to `{artist} - {album} ({year})`. If the song has no year, the folder name is only `{artist} - {album}`.
!!! note "Movies are flat by default"
Episodes and songs are always foldered. unshackle writes movies directly into the output directory *unless* you define a `movies` folder template (or a single-string `folder` template, which folders every kind). Set one if you want each movie in its own directory.
+9 -2
View File
@@ -9,7 +9,7 @@ the [Output & naming guide](../../guide/output-and-naming.md).
- **Type:** `dict`  ·  **Required** (no usable default; `dl` exits with an error if this is missing)
Filename templates keyed by media kind, using `{variable}` placeholders. Recognised kinds are
`movies`, `series`, and `songs` (and `albums` for folder templates). A trailing `?` on a
`movies`, `series`, `sports`, and `songs` (and `albums` for folder templates). A trailing `?` on a
variable (e.g. `{quality?}`) marks it optional: if empty, it and one adjacent separator are
dropped.
@@ -17,16 +17,18 @@ dropped.
output_template:
movies: "{title}.{year}.{quality?}.{source}-{tag}"
series: "{title}.{season_episode}.{episode_name?}.{quality?}.{source}-{tag}"
sports: "{title}.{year?}.{date?}.{episode_name}.{quality?}.{source}-{tag}"
songs: "{track_number}. {title}"
folder: # special nested key, see below
movies: "{title} ({year})"
series: "{title} ({year})"
sports: "{title}.{year?}"
```
**The nested `folder` sub-key** controls output subfolders, and unshackle pops it out of
`output_template`:
- A **dict** sets per-kind folder templates (kinds: `movies`, `series`, `songs`, `albums`).
- A **dict** sets per-kind folder templates (kinds: `movies`, `series`, `sports`, `songs`, `albums`).
- A **str** sets a single folder template for everything.
unshackle validates templates on load and emits warnings for unknown variables, non-string
@@ -59,6 +61,11 @@ folder-kind keys.
[`--enrich`](../../guide/downloading.md#metadata-and-tagging) fills it in from TVDB.
Use `{absolute?}` so the name stays clean when it is unknown.
!!! tip "`sports` names an event by year or date"
A service marks an event episode as sport, and that episode then uses the `sports`
templates. Without them it uses the `series` templates. For the variables and
examples, see [Sports events](../../guide/output-and-naming.md#sports-events).
## Tagging & naming keys
| Key | Type | Default | Description |
+104
View File
@@ -0,0 +1,104 @@
"""Tests for the ``sports`` output and folder template kind of a sport Episode."""
from __future__ import annotations
from datetime import date
import pytest
from unshackle.commands.dl import dl
from unshackle.core.api.handlers import serialize_title
from unshackle.core.config import Config, config
from unshackle.core.remote_service import build_title
from unshackle.core.titles.episode import Episode
SERIES = "{title}.{year?}.{season_episode}.{episode_name?}.{quality?}-{tag?}"
SPORTS = "{title}.{year?}.{date?}.{episode_name}.{quality?}-{tag?}"
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_="ev-0001",
service=DummyService,
title="League",
season=1,
number=2,
name="City Grand Prix Practice One",
year=2026,
sport=True,
)
kwargs.update(overrides)
return Episode(**kwargs)
@pytest.fixture
def templates(monkeypatch):
monkeypatch.setattr(config, "output_template", {"series": SERIES, "sports": SPORTS})
monkeypatch.setattr(config, "folder_templates", {"series": "{title}.{year?}.{season}", "sports": "{title}.{year?}"})
monkeypatch.setattr(config, "folder_template", "")
return config
def filename(episode: Episode, folder: bool = False) -> str:
return episode.get_filename(StubMediaInfo(), folder=folder, show_service=False)
def test_a_yearly_event_names_the_file_by_year_and_name(templates):
assert filename(make_episode()).startswith("League.2026.City.Grand.Prix.Practice.One")
def test_a_dated_event_names_the_file_by_dotted_date(templates):
episode = make_episode(year=2026, name="Home Club vs Away Club", air_date=date(2026, 11, 1))
assert filename(episode).startswith("League.2026.11.01.Home.Club.vs.Away.Club")
def test_the_sports_folder_groups_by_year(templates):
assert filename(make_episode(), folder=True) == "League.2026"
def test_the_sports_folder_takes_the_air_year_without_a_year(templates):
episode = make_episode(year=None, air_date=date(2026, 11, 1))
assert filename(episode, folder=True) == "League.2026"
def test_a_sport_episode_uses_series_templates_without_a_sports_template(templates):
templates.output_template = {"series": SERIES}
templates.folder_templates = {"series": "{title}.{year?}.{season}"}
sport, plain = make_episode(), make_episode(sport=False)
assert filename(sport) == filename(plain)
assert filename(sport).startswith("League.2026.S01E02")
assert filename(sport, folder=True) == filename(plain, folder=True) == "League.2026.S01"
def test_a_non_sport_episode_ignores_the_sports_template(templates):
assert filename(make_episode(sport=False)).startswith("League.2026.S01E02")
def test_the_sport_flag_survives_the_remote_round_trip():
rebuilt = build_title(serialize_title(make_episode()), "TEST", fallback_id="x")
assert isinstance(rebuilt, Episode) and rebuilt.sport
def test_the_sport_flag_survives_the_export_round_trip():
rebuilt = build_title(dl.title_to_meta(make_episode()), "TEST", fallback_id="x")
assert isinstance(rebuilt, Episode) and rebuilt.sport
def test_a_non_sport_episode_sends_no_sport_key():
assert "sport" not in serialize_title(make_episode(sport=False))
def test_sports_templates_validate_without_warnings(recwarn):
Config(output_template={"sports": SPORTS, "folder": {"sports": "{title}.{year?}.{date?}"}})
assert not [str(w.message) for w in recwarn if "template" in str(w.message).lower()]
+2
View File
@@ -4207,6 +4207,8 @@ class dl:
meta["absolute"] = title.absolute
if getattr(title, "daily", None) is not None:
meta["daily"] = title.daily
if title.sport:
meta["sport"] = True
elif isinstance(title, Movie):
meta.update(type="movie", name=title.name, year=title.year)
else:
+2
View File
@@ -1553,6 +1553,8 @@ def serialize_title(title: Title_T) -> Dict[str, Any]:
result["absolute"] = title.absolute
if getattr(title, "daily", None) is not None:
result["daily"] = title.daily
if title.sport:
result["sport"] = True
if is_song:
for field in SONG_FIELDS:
result[field] = getattr(title, field, None)
+5 -4
View File
@@ -303,6 +303,7 @@ class Config:
"label",
},
}
type_only["sports"] = type_only["series"]
type_only["albums"] = type_only["songs"]
shared = valid_variables - set().union(*type_only.values())
@@ -312,8 +313,8 @@ class Config:
if self.folder_template:
all_templates["folder"] = self.folder_template
for kind, tmpl in self.folder_templates.items():
if kind not in {"movies", "series", "songs", "albums"}:
warnings.warn(f"Unknown folder template kind '{kind}' (expected movies/series/songs/albums)")
if kind not in {"movies", "series", "sports", "songs", "albums"}:
warnings.warn(f"Unknown folder template kind '{kind}' (expected movies/series/sports/songs/albums)")
continue
all_templates[f"folder.{kind}"] = tmpl
@@ -347,7 +348,7 @@ class Config:
def get_folder_template(self, kind: str) -> str:
"""Find the folder template for the given title kind.
kind: one of "movies", "series", "songs", "albums".
kind: one of "movies", "series", "sports", "songs", "albums".
Falls back to the legacy single-string folder template, then "".
"""
if self.folder_templates:
@@ -363,7 +364,7 @@ class Config:
between variables. Falls back to dot separator (scene-style) by default.
Args:
template_type: One of "movies", "series", or "songs".
template_type: One of "movies", "series", "sports", or "songs".
"""
template = self.output_template.get(template_type) or ""
between_vars = re.findall(r"\}([^{]*)\{", template)
+1 -1
View File
@@ -546,7 +546,7 @@ def apply_title_fields(title: Title_T, info: Dict[str, Any]) -> None:
elif info.get("name"):
title.name = info["name"]
for key in ("anime", "daily", *(SONG_FIELDS if isinstance(title, Song) else ())):
for key in ("anime", "daily", "sport", *(SONG_FIELDS if isinstance(title, Song) else ())):
if info.get(key) is not None:
setattr(title, key, info[key])
+27 -12
View File
@@ -17,10 +17,11 @@ from unshackle.core.utils.template_formatter import TemplateFormatter, detect_sp
class Episode(Title):
# class-level defaults so Episodes restored from an older title cache read as part-less
# and without an absolute number
# class-level defaults so Episodes restored from an older title cache read as part-less,
# without an absolute number and not as sport
part: Optional[int] = None
absolute: Optional[int] = None
sport: bool = False
def __init__(
self,
@@ -37,6 +38,7 @@ class Episode(Title):
air_date: Optional[Union[date, str]] = None,
part: Optional[Union[int, str]] = None,
absolute: Optional[Union[int, str]] = None,
sport: bool = False,
) -> None:
super().__init__(id_, service, language, data)
@@ -120,6 +122,7 @@ class Episode(Title):
self.air_date = air_date
self.part = part
self.absolute = absolute
self.sport = sport
def matches_wanted(self, wanted: Collection[str]) -> bool:
"""Whether a parsed ``-w`` key set selects this episode.
@@ -136,11 +139,15 @@ class Episode(Title):
return False
return any(k in wanted for k in keys)
def template_kind(self) -> str:
"""``sports`` for a sport episode when a sports template is set, else ``series``."""
return "sports" if self.sport and config.output_template.get("sports") else "series"
def part_suffix(self) -> str:
"""``.Part.2`` / `` Part 2`` in the series template's own separator style."""
"""``.Part.2`` / `` Part 2`` in the active template's own separator style."""
if self.part is None:
return ""
sep = config.get_template_separator("series") if config.output_template.get("series") else "."
sep = config.get_template_separator(self.template_kind())
return f"{sep}Part{sep}{self.part}"
def build_template_context(
@@ -164,7 +171,10 @@ class Episode(Title):
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)
if self.template_kind() == "sports":
context["date"] = disp
else:
context["date"] = self.air_date.isoformat() if isinstance(self.air_date, date) else str(self.air_date)
if include_part and self.part is not None:
# folded into the identity tokens, not a token of its own: a standalone {part} is
# absent from every shipped template, so two parts would render the same filename
@@ -174,9 +184,9 @@ class Episode(Title):
return context
def air_date_display(self) -> str:
"""Render air_date using the series template's own separator (dots or spaces)."""
"""Render air_date using the active 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 "."
sep = config.get_template_separator(self.template_kind())
return f"{self.air_date.year:04}{sep}{self.air_date.month:02}{sep}{self.air_date.day:02}"
return str(self.air_date)
@@ -213,13 +223,18 @@ class Episode(Title):
def get_filename(self, media_info: MediaInfo, folder: bool = False, show_service: bool = True) -> str:
if folder:
template: Optional[str] = config.get_folder_template("series")
sports = self.sport and bool(config.folder_templates.get("sports"))
template: Optional[str] = config.get_folder_template("sports" if sports else "series")
if template:
# all parts of an episode land in the same season folder
context = self.build_template_context(media_info, show_service, include_part=False)
context["season"] = self.folder_season()
# folders keep the year, unless the season label is already the air year
context["year"] = "" if str(self.year) == context["season"] else self.year or ""
if sports:
# a sports folder has no season label, so it groups by the year or the air year
context["year"] = self.year or getattr(self.air_date, "year", "")
else:
# folders keep the year, unless the season label is already the air year
context["year"] = "" if str(self.year) == context["season"] else self.year or ""
spacer = detect_spacer(template) # one style for the whole path
segments = [
TemplateFormatter(seg, spacer).format(context)
@@ -228,7 +243,7 @@ class Episode(Title):
]
return "/".join(s for s in segments if s)
series_template = config.output_template.get("series")
series_template = config.output_template.get(self.template_kind())
if series_template:
derived_template = series_template
derived_template = re.sub(r"\{episode\}", "", derived_template)
@@ -258,7 +273,7 @@ class Episode(Title):
name += f" {self.folder_season()}"
return sanitize_filename(name, " ")
template = config.output_template.get("series")
template = config.output_template.get(self.template_kind())
if not template:
return sanitize_filename(str(self), " ")