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
+2
View File
@@ -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**
+2 -1
View File
@@ -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. |
+2
View File
@@ -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
+2 -1
View File
@@ -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"
+42 -1
View File
@@ -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. |
+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)
+81 -4
View File
@@ -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.
+2
View File
@@ -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.
+1
View File
@@ -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,
+3
View File
@@ -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)
+1
View File
@@ -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
+7 -1
View File
@@ -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)
+1
View File
@@ -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()
+30
View File
@@ -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<left>\d{4}-\d{2}-\d{2})(:(?P<right>\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<season>\d+)(E(?P<episode>\d+)(\.(?P<part>\d+))?)?$", x, re.IGNORECASE)
for x in re.split(r"[:-]", token)