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:
imSp4rky
2026-08-11 17:50:22 -06:00
parent e4f6ddec85
commit 4a21dbebb5
16 changed files with 425 additions and 8 deletions
+41
View File
@@ -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)
+42
View File
@@ -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"
+166
View File
@@ -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)