From b710b5b0c9dff53c0d4014c555c1748ae8b17404 Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Tue, 3 Nov 2026 08:43:05 -0700 Subject: [PATCH] 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. --- docs/dev/rest-api/endpoints.md | 1 + docs/guide/output-and-naming.md | 31 +++++++- docs/reference/configuration/output.md | 11 ++- tests/core/test_episode_sports.py | 104 +++++++++++++++++++++++++ unshackle/commands/dl.py | 2 + unshackle/core/api/handlers.py | 2 + unshackle/core/config.py | 9 ++- unshackle/core/remote_service.py | 2 +- unshackle/core/titles/episode.py | 39 +++++++--- 9 files changed, 180 insertions(+), 21 deletions(-) create mode 100644 tests/core/test_episode_sports.py diff --git a/docs/dev/rest-api/endpoints.md b/docs/dev/rest-api/endpoints.md index 81169484..f9147c8c 100644 --- a/docs/dev/rest-api/endpoints.md +++ b/docs/dev/rest-api/endpoints.md @@ -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" diff --git a/docs/guide/output-and-naming.md b/docs/guide/output-and-naming.md index 039a3549..23d287a7 100644 --- a/docs/guide/output-and-naming.md +++ b/docs/guide/output-and-naming.md @@ -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. diff --git a/docs/reference/configuration/output.md b/docs/reference/configuration/output.md index 17e9b89f..5dd09e74 100644 --- a/docs/reference/configuration/output.md +++ b/docs/reference/configuration/output.md @@ -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 | diff --git a/tests/core/test_episode_sports.py b/tests/core/test_episode_sports.py new file mode 100644 index 00000000..b222c473 --- /dev/null +++ b/tests/core/test_episode_sports.py @@ -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()] diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 82878758..899c9ba3 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -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: diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index a8de1cf6..6d3913ad 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -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) diff --git a/unshackle/core/config.py b/unshackle/core/config.py index b6f32025..6afe6629 100644 --- a/unshackle/core/config.py +++ b/unshackle/core/config.py @@ -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) diff --git a/unshackle/core/remote_service.py b/unshackle/core/remote_service.py index 5d798d6b..4cc7450f 100644 --- a/unshackle/core/remote_service.py +++ b/unshackle/core/remote_service.py @@ -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]) diff --git a/unshackle/core/titles/episode.py b/unshackle/core/titles/episode.py index edd1b484..2ab89763 100644 --- a/unshackle/core/titles/episode.py +++ b/unshackle/core/titles/episode.py @@ -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), " ")