mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-14 13:05:09 +00:00
feat(dl): add daily date-based episodes with tvdb air-date fill
--daily flag plus Service.DAILY/Title.daily, -w selection by ISO air date or colon date range, and a pre-1970 air-date guard.
This commit is contained in:
@@ -154,3 +154,44 @@ def test_exclusion_removes_duplicate_keys():
|
||||
def test_bad_part_tokens_fail(token):
|
||||
with pytest.raises(click.UsageError):
|
||||
parse(token)
|
||||
|
||||
|
||||
# --- SeasonRange date tokens ------------------------------------------------
|
||||
|
||||
|
||||
def test_single_date_token_is_its_own_key():
|
||||
assert parse("2026-08-11") == ["2026-08-11"]
|
||||
|
||||
|
||||
def test_date_range_expands_every_day_inclusive():
|
||||
assert sorted(parse("2026-08-01:2026-08-03")) == ["2026-08-01", "2026-08-02", "2026-08-03"]
|
||||
|
||||
|
||||
def test_date_range_crosses_a_month():
|
||||
assert sorted(parse("2026-01-30:2026-02-02")) == ["2026-01-30", "2026-01-31", "2026-02-01", "2026-02-02"]
|
||||
|
||||
|
||||
def test_date_exclusion_removes_the_day():
|
||||
assert sorted(parse("2026-08-01:2026-08-03", "-2026-08-02")) == ["2026-08-01", "2026-08-03"]
|
||||
|
||||
|
||||
def test_dates_mix_with_season_episode_tokens():
|
||||
assert sorted(parse("S01E01", "2026-08-11")) == ["1x1", "2026-08-11"]
|
||||
|
||||
|
||||
def test_comma_separated_dates_convert():
|
||||
assert sorted(SeasonRange().convert("2026-08-11, 2026-08-12")) == ["2026-08-11", "2026-08-12"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"token",
|
||||
[
|
||||
"2026-13-01", # not a real month
|
||||
"2026-02-30", # not a real day
|
||||
"2026-08-03:2026-08-01", # reversed range
|
||||
"2020-01-01:2026-01-01", # over MAX_DATE_SPAN
|
||||
],
|
||||
)
|
||||
def test_bad_date_tokens_fail(token):
|
||||
with pytest.raises(click.UsageError):
|
||||
parse(token)
|
||||
|
||||
@@ -107,3 +107,45 @@ def test_dated_folder_groups_by_year(reset_template):
|
||||
folder = make_episode(air_date=date(2024, 6, 30)).get_filename(StubMediaInfo(), folder=True)
|
||||
assert "2024" in folder
|
||||
assert "S2024" not in folder # season folder is the year, not the faked season
|
||||
|
||||
|
||||
# --- selection by date ------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_dated_episode_matches_its_iso_date_key():
|
||||
assert make_episode(air_date=date(2024, 6, 30)).matches_wanted({"2024-06-30"})
|
||||
|
||||
|
||||
def test_a_dated_episode_still_matches_its_season_episode_key():
|
||||
assert make_episode(air_date=date(2024, 6, 30)).matches_wanted({"2024x181"})
|
||||
|
||||
|
||||
def test_a_date_key_does_not_match_another_day():
|
||||
assert not make_episode(air_date=date(2024, 6, 30)).matches_wanted({"2024-07-01"})
|
||||
|
||||
|
||||
def test_a_season_episode_exclusion_beats_the_date_key():
|
||||
assert not make_episode(air_date=date(2024, 6, 30)).matches_wanted({"2024-06-30", "!2024x181"})
|
||||
|
||||
|
||||
def test_an_undated_episode_is_unaffected_by_date_keys():
|
||||
episode = make_episode()
|
||||
assert not episode.matches_wanted({"2024-06-30"})
|
||||
assert episode.matches_wanted({"2024x181"})
|
||||
|
||||
|
||||
# --- the air-date guard -----------------------------------------------------
|
||||
|
||||
|
||||
def test_a_pre_1970_air_date_is_rejected():
|
||||
with pytest.raises(ValueError):
|
||||
make_episode(air_date=date(1969, 12, 31))
|
||||
|
||||
|
||||
def test_a_future_air_date_is_accepted():
|
||||
future = date.today().replace(year=date.today().year + 1)
|
||||
assert make_episode(air_date=future).air_date == future
|
||||
|
||||
|
||||
def test_an_unparseable_air_date_string_is_kept_as_is():
|
||||
assert make_episode(air_date="soon").air_date == "soon"
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
"""The --daily air-date fill: dl.fill_air_dates and the daily_hint gate."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from unshackle.core import providers
|
||||
from unshackle.core.titles.episode import Episode, Series
|
||||
|
||||
|
||||
class DummyService:
|
||||
pass
|
||||
|
||||
|
||||
def make_episode(**overrides) -> Episode:
|
||||
kwargs = dict(
|
||||
id_="ep-0001",
|
||||
service=DummyService,
|
||||
title="The Show",
|
||||
season=1,
|
||||
number=1,
|
||||
)
|
||||
kwargs.update(overrides)
|
||||
return Episode(**kwargs)
|
||||
|
||||
|
||||
class _FakeProvider:
|
||||
def __init__(self, episodes: list, source_order: str = "official") -> None:
|
||||
self.episodes = episodes
|
||||
self.source_order = source_order
|
||||
|
||||
def detect_order(self, _id, _keys) -> str:
|
||||
return self.source_order
|
||||
|
||||
def get_episodes(self, _id, _order) -> list:
|
||||
return self.episodes
|
||||
|
||||
|
||||
def _fill(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
titles: list,
|
||||
episodes: list,
|
||||
tvdb_id=73871,
|
||||
provider=True,
|
||||
daily=True,
|
||||
service_daily=False,
|
||||
):
|
||||
from unshackle.commands.dl import dl
|
||||
|
||||
fake = _FakeProvider(episodes) if provider else None
|
||||
monkeypatch.setattr(providers, "get_provider", lambda _name: fake)
|
||||
logged: list = []
|
||||
logger = SimpleNamespace(
|
||||
debug=lambda *a, **k: logged.append(("debug", a)),
|
||||
info=lambda *a, **k: logged.append(("info", a)),
|
||||
warning=lambda *a, **k: logged.append(("warning", a)),
|
||||
)
|
||||
stub = SimpleNamespace(tvdb_id=tvdb_id, log=logger, daily=daily, service_daily=service_daily)
|
||||
stub.daily_hint = lambda title=None: dl.daily_hint(stub, title)
|
||||
series = Series(titles)
|
||||
dl.fill_air_dates(stub, series, tvdb_id)
|
||||
return series, logged
|
||||
|
||||
|
||||
def tvdb_episode(season: int, number: int, aired) -> dict:
|
||||
return {"seasonNumber": season, "number": number, "aired": aired}
|
||||
|
||||
|
||||
LISTING = [tvdb_episode(1, 1, "2026-08-10"), tvdb_episode(1, 2, "2026-08-11")]
|
||||
|
||||
|
||||
def test_fill_populates_missing_air_dates(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
episodes = [make_episode(number=1), make_episode(number=2)]
|
||||
series, logged = _fill(monkeypatch, episodes, LISTING)
|
||||
assert [t.air_date for t in series] == [date(2026, 8, 10), date(2026, 8, 11)]
|
||||
assert [level for level, _ in logged] == ["info"]
|
||||
|
||||
|
||||
def test_a_service_set_air_date_wins(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
episodes = [make_episode(number=1, air_date="1999-01-02"), make_episode(number=2)]
|
||||
series, _ = _fill(monkeypatch, episodes, LISTING)
|
||||
assert [t.air_date for t in series] == [date(1999, 1, 2), date(2026, 8, 11)]
|
||||
|
||||
|
||||
def test_fill_never_touches_season_or_number(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
series, _ = _fill(monkeypatch, [make_episode(number=2)], LISTING)
|
||||
assert [(t.season, t.number) for t in series] == [(1, 2)]
|
||||
|
||||
|
||||
def test_an_unmapped_episode_keeps_no_air_date(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
series, _ = _fill(monkeypatch, [make_episode(season=9, number=9)], LISTING)
|
||||
assert series[0].air_date is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("aired", [None, "", "not-a-date", "2026-13-40"])
|
||||
def test_an_unusable_aired_value_is_skipped(monkeypatch: pytest.MonkeyPatch, aired) -> None:
|
||||
series, _ = _fill(monkeypatch, [make_episode(number=1)], [tvdb_episode(1, 1, aired)])
|
||||
assert series[0].air_date is None
|
||||
|
||||
|
||||
def test_a_pre_1970_date_is_skipped(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
series, _ = _fill(monkeypatch, [make_episode(number=1)], [tvdb_episode(1, 1, "1900-01-01")])
|
||||
assert series[0].air_date is None
|
||||
|
||||
|
||||
def test_a_future_placeholder_date_is_skipped(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
future = (date.today() + timedelta(days=30)).isoformat()
|
||||
series, _ = _fill(monkeypatch, [make_episode(number=1)], [tvdb_episode(1, 1, future)])
|
||||
assert series[0].air_date is None
|
||||
|
||||
|
||||
def test_tomorrow_still_fills(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
tomorrow = date.today() + timedelta(days=1)
|
||||
series, _ = _fill(monkeypatch, [make_episode(number=1)], [tvdb_episode(1, 1, tomorrow.isoformat())])
|
||||
assert series[0].air_date == tomorrow
|
||||
|
||||
|
||||
def test_a_datetime_stamped_aired_value_keeps_the_date(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
series, _ = _fill(monkeypatch, [make_episode(number=1)], [tvdb_episode(1, 1, "2026-08-10T20:00:00Z")])
|
||||
assert series[0].air_date == date(2026, 8, 10)
|
||||
|
||||
|
||||
def test_fill_skips_silently_without_an_api_key(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
series, logged = _fill(monkeypatch, [make_episode(number=1)], LISTING, provider=False)
|
||||
assert series[0].air_date is None
|
||||
assert [level for level, _ in logged] == ["debug"]
|
||||
|
||||
|
||||
def test_fill_skips_silently_without_a_tvdb_id(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
series, logged = _fill(monkeypatch, [make_episode(number=1)], LISTING, tvdb_id=None)
|
||||
assert series[0].air_date is None
|
||||
assert [level for level, _ in logged] == ["debug"]
|
||||
|
||||
|
||||
def test_fill_skips_silently_when_tvdb_lists_no_episodes(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
series, logged = _fill(monkeypatch, [make_episode(number=1)], [])
|
||||
assert series[0].air_date is None
|
||||
assert [level for level, _ in logged] == ["debug"]
|
||||
|
||||
|
||||
def test_nothing_is_filled_when_no_title_is_daily(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
series, logged = _fill(monkeypatch, [make_episode(number=1)], LISTING, daily=False)
|
||||
assert series[0].air_date is None
|
||||
assert logged == []
|
||||
|
||||
|
||||
def test_the_service_daily_flag_fills_without_the_cli_flag(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
series, _ = _fill(monkeypatch, [make_episode(number=1)], LISTING, daily=False, service_daily=True)
|
||||
assert series[0].air_date == date(2026, 8, 10)
|
||||
|
||||
|
||||
def test_a_per_title_daily_false_is_skipped_while_others_fill(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
first, second = make_episode(number=1), make_episode(number=2)
|
||||
first.daily = False
|
||||
series, _ = _fill(monkeypatch, [first, second], LISTING, daily=False, service_daily=True)
|
||||
assert [t.air_date for t in series] == [None, date(2026, 8, 11)]
|
||||
|
||||
|
||||
def test_the_cli_flag_overrides_a_per_title_daily_false(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
episode = make_episode(number=1)
|
||||
episode.daily = False
|
||||
series, _ = _fill(monkeypatch, [episode], LISTING, daily=True)
|
||||
assert series[0].air_date == date(2026, 8, 10)
|
||||
Reference in New Issue
Block a user