From 041ba5293bb45f6680266bb754d0a27e63093ef2 Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Tue, 4 Aug 2026 17:38:30 -0600 Subject: [PATCH] feat(titles): add optional Episode.part, selected with -w s1e1.2 Opt-in and additive: an episode without a part behaves exactly as before. --- docs/dev/creating-a-service.md | 53 +++- docs/dev/rest-api/endpoints.md | 11 +- docs/guide/cli-reference.md | 2 +- docs/guide/downloading.md | 39 +++ docs/guide/output-and-naming.md | 27 ++ docs/reference/configuration/output.md | 20 +- tests/core/test_click_types.py | 76 ++++- tests/core/test_episode_part.py | 199 +++++++++++++ tests/core/test_tvdb.py | 59 ++++ tests/remote/unit/test_api_episode_part.py | 331 +++++++++++++++++++++ unshackle/commands/dl.py | 14 +- unshackle/core/api/download_manager.py | 3 +- unshackle/core/api/handlers.py | 27 +- unshackle/core/config.py | 1 + unshackle/core/remote_service.py | 1 + unshackle/core/titles/episode.py | 70 ++++- unshackle/core/utils/click_types.py | 57 +++- 17 files changed, 946 insertions(+), 44 deletions(-) create mode 100644 tests/core/test_episode_part.py create mode 100644 tests/remote/unit/test_api_episode_part.py diff --git a/docs/dev/creating-a-service.md b/docs/dev/creating-a-service.md index a2578d89..1c3d5f9b 100644 --- a/docs/dev/creating-a-service.md +++ b/docs/dev/creating-a-service.md @@ -304,11 +304,14 @@ Common constructor fields: ```python Episode(id_, service, title, season, number, name=None, year=None, - language=None, data=None, description=None, air_date=None) + language=None, data=None, description=None, air_date=None, + part=None) ``` Pass `air_date` for daily/sports content to name by date instead of - `SxxExx`. Return a `Series([...])` of episodes. + `SxxExx`. Return a `Series([...])` of episodes. Pass `part` only when the + service splits one episode into several videos, see + [Split episodes](#split-episodes). === "Song" @@ -328,6 +331,52 @@ Common constructor fields: framework path to reuse cached results and gracefully fall back to stale data when the API errors. It also applies the per-service `title_map` config. +#### Split episodes {#split-episodes} + +Some services split one logical episode into several separately playable videos, for +example a variety show that ships episode 1 as *Episode 1 (Part 1)*, *(Part 2)* and +*(Part 3)*. `Episode.number` stays an `int`. The optional `part` carries the index. + +```python +Episode(..., season=1, number=1, part=1, name="The Reckoning") +Episode(..., season=1, number=1, part=2, name="The Reckoning") +``` + +1. `part` counts from 1, contiguous. `part=0` is rejected, and `-w` can only address + parts up to 99. +2. Every part of one episode shares the same `season` and `number`. +3. Each part is a separate `Episode` with its own unique `id_`. +4. Unsplit episode: leave `part` unset. Never pass `part=1` for a whole episode. +5. Do not put the part in `name` or `number`. Pass a clean `name` (or `None`) and set + `part`. +6. `part` is a split *episode*, not a split *season*. A service listing *Season 1 Part 2* + describes a group of episodes. Keep numbering those in sequence, the way the shipped + services do. + +`part` accepts an `int` or a digit string. A value that is not an integer raises +`TypeError`. A value of `0` or less raises `ValueError`. + +!!! warning "The framework never infers a part from the episode name" + Rule 6 is the reason. Shipped services already use the word "Part" to mean a *season* + part, so name-sniffing would misread every one of them. The part has to arrive + structurally, as the `part` argument. + +!!! tip "Clean the part out of the name" + `Episode` discards any `name` that starts with `Episode `, so a raw + `"Episode 1 (Part 2): The Reckoning"` leaves you with no episode name at all. Strip + the prefix before you pass it, or pass `None`. + +Once `part` is set, the filename gains a part token (`S01E01.Part.2`, see +[Output & naming](../guide/output-and-naming.md#split-episodes)), `-w S01E01.2` selects a +single part while a bare `-w S01E01` selects them all, and the `--list-titles` tree labels +the episode `01.2`. The REST API reports it as an extra `part` key on the serialized +title, described in [Endpoints](rest-api/endpoints.md#post-apilist-titles). + +!!! note "Opt-in and additive" + Leaving `part` unset costs you nothing: filenames, sort order, `-w` matching and the + API JSON are identical to a plain episode, so a service that does not split episodes + has nothing to adopt. + ### `get_tracks`: required Given one title, return a `Tracks` object holding `Video`, `Audio`, and diff --git a/docs/dev/rest-api/endpoints.md b/docs/dev/rest-api/endpoints.md index 8fc42797..05721df4 100644 --- a/docs/dev/rest-api/endpoints.md +++ b/docs/dev/rest-api/endpoints.md @@ -223,6 +223,12 @@ Service-specific CLI options may also be passed as additional keys. Each serialized title carries `type` (`"episode"`, `"movie"`, or `"other"`), `name`, `id`, `language`, `description`, `date`, and `cover_url`. Episodes and movies add `year`; episodes additionally add `series_title`, `season`, and `number`. +!!! info "`part` on a split episode" + A service that splits one episode into several separately playable videos reports each + one as its own episode. Those titles share a `season` and `number` and add a `part` + integer counting from 1. No other title carries the key, so a client that ignores it + is unaffected. See [Split episodes](../creating-a-service.md#split-episodes). + | Status | Error code | Meaning | | --- | --- | --- | | `200` | - | Titles returned. | @@ -248,9 +254,10 @@ List the video, audio, and subtitle tracks for a title. For series, you can scop | `wanted` | string | no | Episode/season range (e.g. `"S01E01-S01E03"`). | | `season` | int/string | no | Season number (combined with `episode`). | | `episode` | int/string | no | Episode number (combined with `season`). | +| `part` | int/string | no | Part index of a split episode (combined with `season` and `episode`). | | `profile`, `proxy`, `no_proxy`, `cdm_type` | - | no | As on `list-titles`. | -When both `season` and `episode` are given, they are combined into a `"{season}x{episode}"` selector. +When both `season` and `episode` are given, they are combined into a `"{season}x{episode}"` selector. Adding `part` narrows that to `"{season}x{episode}.{part}"`, one part of a [split episode](../creating-a-service.md#split-episodes); without it you get every part of the episode. `part` is read only when `season` and `episode` are both given, and `wanted` takes precedence over all three. To scope a part by range, put it in `wanted` instead (`"S01E01.2"`). === "Single title / movie: Response `200`" @@ -361,7 +368,7 @@ Create a download job. Requires `service` and `title_id`; every other field is a | `range` | string[] | `["SDR"]` | Dynamic range(s). | | `channels` | number | `null` | Audio channel count. | | `no_atmos` | boolean | `false` | Exclude Atmos tracks. | -| `wanted` | string[] | `[]` | Episode/season selectors. | +| `wanted` | string[] | `[]` | Episode/season selectors. Accepts the part form, `"S01E01.2"`. | | `latest_episode` | boolean | `false` | Only the newest episode. | | `lang` / `v_lang` / `a_lang` / `s_lang` | string[] | `["orig"]` / `[]` / `[]` / `["all"]` | Language filters. | | `require_subs` | string[] | `[]` | Required subtitle languages. | diff --git a/docs/guide/cli-reference.md b/docs/guide/cli-reference.md index e6a1c8f9..4e0ec417 100644 --- a/docs/guide/cli-reference.md +++ b/docs/guide/cli-reference.md @@ -112,7 +112,7 @@ The special language tokens `orig`, `all`, and `best` are honoured everywhere a | Flag | Description | |---|---| -| `-w`, `--wanted` | Wanted episodes, e.g. `S01-S05,S07`, `S01E01-S02E03`. Supports exclusions with a leading `-` (e.g. `-S03`). | +| `-w`, `--wanted` | Wanted episodes, e.g. `S01-S05,S07`, `S01E01-S02E03`. Supports exclusions with a leading `-` (e.g. `-S03`). For a [split episode](downloading.md#split-episodes), `.N` picks one part (`S01E01.2`), a range must stay inside the episode (`S01E01.1-S01E01.3`), and `S01E01` on its own takes every part. | | `--select-titles` | Interactively select what to download: episodes of a series, or films when a title has more than one. **Cannot combine with `-w`.** | | `--latest-episode` | Download only the single most recent episode. | | `--list-titles` | List titles only; do not download. | diff --git a/docs/guide/downloading.md b/docs/guide/downloading.md index 047b4be1..a8e013d8 100644 --- a/docs/guide/downloading.md +++ b/docs/guide/downloading.md @@ -368,6 +368,45 @@ with `-` to exclude it. unshackle dl -w S01-S05,-S03 EXAMPLE 81234567 ``` +=== "Split episodes" + + ```shell + # Part 2 of episode 1 only + unshackle dl -w S01E01.2 EXAMPLE 81234567 + + # Parts 1 through 3 of episode 1 + unshackle dl -w S01E01.1-S01E01.3 EXAMPLE 81234567 + + # All of season 1 except episode 1 part 2 + unshackle dl -w S01,-S01E01.2 EXAMPLE 81234567 + ``` + +### Split episodes + +A few services split one episode into several separately playable videos. Where a service +reports that, add `.N` after the episode to pick one part. + +| Token | Selects | +| --- | --- | +| `S01E01` | All parts of episode 1. | +| `S01E01.2` | Part 2 of episode 1, and nothing else. | +| `S01E01.1-S01E01.3` | Parts 1 to 3 of episode 1. | +| `S01,-S01E01.2` | All of season 1 except episode 1 part 2. Parts 1 and 3 are kept. | + +A season token such as `S01` covers every episode and every part, so you only need `.N` +when you want a part on its own. + +!!! warning "A part range stays inside one episode" + `S01E01.1-S01E01.3` is valid. `S01E01.1-S01E02.3` is rejected, because the parser + cannot know how many parts episode 1 has and so cannot work out where the range ends. + To span episodes, list the parts you want as separate tokens: + `-w S01E01.2,S01E02.1`. + +!!! note "A part of an unsplit episode selects nothing" + `-w S01E02.2` on an episode that was never split is deliberately empty: it tells you + the episode has no parts, rather than quietly handing you the whole episode. Run + `--list-titles` to see which episodes carry parts. + ### Other selection flags | Flag | Behaviour | diff --git a/docs/guide/output-and-naming.md b/docs/guide/output-and-naming.md index 9b412d05..abaed437 100644 --- a/docs/guide/output-and-naming.md +++ b/docs/guide/output-and-naming.md @@ -126,11 +126,38 @@ Every variable below is valid in both output and folder templates. Values are de | `episode` | Zero-padded episode (`E%02d`) | `E05` | | `season_episode` | Combined | `S01E05` | | `episode_name` | Episode title | `Pilot` | +| `part` | Part index of a [split episode](#split-episodes), empty otherwise | `2` | | `date` | ISO air date for daily/dated content | `2024-06-01` | !!! note "Daily & sports content" When an episode has an air date, unshackle switches to date-based naming automatically: `season` and `season_episode` become the formatted air date, `episode` and `year` are cleared, and `{date}` holds the ISO date. The date's internal separator (dots or spaces) follows your `series` template style. +### Split episodes {#split-episodes} + +A few services split one episode into several separately playable videos. Where a service reports that, the part index is folded into `{episode}` and `{season_episode}`, so the stock `series` template names it with no change to your config. + +```text +Show.Name.S01E01.Part.2.1080p.EXAMPLE.WEB-DL.DDP5.1.H.264-TAG.mkv +``` + +The part token sits immediately after the episode token, before the quality tags, and the group tag stays last. Its separator follows your template's own style, the same way dated content does, so a spaced template gives `S01E01 Part 2` instead. + +The part is left out of the folder name, so every part of an episode lands in the same season folder: + +```text +The.Show.S01.1080p.EXAMPLE.WEB-DL.DDP5.1.H.264-TAG/ + ├─ The.Show.S01E01.Part.1.1080p.EXAMPLE.WEB-DL.DDP5.1.H.264-TAG.mkv + ├─ The.Show.S01E01.Part.2.1080p.EXAMPLE.WEB-DL.DDP5.1.H.264-TAG.mkv + └─ The.Show.S01E02.1080p.EXAMPLE.WEB-DL.DDP5.1.H.264-TAG.mkv +``` + +The same holds for a folder template of your own: `{season}` never picks up the part, and `{season_episode}` drops it inside a folder template. An episode with no part is named like any other episode. + +!!! note "What media servers do with the part token" + The form above follows scene practice for genuinely split episodes. Kodi stacks the parts back into one playable episode automatically. Jellyfin lists two versions of S01E01, still correct and playable, because its stacking pattern expects the part marker at the end of the name. Plex's scanner is closed-source, so its handling of text after the part token is unverified. + +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. + ### Audio | Variable | Meaning | Example | diff --git a/docs/reference/configuration/output.md b/docs/reference/configuration/output.md index 5f696937..2a58d909 100644 --- a/docs/reference/configuration/output.md +++ b/docs/reference/configuration/output.md @@ -34,13 +34,23 @@ values, filesystem-unsafe characters (`< > : " / \ | ? *`), empty templates, and folder-kind keys. ??? example "All valid template variables" - `title`, `year`, `season`, `episode`, `season_episode`, `episode_name`, `date`, - `quality`, `resolution`, `source`, `tag`, `track_number`, `artist`, `album_artist`, - `album`, `disc`, `track_total`, `disc_total`, `release_type`, `genre`, `explicit`, - `isrc`, `upc`, `label`, `audio`, `audio_channels`, `audio_full`, `atmos`, `dual`, - `multi`, `dubbed`, `video`, `hdr`, `hfr`, `edition`, `repack`, `lang_tag`, + `title`, `year`, `season`, `episode`, `season_episode`, `episode_name`, `part`, + `date`, `quality`, `resolution`, `source`, `tag`, `track_number`, `artist`, + `album_artist`, `album`, `disc`, `track_total`, `disc_total`, `release_type`, `genre`, + `explicit`, `isrc`, `upc`, `label`, `audio`, `audio_channels`, `audio_full`, `atmos`, + `dual`, `multi`, `dubbed`, `video`, `hdr`, `hfr`, `edition`, `repack`, `lang_tag`, `title_type`. +!!! warning "`{part?}` is already inside `{season_episode}`" + `part` holds the bare part index of a + [split episode](../../guide/output-and-naming.md#split-episodes) (`2`) and is empty on + every other title. unshackle folds it into `{episode}` and `{season_episode}`, so the + shipped `series` template names split episodes correctly with no edit. Putting + `{part?}` next to either of them renders the part twice (`S01E01.Part.2.2`). Use the + standalone variable only in a template that names neither `{episode}` nor + `{season_episode}`, and note that it is always empty in a folder template, since + folders do not carry the part. + ## Tagging & naming keys | Key | Type | Default | Description | diff --git a/tests/core/test_click_types.py b/tests/core/test_click_types.py index 6b875ec9..4581816a 100644 --- a/tests/core/test_click_types.py +++ b/tests/core/test_click_types.py @@ -3,10 +3,11 @@ services set via the ``sub_format`` override (must not be rejected as an invalid from __future__ import annotations +import click import pytest from unshackle.core.tracks.subtitle import Subtitle -from unshackle.core.utils.click_types import QUALITY_LIST, SLOW_DELAY_RANGE, SubtitleCodecChoice +from unshackle.core.utils.click_types import QUALITY_LIST, SLOW_DELAY_RANGE, SeasonRange, SubtitleCodecChoice choice = SubtitleCodecChoice(Subtitle.Codec) @@ -57,3 +58,76 @@ def test_quality_list_accepts_yaml_native_values(value, expected): ) def test_slow_delay_range_accepts_bool(value, expected): assert SLOW_DELAY_RANGE.convert(value, None, None) == expected + + +# --- SeasonRange ------------------------------------------------------------ + + +def parse(*tokens: str) -> list[str]: + return SeasonRange().parse_tokens(*tokens) + + +def test_single_episode_token_is_unchanged(): + assert parse("S01E01") == ["1x1"] + + +def test_bare_season_spans_the_whole_episode_range(): + """A bare S01 reads the one match object with a different default per side.""" + keys = parse("S01") + assert len(keys) == 1000 + assert "1x0" in keys and "1x999" in keys + + +def test_multi_token_range_is_unchanged(): + assert sorted(parse("S02E01", "S02E03-S02E05")) == ["2x1", "2x3", "2x4", "2x5"] + + +def test_exclusions_are_unchanged(): + keys = parse("S01-S05", "-S03", "-S02E01") + assert not any(k.startswith("3x") for k in keys) + assert "2x1" not in keys + assert "2x0" in keys and "2x2" in keys + assert len(keys) == 3999 # 5 seasons less all of S03 less 2x1 + + +@pytest.mark.parametrize("tokens", [("S01E01",), ("S01",), ("S01-S05", "-S03", "-S02E01"), ("S02E03-S02E05",)]) +def test_part_less_tokens_never_emit_a_part_or_exclusion_key(tokens): + assert all("." not in k and "!" not in k for k in parse(*tokens)) + + +def test_part_token(): + assert parse("S01E01.2") == ["1x1.2"] + + +def test_part_range_within_one_episode(): + assert sorted(parse("S01E01.1-S01E01.3")) == ["1x1.1", "1x1.2", "1x1.3"] + + +def test_part_exclusion_becomes_a_negative_key(): + keys = parse("S01", "-S01E01.2") + assert "!1x1.2" in keys + assert "1x1" in keys # base key stays so the other parts still match + + +def test_base_exclusion_covers_part_keys(): + assert parse("S01E01.1-S01E01.3", "S01E02", "-S01E01") == ["1x2"] + + +def test_exclusion_removes_duplicate_keys(): + # "S01" and "S01E01" both emit 1x1; the exclusion must remove every copy + assert "1x1" not in parse("S01", "S01E01", "-S01E01") + + +@pytest.mark.parametrize( + "token", + [ + "S01E01.0", # parts count from 1 + "S01E01.1-S01E02.2", # a part range cannot cross episodes + "S01-S01E01.2", # a part on only one side + "S01E01.3-S01E01.1", # reversed part range + "S01E01.200", # above MAX_PART + ], +) +def test_bad_part_tokens_fail(token): + with pytest.raises(click.UsageError): + parse(token) diff --git a/tests/core/test_episode_part.py b/tests/core/test_episode_part.py new file mode 100644 index 00000000..b4d1e749 --- /dev/null +++ b/tests/core/test_episode_part.py @@ -0,0 +1,199 @@ +"""Tests for multi-part episodes: Episode.part, its naming, sorting and -w matching.""" + +from __future__ import annotations + +import pytest + +from unshackle.core.config import config +from unshackle.core.titles.episode import Episode, Series +from unshackle.core.utils.click_types import SeasonRange + + +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=1, + number=1, + name="The Reckoning", + ) + kwargs.update(overrides) + return Episode(**kwargs) + + +@pytest.fixture +def reset_template(): + saved = (config.output_template, config.folder_templates, config.folder_template, config.tag) + config.output_template = {"series": "{title}.{season_episode}.{quality?}.{source}-{tag}"} + config.folder_templates = {} + config.folder_template = "" + config.tag = "GROUP" + yield config + config.output_template, config.folder_templates, config.folder_template, config.tag = saved + + +# --- validation ------------------------------------------------------------- + + +def test_part_defaults_to_none(): + assert make_episode().part is None + + +def test_digit_string_part_coerces(): + assert make_episode(part="2").part == 2 + + +@pytest.mark.parametrize("bad", [0, -1]) +def test_non_positive_part_rejected(bad): + with pytest.raises(ValueError): + make_episode(part=bad) + + +def test_non_int_part_rejected(): + with pytest.raises(TypeError): + make_episode(part="x") + + +def test_bool_part_rejected(): + """bool is an int subclass; True would render as '.True' in keys and filenames.""" + with pytest.raises(TypeError): + make_episode(part=True) + + +def test_old_cache_entry_reads_as_part_less(): + """jsonpickle restores by populating __dict__; an older entry has no 'part' key.""" + restored = Episode.__new__(Episode) + restored.__dict__.update(title="The Show", season=1, number=1, name=None, year=None) + assert restored.part is None + + +# --- display ---------------------------------------------------------------- + + +def test_str_is_unchanged_without_a_part(): + without = make_episode() + assert str(without) == "The Show S01E01 The Reckoning" + + +def test_str_shows_the_selection_syntax(): + assert str(make_episode(part=2)) == "The Show S01E01.2 The Reckoning" + + +def test_str_tells_dated_parts_apart(): + # dated content renders the air date instead of SxxExx, so without the suffix both + # parts print the same string while their filenames differ + assert str(make_episode(part=2, air_date="2024-05-06")) == "The Show 2024.05.06.2 The Reckoning" + assert str(make_episode(air_date="2024-05-06")) == "The Show 2024.05.06 The Reckoning" + + +def test_tree_label_carries_the_part_on_dated_content(): + series = Series([make_episode(part=1, air_date="2024-05-06"), make_episode(part=2, air_date="2024-05-06")]) + labels = [node.label for node in series.tree(verbose=True).children[0].children] + assert labels == [ + "[bold]2024.05.06.1.[/] [bright_black]The Reckoning", + "[bold]2024.05.06.2.[/] [bright_black]The Reckoning", + ] + + +def test_tree_label_carries_the_part(): + series = Series([make_episode(part=1), make_episode(part=2)]) + labels = [node.label for node in series.tree(verbose=True).children[0].children] + assert labels == ["[bold]1.1.[/] [bright_black]The Reckoning", "[bold]1.2.[/] [bright_black]The Reckoning"] + + +def test_series_sorts_parts_within_their_episode(): + series = Series( + [ + make_episode(number=2), + make_episode(number=1, part=2), + make_episode(number=1), + make_episode(number=1, part=1), + ] + ) + assert [(e.number, e.part) for e in series] == [(1, None), (1, 1), (1, 2), (2, None)] + + +# --- naming ----------------------------------------------------------------- + + +def test_filename_is_unchanged_without_a_part(reset_template): + assert make_episode().get_filename(StubMediaInfo()) == "The.Show.S01E01.DummyService-GROUP" + + +def test_dotted_template_folds_the_part_after_the_episode(reset_template): + name = make_episode(part=2).get_filename(StubMediaInfo()) + assert name == "The.Show.S01E01.Part.2.DummyService-GROUP" + assert name.endswith("-GROUP") # group tag stays last + + +def test_spaced_template_uses_spaces(reset_template): + reset_template.output_template = {"series": "{title} {season_episode} {quality?} {source}-{tag}"} + assert make_episode(part=2).get_filename(StubMediaInfo()) == "The Show S01E01 Part 2 DummyService-GROUP" + + +def test_template_context_leaves_the_season_token_alone(reset_template): + ctx = make_episode(part=2)._build_template_context(StubMediaInfo()) + assert ctx["season"] == "S01" + assert ctx["episode"] == "E01.Part.2" + assert ctx["season_episode"] == "S01E01.Part.2" + assert ctx["part"] == 2 + + +def test_part_token_is_empty_without_a_part(reset_template): + assert make_episode()._build_template_context(StubMediaInfo())["part"] == "" + + +def test_parts_share_one_season_folder(reset_template): + # {season_episode} in a folder template would otherwise carry the part into the path + reset_template.folder_template = "{title}/{season_episode}" + folders = {make_episode(part=p).get_filename(StubMediaInfo(), folder=True) for p in (1, 2)} + assert folders == {"The.Show/S01E01"} + + +def test_parts_share_one_derived_season_folder(reset_template): + folders = {make_episode(part=p).get_filename(StubMediaInfo(), folder=True) for p in (1, 2)} + assert len(folders) == 1 + assert folders == {make_episode().get_filename(StubMediaInfo(), folder=True)} + + +# --- matching --------------------------------------------------------------- + +# the worked key scheme from the design: (token, part-less E1 selected, E1 part 2 selected) +MATCH_TABLE = [ + (("S01",), True, True), + (("S01E01",), True, True), + (("S01E01.2",), False, True), + (("S01E01.1-S01E01.3",), False, True), + (("S01", "-S01E01.2"), True, False), + (("S01", "-S01E01"), False, False), + (("S01E02",), False, False), +] + + +@pytest.mark.parametrize("tokens,wants_plain,wants_part2", MATCH_TABLE) +def test_matches_wanted_table(tokens, wants_plain, wants_part2): + wanted = SeasonRange().parse_tokens(*tokens) + assert make_episode().matches_wanted(wanted) is wants_plain + assert make_episode(part=2).matches_wanted(wanted) is wants_part2 + + +def test_a_part_exclusion_leaves_the_other_parts_alone(): + wanted = SeasonRange().parse_tokens("S01", "-S01E01.2") + assert make_episode(part=1).matches_wanted(wanted) is True + assert make_episode(part=3).matches_wanted(wanted) is True + + +def test_asking_for_a_part_of_an_unsplit_episode_selects_nothing(): + assert make_episode().matches_wanted(SeasonRange().parse_tokens("S01E01.2")) is False diff --git a/tests/core/test_tvdb.py b/tests/core/test_tvdb.py index 03f82573..c50711a8 100644 --- a/tests/core/test_tvdb.py +++ b/tests/core/test_tvdb.py @@ -171,3 +171,62 @@ def test_get_episodes_does_not_cache_an_empty_listing(monkeypatch: pytest.Monkey monkeypatch.setattr(p, "_get", lambda _path, params: None) assert p.get_episodes(73871, "official") == [] assert p._episodes == {} + + +# --- apply_tvdb_order over multi-part episodes ------------------------------- + + +class _Svc: + pass + + +class _FakeProvider: + def __init__(self, mapping: dict) -> None: + self.mapping = mapping + + def detect_order(self, _id, _keys) -> str: + return "official" + + def get_order_map(self, _id, _order, source_order=None) -> dict: + return self.mapping + + +def _renumber(monkeypatch: pytest.MonkeyPatch, episodes: list, mapping: dict): + from types import SimpleNamespace + + from unshackle.commands.dl import dl + from unshackle.core.titles.episode import Series + + monkeypatch.setattr(providers, "get_provider", lambda _name: _FakeProvider(mapping)) + errors: list = [] + stub = SimpleNamespace( + tvdb_id=73871, + tvdb_order="dvd", + service="STUB", + log=SimpleNamespace(warning=lambda *a, **k: None, info=lambda *a, **k: None, error=lambda *a: errors.append(a)), + ) + return dl.apply_tvdb_order(stub, Series(episodes)), errors + + +def _ep(season: int, number: int, part=None): + from unshackle.core.titles.episode import Episode + + return Episode(id_=f"{season}x{number}.{part}", service=_Svc, title="Show", season=season, number=number, part=part) + + +def test_parts_of_one_episode_renumber_together(monkeypatch: pytest.MonkeyPatch) -> None: + """Three parts share one (season, number) slot, so they must not read as a collision.""" + episodes = [_ep(1, 1, 1), _ep(1, 1, 2), _ep(1, 1, 3), _ep(1, 2)] + mapping = {(1, 1): (2, 1, "Part-ful"), (1, 2): (2, 2, "Whole")} + titles, errors = _renumber(monkeypatch, episodes, mapping) + assert errors == [] + assert [(t.season, t.number, t.part) for t in titles] == [(2, 1, 1), (2, 1, 2), (2, 1, 3), (2, 2, None)] + + +def test_a_real_collision_still_bails(monkeypatch: pytest.MonkeyPatch) -> None: + """Dedupe must not disable the guard: two distinct episodes on one slot still refuses.""" + episodes = [_ep(1, 1), _ep(1, 2)] + mapping = {(1, 2): (1, 1, "Clash")} # (1, 1) is unmapped and keeps the slot (1, 2) moves onto + titles, errors = _renumber(monkeypatch, episodes, mapping) + assert errors # refused, with the "two episodes each" error + assert [(t.season, t.number) for t in titles] == [(1, 1), (1, 2)] # numbering untouched diff --git a/tests/remote/unit/test_api_episode_part.py b/tests/remote/unit/test_api_episode_part.py new file mode 100644 index 00000000..f1fb8bf9 --- /dev/null +++ b/tests/remote/unit/test_api_episode_part.py @@ -0,0 +1,331 @@ +"""Unit tests for multi-part episode support across the REST/API layer. + +Covers serialize_title's conditional `part` key, the list-tracks wanted filter +routing through Episode.matches_wanted, the discrete season/episode/part request +fields, and download_manager's already-internal wanted sniff. +""" + +from __future__ import annotations + +import asyncio +import json +import re +from typing import Any, Dict, List, Optional + +import pytest + +from unshackle.core.api import handlers +from unshackle.core.api.errors import APIError +from unshackle.core.api.handlers import serialize_title +from unshackle.core.titles.episode import Episode + +pytestmark = pytest.mark.unit + + +class _FakeSvc: + pass + + +def _ep(season: int = 1, number: int = 1, part: Optional[int] = None, **overrides: Any) -> Episode: + base: Dict[str, Any] = dict( + id_=f"ep-{season}-{number}-{part or 0}", + service=_FakeSvc, + title="My Show", + season=season, + number=number, + name="Pilot", + year=2024, + language=None, + ) + base.update(overrides) + return Episode(part=part, **base) + + +class _FakeTracks: + videos: List[Any] = [] + audio: List[Any] = [] + subtitles: List[Any] = [] + + +class _FakeService: + def __init__(self, titles: List[Episode]) -> None: + self._titles = titles + + def get_titles(self) -> List[Episode]: + return self._titles + + def get_tracks(self, title: Episode) -> _FakeTracks: + return _FakeTracks() + + +def _run_list_tracks(monkeypatch: pytest.MonkeyPatch, titles: List[Episode], **data: Any) -> Dict[str, Any]: + """Drive list_tracks_handler end to end with the service layer stubbed out.""" + monkeypatch.setattr(handlers, "validate_service", lambda tag, request=None: "FAKE") + monkeypatch.setattr(handlers, "setup_list_service", lambda *a, **kw: _FakeService(titles)) + payload = {"service": "FAKE", "title_id": "t1", **data} + try: + response = asyncio.run(handlers.list_tracks_handler(payload)) + except APIError as e: + # routes.py turns this into the HTTP error body; keep the fields the tests assert on + return {"status": e.http_status, "error_code": e.error_code, "details": e.details} + return json.loads(response.body) + + +def _selected(result: Dict[str, Any]) -> List[str]: + """Selection-syntax keys of whatever the handler returned, single or multi.""" + entries = result["episodes"] if "episodes" in result else [{"title": result["title"]}] + keys = [] + for entry in entries: + t = entry["title"] + suffix = f".{t['part']}" if "part" in t else "" + keys.append(f"{t['season']}x{t['number']}{suffix}") + return keys + + +# ---------- serialize_title: the JSON contract ---------- + + +def test_partless_episode_json_has_no_part_key() -> None: + d = serialize_title(_ep(part=None)) + assert "part" not in d + assert list(d.keys()) == [ + "type", + "name", + "id", + "language", + "description", + "date", + "cover_url", + "year", + "series_title", + "season", + "number", + ] + assert d["name"] == "Pilot" + + +def test_partful_episode_json_carries_part_last() -> None: + d = serialize_title(_ep(part=2)) + assert d["part"] == 2 + # additive: `part` is appended, every pre-existing key keeps its position + assert list(d.keys())[:11] == list(serialize_title(_ep(part=None)).keys()) + assert list(d.keys())[11] == "part" + + +def test_part_stays_out_of_the_name_field() -> None: + """`name` is a reconstruction input for _build_title, not a display string.""" + assert serialize_title(_ep(part=2))["name"] == "Pilot" + assert serialize_title(_ep(part=3, name=None))["name"] == "Episode 01" + + +def test_remote_build_title_round_trip() -> None: + from unshackle.core.remote_service import _build_title + + rebuilt = _build_title(serialize_title(_ep(1, 4, 2)), "FAKE", "fallback-id") + assert (rebuilt.season, rebuilt.number, rebuilt.part, rebuilt.name) == (1, 4, 2, "Pilot") + + partless = _build_title(serialize_title(_ep(1, 4, None)), "FAKE", "fallback-id") + assert partless.part is None + + +def test_movie_json_unaffected() -> None: + from langcodes import Language + + from unshackle.core.titles.movie import Movie + + d = serialize_title(Movie(id_="movie-0001", service=_FakeSvc, name="Film", year=2024, language=Language.get("en"))) + assert "part" not in d + + +# ---------- list_tracks_handler: the REST wanted filter ---------- + + +@pytest.fixture +def split_titles() -> List[Episode]: + """E1 split into three parts, E2 unsplit.""" + return [_ep(1, 1, 1), _ep(1, 1, 2), _ep(1, 1, 3), _ep(1, 2, None)] + + +def test_rest_bare_episode_key_selects_every_part(monkeypatch, split_titles) -> None: + result = _run_list_tracks(monkeypatch, split_titles, wanted="S01E01") + assert _selected(result) == ["1x1.1", "1x1.2", "1x1.3"] + + +def test_rest_part_key_selects_exactly_one(monkeypatch, split_titles) -> None: + result = _run_list_tracks(monkeypatch, split_titles, wanted="S01E01.2") + assert _selected(result) == ["1x1.2"] + + +def test_rest_negative_part_key_excludes_only_that_part(monkeypatch, split_titles) -> None: + result = _run_list_tracks(monkeypatch, split_titles, wanted=["S01", "-S01E01.2"]) + assert _selected(result) == ["1x1.1", "1x1.3", "1x2"] + + +def test_rest_partless_episode_unchanged(monkeypatch, split_titles) -> None: + result = _run_list_tracks(monkeypatch, split_titles, wanted="S01E02") + assert _selected(result) == ["1x2"] + assert "part" not in result["title"] + + +def test_rest_part_of_unsplit_episode_selects_nothing(monkeypatch, split_titles) -> None: + result = _run_list_tracks(monkeypatch, split_titles, wanted="S01E02.1") + assert result["status"] == 404 + + +# ---------- discrete season/episode/part request fields ---------- + + +def test_discrete_fields_without_part_select_every_part(monkeypatch, split_titles) -> None: + result = _run_list_tracks(monkeypatch, split_titles, season=1, episode=1) + assert _selected(result) == ["1x1.1", "1x1.2", "1x1.3"] + + +def test_discrete_fields_with_part_select_one(monkeypatch, split_titles) -> None: + result = _run_list_tracks(monkeypatch, split_titles, season=1, episode=1, part=3) + assert _selected(result) == ["1x1.3"] + + +def test_discrete_part_accepts_string(monkeypatch, split_titles) -> None: + result = _run_list_tracks(monkeypatch, split_titles, season=1, episode=1, part="3") + assert _selected(result) == ["1x1.3"] + + +def test_discrete_no_match_error_reports_the_part_qualified_key(monkeypatch, split_titles) -> None: + result = _run_list_tracks(monkeypatch, split_titles, season=1, episode=1, part=9) + assert result["status"] == 404 + assert result["details"]["wanted"] == "1x1.9" + + +def test_discrete_partless_error_key_unchanged(monkeypatch, split_titles) -> None: + result = _run_list_tracks(monkeypatch, split_titles, season=9, episode=9) + assert result["details"]["wanted"] == "9x9" + + +def test_part_is_a_transport_key_not_a_service_option() -> None: + """`part` addresses the title, so it must never reach a service's own --part option.""" + assert "part" in handlers.LIST_HANDLER_TRANSPORT_KEYS + assert "part" in handlers.SESSION_TRANSPORT_KEYS + + +# ---------- ordering ---------- + + +def test_rest_multi_episode_order_is_stable_across_parts(monkeypatch) -> None: + titles = [_ep(1, 2, None), _ep(1, 1, 3), _ep(1, 1, 1), _ep(1, 1, 2)] + result = _run_list_tracks(monkeypatch, titles, wanted="S01") + assert _selected(result) == ["1x1.1", "1x1.2", "1x1.3", "1x2"] + + +# ---------- download_manager: already-internal wanted sniff ---------- + + +_SNIFF = r"^!?\d+x\d+(\.\d+)?$" + + +@pytest.mark.parametrize( + "wanted, internal", + [ + (["1x1"], True), # today's pre-parsed form + (["1x1", "1x2"], True), + (["1x1.2"], True), # part key + (["!1x1.2"], True), # negative part key + (["1x1", "!1x1.2"], True), + (["S01E01"], False), + (["s1e1.2"], False), + (["S01"], False), + (["1x1", "S01E02"], False), + ], +) +def test_download_manager_sniff_regex(wanted: List[str], internal: bool) -> None: + needs_conversion = any(not re.match(_SNIFF, w) for w in wanted) + assert needs_conversion is (not internal) + + +def test_sniff_regex_matches_the_one_in_download_manager() -> None: + """Guard the copy above against drift from the production regex.""" + from pathlib import Path + + import unshackle.core.api.download_manager as dm + + source = Path(dm.__file__).read_text(encoding="utf8") + assert f'needs_conversion = any(not re.match(r"{_SNIFF}", w) for w in wanted_raw)' in source + + +def test_perform_download_skips_reconversion_for_preparsed_part_keys(monkeypatch) -> None: + """A pre-parsed list with part and negative keys must not reach parse_tokens.""" + from unshackle.core.api.download_manager import _perform_download + from unshackle.core.utils.click_types import SeasonRange + + calls = [] + monkeypatch.setattr(SeasonRange, "parse_tokens", lambda self, *t: calls.append(t) or []) + + params: Dict[str, Any] = {"wanted": ["1x1", "1x1.2", "!1x1.3"]} + with pytest.raises(BaseException): + # blows up later on the unknown service; the wanted block runs first + _perform_download("job-1", "NoSuchServiceXYZ", "t1", params) + + assert calls == [] + assert params["wanted"] == ["1x1", "1x1.2", "!1x1.3"] + + +def test_perform_download_still_converts_cli_style_tokens(monkeypatch) -> None: + from unshackle.core.api.download_manager import _perform_download + from unshackle.core.utils.click_types import SeasonRange + + calls = [] + monkeypatch.setattr(SeasonRange, "parse_tokens", lambda self, *t: calls.append(t) or ["1x1"]) + + params: Dict[str, Any] = {"wanted": "S01E01"} + with pytest.raises(BaseException): + _perform_download("job-2", "NoSuchServiceXYZ", "t1", params) + + assert calls == [("S01E01",)] + assert params["wanted"] == ["1x1"] + + +# ---------- CLI / REST agreement ---------- + + +def test_dl_and_handlers_share_the_one_match_helper() -> None: + """Both filters must route through Episode.matches_wanted, or they drift.""" + from pathlib import Path + + import unshackle.commands.dl as dl_module + + dl_source = Path(dl_module.__file__).read_text(encoding="utf8") + handlers_source = Path(handlers.__file__).read_text(encoding="utf8") + + assert "matches_wanted(wanted)" in dl_source + assert "matches_wanted(wanted)" in handlers_source + # no inline key compare left behind on either side + inline = re.compile(r"f\"\{title\.season\}x\{title\.number\}\"\s*\n?\s*(?:in|not in)\s+wanted") + assert not inline.search(dl_source) + assert not inline.search(handlers_source) + + +@pytest.mark.parametrize( + "wanted_tokens", + [ + ("S01E01",), + ("S01E01.2",), + ("S01",), + ("S01", "-S01E01.2"), + ("S01", "-S01E01"), + ("S01E01.1-S01E01.3",), + ("S01E02",), + ], +) +def test_rest_filter_agrees_with_cli_filter(monkeypatch, split_titles, wanted_tokens) -> None: + from unshackle.core.utils.click_types import SeasonRange + + wanted = SeasonRange().parse_tokens(*wanted_tokens) + cli_selected = [ + f"{t.season}x{t.number}" + (f".{t.part}" if t.part is not None else "") + for t in split_titles + if t.matches_wanted(wanted) + ] + + result = _run_list_tracks(monkeypatch, split_titles, wanted=list(wanted_tokens)) + rest_selected = [] if result.get("status") == 404 else _selected(result) + + assert sorted(rest_selected) == sorted(cli_selected) diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 35763bcf..c26dd713 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -1645,7 +1645,8 @@ class dl: # Apply indentation only for multiple seasons prefix = " " if multiple_seasons else "" - option_text = f"{prefix}{t.number}" + (f". {display_name}" if t.name else "") + part_label = f".{t.part}" if t.part is not None else "" + option_text = f"{prefix}{t.number}{part_label}" + (f". {display_name}" if t.name else "") selection_titles.append(option_text) current_ui_idx = len(selection_titles) - 1 @@ -2217,7 +2218,7 @@ class dl: # If --latest-episode is set, only process the latest episode if f"{title.season}x{title.number}" != latest_episode_id: continue - elif isinstance(title, Episode) and wanted and f"{title.season}x{title.number}" not in wanted: + elif isinstance(title, Episode) and wanted and not title.matches_wanted(wanted): continue if progress_sink: @@ -2225,7 +2226,8 @@ class dl: progress_sink( { "title": title.title, - "current_title": f"S{title.season or 0:02}E{title.number or 0:02}", + "current_title": f"S{title.season or 0:02}E{title.number or 0:02}" + + (f".{title.part}" if title.part is not None else ""), } ) else: @@ -3741,7 +3743,9 @@ class dl: return titles self.tvdb_id = tvdb_id # keep the mux tag on the series the renumbering used - keys = [(t.season, t.number) for t in titles] + # parts of one episode share a (season, number) slot; without the dedupe the + # collision check below reads them as several episodes clashing on one slot + keys = list(dict.fromkeys((t.season, t.number) for t in titles)) source_order = tvdb.detect_order(tvdb_id, keys) # type: ignore[attr-defined] if source_order == self.tvdb_order: self.log.info("%s already lists this series in TVDB %s order.", self.service, self.tvdb_order) @@ -3809,6 +3813,8 @@ class dl: year=title.year, air_date=title.air_date.isoformat() if isinstance(title.air_date, date) else title.air_date, ) + if title.part is not None: + meta["part"] = title.part elif isinstance(title, Movie): meta.update(type="movie", name=title.name, year=title.year) else: diff --git a/unshackle/core/api/download_manager.py b/unshackle/core/api/download_manager.py index 56fc0d99..b250e543 100644 --- a/unshackle/core/api/download_manager.py +++ b/unshackle/core/api/download_manager.py @@ -387,7 +387,8 @@ def _perform_download( if isinstance(wanted_raw, str): wanted_raw = [wanted_raw] # Only convert if not already in internal "SxE" format - needs_conversion = any(not re.match(r"^\d+x\d+$", w) for w in wanted_raw) + # the !? keeps a pre-parsed part exclusion from being re-fed through parse_tokens + needs_conversion = any(not re.match(r"^!?\d+x\d+(\.\d+)?$", w) for w in wanted_raw) if needs_conversion: season_range = SeasonRange() params["wanted"] = season_range.parse_tokens(*wanted_raw) diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index 569dafb2..c6d52d45 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -93,6 +93,7 @@ LIST_HANDLER_TRANSPORT_KEYS = { "profile", "season", "episode", + "part", "wanted", "proxy", "no_proxy", @@ -324,6 +325,11 @@ def require_fields(data: Dict[str, Any], *names: str) -> None: ) +def _part_key_suffix(part: Optional[int]) -> str: + """`.2` selection-syntax suffix for a part-ful episode, empty otherwise.""" + return f".{part}" if part is not None else "" + + def serialize_title(title: Title_T) -> Dict[str, Any]: """Convert a title object to JSON-serializable dict.""" title_language = str(title.language) if hasattr(title, "language") and title.language else None @@ -336,7 +342,10 @@ def serialize_title(title: Title_T) -> Dict[str, Any]: cover_url = _data.get("cover_url") if isinstance(_data, dict) else None is_episode = isinstance(title, Episode) + episode_part = title.part if isinstance(title, Episode) else None if is_episode: + # no part suffix here: remote_service._build_title rebuilds the Episode from this dict, so a + # suffixed name plus the structural `part` below would render the part twice in the filename name = title.name if title.name else f"Episode {title.number:02d}" else: name = str(title.name) if hasattr(title, "name") else str(title) @@ -357,6 +366,8 @@ def serialize_title(title: Title_T) -> Dict[str, Any]: result["series_title"] = str(title.title) result["season"] = title.season result["number"] = title.number + if episode_part is not None: + result["part"] = episode_part # conditional, so part-less JSON is unchanged return result @@ -705,6 +716,7 @@ async def list_tracks_handler(data: Dict[str, Any], request: Optional[web.Reques wanted_param = data.get("wanted") season = data.get("season") episode = data.get("episode") + part = data.get("part") if hasattr(titles, "__iter__") and not isinstance(titles, str): titles_list = list(titles) @@ -729,7 +741,7 @@ async def list_tracks_handler(data: Dict[str, Any], request: Optional[web.Reques details={"wanted": wanted_param, "service": normalized_service}, ) elif season is not None and episode is not None: - wanted = [f"{season}x{episode}"] + wanted = [f"{season}x{episode}{_part_key_suffix(part)}"] if wanted: # Filter titles based on wanted episodes, similar to how dl.py does it @@ -737,8 +749,8 @@ async def list_tracks_handler(data: Dict[str, Any], request: Optional[web.Reques log.debug(f"Filtering {len(titles_list)} titles with {len(wanted)} wanted episodes") for title in titles_list: if isinstance(title, Episode): - episode_key = f"{title.season}x{title.number}" - if episode_key in wanted: + episode_key = f"{title.season}x{title.number}{_part_key_suffix(title.part)}" + if title.matches_wanted(wanted): log.debug(f"Episode {episode_key} matches wanted list") matching_titles.append(title) else: @@ -755,7 +767,7 @@ async def list_tracks_handler(data: Dict[str, Any], request: Optional[web.Reques details={ "service": normalized_service, "title_id": title_id, - "wanted": wanted_param or f"{season}x{episode}", + "wanted": wanted_param or wanted[0], }, ) @@ -765,7 +777,7 @@ async def list_tracks_handler(data: Dict[str, Any], request: Optional[web.Reques failed_episodes = [] # Sort matching titles by season and episode number for consistent ordering - sorted_titles = sorted(matching_titles, key=lambda t: (t.season, t.number)) + sorted_titles = sorted(matching_titles, key=lambda t: (t.season, t.number, t.part or 0)) for title in sorted_titles: try: @@ -786,12 +798,12 @@ async def list_tracks_handler(data: Dict[str, Any], request: Optional[web.Reques log.debug(f"Successfully got tracks for {title.season}x{title.number}") except SystemExit: # Service calls sys.exit() for unavailable episodes - catch and skip - failed_episodes.append(f"S{title.season}E{title.number:02d}") + failed_episodes.append(f"S{title.season}E{title.number:02d}{_part_key_suffix(title.part)}") log.debug(f"Episode {title.season}x{title.number} not available, skipping") continue except (Exception, SystemExit) as e: # Handle other errors gracefully - failed_episodes.append(f"S{title.season}E{title.number:02d}") + failed_episodes.append(f"S{title.season}E{title.number:02d}{_part_key_suffix(title.part)}") log.debug(f"Error getting tracks for {title.season}x{title.number}: {e}") continue @@ -1586,6 +1598,7 @@ SESSION_TRANSPORT_KEYS = { "title_id", "season", "episode", + "part", "wanted", "proxy", "no_proxy", diff --git a/unshackle/core/config.py b/unshackle/core/config.py index 9a94e291..adc7e9f4 100644 --- a/unshackle/core/config.py +++ b/unshackle/core/config.py @@ -173,6 +173,7 @@ class Config: "episode", "season_episode", "episode_name", + "part", "date", "quality", "resolution", diff --git a/unshackle/core/remote_service.py b/unshackle/core/remote_service.py index 2524ee85..996d4010 100644 --- a/unshackle/core/remote_service.py +++ b/unshackle/core/remote_service.py @@ -299,6 +299,7 @@ def _build_title(info: Dict[str, Any], service_tag: str, fallback_id: str) -> Un year=info.get("year"), language=lang, air_date=info.get("air_date"), + part=info.get("part"), ) return Movie( id_=info.get("id", fallback_id), diff --git a/unshackle/core/titles/episode.py b/unshackle/core/titles/episode.py index c3678d88..4b1d5d7a 100644 --- a/unshackle/core/titles/episode.py +++ b/unshackle/core/titles/episode.py @@ -2,7 +2,7 @@ import re from abc import ABC from collections import Counter from datetime import date, datetime -from typing import Any, Iterable, Optional, Union +from typing import Any, Collection, Iterable, Optional, Union from langcodes import Language from pymediainfo import MediaInfo @@ -16,6 +16,9 @@ from unshackle.core.utils.template_formatter import TemplateFormatter, detect_sp class Episode(Title): + # class-level default so Episodes restored from an older title cache read as part-less + part: Optional[int] = None + def __init__( self, id_: Any, @@ -29,6 +32,7 @@ class Episode(Title): data: Optional[Any] = None, description: Optional[str] = None, air_date: Optional[Union[date, str]] = None, + part: Optional[Union[int, str]] = None, ) -> None: super().__init__(id_, service, language, data) @@ -51,6 +55,16 @@ class Episode(Title): elif not isinstance(number, int): raise TypeError(f"Expected number to be an int, not {number!r}") + if part is not None: + if isinstance(part, str) and part.isdigit(): + part = int(part) + # bool is an int subclass; True would render as ".True" in keys and filenames + elif isinstance(part, bool) or not isinstance(part, int): + raise TypeError(f"Expected part to be an int, not {part!r}") + # parts are 1-based; a falsy 0 would read as "no part" downstream + if part <= 0: + raise ValueError(f"Episode part cannot be {part}") + if name is not None and not isinstance(name, str): raise TypeError(f"Expected name to be a str, not {name!r}") @@ -89,8 +103,30 @@ class Episode(Title): self.year = year self.description = description self.air_date = air_date + self.part = part - def _build_template_context(self, media_info: MediaInfo, show_service: bool = True) -> dict: + def matches_wanted(self, wanted: Collection[str]) -> bool: + """Whether a parsed ``-w`` key set selects this episode. + + A part-ful episode answers to both its base key and its part key, so ``-w s1e1`` + takes every part. ``!`` keys are part-qualified exclusions resolved here. + """ + base = f"{self.season}x{self.number}" + keys = (base,) if self.part is None else (base, f"{base}.{self.part}") + if any(f"!{k}" in wanted for k in keys): + return False + return any(k in wanted for k in keys) + + def _part_suffix(self) -> str: + """``.Part.2`` / `` Part 2`` in the series template's own separator style.""" + if self.part is None: + return "" + sep = config.get_template_separator("series") if config.output_template.get("series") else "." + return f"{sep}Part{sep}{self.part}" + + def _build_template_context( + self, media_info: MediaInfo, show_service: bool = True, include_part: bool = True + ) -> dict: """Build template context dictionary from MediaInfo.""" context = self._build_base_template_context(media_info, show_service) context["title"] = self.title.replace("$", "S") @@ -100,6 +136,7 @@ class Episode(Title): context["season_episode"] = f"S{self.season:02}E{self.number:02}" context["episode_name"] = self.name or "" context["date"] = "" + context["part"] = self.part if include_part and self.part is not None else "" if self.air_date: # daily/sports: air date replaces SxxExx disp = self._air_date_display() @@ -108,6 +145,12 @@ class Episode(Title): 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 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 + suffix = self._part_suffix() + context["episode"] = f"{context['episode']}{suffix}" + context["season_episode"] = f"{context['season_episode']}{suffix}" return context def _air_date_display(self) -> str: @@ -123,19 +166,28 @@ class Episode(Title): return f"{self.air_date.year:04}" return f"S{self.season:02}" + def _part_label(self) -> str: + """``.2`` selection-syntax suffix for console output, empty when part-less.""" + return f".{self.part}" if self.part is not None else "" + def __str__(self) -> str: if self.air_date: - return "{title}{year} {date} {name}".format( + # dated content has no SxxExx to hang the part off, but the parts still have to + # be told apart, so the same .N suffix follows the date + return "{title}{year} {date}{part} {name}".format( title=self.title, year=f" {self.year}" if self.year else "", date=self._air_date_display(), + part=self._part_label(), name=self.name or "", ).strip() - return "{title}{year} S{season:02}E{number:02} {name}".format( + # the console shows the -w selection syntax (S01E01.2), not the filename form + return "{title}{year} S{season:02}E{number:02}{part} {name}".format( title=self.title, year=f" {self.year}" if self.year else "", season=self.season, number=self.number, + part=self._part_label(), name=self.name or "", ).strip() @@ -143,7 +195,8 @@ class Episode(Title): if folder: template = config.get_folder_template("series") if template: - context = self._build_template_context(media_info, show_service) + # 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() context["year"] = self.year or "" # folders keep the year spacer = detect_spacer(template) # one style for the whole path @@ -167,7 +220,7 @@ class Episode(Title): derived_template = re.sub(r"^[\.\s]+|[\.\s]+$", "", derived_template) formatter = TemplateFormatter(derived_template) - context = self._build_template_context(media_info, show_service) + context = self._build_template_context(media_info, show_service, include_part=False) context["season"] = self._folder_season() context["year"] = self.year or "" # folders keep the year @@ -190,7 +243,8 @@ class Episode(Title): class Series(SortedKeyList, ABC): def __init__(self, iterable: Optional[Iterable] = None): - super().__init__(iterable, key=lambda x: (x.season, x.number, x.year or 0)) + # part slots before year so existing ties still resolve by year + super().__init__(iterable, key=lambda x: (x.season, x.number, x.part or 0, x.year or 0)) def __str__(self) -> str: if not self: @@ -218,7 +272,7 @@ class Series(SortedKeyList, ABC): episode._air_date_display() if episode.air_date else str(episode.number).zfill(len(str(episodes))) - ) + ) + episode._part_label() if episode.name: season_tree.add(f"[bold]{label}.[/] [bright_black]{episode.name}") elif episode.air_date: diff --git a/unshackle/core/utils/click_types.py b/unshackle/core/utils/click_types.py index ca4e13aa..9f0f4334 100644 --- a/unshackle/core/utils/click_types.py +++ b/unshackle/core/utils/click_types.py @@ -174,11 +174,18 @@ class SeasonRange(click.ParamType): MIN_EPISODE = 0 MAX_EPISODE = 999 + MIN_PART = 1 + MAX_PART = 99 def parse_tokens(self, *tokens: str) -> list[str]: """ Parse multiple tokens or ranged tokens as '{s}x{e}' strings. + An episode split into separately playable parts is addressed as '{s}x{e}.{p}'. + A part range must stay inside one episode, since how many parts an episode has + is not knowable here. A part-qualified exclusion cannot be removed from the + computed keys (they are base keys), so it becomes a '!' key resolved at match time. + Supports exclusioning by putting a `-` before the token. Example: @@ -189,6 +196,10 @@ class SeasonRange(click.ParamType): ["2x1", "2x3", "2x4", "2x5"] >>> sr.parse_tokens("S01-S05", "-S03", "-S02E01") ["1x0", "1x1", ..., "2x0", (...), "2x2", (...), "4x0", ..., "5x0", ...] + >>> sr.parse_tokens("S01E01.1-S01E01.3") + ["1x1.1", "1x1.2", "1x1.3"] + >>> sr.parse_tokens("S01E01", "-S01E01.2") + ["1x1", "!1x1.2"] """ if len(tokens) == 0: return [] @@ -199,36 +210,56 @@ class SeasonRange(click.ParamType): if exclude: token = token[1:] parsed = [ - re.match(r"^S(?P\d+)(E(?P\d+))?$", x, re.IGNORECASE) for x in re.split(r"[:-]", token) + re.match(r"^S(?P\d+)(E(?P\d+)(\.(?P\d+))?)?$", x, re.IGNORECASE) + for x in re.split(r"[:-]", token) ] if len(parsed) > 2: self.fail(f"Invalid token, only a left and right range is acceptable: {token}") if len(parsed) == 1: + # the same match object is read with different per-side defaults, so a bare + # S01 spans from MIN_EPISODE to MAX_EPISODE parsed.append(parsed[0]) if any(x is None for x in parsed): self.fail(f"Invalid token, syntax error occurred: {token}") - from_season, from_episode = [ - int(v) if v is not None else self.MIN_EPISODE - for k, v in parsed[0].groupdict().items() - if parsed[0] # type: ignore[union-attr] - ] - to_season, to_episode = [ - int(v) if v is not None else self.MAX_EPISODE - for k, v in parsed[1].groupdict().items() - if parsed[1] # type: ignore[union-attr] - ] + left, right = parsed[0], parsed[1] + from_season = int(left.group("season")) # type: ignore[union-attr] + from_episode_raw = left.group("episode") # type: ignore[union-attr] + from_episode = int(from_episode_raw) if from_episode_raw is not None else self.MIN_EPISODE + from_part_raw = left.group("part") # type: ignore[union-attr] + from_part = int(from_part_raw) if from_part_raw is not None else None + to_season = int(right.group("season")) # type: ignore[union-attr] + to_episode_raw = right.group("episode") # type: ignore[union-attr] + to_episode = int(to_episode_raw) if to_episode_raw is not None else self.MAX_EPISODE + to_part_raw = right.group("part") # type: ignore[union-attr] + to_part = int(to_part_raw) if to_part_raw is not None else None if from_season > to_season: self.fail(f"Invalid range, left side season cannot be bigger than right side season: {token}") if from_season == to_season and from_episode > to_episode: self.fail(f"Invalid range, left side episode cannot be bigger than right side episode: {token}") + if (from_part is None) != (to_part is None): + self.fail(f"Invalid range, a part must be given on both sides or on neither: {token}") + if from_part is not None and to_part is not None: + if (from_season, from_episode) != (to_season, to_episode): + self.fail(f"Invalid range, a part range must stay within one episode: {token}") + if not all(self.MIN_PART <= p <= self.MAX_PART for p in (from_part, to_part)): + self.fail(f"Invalid part, must be between {self.MIN_PART} and {self.MAX_PART}: {token}") + if from_part > to_part: + self.fail(f"Invalid range, left side part cannot be bigger than right side part: {token}") + for p in range(from_part, to_part + 1): + (computed if not exclude else exclusions).append(f"{from_season}x{from_episode}.{p}") + continue for s in range(from_season, to_season + 1): for e in range( from_episode if s == from_season else 0, (self.MAX_EPISODE if s < to_season else to_episode) + 1 ): (computed if not exclude else exclusions).append(f"{s}x{e}") for exclusion in exclusions: - if exclusion in computed: - computed.remove(exclusion) + if "." in exclusion: + computed.append(f"!{exclusion}") # base key stays; resolved at match time + else: + # a base exclusion must drop that episode's part keys too, or they still match + prefix = f"{exclusion}." + computed = [k for k in computed if k != exclusion and not k.startswith(prefix)] return list(set(computed)) def convert(