feat(dl): add --latest-episodes N to download the N newest episodes

Split episodes count once. Exposed as latest_episodes on the REST API. Closes #159.
This commit is contained in:
imSp4rky
2026-09-28 09:01:40 -06:00
parent c81c99fd96
commit 37e2b59bce
9 changed files with 140 additions and 24 deletions
+1
View File
@@ -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`. |
+1
View File
@@ -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
+12 -4
View File
@@ -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`. |
+61
View File
@@ -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
@@ -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
+27 -19
View File
@@ -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:
+1
View File
@@ -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"],
+2 -1
View File
@@ -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:
+4
View File
@@ -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: