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