diff --git a/docs/dev/rest-api/endpoints.md b/docs/dev/rest-api/endpoints.md index 9607cdf2..6f66997f 100644 --- a/docs/dev/rest-api/endpoints.md +++ b/docs/dev/rest-api/endpoints.md @@ -429,6 +429,7 @@ Make a download job. It requires `service` and `title_id`. Every other field is | `no_atmos` | boolean | `false` | Exclude Atmos tracks. | | `wanted` | string / string[] | `[]` | Episode/season selectors, as a list or as one comma-separated string. Accepts the part form, `"S01E01.2"`, and the air-date form, `"2026-08-11"` or `"2026-08-01:2026-08-31"`. For a music release, a selector is a track number, `"1-5"` or `"1,3,7"`, or `"{disc}x{track}"` such as `"2x3"`. | | `latest_episode` | boolean | `false` | Only the newest episode. | +| `latest_episodes` | integer | `null` | Only the N newest episodes. A positive integer. When set, it overrides `latest_episode`. | | `lang` / `v_lang` / `a_lang` / `s_lang` | string[] | `["orig"]` / `[]` / `[]` / `["all"]` | Language filters. | | `require_audio` | string[] | `[]` | Audio languages that must exist. The job fails if one is missing, even with `best_available`. | | `require_video` | string[] | `[]` | Video languages that must exist. The job fails if one is missing, even with `best_available`. | diff --git a/docs/guide/cli-reference.md b/docs/guide/cli-reference.md index a0840edd..b6e74086 100644 --- a/docs/guide/cli-reference.md +++ b/docs/guide/cli-reference.md @@ -117,6 +117,7 @@ unshackle honours the special language tokens `orig`, `all`, and `best` everywhe | `-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. For a [dated episode](downloading.md#daily-and-date-based-content), a token can also be an ISO air date (`2026-08-11`) or a date range with a colon (`2026-08-01:2026-08-31`). For a [music release](downloading.md#music-tracks), a token is a track number (`1-5`, `1,3,7`), or `{disc}x{track}` (`2x3`) when the release has more than one disc. | | `--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. | +| `--latest-episodes N` | Download only the N most recent episodes. | | `--list-titles` | List titles only; do not download. | ### Track-type inclusion / exclusion diff --git a/docs/guide/downloading.md b/docs/guide/downloading.md index 0c85c4c6..6dd032d4 100644 --- a/docs/guide/downloading.md +++ b/docs/guide/downloading.md @@ -55,7 +55,7 @@ with `--skip-dl`. 2. **Authenticate**: sign in to the service with your profile's cookies/credentials. 3. **Fetch titles**: retrieve the movie, episode list, or album (cached unless `--no-cache`/`--reset-cache`). -4. **Filter titles**: apply `--wanted`, `--latest-episode`, or `--select-titles`. +4. **Filter titles**: apply `--wanted`, `--latest-episode`, `--latest-episodes`, or `--select-titles`. 5. **Get tracks**: parse the manifest into video, audio, subtitle, and chapter tracks. 6. **Select tracks**: narrow each type down using your quality, codec, range, language, and bitrate flags. @@ -65,7 +65,7 @@ with `--skip-dl`. `mkvmerge`, and move the finished file to your downloads directory. `--list` prints the tracks a title exposes and stops before selection. `--list-titles` -prints every title the service returned and stops before `--wanted`/`--latest-episode` +prints every title the service returned and stops before `--wanted`/`--latest-episode`/`--latest-episodes` filtering. `--skip-dl` runs the license step but skips the actual segment download. ## Choosing quality @@ -600,12 +600,19 @@ when you want a part on its own. | --- | --- | | `--select-titles` | Interactively pick 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 every title the service returned, then stop. `-w`/`--latest-episode` are not applied to this listing. | +| `--latest-episodes N` | Download only the N most recent episodes. The newest episodes can come from more than one season. A [split episode](#split-episodes) counts as one episode, and all its parts download. | +| `--list-titles` | List every title the service returned, then stop. `-w`, `--latest-episode` and `--latest-episodes` are not applied to this listing. | + +When `--latest-episode` or `--latest-episodes` is set, `-w` has no effect. ```shell title="Grab just the newest episode" unshackle dl --latest-episode EXAMPLE 81234567 ``` +```shell title="Grab the three newest episodes" +unshackle dl --latest-episodes 3 EXAMPLE 81234567 +``` + ## Including and excluding track types You can restrict a download to certain track categories, either positively (only these) @@ -653,7 +660,7 @@ Before committing to a long download, inspect what unshackle *would* do: | Flag | Effect | | --- | --- | | `--list` | List the tracks the service exposes for each title, then stop. No selection, no download. | -| `--list-titles` | List every title the service returned, then stop. `-w`/`--latest-episode` are not applied to this listing. | +| `--list-titles` | List every title the service returned, then stop. `-w`, `--latest-episode` and `--latest-episodes` are not applied to this listing. | | `--skip-dl` | Skip downloading but still acquire the decryption keys. | ```shell title="See the track selection without downloading" @@ -1002,6 +1009,7 @@ authoritative list. | `--daily` | | Fill missing air dates from TVDB during `--enrich`. | | `--select-titles` | | Interactively pick episodes or films. | | `--latest-episode` | | Only the newest episode. | +| `--latest-episodes N` | | Only the N newest episodes. | | `--video-only` / `--audio-only` / `--subs-only` | `-V` / `-A` / `-S` | Restrict track types. | | `--no-video` / `--no-audio` / `--no-subs` / `--no-chapters` / `--no-attachments` | `-nv` / `-na` / `-ns` / `-nc` / `-nt` | Skip track types. | | `--worst` | | Lowest bitrate within `-q`. | diff --git a/tests/core/test_latest_episodes.py b/tests/core/test_latest_episodes.py new file mode 100644 index 00000000..b32584b1 --- /dev/null +++ b/tests/core/test_latest_episodes.py @@ -0,0 +1,61 @@ +"""Selection for --latest-episode / --latest-episodes and its REST API validation.""" + +from __future__ import annotations + +from typing import Optional + +import pytest + +from unshackle.commands.dl import latest_episode_keys +from unshackle.core.api.handlers import validate_download_parameters +from unshackle.core.titles import Movie, Movies, Series +from unshackle.core.titles.episode import Episode + + +class Svc: + pass + + +def episode(season: int, number: int, part: Optional[int] = None) -> Episode: + return Episode(id_=f"ep-{season}x{number}.{part}", service=Svc, title="T", season=season, number=number, part=part) + + +def series() -> Series: + # out of order on purpose: the Series sorts itself + return Series(episode(s, n) for s, n in [(2, 1), (1, 1), (2, 3), (1, 2), (2, 2)]) + + +@pytest.mark.parametrize( + ("latest_episode", "latest_episodes", "expected"), + [ + (False, None, []), + (True, None, [(2, 3)]), + (False, 3, [(2, 1), (2, 2), (2, 3)]), + (True, 2, [(2, 2), (2, 3)]), + (False, 99, [(1, 1), (1, 2), (2, 1), (2, 2), (2, 3)]), + ], +) +def test_the_newest_episodes_are_picked_across_seasons( + latest_episode: bool, latest_episodes: Optional[int], expected: list[tuple[int, int]] +) -> None: + assert latest_episode_keys(series(), latest_episode, latest_episodes) == expected + + +def test_a_split_episode_takes_one_slot() -> None: + titles = Series([episode(1, 4), episode(1, 5, part=1), episode(1, 5, part=2)]) + assert latest_episode_keys(titles, False, 2) == [(1, 4), (1, 5)] + assert latest_episode_keys(titles, True, None) == [(1, 5)] + + +def test_titles_that_are_not_a_series_are_left_alone() -> None: + movies = Movies([Movie(id_="movie-1", service=Svc, name="M")]) + assert latest_episode_keys(movies, True, 3) == [] + + +@pytest.mark.parametrize("value", [0, -1, True, "3", 1.5]) +def test_a_non_positive_or_non_integer_count_is_refused(value: object) -> None: + assert validate_download_parameters({"latest_episodes": value}) == "latest_episodes must be a positive integer" + + +def test_a_positive_count_is_accepted() -> None: + assert validate_download_parameters({"latest_episodes": 3}) is None diff --git a/tests/remote/unit/test_download_manager.py b/tests/remote/unit/test_download_manager.py index d5b62e8f..2fbbb147 100644 --- a/tests/remote/unit/test_download_manager.py +++ b/tests/remote/unit/test_download_manager.py @@ -227,6 +227,37 @@ def test_perform_download_puts_the_lang_selection_in_the_service_ctx( assert (p["lang"], p["v_lang"], p["a_lang"], [c.name for c in p["acodec"]], p["forced_subs"]) == expected +def test_perform_download_passes_latest_episodes_to_dl_result(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + import click + + import unshackle.commands.dl as dl_module + from unshackle.core.api import download_manager, handlers + from unshackle.core.services import Services + + class FakeDl: + cli = click.Command("dl") + + def __init__(self, ctx: click.Context, **kwargs: object) -> None: + pass + + def result(self, **kwargs: object) -> None: + raise _CapturedCtx(kwargs) + + class FakeService: + def __init__(self, ctx: click.Context) -> None: + pass + + monkeypatch.setattr(dl_module, "dl", FakeDl) + monkeypatch.setattr(Services, "get_path", staticmethod(lambda s: tmp_path)) + monkeypatch.setattr(Services, "load", staticmethod(lambda s: FakeService)) + monkeypatch.setattr(handlers, "load_full_cdm", lambda *a: None) + + with pytest.raises(_CapturedCtx) as exc: + download_manager.perform_download("job-1", "EXAMPLE", "t1", {"latest_episodes": 3}) + + assert exc.value.args[0]["latest_episodes"] == 3 + + def test_worker_relays_a_prompt_and_reads_the_answer(monkeypatch: pytest.MonkeyPatch) -> None: import json import os diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index b9558e14..1b5cf0d3 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -214,6 +214,18 @@ def title_wanted(candidate: Any, wanted: Collection[str]) -> bool: return bool(candidate.matches_wanted(wanted)) +def latest_episode_keys(titles: Any, latest_episode: bool, latest_episodes: Optional[int]) -> list[tuple[int, int]]: + """The (season, number) of each episode ``--latest-episode(s)`` keeps, oldest first. + + Counts distinct episodes, not Series entries, so a split episode takes one slot and all its parts download. + """ + count = latest_episodes or (1 if latest_episode else 0) + if not count or not isinstance(titles, Series): + return [] + # a Series keeps itself sorted, so the newest episodes are last + return list(dict.fromkeys((ep.season, ep.number) for ep in titles))[-count:] + + def server_url(server_name: Optional[str]) -> str: """The configured URL of a remote server, or an empty string when the config cannot be read.""" from unshackle.core.remote_service import resolve_server @@ -644,6 +656,12 @@ class dl: default=False, help="Download only the single most recent episode available.", ) + @click.option( + "--latest-episodes", + type=click.IntRange(min=1), + default=None, + help="Download only the N most recent episodes available.", + ) @click.option( "-vl", "--v-lang", @@ -1431,6 +1449,7 @@ class dl: select_titles: bool, wanted: list[str], latest_episode: bool, + latest_episodes: Optional[int], lang: list[str], v_lang: list[str], a_lang: list[str], @@ -1898,13 +1917,12 @@ class dl: count = len(titles) console.print(Padding(f"[text]Total selected: {count}[/]", (0, 5))) - latest_episode_id = None - if latest_episode and isinstance(titles, Series) and len(titles) > 0: - # Series is already sorted by (season, number, year) - # The last episode in the sorted list is the latest - latest_ep = titles[-1] - latest_episode_id = f"{latest_ep.season}x{latest_ep.number}" - self.log.info(f"Latest episode mode: Selecting S{latest_ep.season:02}E{latest_ep.number:02}") + latest_keys = latest_episode_keys(titles, latest_episode, latest_episodes) + if latest_keys: + picked = ", ".join(f"S{season:02}E{number:02}" for season, number in latest_keys) + self.log.info(f"Latest episode mode: Selecting {picked}") + # replaces -w; a base SxE key selects every part of a split episode + wanted = [f"{season}x{number}" for season, number in latest_keys] def select_best_audio( audio: list[Audio], @@ -1955,15 +1973,9 @@ class dl: base_selection = (v_lang, a_lang, s_lang, range_) - def post_script_queued(candidate: Any) -> bool: - """Mirror of the filters the loop below applies, so the season counter matches it.""" - if isinstance(candidate, Episode) and latest_episode and latest_episode_id: - return f"{candidate.season}x{candidate.number}" == latest_episode_id - return title_wanted(candidate, wanted) - post_script_pending: dict[Any, int] = {} for candidate in titles: - if post_script_queued(candidate): + if title_wanted(candidate, wanted): key = post_script_group(candidate) post_script_pending[key] = post_script_pending.get(key, 0) + 1 post_script_last: dict[Any, dict[Path, dict[str, str]]] = {} @@ -1972,11 +1984,7 @@ class dl: for i, title in enumerate(titles): v_lang, a_lang, s_lang, range_ = base_selection - if isinstance(title, Episode) and latest_episode and latest_episode_id: - # If --latest-episode is set, only process the latest episode - if f"{title.season}x{title.number}" != latest_episode_id: - continue - elif not title_wanted(title, wanted): + if not title_wanted(title, wanted): continue if progress_sink: diff --git a/unshackle/core/api/download_manager.py b/unshackle/core/api/download_manager.py index 485d2559..875ab2c0 100644 --- a/unshackle/core/api/download_manager.py +++ b/unshackle/core/api/download_manager.py @@ -591,6 +591,7 @@ def perform_download( select_titles=False, wanted=params.get("wanted", []), latest_episode=params.get("latest_episode", False), + latest_episodes=params.get("latest_episodes"), lang=ctx.params["lang"], v_lang=ctx.params["v_lang"], a_lang=ctx.params["a_lang"], diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index 0120f369..40a97a75 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -55,6 +55,7 @@ DEFAULT_DOWNLOAD_PARAMS = { "no_atmos": False, "wanted": [], "latest_episode": False, + "latest_episodes": None, "lang": ["orig"], "v_lang": [], "a_lang": [], @@ -1871,7 +1872,7 @@ def validate_download_parameters(data: Dict[str, Any]) -> Optional[str]: if not isinstance(data["downloads"], int) or data["downloads"] <= 0: return "downloads must be a positive integer" - for name in ("tmdb_id", "tvdb_id"): + for name in ("tmdb_id", "tvdb_id", "latest_episodes"): if data.get(name) is not None: value = data[name] if isinstance(value, bool) or not isinstance(value, int) or value <= 0: diff --git a/unshackle/core/api/routes.py b/unshackle/core/api/routes.py index dcc7afde..ab45d578 100644 --- a/unshackle/core/api/routes.py +++ b/unshackle/core/api/routes.py @@ -853,6 +853,10 @@ async def download(request: web.Request) -> web.Response: latest_episode: type: boolean description: Download only the single most recent episode (default - false) + latest_episodes: + type: integer + minimum: 1 + description: Download only the N most recent episodes; overrides latest_episode (default - null) lang: type: array items: