mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-11 19:45:06 +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:
@@ -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**
|
||||
|
||||
@@ -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. |
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user