From 590a57113bb2675337eef5909d750207552e9dc7 Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Wed, 11 Nov 2026 08:38:27 -0700 Subject: [PATCH] feat(dl): add -ce/--continuous-episodes to number seasons on Each season's episodes continue from the previous season's last episode (S02E05 after a four-episode S01); also on the REST API. A --remote get_tracks refresh no longer resets season, number and part. --- docs/dev/rest-api/endpoints.md | 1 + docs/guide/cli-reference.md | 1 + docs/guide/downloading.md | 19 ++++++ docs/reference/configuration/output.md | 4 +- tests/core/test_continuous_episodes.py | 82 ++++++++++++++++++++++++++ unshackle/commands/dl.py | 36 ++++++++++- unshackle/core/api/handlers.py | 1 + unshackle/core/api/routes.py | 3 + unshackle/core/remote_service.py | 11 ++-- 9 files changed, 151 insertions(+), 7 deletions(-) create mode 100644 tests/core/test_continuous_episodes.py diff --git a/docs/dev/rest-api/endpoints.md b/docs/dev/rest-api/endpoints.md index 3b56abee..dca7e38a 100644 --- a/docs/dev/rest-api/endpoints.md +++ b/docs/dev/rest-api/endpoints.md @@ -485,6 +485,7 @@ Make a download job. It requires `service` and `title_id`. Every other field is | `tag` | string | `null` | Release group tag. | | `tmdb_id` / `imdb_id` / `tvdb_id` / `anilist_id` | - | `null` | External ID overrides. Each resolves its metadata directly instead of by a title search, and is used for tagging. Set `enrich` to also take the title, year and original language. Give at most one of `tmdb_id`, `imdb_id` and `tvdb_id`, since unshackle resolves the others from it. Sending two returns `400`. `anilist_id` still combines with one of them. `tmdb_id` and `tvdb_id` must be positive integers, `imdb_id` must look like `tt1375666`, and `anilist_id` must be a positive integer or a string like `mal:12345`, or the request returns `400`. An ID whose provider is unconfigured, such as `tmdb_id` with no `tmdb_api_key`, fails the job rather than returning `400`. `anilist_id` needs no API key. | | `tvdb_order` | `official`, `dvd`, `absolute`, `alternate`, `regional` | `null` | Renumber episodes to a TVDB season order. Falls back to the `tvdb_order` config option. | +| `continuous_episodes` | boolean | `false` | Number each season on from the season before it, so season two starts at episode 5 after four episodes. Season 0 and episode 0 keep their numbers. `wanted` selects by the new numbers, but the list endpoints show the numbers of the service. | | `enrich` | boolean | `false` | Overwrite title, year and original language with the external source's. Needs one of `tmdb_id`, `imdb_id`, `tvdb_id` or `anilist_id`. Without one the job fails instead of returning `400`. | | `daily` | boolean | `false` | Treat the title as daily/date-based content and fill missing episode air dates from TVDB. The fill needs `enrich` and a TVDB ID. An air date the service already set is kept. | | `output_dir` | string | `null` | Output directory, relative to the server's downloads directory. The server rejects a path that resolves outside it with `400`. | diff --git a/docs/guide/cli-reference.md b/docs/guide/cli-reference.md index 2de93ec9..c39af300 100644 --- a/docs/guide/cli-reference.md +++ b/docs/guide/cli-reference.md @@ -172,6 +172,7 @@ Keep only certain track types, or skip certain track types. | `--enrich` | Overwrite show title, year and original language with the external source's. **Requires** one of `--tmdb`, `--imdb`, `--tvdb`, or `--anilist`. | | `--daily` | Treat the title as daily/date-based content and fill missing episode air dates from TVDB. The fill needs `--enrich` and a TVDB ID. See [Daily and date-based content](downloading.md#daily-and-date-based-content). | | `--tvdb-order` | Renumber episodes to a TVDB season order: `official` (aired), `dvd`, `absolute`, `alternate`, or `regional`. Needs `tvdb_api_key`. | +| `-ce` / `--continuous-episodes` | Number each season on from the season before it. After a season one of four episodes, season two starts at `S02E05`. Season 0 and episode 0 keep their numbers. | !!! note "One ID at a time" You cannot combine `--tmdb`, `--imdb` and `--tvdb`. Give one and unshackle finds the diff --git a/docs/guide/downloading.md b/docs/guide/downloading.md index d7a690a6..6133f808 100644 --- a/docs/guide/downloading.md +++ b/docs/guide/downloading.md @@ -1047,6 +1047,7 @@ lookups but keep the IDs you give, set | `--anilist` | `--anilist 21` | Use this AniList ID instead of an automatic search. `mal:12345` is also accepted. Needs no API key. | | `--enrich` | - | Overwrite the show title, year and original language with the external source's. **Requires** one of `--tmdb`, `--imdb`, `--tvdb`, or `--anilist`. | | `--tvdb-order` | `--tvdb-order dvd` | Renumber episodes to a TVDB season order. Needs `tvdb_api_key`. | +| `-ce` / `--continuous-episodes` | - | Number each season on from the season before it, so season two starts at `S02E05` after four episodes. | ```shell title="Force the right IMDb match, and keep the service's own naming" unshackle dl --imdb tt1375666 EXAMPLE 81234567 @@ -1148,6 +1149,23 @@ Available orders are `official` (aired), `dvd`, `absolute`, `alternate`, and `re two episodes the same season/episode slot, and so the same filename, unshackle logs an error and leaves the numbering untouched. Pick an order that covers the whole series. +### Continuous episode numbers + +Some anime release groups number each season on from the season before it. With +`--continuous-episodes`, a season two that follows a four-episode season one starts at +`S02E05`: + +```shell title="Number season two on from season one" +unshackle dl -ce EXAMPLE 81234567 +``` + +The count comes from the episodes the service lists, so download from the series URL, not +from a single season. A season the service does not list in your region is not counted. +Season 0 and episode 0 keep their numbers, and a season that already starts past the +season before it is not changed. The renumbering runs before `--wanted`, so select episodes +by their new numbers, for example `-w S02E05`. A whole-season selection such as `-w S05` +matches episodes up to E999 only. + ## Configuration defaults You can set every flag on this page as a default in your configuration file so you do not @@ -1202,3 +1220,4 @@ authoritative list. | `--export` | | Export track info and keys to JSON. | | `--tmdb` / `--imdb` / `--tvdb` / `--anilist` / `--enrich` | | Metadata overrides. | | `--tvdb-order` | | Renumber episodes to a TVDB season order. | +| `-ce` / `--continuous-episodes` | | Number each season on from the season before it. | diff --git a/docs/reference/configuration/output.md b/docs/reference/configuration/output.md index 5dd09e74..63ae2750 100644 --- a/docs/reference/configuration/output.md +++ b/docs/reference/configuration/output.md @@ -59,7 +59,9 @@ folder-kind keys. or `{episode}`. Any series with a TVDB absolute order can use it, anime most often. Services can supply it, and [`--enrich`](../../guide/downloading.md#metadata-and-tagging) fills it in from TVDB. - Use `{absolute?}` so the name stays clean when it is unknown. + Use `{absolute?}` so the name stays clean when it is unknown. To keep the season and + continue the episode number instead (`S02E05`), use + [`--continuous-episodes`](../../guide/downloading.md#continuous-episode-numbers). !!! tip "`sports` names an event by year or date" A service marks an event episode as sport, and that episode then uses the `sports` diff --git a/tests/core/test_continuous_episodes.py b/tests/core/test_continuous_episodes.py new file mode 100644 index 00000000..85b2877b --- /dev/null +++ b/tests/core/test_continuous_episodes.py @@ -0,0 +1,82 @@ +"""--continuous-episodes: each season is numbered on from the season before it.""" + +from __future__ import annotations + +from types import SimpleNamespace + +from unshackle.commands.dl import dl +from unshackle.core.remote_service import apply_title_fields +from unshackle.core.titles.episode import Episode, Series + + +class DummyService: + pass + + +def episode(season: int, number: int, part: int | None = None) -> Episode: + return Episode( + id_=f"ep-{season:02}-{number:02}-{part}", + service=DummyService, + title="The Show", + season=season, + number=number, + part=part, + ) + + +def renumber(series: Series) -> Series: + stub = SimpleNamespace(log=SimpleNamespace(info=lambda *a, **k: None)) + return dl.apply_continuous_episodes(stub, series) + + +def numbers(slots: list[tuple[int, int]]) -> list[tuple[int, int]]: + return [(t.season, t.number) for t in renumber(Series(episode(s, n) for s, n in slots))] + + +def test_season_two_starts_after_season_one(): + assert numbers([(1, 1), (1, 2), (1, 3), (1, 4), (2, 1), (2, 2), (3, 1)]) == [ + (1, 1), + (1, 2), + (1, 3), + (1, 4), + (2, 5), + (2, 6), + (3, 7), + ] + + +def test_specials_keep_their_numbers(): + assert numbers([(0, 1), (1, 1), (1, 2), (2, 1)]) == [(0, 1), (1, 1), (1, 2), (2, 3)] + + +def test_already_continuous_season_is_kept(): + assert numbers([(1, 1), (1, 2), (2, 3), (2, 4), (3, 1)]) == [(1, 1), (1, 2), (2, 3), (2, 4), (3, 5)] + + +def test_episode_zero_keeps_its_number_and_a_second_run_changes_nothing(): + once = numbers([(1, 1), (1, 2), (1, 3), (1, 4), (2, 0), (2, 1), (2, 2)]) + assert once == [(1, 1), (1, 2), (1, 3), (1, 4), (2, 0), (2, 5), (2, 6)] + assert numbers(once) == once + + +def test_episode_zero_does_not_make_a_continuous_season_move(): + slots = [(1, 1), (1, 2), (2, 0), (2, 3), (2, 4)] + assert numbers(slots) == slots + + +def test_parts_of_one_episode_move_together(): + series = renumber(Series([episode(1, 1), episode(1, 2), episode(2, 1, 1), episode(2, 1, 2)])) + assert [(t.season, t.number, t.part) for t in series] == [(1, 1, None), (1, 2, None), (2, 3, 1), (2, 3, 2)] + + +def test_the_rebuilt_series_finds_a_renumbered_episode(): + moved = episode(2, 1) + series = renumber(Series([episode(1, 1), moved])) + assert moved in series + assert series.index(moved) == 1 + + +def test_the_get_tracks_refresh_keeps_the_local_numbers(): + title = episode(2, 3) + apply_title_fields(title, {"season": 2, "number": 1, "name": "Pilot"}) + assert (title.season, title.number, title.name) == (2, 3, "Pilot") diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 6540859f..70de3ee2 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -20,7 +20,8 @@ from datetime import date, timedelta from functools import partial from http.cookiejar import CookieJar, MozillaCookieJar from io import StringIO -from itertools import product +from itertools import groupby, product +from operator import attrgetter from pathlib import Path from threading import Lock from typing import TYPE_CHECKING, Any, Callable, Collection, Optional, Sequence, TypedDict, Union, cast @@ -995,6 +996,13 @@ class dl: help="Renumber episodes to a TVDB season order. Services normally use 'official' (aired) order; " "a series' Streaming Order is 'alternate'. Defaults to the tvdb_order config option.", ) + @click.option( + "-ce", + "--continuous-episodes", + is_flag=True, + default=False, + help="Number each season on from the season before it, e.g. S02E05 after a four-episode season one.", + ) @click.option( "--anilist", "anilist_id", @@ -1284,6 +1292,7 @@ class dl: imdb_id: Optional[str] = None, tvdb_id: Optional[int] = None, tvdb_order: Optional[str] = None, + continuous_episodes: bool = False, anilist_id: Optional[Union[int, str]] = None, enrich: bool = False, daily: bool = False, @@ -1322,6 +1331,7 @@ class dl: imdb_id = ctx.params.get("imdb_id", imdb_id) tvdb_id = ctx.params.get("tvdb_id", tvdb_id) tvdb_order = ctx.params.get("tvdb_order", tvdb_order) + continuous_episodes = ctx.params.get("continuous_episodes", continuous_episodes) anilist_id = ctx.params.get("anilist_id", anilist_id) enrich = ctx.params.get("enrich", enrich) daily = ctx.params.get("daily", daily) @@ -1337,6 +1347,7 @@ class dl: self.tvdb_order = (tvdb_order or config.tvdb_order or "").lower() if self.tvdb_order and self.tvdb_order not in SEASON_TYPES: raise click.UsageError(f"tvdb_order must be one of {', '.join(SEASON_TYPES)}, not {self.tvdb_order!r}") + self.continuous_episodes = continuous_episodes self.enrich = enrich self.daily = daily self.output_dir = output_dir @@ -2063,6 +2074,9 @@ class dl: if self.tvdb_order and isinstance(titles, Series): titles = self.apply_tvdb_order(titles, title_cacher, cache_title_id, cache_region, cache_account_hash) + if self.continuous_episodes and isinstance(titles, Series): + titles = self.apply_continuous_episodes(titles) + console.print(Padding(Rule(f"[rule.text]{titles.__class__.__name__}: {titles}"), (1, 2))) console.print(Padding(titles.tree(verbose=list_titles), (0, 5))) @@ -4099,6 +4113,26 @@ class dl: if filled: self.log.info("Filled absolute numbers for %d episode(s) from TVDB (ID %s).", filled, tvdb_id) + def apply_continuous_episodes(self, titles: Series) -> Series: + """Number each season on from the last episode of the season before it. + + Season 0 and episode 0 keep their numbers, and a season that already starts past the season before it is kept. + The Series is rebuilt, as it does not re-sort an episode whose number changed. + """ + last = 0 + renumbered = 0 + for season, group in groupby(titles, key=attrgetter("season")): + episodes = [t for t in group if t.number] + if not season or not episodes: + continue + if min(t.number for t in episodes) <= last: + for t in episodes: + t.number += last + renumbered += len(episodes) + last = max(t.number for t in episodes) + self.log.info("Numbered %d episode(s) on from the season before.", renumbered) + return type(titles)(list(titles)) + def apply_tvdb_order( self, titles: Series, diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index 42a8fef2..d8e12a38 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -133,6 +133,7 @@ DEFAULT_DOWNLOAD_PARAMS = { "imdb_id": None, "tvdb_id": None, "tvdb_order": None, + "continuous_episodes": False, "anilist_id": None, "enrich": False, "daily": False, diff --git a/unshackle/core/api/routes.py b/unshackle/core/api/routes.py index 38b0f9a0..dd96d8ea 100644 --- a/unshackle/core/api/routes.py +++ b/unshackle/core/api/routes.py @@ -1184,6 +1184,9 @@ async def download(request: web.Request) -> web.Response: type: string enum: [official, dvd, absolute, alternate, regional] description: Renumber episodes to a TVDB season order (default - the tvdb_order config option) + continuous_episodes: + type: boolean + description: Number each season on from the season before it, e.g. S02E05 after a four-episode season one (default - false) output_dir: type: string description: > diff --git a/unshackle/core/remote_service.py b/unshackle/core/remote_service.py index f514482e..84b43a30 100644 --- a/unshackle/core/remote_service.py +++ b/unshackle/core/remote_service.py @@ -552,11 +552,12 @@ def parse_air_date(value: Any) -> Any: def apply_title_fields(title: Title_T, info: Dict[str, Any]) -> None: - """Copy every serialized title field onto a local title. + """Copy the serialized metadata fields onto a local title. A service can change its title inside ``get_tracks`` (a corrected original language, a real episode name, an air date), so the client re-applies the server's view of the - title after every call that returns one. + title after every call that returns one. Season, number and part are set only by + ``build_title``, as dl can renumber them after the listing. """ if info.get("language"): title.language = Language.get(info["language"]) @@ -570,9 +571,8 @@ def apply_title_fields(title: Title_T, info: Dict[str, Any]) -> None: title.title = info["series_title"] if "name" in info: title.name = episode_name(info["name"], title.title) - for key in ("season", "number", "part", "absolute"): - if info.get(key) is not None: - setattr(title, key, info[key]) + if info.get("absolute") is not None: + title.absolute = info["absolute"] if info.get("air_date") is not None: title.air_date = parse_air_date(info["air_date"]) elif info.get("name"): @@ -602,6 +602,7 @@ def build_title(info: Dict[str, Any], service_tag: str, fallback_id: str) -> Tit title=info.get("series_title", "Unknown"), season=info.get("season", 0), number=info.get("number", 0), + part=info.get("part"), language=lang, ) elif title_type == "movie":