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
+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