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.
This commit is contained in:
imSp4rky
2026-08-11 17:50:38 -06:00
parent 4a21dbebb5
commit ec594b0b9c
5 changed files with 294 additions and 13 deletions
+10
View File
@@ -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`
+227
View File
@@ -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
+7
View File
@@ -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:
+31 -5
View File
@@ -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(
{
+19 -8
View File
@@ -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,