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