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.
This commit is contained in:
imSp4rky
2026-11-11 08:38:27 -07:00
parent 9fff0a6646
commit 590a57113b
9 changed files with 151 additions and 7 deletions
+1
View File
@@ -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`. |
+1
View File
@@ -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
+19
View File
@@ -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. |
+3 -1
View File
@@ -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`
+82
View File
@@ -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")
+35 -1
View File
@@ -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,
+1
View File
@@ -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,
+3
View File
@@ -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: >
+6 -5
View File
@@ -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":