diff --git a/docs/dev/creating-a-service.md b/docs/dev/creating-a-service.md index 1c3d5f9b..04fa6163 100644 --- a/docs/dev/creating-a-service.md +++ b/docs/dev/creating-a-service.md @@ -99,6 +99,8 @@ Declared at class level to configure framework behaviour: | `VAULT_TAG` | `Optional[str]` | Overrides the key-vault namespace so sibling services can share one vault. Default `None` (use the service's own tag). | | `AUTH_METHODS` | `Optional[tuple[str, ...]]` | Auth methods accepted (`"cookies"` / `"credentials"`). When `None`, the REST `/services` endpoint infers them from `authenticate()`. | | `NO_SUBTITLES` | `bool` | Set `True` on a service with no subtitle tracks to skip subtitle handling entirely. | +| `ANIME` | `bool` | Set `True` when the catalog is anime, so metadata lookups prefer AniList. A title's own `anime` flag overrides it. | +| `DAILY` | `bool` | Set `True` when the catalog is daily/date-based (talk shows, news, sports), so episodes are named by air date. A title's own `daily` flag overrides it. | !!! note "`NO_SUBTITLES` is a convention, not a base-class attribute" `NO_SUBTITLES` is checked by `dl.py` via `hasattr` and is deliberately **not** diff --git a/docs/dev/rest-api/endpoints.md b/docs/dev/rest-api/endpoints.md index 1603b2ba..f990e271 100644 --- a/docs/dev/rest-api/endpoints.md +++ b/docs/dev/rest-api/endpoints.md @@ -368,7 +368,7 @@ Create a download job. Requires `service` and `title_id`; every other field is a | `range` | string[] | `["SDR"]` | Dynamic range(s). | | `channels` | number | `null` | Audio channel count. | | `no_atmos` | boolean | `false` | Exclude Atmos tracks. | -| `wanted` | string[] | `[]` | Episode/season selectors. Accepts the part form, `"S01E01.2"`. | +| `wanted` | string[] | `[]` | Episode/season selectors. Accepts the part form, `"S01E01.2"`, and the air-date form, `"2026-08-11"` or `"2026-08-01:2026-08-31"`. | | `latest_episode` | boolean | `false` | Only the newest episode. | | `lang` / `v_lang` / `a_lang` / `s_lang` | string[] | `["orig"]` / `[]` / `[]` / `["all"]` | Language filters. | | `require_subs` | string[] | `[]` | Required subtitle languages. | @@ -395,6 +395,7 @@ Create a download job. Requires `service` and `title_id`; every other field is a | `tmdb_id` / `imdb_id` / `tvdb_id` / `anilist_id` | - | `null` | External ID overrides. Each resolves its metadata directly instead of by a title search, and is used for tagging. Set `enrich` to also take the title, year and original language. Give at most one of `tmdb_id`, `imdb_id` and `tvdb_id`, since unshackle resolves the others from it. Sending two returns `400`. `anilist_id` still combines with one of them. `tmdb_id` and `tvdb_id` must be positive integers, `imdb_id` must look like `tt1375666`, and `anilist_id` must be a positive integer or a string like `mal:12345`, or the request returns `400`. An ID whose provider is unconfigured, such as `tmdb_id` with no `tmdb_api_key`, fails the job rather than returning `400`. `anilist_id` needs no key. | | `tvdb_order` | `official`, `dvd`, `absolute`, `alternate`, `regional` | `null` | Renumber episodes to a TVDB season order. Falls back to the `tvdb_order` config option. | | `enrich` | boolean | `false` | Overwrite title, year and original language with the external source's. Needs one of `tmdb_id`, `imdb_id`, `tvdb_id` or `anilist_id`. Without one the job fails instead of returning `400`. | +| `daily` | boolean | `false` | Treat the title as daily/date-based content and fill missing episode air dates from TVDB. The fill needs `enrich` and a TVDB ID. An air date the service already set is kept. | | `output_dir` | string | `null` | Override output directory. | | `no_cache` / `reset_cache` | boolean | `false` | Title cache controls. | diff --git a/docs/dev/service-architecture.md b/docs/dev/service-architecture.md index 59598417..4819ffe4 100644 --- a/docs/dev/service-architecture.md +++ b/docs/dev/service-architecture.md @@ -64,6 +64,8 @@ Declared at the top of your service class, these are static descriptors: | `GEOFENCE` | `tuple[str, ...]` | ISO region codes the service requires; empty means no geofence. The first entry is treated as the "main region" for automatic proxy selection. | | `VAULT_TAG` | `Optional[str]` | Overrides the key-vault namespace; defaults to the service's own tag. | | `AUTH_METHODS` | `Optional[tuple[str, ...]]` | Accepted auth methods (`"cookies"` / `"credentials"`). When `None`, the REST `/services` endpoint infers them from `authenticate()`. | +| `ANIME` | `bool` | The catalog is anime, so metadata lookups prefer AniList. A title's own `anime` flag overrides it. | +| `DAILY` | `bool` | The catalog is daily/date-based, so episodes are named by air date. A title's own `daily` flag overrides it. | ### Required methods diff --git a/docs/guide/cli-reference.md b/docs/guide/cli-reference.md index 13b176a1..40ca066c 100644 --- a/docs/guide/cli-reference.md +++ b/docs/guide/cli-reference.md @@ -112,7 +112,7 @@ The special language tokens `orig`, `all`, and `best` are honoured everywhere a | Flag | Description | |---|---| -| `-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. | +| `-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 [dated content](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`). | | `--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. | | `--list-titles` | List titles only; do not download. | @@ -155,6 +155,7 @@ Keep only certain track types, or skip certain track types. Attachments are alwa | `--tvdb` | TVDB ID (integer). Used for the tags. Skips the series lookup that `--tvdb-order` would otherwise do. `--enrich` reads it too. Needs `tvdb_api_key`. | | `--anilist` | AniList ID (integer), e.g. `--anilist 21`. A MyAnimeList ID is accepted as `mal:12345` and resolved to the AniList entry. Used for the tags. Skips the title search. `--enrich` reads it too. Needs no key. | | `--enrich` | Overwrite show title, year and original language with the external source's. **Requires** one of `--tmdb`, `--imdb`, `--tvdb`, or `--anilist`. | +| `--daily` | Treat the title as daily/date-based content and fill missing episode air dates from TVDB. The fill needs `--enrich` and a TVDB ID. See [Daily and date-based content](downloading.md#daily-and-date-based-content). | | `--tvdb-order` | Renumber episodes to a TVDB season order: `official` (aired), `dvd`, `absolute`, `alternate`, or `regional`. Needs `tvdb_api_key`. | !!! note "One ID at a time" diff --git a/docs/guide/downloading.md b/docs/guide/downloading.md index 0488291b..9c5faf9b 100644 --- a/docs/guide/downloading.md +++ b/docs/guide/downloading.md @@ -427,6 +427,43 @@ with `-` to exclude it. unshackle dl -w S01,-S01E01.2 EXAMPLE 81234567 ``` +=== "Air dates" + + ```shell + # One day + unshackle dl -w 2026-08-11 EXAMPLE 81234567 + + # A range of days, colon separated + unshackle dl -w 2026-08-01:2026-08-31 EXAMPLE 81234567 + + # August, but not the 15th + unshackle dl -w 2026-08-01:2026-08-31,-2026-08-15 EXAMPLE 81234567 + ``` + +### Daily and date-based content + +Talk shows, news and sports have no official episode numbering, so unshackle names them +by air date. An episode that carries an air date is written as `Show.YYYY.MM.DD` instead of +`SxxExx`, the `{date}` token holds the ISO date, and the season folder becomes the year. + +A service that only carries this kind of content sets `DAILY = True` on its class, and a +service can set `air_date` on each episode itself. Add `--daily` to mark any other title +as date-based: + +```shell +unshackle dl --daily --tvdb 73871 --enrich EXAMPLE 81234567 +``` + +With `--enrich` and a TVDB ID, `--daily` fills in the air date of every episode that has +none. An air date the service already set is kept. Dates before 1970 and dates in the +future are skipped, because TVDB carries placeholder schedule dates for episodes that +have not aired. Without `--enrich` unshackle has no source to fill from, and says so. + +A dated episode answers to its air date in `-w`, as well as to its `SxxExx` key. A date +token is a plain ISO date (`2026-08-11`). A date range uses a colon (`2026-08-01:2026-08-31`), +because the dashes in a date are part of the date. A range cannot span more than 1000 days. +Date tokens and `SxxExx` tokens can be mixed in one `-w`. + ### Split episodes A few services split one episode into several separately playable videos. Where a service @@ -704,6 +741,9 @@ usual beneficiary, but no part of this is limited to anime. This only adds the [`{absolute}`](../reference/configuration/output.md#output_template) naming variable. The season and episode numbers are never changed. +With `--daily`, `--enrich` also fills in the air date of each episode from TVDB. See +[Daily and date-based content](#daily-and-date-based-content). + !!! warning "`--enrich` replaces the original language, which affects more than the filename" Track selection reads the original language, so replacing it changes which audio is treated as the original. That is the point when a service mislabels it, but it means a @@ -762,7 +802,8 @@ authoritative list. | `--forced-subs` | `-fs` | Include forced subtitles. | | `--forced-s-lang` | `-fsl` | Forced subtitle language(s); implies `-fs`. `-` excludes. | | `--sub-format` | | Output subtitle format. | -| `--wanted` | `-w` | Episode/season range. | +| `--wanted` | `-w` | Episode/season range, or an air date. | +| `--daily` | | Fill missing air dates from TVDB during `--enrich`. | | `--select-titles` | | Interactively pick episodes or films. | | `--latest-episode` | | Only the newest episode. | | `--video-only` / `--audio-only` / `--subs-only` | `-V` / `-A` / `-S` | Restrict track types. | diff --git a/tests/core/test_click_types.py b/tests/core/test_click_types.py index 8c38a807..fe2c95b7 100644 --- a/tests/core/test_click_types.py +++ b/tests/core/test_click_types.py @@ -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) diff --git a/tests/core/test_episode_air_date.py b/tests/core/test_episode_air_date.py index d14a4629..87794e4e 100644 --- a/tests/core/test_episode_air_date.py +++ b/tests/core/test_episode_air_date.py @@ -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" diff --git a/tests/core/test_fill_air_dates.py b/tests/core/test_fill_air_dates.py new file mode 100644 index 00000000..fcca4b81 --- /dev/null +++ b/tests/core/test_fill_air_dates.py @@ -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) diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index e474dbb0..67e8aa24 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -16,6 +16,7 @@ from collections.abc import Iterable from concurrent import futures from concurrent.futures import ThreadPoolExecutor from copy import deepcopy +from datetime import date, timedelta from functools import partial from http.cookiejar import CookieJar, MozillaCookieJar from itertools import product @@ -60,7 +61,7 @@ from unshackle.core.music import ( write_music_metadata, ) from unshackle.core.providers.anilist import parse_anilist_ref -from unshackle.core.providers.tvdb import SEASON_TYPES +from unshackle.core.providers.tvdb import SEASON_TYPES, _parse_int from unshackle.core.proxies import Basic, ExpressVPN, Gluetun, Hola, NordVPN, ProtonVPN, SurfsharkVPN, WindscribeVPN from unshackle.core.service import Service from unshackle.core.services import Services @@ -700,6 +701,12 @@ class dl: "Requires --tmdb, --imdb, --tvdb, or --anilist." ), ) + @click.option( + "--daily", + is_flag=True, + default=False, + help="Treat the title as daily/date-based content and fill missing air dates from TVDB during --enrich.", + ) @click.option( "--imdb", "imdb_id", @@ -865,6 +872,7 @@ class dl: tvdb_order: Optional[str] = None, anilist_id: Optional[Union[int, str]] = None, enrich: bool = False, + daily: bool = False, output_dir: Optional[Path] = None, *_: Any, **__: Any, @@ -905,6 +913,7 @@ class dl: tvdb_order = ctx.params.get("tvdb_order", tvdb_order) anilist_id = ctx.params.get("anilist_id", anilist_id) enrich = ctx.params.get("enrich", enrich) + daily = ctx.params.get("daily", daily) output_dir = ctx.params.get("output_dir", output_dir) self.profile = profile @@ -918,14 +927,19 @@ class dl: if self.tvdb_order and self.tvdb_order not in SEASON_TYPES: raise click.UsageError(f"tvdb_order must be one of {', '.join(SEASON_TYPES)}, not {self.tvdb_order!r}") self.enrich = enrich + self.daily = daily self.output_dir = output_dir self.service_anime = False + self.service_daily = False if self.enrich and not (self.tmdb_id or self.imdb_id or self.tvdb_id or self.anilist_id): raise click.UsageError( "--enrich requires --tmdb, --imdb, --tvdb, or --anilist to provide a metadata source." ) + if self.daily and not self.enrich: + self.log.warning("--daily fills air dates from TVDB only with --enrich. A service can set them itself.") + # Initialize debug logger with service name if debug logging is enabled if config.debug or logging.root.level == logging.DEBUG: from collections import defaultdict @@ -1387,6 +1401,7 @@ class dl: self.tmdb_searched = False self.search_source = None self.service_anime = bool(getattr(service, "ANIME", False)) + self.service_daily = bool(getattr(service, "DAILY", False)) self.server_cdm = getattr(service, "_server_cdm", False) self._remote_service = service if self.server_cdm else None start_time = time.time() @@ -1657,9 +1672,10 @@ class dl: t.language = enrich_lang if isinstance(titles, Series): - self.fill_absolute_numbers( - titles, self.tvdb_id or (enrich_result.external_ids.tvdb_id if enrich_result else None) - ) + enrich_tvdb_id = self.tvdb_id or (enrich_result.external_ids.tvdb_id if enrich_result else None) + self.fill_absolute_numbers(titles, enrich_tvdb_id) + if any(self.daily_hint(t) for t in titles): + self.fill_air_dates(titles, enrich_tvdb_id) if self.tvdb_order and isinstance(titles, Series): titles = self.apply_tvdb_order(titles, title_cacher, cache_title_id, cache_region, cache_account_hash) @@ -3916,6 +3932,67 @@ class dl: per_title = getattr(title, "anime", None) return self.service_anime if per_title is None else bool(per_title) + def daily_hint(self, title: Optional[Title_T] = None) -> bool: + """Whether this title is daily/date-based content named by air date.""" + per_title = getattr(title, "daily", None) + return self.daily or (self.service_daily if per_title is None else bool(per_title)) + + def fill_air_dates(self, titles: Series, tvdb_id: Optional[int] = None) -> None: + """Fill in missing episode air dates from TVDB. + + Additive only: an air date the service already set is kept. + """ + tvdb = providers.get_provider("tvdb") + if not tvdb: + self.log.debug("Air dates need a tvdb_api_key in your config, skipping.") + return + + if not tvdb_id: + self.log.debug("Could not resolve a TVDB ID, skipping air dates.") + return + + wanted = [t for t in titles if self.daily_hint(t)] + if not wanted: + return + + keys = list(dict.fromkeys((t.season, t.number) for t in titles)) + source_order = tvdb.detect_order(tvdb_id, keys) # type: ignore[attr-defined] + episodes = tvdb.get_episodes(tvdb_id, source_order) # type: ignore[attr-defined] + if not episodes: + self.log.debug("TVDB listed no episodes for series %s, skipping air dates.", tvdb_id) + return + + aired_by_key: dict[tuple[int, int], str] = {} + for episode in episodes: + season, number = _parse_int(episode.get("seasonNumber")), _parse_int(episode.get("number")) + aired = episode.get("aired") + if season is None or number is None or not aired: + continue + aired_by_key[(season, number)] = str(aired) + + # TVDB carries placeholder schedule dates for unaired episodes. Stamping one would + # name the file with a date the episode never aired on. + limit = date.today() + timedelta(days=1) + + filled = 0 + for title in wanted: + if title.air_date is not None: + continue + aired = aired_by_key.get((title.season, title.number)) + if not aired: + continue + try: + air_date = date.fromisoformat(aired[:10]) + except ValueError: + continue + if air_date.year < 1970 or air_date > limit: + continue + title.air_date = air_date + filled += 1 + + if filled: + self.log.info("Filled air dates for %d episode(s) from TVDB (ID %s).", filled, tvdb_id) + def fill_absolute_numbers(self, titles: Series, tvdb_id: Optional[int] = None) -> None: """Fill in missing absolute episode numbers from TVDB's absolute order. diff --git a/unshackle/core/api/download_manager.py b/unshackle/core/api/download_manager.py index 66c594a3..1afc246b 100644 --- a/unshackle/core/api/download_manager.py +++ b/unshackle/core/api/download_manager.py @@ -422,6 +422,7 @@ def _perform_download( "tvdb_order": params.get("tvdb_order"), "anilist_id": params.get("anilist_id"), "enrich": params.get("enrich", False), + "daily": params.get("daily", False), "output_dir": Path(params["output_dir"]) if params.get("output_dir") else None, "no_cache": params.get("no_cache", False), "reset_cache": params.get("reset_cache", False), @@ -454,6 +455,7 @@ def _perform_download( tvdb_order=params.get("tvdb_order"), anilist_id=params.get("anilist_id"), enrich=params.get("enrich", False), + daily=params.get("daily", False), output_dir=Path(params["output_dir"]) if params.get("output_dir") else None, ) # Per-request CDM override (a device name in the WVDs dir); get_cdm() takes it first. diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index 5baad3f5..7bbbef54 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -80,6 +80,7 @@ DEFAULT_DOWNLOAD_PARAMS = { "tvdb_order": None, "anilist_id": None, "enrich": False, + "daily": False, "output_dir": None, "no_cache": False, "reset_cache": False, diff --git a/unshackle/core/api/routes.py b/unshackle/core/api/routes.py index bfb2b716..f91b15a3 100644 --- a/unshackle/core/api/routes.py +++ b/unshackle/core/api/routes.py @@ -767,6 +767,9 @@ async def download(request: web.Request) -> web.Response: enrich: type: boolean description: Overwrite show title, year and original language with the external source's. Requires one of tmdb_id, imdb_id, tvdb_id or anilist_id (default - false) + daily: + type: boolean + description: Treat the title as daily/date-based content and fill missing air dates from TVDB. Needs enrich (default - false) no_folder: type: boolean description: Disable folder creation for TV shows (default - false) diff --git a/unshackle/core/service.py b/unshackle/core/service.py index ca7eab6c..7331e919 100644 --- a/unshackle/core/service.py +++ b/unshackle/core/service.py @@ -94,6 +94,7 @@ class Service(metaclass=ABCMeta): ALIASES: tuple[str, ...] = () # list of aliases for the service; alternatives to the service tag. GEOFENCE: tuple[str, ...] = () # list of ip regions required to use the service. empty list == no specific region. ANIME: bool = False # service catalogue is anime; metadata lookups prefer AniList. Title.anime overrides per title. + DAILY: bool = False # catalog is daily/date-based. episodes are named by air date. Title.daily overrides per title. # vault namespace override; when set, key vault read/write uses this tag instead of the service's own. VAULT_TAG: Optional[str] = None # Auth methods the service accepts ("cookies"/"credentials"); when None the REST /services diff --git a/unshackle/core/titles/episode.py b/unshackle/core/titles/episode.py index 0edefe8c..2a133f60 100644 --- a/unshackle/core/titles/episode.py +++ b/unshackle/core/titles/episode.py @@ -108,6 +108,9 @@ class Episode(Title): except ValueError: pass + if isinstance(air_date, date) and air_date.year < 1970: + raise ValueError(f"Episode air date cannot be {air_date}") + self.title = title self.season = season self.number = number @@ -122,10 +125,13 @@ class Episode(Title): """Whether a parsed ``-w`` key set selects this episode. A part-ful episode answers to both its base key and its part key, so ``-w s1e1`` - takes every part. ``!`` keys are part-qualified exclusions resolved here. + takes every part. A dated episode also answers to its ISO air date. + ``!`` keys are part-qualified exclusions resolved here. """ base = f"{self.season}x{self.number}" keys = (base,) if self.part is None else (base, f"{base}.{self.part}") + if isinstance(self.air_date, date): + keys = (*keys, self.air_date.isoformat()) if any(f"!{k}" in wanted for k in keys): return False return any(k in wanted for k in keys) diff --git a/unshackle/core/titles/title.py b/unshackle/core/titles/title.py index ce4716b7..bab31320 100644 --- a/unshackle/core/titles/title.py +++ b/unshackle/core/titles/title.py @@ -48,6 +48,7 @@ class Title: self.language = language self.data = data self.anime: Optional[bool] = None + self.daily: Optional[bool] = None self.tracks = Tracks() diff --git a/unshackle/core/utils/click_types.py b/unshackle/core/utils/click_types.py index 9f0f4334..e02f88ca 100644 --- a/unshackle/core/utils/click_types.py +++ b/unshackle/core/utils/click_types.py @@ -1,4 +1,5 @@ import re +from datetime import date, timedelta from typing import Any, Optional, Union import click @@ -176,6 +177,23 @@ class SeasonRange(click.ParamType): MAX_EPISODE = 999 MIN_PART = 1 MAX_PART = 99 + MAX_DATE_SPAN = 1000 + + DATE_TOKEN = re.compile(r"^(?P\d{4}-\d{2}-\d{2})(:(?P\d{4}-\d{2}-\d{2}))?$") + + def _parse_date_token(self, token: str, match: re.Match) -> list[str]: + """Expand an ISO date token or ':'-separated date range into ISO day keys.""" + try: + left = date.fromisoformat(match.group("left")) + right = date.fromisoformat(match.group("right")) if match.group("right") else left + except ValueError: + self.fail(f"Invalid date, must be a real YYYY-MM-DD date: {token}") + if left > right: + self.fail(f"Invalid range, left side date cannot be later than right side date: {token}") + span = (right - left).days + 1 + if span > self.MAX_DATE_SPAN: + self.fail(f"Invalid range, a date range cannot span more than {self.MAX_DATE_SPAN} days: {token}") + return [(left + timedelta(days=i)).isoformat() for i in range(span)] def parse_tokens(self, *tokens: str) -> list[str]: """ @@ -186,6 +204,9 @@ class SeasonRange(click.ParamType): is not knowable here. A part-qualified exclusion cannot be removed from the computed keys (they are base keys), so it becomes a '!' key resolved at match time. + Dated content is addressed by ISO air date. A date range uses ':' only, because + a date's own '-' separators are not a range separator. + Supports exclusioning by putting a `-` before the token. Example: @@ -200,6 +221,10 @@ class SeasonRange(click.ParamType): ["1x1.1", "1x1.2", "1x1.3"] >>> sr.parse_tokens("S01E01", "-S01E01.2") ["1x1", "!1x1.2"] + >>> sr.parse_tokens("2026-08-11") + ["2026-08-11"] + >>> sr.parse_tokens("2026-08-01:2026-08-03", "-2026-08-02") + ["2026-08-01", "2026-08-03"] """ if len(tokens) == 0: return [] @@ -209,6 +234,11 @@ class SeasonRange(click.ParamType): exclude = token.startswith("-") if exclude: token = token[1:] + # dates carry their own '-' separators, so they must be read before the range split + date_match = self.DATE_TOKEN.match(token) + if date_match: + (computed if not exclude else exclusions).extend(self._parse_date_token(token, date_match)) + continue parsed = [ re.match(r"^S(?P\d+)(E(?P\d+)(\.(?P\d+))?)?$", x, re.IGNORECASE) for x in re.split(r"[:-]", token)