From ec594b0b9ccb6850f157dadbc40f01eec4806c15 Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Tue, 11 Aug 2026 17:50:38 -0600 Subject: [PATCH] fix(api): carry air_date, absolute, anime and daily across the title wire serialize_title and title_to_meta now emit them as conditional keys, _build_title restores them, and service-class flags stamp per title. --- docs/dev/rest-api/endpoints.md | 10 + tests/remote/unit/test_api_title_fields.py | 227 +++++++++++++++++++++ unshackle/commands/dl.py | 7 + unshackle/core/api/handlers.py | 36 +++- unshackle/core/remote_service.py | 27 ++- 5 files changed, 294 insertions(+), 13 deletions(-) create mode 100644 tests/remote/unit/test_api_title_fields.py diff --git a/docs/dev/rest-api/endpoints.md b/docs/dev/rest-api/endpoints.md index f990e271..1ac28f05 100644 --- a/docs/dev/rest-api/endpoints.md +++ b/docs/dev/rest-api/endpoints.md @@ -223,6 +223,16 @@ Service-specific CLI options may also be passed as additional keys. Each serialized title carries `type` (`"episode"`, `"movie"`, or `"other"`), `name`, `id`, `language`, `description`, `date`, and `cover_url`. Episodes and movies add `year`; episodes additionally add `series_title`, `season`, and `number`. +These keys are sent only when the title carries them, so a title without them serializes exactly as before: + +| Key | Type | On | Meaning | +| --- | --- | --- | --- | +| `part` | integer | episode | Part index of a [split episode](../creating-a-service.md#split-episodes), counting from 1. | +| `air_date` | string | episode | ISO air date of [dated content](../../guide/downloading.md#daily-and-date-based-content). unshackle names the episode by date instead of `SxxExx`. | +| `absolute` | integer | episode | Absolute episode number across all seasons. | +| `daily` | boolean | episode | The episode is daily/date-based. Set from the title, or from the service's `DAILY` class attribute. | +| `anime` | boolean | episode, movie | The title is anime, so metadata lookups prefer AniList. Set from the title, or from the service's `ANIME` class attribute. | + !!! info "`part` on a split episode" A service that splits one episode into several separately playable videos reports each one as its own episode. Those titles share a `season` and `number` and add a `part` diff --git a/tests/remote/unit/test_api_title_fields.py b/tests/remote/unit/test_api_title_fields.py new file mode 100644 index 00000000..e62770f6 --- /dev/null +++ b/tests/remote/unit/test_api_title_fields.py @@ -0,0 +1,227 @@ +"""The title fields that cross the remote wire: air_date, absolute, anime and daily. + +Covers serialize_title's conditional keys, the service-class ANIME/DAILY stamp, and the +serialize_title / title_to_meta round trips through remote_service._build_title. +""" + +from __future__ import annotations + +import asyncio +import json +from datetime import date +from typing import Any, Dict, List, Optional + +import pytest +from langcodes import Language + +from unshackle.commands.dl import dl +from unshackle.core.api import handlers +from unshackle.core.api.handlers import _stamp_service_flags, serialize_title +from unshackle.core.remote_service import _build_title +from unshackle.core.titles.episode import Episode +from unshackle.core.titles.movie import Movie + +pytestmark = pytest.mark.unit + + +class _FakeSvc: + pass + + +def _ep(**overrides: Any) -> Episode: + base: Dict[str, Any] = dict( + id_="ep-0001", + service=_FakeSvc, + title="My Show", + season=1, + number=1, + name="Pilot", + year=2024, + language=None, + ) + base.update(overrides) + return Episode(**base) + + +def _movie(**overrides: Any) -> Movie: + base: Dict[str, Any] = dict(id_="movie-0001", service=_FakeSvc, name="Film", year=2024, language=Language.get("en")) + base.update(overrides) + return Movie(**base) + + +# ---------- serialize_title: the new conditional keys ---------- + + +NEW_KEYS = ("air_date", "absolute", "daily", "anime") + + +def test_a_plain_episode_gains_no_new_keys() -> None: + d = serialize_title(_ep()) + assert not [k for k in NEW_KEYS if k in d] + + +def test_a_plain_movie_gains_no_new_keys() -> None: + d = serialize_title(_movie()) + assert not [k for k in NEW_KEYS if k in d] + + +def test_air_date_is_serialized_as_an_iso_string() -> None: + assert serialize_title(_ep(air_date=date(2026, 8, 11)))["air_date"] == "2026-08-11" + + +def test_an_unparseable_air_date_is_serialized_as_text() -> None: + assert serialize_title(_ep(air_date="soon"))["air_date"] == "soon" + + +def test_absolute_is_serialized() -> None: + assert serialize_title(_ep(absolute=57))["absolute"] == 57 + + +@pytest.mark.parametrize("value", [True, False]) +def test_per_title_daily_and_anime_are_serialized_both_ways(value: bool) -> None: + episode = _ep() + episode.daily = value + episode.anime = value + d = serialize_title(episode) + assert (d["daily"], d["anime"]) == (value, value) + + +def test_a_movie_carries_the_anime_flag_but_never_daily() -> None: + movie = _movie() + movie.anime = True + d = serialize_title(movie) + assert d["anime"] is True + assert "daily" not in d + + +# ---------- the service-class ANIME/DAILY stamp ---------- + + +class _AnimeDailySvc: + ANIME = True + DAILY = True + + +class _PlainSvc: + pass + + +def test_the_stamp_fills_both_flags_from_the_service_class() -> None: + d = _stamp_service_flags(serialize_title(_ep()), _AnimeDailySvc()) + assert (d["anime"], d["daily"]) == (True, True) + + +def test_the_stamp_never_overrides_a_per_title_false() -> None: + episode = _ep() + episode.anime = False + episode.daily = False + d = _stamp_service_flags(serialize_title(episode), _AnimeDailySvc()) + assert (d["anime"], d["daily"]) == (False, False) + + +def test_a_plain_service_stamps_nothing() -> None: + d = _stamp_service_flags(serialize_title(_ep()), _PlainSvc()) + assert not [k for k in NEW_KEYS if k in d] + + +def test_a_movie_is_never_stamped_daily() -> None: + d = _stamp_service_flags(serialize_title(_movie()), _AnimeDailySvc()) + assert d["anime"] is True + assert "daily" not in d + + +class _FakeService: + ANIME = True + DAILY = True + + def __init__(self, titles: List[Episode]) -> None: + self._titles = titles + + def get_titles(self) -> List[Episode]: + return self._titles + + +def test_list_titles_stamps_the_service_flags(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(handlers, "validate_service", lambda tag, request=None: "FAKE") + monkeypatch.setattr(handlers, "setup_list_service", lambda *a, **kw: _FakeService([_ep()])) + response = asyncio.run(handlers.list_titles_handler({"service": "FAKE", "title_id": "t1"})) + title = json.loads(response.body)["titles"][0] + assert (title["anime"], title["daily"]) == (True, True) + + +# ---------- round trips through _build_title ---------- + + +def _round_trip(episode: Episode, stamp: Optional[Any] = None) -> Episode: + info = serialize_title(episode) + if stamp is not None: + info = _stamp_service_flags(info, stamp) + return _build_title(info, "FAKE", "fallback-id") + + +def test_serialize_round_trip_keeps_the_new_fields() -> None: + episode = _ep(air_date=date(2026, 8, 11), absolute=57) + episode.daily = True + episode.anime = True + rebuilt = _round_trip(episode) + assert rebuilt.air_date == date(2026, 8, 11) + assert rebuilt.absolute == 57 + assert (rebuilt.daily, rebuilt.anime) == (True, True) + + +def test_serialize_round_trip_leaves_unset_flags_inheriting() -> None: + rebuilt = _round_trip(_ep()) + assert rebuilt.air_date is None + assert rebuilt.absolute is None + assert (rebuilt.daily, rebuilt.anime) == (None, None) + + +def test_the_stamped_service_flags_survive_the_round_trip() -> None: + rebuilt = _round_trip(_ep(), stamp=_AnimeDailySvc()) + assert (rebuilt.daily, rebuilt.anime) == (True, True) + + +def test_a_per_title_false_survives_the_round_trip() -> None: + episode = _ep() + episode.daily = False + rebuilt = _round_trip(episode) + assert rebuilt.daily is False + + +def test_a_movie_round_trip_keeps_the_anime_flag() -> None: + movie = _movie() + movie.anime = True + rebuilt = _build_title(serialize_title(movie), "FAKE", "fallback-id") + assert isinstance(rebuilt, Movie) + assert rebuilt.anime is True + + +# ---------- round trips through title_to_meta (the export/import path) ---------- + + +def test_title_to_meta_omits_the_new_keys_for_a_plain_episode() -> None: + meta = dl.title_to_meta(_ep()) + assert not [k for k in ("absolute", "daily", "anime") if k in meta] + + +def test_title_to_meta_round_trip_keeps_the_new_fields() -> None: + episode = _ep(air_date=date(2026, 8, 11), absolute=57) + episode.daily = True + episode.anime = True + rebuilt = _build_title(dl.title_to_meta(episode), "FAKE", "fallback-id") + assert rebuilt.air_date == date(2026, 8, 11) + assert rebuilt.absolute == 57 + assert (rebuilt.daily, rebuilt.anime) == (True, True) + + +def test_title_to_meta_round_trip_leaves_unset_flags_inheriting() -> None: + rebuilt = _build_title(dl.title_to_meta(_ep()), "FAKE", "fallback-id") + assert rebuilt.absolute is None + assert (rebuilt.daily, rebuilt.anime) == (None, None) + + +def test_title_to_meta_round_trip_keeps_the_movie_anime_flag() -> None: + movie = _movie() + movie.anime = True + rebuilt = _build_title(dl.title_to_meta(movie), "FAKE", "fallback-id") + assert rebuilt.anime is True diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 67e8aa24..b2e2558a 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -4138,12 +4138,19 @@ class dl: year=title.year, air_date=title.air_date.isoformat() if isinstance(title.air_date, date) else title.air_date, ) + # conditional, so meta for a title without them is unchanged if title.part is not None: meta["part"] = title.part + if title.absolute is not None: + meta["absolute"] = title.absolute + if getattr(title, "daily", None) is not None: + meta["daily"] = title.daily elif isinstance(title, Movie): meta.update(type="movie", name=title.name, year=title.year) else: meta.update(type="movie", name=str(title)) + if getattr(title, "anime", None) is not None: + meta["anime"] = title.anime return meta def write_export(self, export: Path, title: Title_T, track: AnyTrack, drm: Any = None) -> None: diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index 7bbbef54..a0fc56f9 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -2,6 +2,7 @@ import asyncio import enum import logging import re +from datetime import date as date_ from pathlib import Path from types import SimpleNamespace from typing import Any, Dict, List, Optional @@ -364,16 +365,41 @@ def serialize_title(title: Title_T) -> Dict[str, Any]: # "other" titles carry no year; only Episode/Movie do. if isinstance(title, (Episode, Movie)): result["year"] = title.year - if is_episode: + if isinstance(title, Episode): result["series_title"] = str(title.title) result["season"] = title.season result["number"] = title.number + # every key below is conditional, so JSON for a title without them is unchanged if episode_part is not None: - result["part"] = episode_part # conditional, so part-less JSON is unchanged + result["part"] = episode_part + if title.air_date is not None: + result["air_date"] = ( + title.air_date.isoformat() if isinstance(title.air_date, date_) else str(title.air_date) + ) + if title.absolute is not None: + result["absolute"] = title.absolute + if getattr(title, "daily", None) is not None: + result["daily"] = title.daily + if isinstance(title, (Episode, Movie)) and getattr(title, "anime", None) is not None: + result["anime"] = title.anime return result +def _stamp_service_flags(serialized: Dict[str, Any], service_instance: Any) -> Dict[str, Any]: + """Fill anime/daily from the service class for titles that set neither. + + The client rebuilds titles from this JSON against a synthetic service class, so a + class-level ANIME/DAILY would otherwise be lost on the way across. + """ + for key, attr in (("anime", "ANIME"), ("daily", "DAILY")): + if key == "daily" and serialized.get("type") != "episode": + continue + if serialized.get(key) is None and getattr(type(service_instance), attr, False): + serialized[key] = True + return serialized + + def _extract_manifests(tracks) -> List[Dict[str, Any]]: """Extract manifest data from tracks for client-side re-parsing. @@ -678,9 +704,9 @@ async def list_titles_handler(data: Dict[str, Any], request: Optional[web.Reques titles = service_instance.get_titles() if hasattr(titles, "__iter__") and not isinstance(titles, str): - title_list = [serialize_title(t) for t in titles] + title_list = [_stamp_service_flags(serialize_title(t), service_instance) for t in titles] else: - title_list = [serialize_title(titles)] + title_list = [_stamp_service_flags(serialize_title(titles), service_instance)] return web.json_response({"titles": title_list}) @@ -1877,7 +1903,7 @@ async def session_titles_handler(session_id: str, request: Optional[web.Request] for t in titles_list: tid = str(t.id) if hasattr(t, "id") else str(id(t)) session.title_map[tid] = t - serialized_titles.append(serialize_title(t)) + serialized_titles.append(_stamp_service_flags(serialize_title(t), service_instance)) return web.json_response( { diff --git a/unshackle/core/remote_service.py b/unshackle/core/remote_service.py index 996d4010..214a30df 100644 --- a/unshackle/core/remote_service.py +++ b/unshackle/core/remote_service.py @@ -288,8 +288,9 @@ def _match_track(remote_track: Track, local_tracks: list) -> Optional[Track]: def _build_title(info: Dict[str, Any], service_tag: str, fallback_id: str) -> Union[Episode, Movie]: svc_class = type(service_tag, (), {}) lang = Language.get(info["language"]) if info.get("language") else None + title: Union[Episode, Movie] if info.get("type") == "episode": - return Episode( + title = Episode( id_=info.get("id", fallback_id), service=svc_class, title=info.get("series_title", "Unknown"), @@ -300,14 +301,22 @@ def _build_title(info: Dict[str, Any], service_tag: str, fallback_id: str) -> Un language=lang, air_date=info.get("air_date"), part=info.get("part"), + absolute=info.get("absolute"), ) - return Movie( - id_=info.get("id", fallback_id), - service=svc_class, - name=info.get("name", "Unknown"), - year=info.get("year"), - language=lang, - ) + if "daily" in info: + title.daily = info["daily"] + else: + title = Movie( + id_=info.get("id", fallback_id), + service=svc_class, + name=info.get("name", "Unknown"), + year=info.get("year"), + language=lang, + ) + # the synthetic service class carries no ANIME/DAILY, so the flags only survive per title + if "anime" in info: + title.anime = info["anime"] + return title def resolve_server(server_name: Optional[str]) -> tuple[str, str, dict]: @@ -388,6 +397,8 @@ class RemoteService: ALIASES: tuple[str, ...] = () GEOFENCE: tuple[str, ...] = () NO_SUBTITLES: bool = False + ANIME: bool = False + DAILY: bool = False def __init__( self,