mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-14 21:15:06 +00:00
feat(titles): add optional Episode.part, selected with -w s1e1.2
Opt-in and additive: an episode without a part behaves exactly as before.
This commit is contained in:
@@ -3,10 +3,11 @@ services set via the ``sub_format`` override (must not be rejected as an invalid
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import click
|
||||
import pytest
|
||||
|
||||
from unshackle.core.tracks.subtitle import Subtitle
|
||||
from unshackle.core.utils.click_types import QUALITY_LIST, SLOW_DELAY_RANGE, SubtitleCodecChoice
|
||||
from unshackle.core.utils.click_types import QUALITY_LIST, SLOW_DELAY_RANGE, SeasonRange, SubtitleCodecChoice
|
||||
|
||||
choice = SubtitleCodecChoice(Subtitle.Codec)
|
||||
|
||||
@@ -57,3 +58,76 @@ def test_quality_list_accepts_yaml_native_values(value, expected):
|
||||
)
|
||||
def test_slow_delay_range_accepts_bool(value, expected):
|
||||
assert SLOW_DELAY_RANGE.convert(value, None, None) == expected
|
||||
|
||||
|
||||
# --- SeasonRange ------------------------------------------------------------
|
||||
|
||||
|
||||
def parse(*tokens: str) -> list[str]:
|
||||
return SeasonRange().parse_tokens(*tokens)
|
||||
|
||||
|
||||
def test_single_episode_token_is_unchanged():
|
||||
assert parse("S01E01") == ["1x1"]
|
||||
|
||||
|
||||
def test_bare_season_spans_the_whole_episode_range():
|
||||
"""A bare S01 reads the one match object with a different default per side."""
|
||||
keys = parse("S01")
|
||||
assert len(keys) == 1000
|
||||
assert "1x0" in keys and "1x999" in keys
|
||||
|
||||
|
||||
def test_multi_token_range_is_unchanged():
|
||||
assert sorted(parse("S02E01", "S02E03-S02E05")) == ["2x1", "2x3", "2x4", "2x5"]
|
||||
|
||||
|
||||
def test_exclusions_are_unchanged():
|
||||
keys = parse("S01-S05", "-S03", "-S02E01")
|
||||
assert not any(k.startswith("3x") for k in keys)
|
||||
assert "2x1" not in keys
|
||||
assert "2x0" in keys and "2x2" in keys
|
||||
assert len(keys) == 3999 # 5 seasons less all of S03 less 2x1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tokens", [("S01E01",), ("S01",), ("S01-S05", "-S03", "-S02E01"), ("S02E03-S02E05",)])
|
||||
def test_part_less_tokens_never_emit_a_part_or_exclusion_key(tokens):
|
||||
assert all("." not in k and "!" not in k for k in parse(*tokens))
|
||||
|
||||
|
||||
def test_part_token():
|
||||
assert parse("S01E01.2") == ["1x1.2"]
|
||||
|
||||
|
||||
def test_part_range_within_one_episode():
|
||||
assert sorted(parse("S01E01.1-S01E01.3")) == ["1x1.1", "1x1.2", "1x1.3"]
|
||||
|
||||
|
||||
def test_part_exclusion_becomes_a_negative_key():
|
||||
keys = parse("S01", "-S01E01.2")
|
||||
assert "!1x1.2" in keys
|
||||
assert "1x1" in keys # base key stays so the other parts still match
|
||||
|
||||
|
||||
def test_base_exclusion_covers_part_keys():
|
||||
assert parse("S01E01.1-S01E01.3", "S01E02", "-S01E01") == ["1x2"]
|
||||
|
||||
|
||||
def test_exclusion_removes_duplicate_keys():
|
||||
# "S01" and "S01E01" both emit 1x1; the exclusion must remove every copy
|
||||
assert "1x1" not in parse("S01", "S01E01", "-S01E01")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"token",
|
||||
[
|
||||
"S01E01.0", # parts count from 1
|
||||
"S01E01.1-S01E02.2", # a part range cannot cross episodes
|
||||
"S01-S01E01.2", # a part on only one side
|
||||
"S01E01.3-S01E01.1", # reversed part range
|
||||
"S01E01.200", # above MAX_PART
|
||||
],
|
||||
)
|
||||
def test_bad_part_tokens_fail(token):
|
||||
with pytest.raises(click.UsageError):
|
||||
parse(token)
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
"""Tests for multi-part episodes: Episode.part, its naming, sorting and -w matching."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from unshackle.core.config import config
|
||||
from unshackle.core.titles.episode import Episode, Series
|
||||
from unshackle.core.utils.click_types import SeasonRange
|
||||
|
||||
|
||||
class DummyService:
|
||||
pass
|
||||
|
||||
|
||||
class StubMediaInfo:
|
||||
"""Minimal MediaInfo stand-in: the template context only reads track lists."""
|
||||
|
||||
video_tracks: list = []
|
||||
audio_tracks: list = []
|
||||
|
||||
|
||||
def make_episode(**overrides) -> Episode:
|
||||
kwargs = dict(
|
||||
id_="ep-0001",
|
||||
service=DummyService,
|
||||
title="The Show",
|
||||
season=1,
|
||||
number=1,
|
||||
name="The Reckoning",
|
||||
)
|
||||
kwargs.update(overrides)
|
||||
return Episode(**kwargs)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reset_template():
|
||||
saved = (config.output_template, config.folder_templates, config.folder_template, config.tag)
|
||||
config.output_template = {"series": "{title}.{season_episode}.{quality?}.{source}-{tag}"}
|
||||
config.folder_templates = {}
|
||||
config.folder_template = ""
|
||||
config.tag = "GROUP"
|
||||
yield config
|
||||
config.output_template, config.folder_templates, config.folder_template, config.tag = saved
|
||||
|
||||
|
||||
# --- validation -------------------------------------------------------------
|
||||
|
||||
|
||||
def test_part_defaults_to_none():
|
||||
assert make_episode().part is None
|
||||
|
||||
|
||||
def test_digit_string_part_coerces():
|
||||
assert make_episode(part="2").part == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [0, -1])
|
||||
def test_non_positive_part_rejected(bad):
|
||||
with pytest.raises(ValueError):
|
||||
make_episode(part=bad)
|
||||
|
||||
|
||||
def test_non_int_part_rejected():
|
||||
with pytest.raises(TypeError):
|
||||
make_episode(part="x")
|
||||
|
||||
|
||||
def test_bool_part_rejected():
|
||||
"""bool is an int subclass; True would render as '.True' in keys and filenames."""
|
||||
with pytest.raises(TypeError):
|
||||
make_episode(part=True)
|
||||
|
||||
|
||||
def test_old_cache_entry_reads_as_part_less():
|
||||
"""jsonpickle restores by populating __dict__; an older entry has no 'part' key."""
|
||||
restored = Episode.__new__(Episode)
|
||||
restored.__dict__.update(title="The Show", season=1, number=1, name=None, year=None)
|
||||
assert restored.part is None
|
||||
|
||||
|
||||
# --- display ----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_str_is_unchanged_without_a_part():
|
||||
without = make_episode()
|
||||
assert str(without) == "The Show S01E01 The Reckoning"
|
||||
|
||||
|
||||
def test_str_shows_the_selection_syntax():
|
||||
assert str(make_episode(part=2)) == "The Show S01E01.2 The Reckoning"
|
||||
|
||||
|
||||
def test_str_tells_dated_parts_apart():
|
||||
# dated content renders the air date instead of SxxExx, so without the suffix both
|
||||
# parts print the same string while their filenames differ
|
||||
assert str(make_episode(part=2, air_date="2024-05-06")) == "The Show 2024.05.06.2 The Reckoning"
|
||||
assert str(make_episode(air_date="2024-05-06")) == "The Show 2024.05.06 The Reckoning"
|
||||
|
||||
|
||||
def test_tree_label_carries_the_part_on_dated_content():
|
||||
series = Series([make_episode(part=1, air_date="2024-05-06"), make_episode(part=2, air_date="2024-05-06")])
|
||||
labels = [node.label for node in series.tree(verbose=True).children[0].children]
|
||||
assert labels == [
|
||||
"[bold]2024.05.06.1.[/] [bright_black]The Reckoning",
|
||||
"[bold]2024.05.06.2.[/] [bright_black]The Reckoning",
|
||||
]
|
||||
|
||||
|
||||
def test_tree_label_carries_the_part():
|
||||
series = Series([make_episode(part=1), make_episode(part=2)])
|
||||
labels = [node.label for node in series.tree(verbose=True).children[0].children]
|
||||
assert labels == ["[bold]1.1.[/] [bright_black]The Reckoning", "[bold]1.2.[/] [bright_black]The Reckoning"]
|
||||
|
||||
|
||||
def test_series_sorts_parts_within_their_episode():
|
||||
series = Series(
|
||||
[
|
||||
make_episode(number=2),
|
||||
make_episode(number=1, part=2),
|
||||
make_episode(number=1),
|
||||
make_episode(number=1, part=1),
|
||||
]
|
||||
)
|
||||
assert [(e.number, e.part) for e in series] == [(1, None), (1, 1), (1, 2), (2, None)]
|
||||
|
||||
|
||||
# --- naming -----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_filename_is_unchanged_without_a_part(reset_template):
|
||||
assert make_episode().get_filename(StubMediaInfo()) == "The.Show.S01E01.DummyService-GROUP"
|
||||
|
||||
|
||||
def test_dotted_template_folds_the_part_after_the_episode(reset_template):
|
||||
name = make_episode(part=2).get_filename(StubMediaInfo())
|
||||
assert name == "The.Show.S01E01.Part.2.DummyService-GROUP"
|
||||
assert name.endswith("-GROUP") # group tag stays last
|
||||
|
||||
|
||||
def test_spaced_template_uses_spaces(reset_template):
|
||||
reset_template.output_template = {"series": "{title} {season_episode} {quality?} {source}-{tag}"}
|
||||
assert make_episode(part=2).get_filename(StubMediaInfo()) == "The Show S01E01 Part 2 DummyService-GROUP"
|
||||
|
||||
|
||||
def test_template_context_leaves_the_season_token_alone(reset_template):
|
||||
ctx = make_episode(part=2)._build_template_context(StubMediaInfo())
|
||||
assert ctx["season"] == "S01"
|
||||
assert ctx["episode"] == "E01.Part.2"
|
||||
assert ctx["season_episode"] == "S01E01.Part.2"
|
||||
assert ctx["part"] == 2
|
||||
|
||||
|
||||
def test_part_token_is_empty_without_a_part(reset_template):
|
||||
assert make_episode()._build_template_context(StubMediaInfo())["part"] == ""
|
||||
|
||||
|
||||
def test_parts_share_one_season_folder(reset_template):
|
||||
# {season_episode} in a folder template would otherwise carry the part into the path
|
||||
reset_template.folder_template = "{title}/{season_episode}"
|
||||
folders = {make_episode(part=p).get_filename(StubMediaInfo(), folder=True) for p in (1, 2)}
|
||||
assert folders == {"The.Show/S01E01"}
|
||||
|
||||
|
||||
def test_parts_share_one_derived_season_folder(reset_template):
|
||||
folders = {make_episode(part=p).get_filename(StubMediaInfo(), folder=True) for p in (1, 2)}
|
||||
assert len(folders) == 1
|
||||
assert folders == {make_episode().get_filename(StubMediaInfo(), folder=True)}
|
||||
|
||||
|
||||
# --- matching ---------------------------------------------------------------
|
||||
|
||||
# the worked key scheme from the design: (token, part-less E1 selected, E1 part 2 selected)
|
||||
MATCH_TABLE = [
|
||||
(("S01",), True, True),
|
||||
(("S01E01",), True, True),
|
||||
(("S01E01.2",), False, True),
|
||||
(("S01E01.1-S01E01.3",), False, True),
|
||||
(("S01", "-S01E01.2"), True, False),
|
||||
(("S01", "-S01E01"), False, False),
|
||||
(("S01E02",), False, False),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tokens,wants_plain,wants_part2", MATCH_TABLE)
|
||||
def test_matches_wanted_table(tokens, wants_plain, wants_part2):
|
||||
wanted = SeasonRange().parse_tokens(*tokens)
|
||||
assert make_episode().matches_wanted(wanted) is wants_plain
|
||||
assert make_episode(part=2).matches_wanted(wanted) is wants_part2
|
||||
|
||||
|
||||
def test_a_part_exclusion_leaves_the_other_parts_alone():
|
||||
wanted = SeasonRange().parse_tokens("S01", "-S01E01.2")
|
||||
assert make_episode(part=1).matches_wanted(wanted) is True
|
||||
assert make_episode(part=3).matches_wanted(wanted) is True
|
||||
|
||||
|
||||
def test_asking_for_a_part_of_an_unsplit_episode_selects_nothing():
|
||||
assert make_episode().matches_wanted(SeasonRange().parse_tokens("S01E01.2")) is False
|
||||
@@ -171,3 +171,62 @@ def test_get_episodes_does_not_cache_an_empty_listing(monkeypatch: pytest.Monkey
|
||||
monkeypatch.setattr(p, "_get", lambda _path, params: None)
|
||||
assert p.get_episodes(73871, "official") == []
|
||||
assert p._episodes == {}
|
||||
|
||||
|
||||
# --- apply_tvdb_order over multi-part episodes -------------------------------
|
||||
|
||||
|
||||
class _Svc:
|
||||
pass
|
||||
|
||||
|
||||
class _FakeProvider:
|
||||
def __init__(self, mapping: dict) -> None:
|
||||
self.mapping = mapping
|
||||
|
||||
def detect_order(self, _id, _keys) -> str:
|
||||
return "official"
|
||||
|
||||
def get_order_map(self, _id, _order, source_order=None) -> dict:
|
||||
return self.mapping
|
||||
|
||||
|
||||
def _renumber(monkeypatch: pytest.MonkeyPatch, episodes: list, mapping: dict):
|
||||
from types import SimpleNamespace
|
||||
|
||||
from unshackle.commands.dl import dl
|
||||
from unshackle.core.titles.episode import Series
|
||||
|
||||
monkeypatch.setattr(providers, "get_provider", lambda _name: _FakeProvider(mapping))
|
||||
errors: list = []
|
||||
stub = SimpleNamespace(
|
||||
tvdb_id=73871,
|
||||
tvdb_order="dvd",
|
||||
service="STUB",
|
||||
log=SimpleNamespace(warning=lambda *a, **k: None, info=lambda *a, **k: None, error=lambda *a: errors.append(a)),
|
||||
)
|
||||
return dl.apply_tvdb_order(stub, Series(episodes)), errors
|
||||
|
||||
|
||||
def _ep(season: int, number: int, part=None):
|
||||
from unshackle.core.titles.episode import Episode
|
||||
|
||||
return Episode(id_=f"{season}x{number}.{part}", service=_Svc, title="Show", season=season, number=number, part=part)
|
||||
|
||||
|
||||
def test_parts_of_one_episode_renumber_together(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Three parts share one (season, number) slot, so they must not read as a collision."""
|
||||
episodes = [_ep(1, 1, 1), _ep(1, 1, 2), _ep(1, 1, 3), _ep(1, 2)]
|
||||
mapping = {(1, 1): (2, 1, "Part-ful"), (1, 2): (2, 2, "Whole")}
|
||||
titles, errors = _renumber(monkeypatch, episodes, mapping)
|
||||
assert errors == []
|
||||
assert [(t.season, t.number, t.part) for t in titles] == [(2, 1, 1), (2, 1, 2), (2, 1, 3), (2, 2, None)]
|
||||
|
||||
|
||||
def test_a_real_collision_still_bails(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Dedupe must not disable the guard: two distinct episodes on one slot still refuses."""
|
||||
episodes = [_ep(1, 1), _ep(1, 2)]
|
||||
mapping = {(1, 2): (1, 1, "Clash")} # (1, 1) is unmapped and keeps the slot (1, 2) moves onto
|
||||
titles, errors = _renumber(monkeypatch, episodes, mapping)
|
||||
assert errors # refused, with the "two episodes each" error
|
||||
assert [(t.season, t.number) for t in titles] == [(1, 1), (1, 2)] # numbering untouched
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
"""Unit tests for multi-part episode support across the REST/API layer.
|
||||
|
||||
Covers serialize_title's conditional `part` key, the list-tracks wanted filter
|
||||
routing through Episode.matches_wanted, the discrete season/episode/part request
|
||||
fields, and download_manager's already-internal wanted sniff.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import pytest
|
||||
|
||||
from unshackle.core.api import handlers
|
||||
from unshackle.core.api.errors import APIError
|
||||
from unshackle.core.api.handlers import serialize_title
|
||||
from unshackle.core.titles.episode import Episode
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
class _FakeSvc:
|
||||
pass
|
||||
|
||||
|
||||
def _ep(season: int = 1, number: int = 1, part: Optional[int] = None, **overrides: Any) -> Episode:
|
||||
base: Dict[str, Any] = dict(
|
||||
id_=f"ep-{season}-{number}-{part or 0}",
|
||||
service=_FakeSvc,
|
||||
title="My Show",
|
||||
season=season,
|
||||
number=number,
|
||||
name="Pilot",
|
||||
year=2024,
|
||||
language=None,
|
||||
)
|
||||
base.update(overrides)
|
||||
return Episode(part=part, **base)
|
||||
|
||||
|
||||
class _FakeTracks:
|
||||
videos: List[Any] = []
|
||||
audio: List[Any] = []
|
||||
subtitles: List[Any] = []
|
||||
|
||||
|
||||
class _FakeService:
|
||||
def __init__(self, titles: List[Episode]) -> None:
|
||||
self._titles = titles
|
||||
|
||||
def get_titles(self) -> List[Episode]:
|
||||
return self._titles
|
||||
|
||||
def get_tracks(self, title: Episode) -> _FakeTracks:
|
||||
return _FakeTracks()
|
||||
|
||||
|
||||
def _run_list_tracks(monkeypatch: pytest.MonkeyPatch, titles: List[Episode], **data: Any) -> Dict[str, Any]:
|
||||
"""Drive list_tracks_handler end to end with the service layer stubbed out."""
|
||||
monkeypatch.setattr(handlers, "validate_service", lambda tag, request=None: "FAKE")
|
||||
monkeypatch.setattr(handlers, "setup_list_service", lambda *a, **kw: _FakeService(titles))
|
||||
payload = {"service": "FAKE", "title_id": "t1", **data}
|
||||
try:
|
||||
response = asyncio.run(handlers.list_tracks_handler(payload))
|
||||
except APIError as e:
|
||||
# routes.py turns this into the HTTP error body; keep the fields the tests assert on
|
||||
return {"status": e.http_status, "error_code": e.error_code, "details": e.details}
|
||||
return json.loads(response.body)
|
||||
|
||||
|
||||
def _selected(result: Dict[str, Any]) -> List[str]:
|
||||
"""Selection-syntax keys of whatever the handler returned, single or multi."""
|
||||
entries = result["episodes"] if "episodes" in result else [{"title": result["title"]}]
|
||||
keys = []
|
||||
for entry in entries:
|
||||
t = entry["title"]
|
||||
suffix = f".{t['part']}" if "part" in t else ""
|
||||
keys.append(f"{t['season']}x{t['number']}{suffix}")
|
||||
return keys
|
||||
|
||||
|
||||
# ---------- serialize_title: the JSON contract ----------
|
||||
|
||||
|
||||
def test_partless_episode_json_has_no_part_key() -> None:
|
||||
d = serialize_title(_ep(part=None))
|
||||
assert "part" not in d
|
||||
assert list(d.keys()) == [
|
||||
"type",
|
||||
"name",
|
||||
"id",
|
||||
"language",
|
||||
"description",
|
||||
"date",
|
||||
"cover_url",
|
||||
"year",
|
||||
"series_title",
|
||||
"season",
|
||||
"number",
|
||||
]
|
||||
assert d["name"] == "Pilot"
|
||||
|
||||
|
||||
def test_partful_episode_json_carries_part_last() -> None:
|
||||
d = serialize_title(_ep(part=2))
|
||||
assert d["part"] == 2
|
||||
# additive: `part` is appended, every pre-existing key keeps its position
|
||||
assert list(d.keys())[:11] == list(serialize_title(_ep(part=None)).keys())
|
||||
assert list(d.keys())[11] == "part"
|
||||
|
||||
|
||||
def test_part_stays_out_of_the_name_field() -> None:
|
||||
"""`name` is a reconstruction input for _build_title, not a display string."""
|
||||
assert serialize_title(_ep(part=2))["name"] == "Pilot"
|
||||
assert serialize_title(_ep(part=3, name=None))["name"] == "Episode 01"
|
||||
|
||||
|
||||
def test_remote_build_title_round_trip() -> None:
|
||||
from unshackle.core.remote_service import _build_title
|
||||
|
||||
rebuilt = _build_title(serialize_title(_ep(1, 4, 2)), "FAKE", "fallback-id")
|
||||
assert (rebuilt.season, rebuilt.number, rebuilt.part, rebuilt.name) == (1, 4, 2, "Pilot")
|
||||
|
||||
partless = _build_title(serialize_title(_ep(1, 4, None)), "FAKE", "fallback-id")
|
||||
assert partless.part is None
|
||||
|
||||
|
||||
def test_movie_json_unaffected() -> None:
|
||||
from langcodes import Language
|
||||
|
||||
from unshackle.core.titles.movie import Movie
|
||||
|
||||
d = serialize_title(Movie(id_="movie-0001", service=_FakeSvc, name="Film", year=2024, language=Language.get("en")))
|
||||
assert "part" not in d
|
||||
|
||||
|
||||
# ---------- list_tracks_handler: the REST wanted filter ----------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def split_titles() -> List[Episode]:
|
||||
"""E1 split into three parts, E2 unsplit."""
|
||||
return [_ep(1, 1, 1), _ep(1, 1, 2), _ep(1, 1, 3), _ep(1, 2, None)]
|
||||
|
||||
|
||||
def test_rest_bare_episode_key_selects_every_part(monkeypatch, split_titles) -> None:
|
||||
result = _run_list_tracks(monkeypatch, split_titles, wanted="S01E01")
|
||||
assert _selected(result) == ["1x1.1", "1x1.2", "1x1.3"]
|
||||
|
||||
|
||||
def test_rest_part_key_selects_exactly_one(monkeypatch, split_titles) -> None:
|
||||
result = _run_list_tracks(monkeypatch, split_titles, wanted="S01E01.2")
|
||||
assert _selected(result) == ["1x1.2"]
|
||||
|
||||
|
||||
def test_rest_negative_part_key_excludes_only_that_part(monkeypatch, split_titles) -> None:
|
||||
result = _run_list_tracks(monkeypatch, split_titles, wanted=["S01", "-S01E01.2"])
|
||||
assert _selected(result) == ["1x1.1", "1x1.3", "1x2"]
|
||||
|
||||
|
||||
def test_rest_partless_episode_unchanged(monkeypatch, split_titles) -> None:
|
||||
result = _run_list_tracks(monkeypatch, split_titles, wanted="S01E02")
|
||||
assert _selected(result) == ["1x2"]
|
||||
assert "part" not in result["title"]
|
||||
|
||||
|
||||
def test_rest_part_of_unsplit_episode_selects_nothing(monkeypatch, split_titles) -> None:
|
||||
result = _run_list_tracks(monkeypatch, split_titles, wanted="S01E02.1")
|
||||
assert result["status"] == 404
|
||||
|
||||
|
||||
# ---------- discrete season/episode/part request fields ----------
|
||||
|
||||
|
||||
def test_discrete_fields_without_part_select_every_part(monkeypatch, split_titles) -> None:
|
||||
result = _run_list_tracks(monkeypatch, split_titles, season=1, episode=1)
|
||||
assert _selected(result) == ["1x1.1", "1x1.2", "1x1.3"]
|
||||
|
||||
|
||||
def test_discrete_fields_with_part_select_one(monkeypatch, split_titles) -> None:
|
||||
result = _run_list_tracks(monkeypatch, split_titles, season=1, episode=1, part=3)
|
||||
assert _selected(result) == ["1x1.3"]
|
||||
|
||||
|
||||
def test_discrete_part_accepts_string(monkeypatch, split_titles) -> None:
|
||||
result = _run_list_tracks(monkeypatch, split_titles, season=1, episode=1, part="3")
|
||||
assert _selected(result) == ["1x1.3"]
|
||||
|
||||
|
||||
def test_discrete_no_match_error_reports_the_part_qualified_key(monkeypatch, split_titles) -> None:
|
||||
result = _run_list_tracks(monkeypatch, split_titles, season=1, episode=1, part=9)
|
||||
assert result["status"] == 404
|
||||
assert result["details"]["wanted"] == "1x1.9"
|
||||
|
||||
|
||||
def test_discrete_partless_error_key_unchanged(monkeypatch, split_titles) -> None:
|
||||
result = _run_list_tracks(monkeypatch, split_titles, season=9, episode=9)
|
||||
assert result["details"]["wanted"] == "9x9"
|
||||
|
||||
|
||||
def test_part_is_a_transport_key_not_a_service_option() -> None:
|
||||
"""`part` addresses the title, so it must never reach a service's own --part option."""
|
||||
assert "part" in handlers.LIST_HANDLER_TRANSPORT_KEYS
|
||||
assert "part" in handlers.SESSION_TRANSPORT_KEYS
|
||||
|
||||
|
||||
# ---------- ordering ----------
|
||||
|
||||
|
||||
def test_rest_multi_episode_order_is_stable_across_parts(monkeypatch) -> None:
|
||||
titles = [_ep(1, 2, None), _ep(1, 1, 3), _ep(1, 1, 1), _ep(1, 1, 2)]
|
||||
result = _run_list_tracks(monkeypatch, titles, wanted="S01")
|
||||
assert _selected(result) == ["1x1.1", "1x1.2", "1x1.3", "1x2"]
|
||||
|
||||
|
||||
# ---------- download_manager: already-internal wanted sniff ----------
|
||||
|
||||
|
||||
_SNIFF = r"^!?\d+x\d+(\.\d+)?$"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"wanted, internal",
|
||||
[
|
||||
(["1x1"], True), # today's pre-parsed form
|
||||
(["1x1", "1x2"], True),
|
||||
(["1x1.2"], True), # part key
|
||||
(["!1x1.2"], True), # negative part key
|
||||
(["1x1", "!1x1.2"], True),
|
||||
(["S01E01"], False),
|
||||
(["s1e1.2"], False),
|
||||
(["S01"], False),
|
||||
(["1x1", "S01E02"], False),
|
||||
],
|
||||
)
|
||||
def test_download_manager_sniff_regex(wanted: List[str], internal: bool) -> None:
|
||||
needs_conversion = any(not re.match(_SNIFF, w) for w in wanted)
|
||||
assert needs_conversion is (not internal)
|
||||
|
||||
|
||||
def test_sniff_regex_matches_the_one_in_download_manager() -> None:
|
||||
"""Guard the copy above against drift from the production regex."""
|
||||
from pathlib import Path
|
||||
|
||||
import unshackle.core.api.download_manager as dm
|
||||
|
||||
source = Path(dm.__file__).read_text(encoding="utf8")
|
||||
assert f'needs_conversion = any(not re.match(r"{_SNIFF}", w) for w in wanted_raw)' in source
|
||||
|
||||
|
||||
def test_perform_download_skips_reconversion_for_preparsed_part_keys(monkeypatch) -> None:
|
||||
"""A pre-parsed list with part and negative keys must not reach parse_tokens."""
|
||||
from unshackle.core.api.download_manager import _perform_download
|
||||
from unshackle.core.utils.click_types import SeasonRange
|
||||
|
||||
calls = []
|
||||
monkeypatch.setattr(SeasonRange, "parse_tokens", lambda self, *t: calls.append(t) or [])
|
||||
|
||||
params: Dict[str, Any] = {"wanted": ["1x1", "1x1.2", "!1x1.3"]}
|
||||
with pytest.raises(BaseException):
|
||||
# blows up later on the unknown service; the wanted block runs first
|
||||
_perform_download("job-1", "NoSuchServiceXYZ", "t1", params)
|
||||
|
||||
assert calls == []
|
||||
assert params["wanted"] == ["1x1", "1x1.2", "!1x1.3"]
|
||||
|
||||
|
||||
def test_perform_download_still_converts_cli_style_tokens(monkeypatch) -> None:
|
||||
from unshackle.core.api.download_manager import _perform_download
|
||||
from unshackle.core.utils.click_types import SeasonRange
|
||||
|
||||
calls = []
|
||||
monkeypatch.setattr(SeasonRange, "parse_tokens", lambda self, *t: calls.append(t) or ["1x1"])
|
||||
|
||||
params: Dict[str, Any] = {"wanted": "S01E01"}
|
||||
with pytest.raises(BaseException):
|
||||
_perform_download("job-2", "NoSuchServiceXYZ", "t1", params)
|
||||
|
||||
assert calls == [("S01E01",)]
|
||||
assert params["wanted"] == ["1x1"]
|
||||
|
||||
|
||||
# ---------- CLI / REST agreement ----------
|
||||
|
||||
|
||||
def test_dl_and_handlers_share_the_one_match_helper() -> None:
|
||||
"""Both filters must route through Episode.matches_wanted, or they drift."""
|
||||
from pathlib import Path
|
||||
|
||||
import unshackle.commands.dl as dl_module
|
||||
|
||||
dl_source = Path(dl_module.__file__).read_text(encoding="utf8")
|
||||
handlers_source = Path(handlers.__file__).read_text(encoding="utf8")
|
||||
|
||||
assert "matches_wanted(wanted)" in dl_source
|
||||
assert "matches_wanted(wanted)" in handlers_source
|
||||
# no inline key compare left behind on either side
|
||||
inline = re.compile(r"f\"\{title\.season\}x\{title\.number\}\"\s*\n?\s*(?:in|not in)\s+wanted")
|
||||
assert not inline.search(dl_source)
|
||||
assert not inline.search(handlers_source)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"wanted_tokens",
|
||||
[
|
||||
("S01E01",),
|
||||
("S01E01.2",),
|
||||
("S01",),
|
||||
("S01", "-S01E01.2"),
|
||||
("S01", "-S01E01"),
|
||||
("S01E01.1-S01E01.3",),
|
||||
("S01E02",),
|
||||
],
|
||||
)
|
||||
def test_rest_filter_agrees_with_cli_filter(monkeypatch, split_titles, wanted_tokens) -> None:
|
||||
from unshackle.core.utils.click_types import SeasonRange
|
||||
|
||||
wanted = SeasonRange().parse_tokens(*wanted_tokens)
|
||||
cli_selected = [
|
||||
f"{t.season}x{t.number}" + (f".{t.part}" if t.part is not None else "")
|
||||
for t in split_titles
|
||||
if t.matches_wanted(wanted)
|
||||
]
|
||||
|
||||
result = _run_list_tracks(monkeypatch, split_titles, wanted=list(wanted_tokens))
|
||||
rest_selected = [] if result.get("status") == 404 else _selected(result)
|
||||
|
||||
assert sorted(rest_selected) == sorted(cli_selected)
|
||||
Reference in New Issue
Block a user