mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-11 19:45:06 +00:00
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:
@@ -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"
|
||||
|
||||
@@ -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,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 |
|
||||
|
||||
@@ -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()]
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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), " ")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user