feat(tmdb): add TmdbMovieInfo DTO and get_movie_info
Symmetric to TmdbShowInfo / get_tv_show_info — gives the upcoming
sync_movie orchestrator a typed cache snapshot for the v2 movie
library index.
* TmdbMovieInfo(tmdb_id, imdb_id, title, release_year)
* parse_movie_info(details, external_ids) — pure builder, parses
release_year from the first 4 chars of release_date (None on
missing/empty/non-numeric)
* TMDBClient.get_movie_info(tmdb_id) — aggregates
/movie/{id} + /movie/{id}/external_ids and feeds the parser
Tests cover happy path, missing/null/empty imdb_id, every
release_year edge (none/empty/short/non-numeric/missing key),
and the two required-field errors (id, title).
This commit is contained in:
@@ -8,7 +8,13 @@ from requests.exceptions import HTTPError, RequestException, Timeout
|
|||||||
|
|
||||||
from alfred.settings import Settings, settings
|
from alfred.settings import Settings, settings
|
||||||
|
|
||||||
from .dto import MediaResult, TmdbShowInfo, parse_tv_show_info
|
from .dto import (
|
||||||
|
MediaResult,
|
||||||
|
TmdbMovieInfo,
|
||||||
|
TmdbShowInfo,
|
||||||
|
parse_movie_info,
|
||||||
|
parse_tv_show_info,
|
||||||
|
)
|
||||||
from .exceptions import (
|
from .exceptions import (
|
||||||
TMDBAPIError,
|
TMDBAPIError,
|
||||||
TMDBConfigurationError,
|
TMDBConfigurationError,
|
||||||
@@ -302,6 +308,30 @@ class TMDBClient:
|
|||||||
external = self.get_external_ids("tv", tmdb_id)
|
external = self.get_external_ids("tv", tmdb_id)
|
||||||
return parse_tv_show_info(details, external)
|
return parse_tv_show_info(details, external)
|
||||||
|
|
||||||
|
def get_movie_info(self, tmdb_id: int) -> TmdbMovieInfo:
|
||||||
|
"""
|
||||||
|
Aggregate ``/movie/{id}`` + ``/movie/{id}/external_ids`` into a
|
||||||
|
:class:`TmdbMovieInfo` — the shape consumed by the v2 movie
|
||||||
|
library-root index cache. Symmetric to
|
||||||
|
:meth:`get_tv_show_info`.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tmdb_id: TMDB movie ID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`TmdbMovieInfo` with ``imdb_id`` (when available)
|
||||||
|
and ``release_year`` (parsed from ``release_date``).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TMDBAPIError: if either HTTP call fails.
|
||||||
|
TMDBNotFoundError: if the movie id is unknown.
|
||||||
|
ValueError: if the TMDB payload is missing required fields
|
||||||
|
(``id``, ``title``).
|
||||||
|
"""
|
||||||
|
details = self.get_movie_details(tmdb_id)
|
||||||
|
external = self.get_external_ids("movie", tmdb_id)
|
||||||
|
return parse_movie_info(details, external)
|
||||||
|
|
||||||
def is_configured(self) -> bool:
|
def is_configured(self) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if TMDB client is properly configured.
|
Check if TMDB client is properly configured.
|
||||||
|
|||||||
@@ -156,3 +156,85 @@ def _is_aired(air_date_raw: Any, today: date) -> bool:
|
|||||||
return date.fromisoformat(air_date_raw) <= today
|
return date.fromisoformat(air_date_raw) <= today
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Movie details — used by the v2 library-root index cache
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TmdbMovieInfo:
|
||||||
|
"""TMDB-cached identity for one movie.
|
||||||
|
|
||||||
|
Populated by :meth:`TMDBClient.get_movie_info`. Carries only the
|
||||||
|
fields the v2 movie library index needs to cache; richer details
|
||||||
|
remain on-demand via the raw client methods. Symmetric to
|
||||||
|
:class:`TmdbShowInfo`.
|
||||||
|
|
||||||
|
``release_year`` is parsed from TMDB's ``release_date`` (the
|
||||||
|
first 4 chars). It is ``None`` when TMDB has no release date
|
||||||
|
(very old or future-dated titles).
|
||||||
|
"""
|
||||||
|
|
||||||
|
tmdb_id: int
|
||||||
|
imdb_id: str | None
|
||||||
|
title: str
|
||||||
|
release_year: int | None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_movie_info(
|
||||||
|
details: dict[str, Any],
|
||||||
|
external_ids: dict[str, Any],
|
||||||
|
) -> TmdbMovieInfo:
|
||||||
|
"""Build a :class:`TmdbMovieInfo` from raw TMDB payloads.
|
||||||
|
|
||||||
|
Pure function — no HTTP, no I/O. The HTTP layer
|
||||||
|
(:meth:`TMDBClient.get_movie_info`) calls this with the JSON it
|
||||||
|
received from TMDB.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
details: payload from ``/movie/{tmdb_id}`` (``title``,
|
||||||
|
``release_date`` …).
|
||||||
|
external_ids: payload from ``/movie/{tmdb_id}/external_ids``
|
||||||
|
(``imdb_id`` mostly).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: if a required field (``id``, ``title``) is missing
|
||||||
|
from ``details``.
|
||||||
|
"""
|
||||||
|
tmdb_id = details.get("id")
|
||||||
|
if not isinstance(tmdb_id, int):
|
||||||
|
raise ValueError(
|
||||||
|
f"TMDB movie payload missing/invalid 'id': {tmdb_id!r}"
|
||||||
|
)
|
||||||
|
title = details.get("title")
|
||||||
|
if not isinstance(title, str) or not title:
|
||||||
|
raise ValueError(
|
||||||
|
f"TMDB movie payload missing/invalid 'title': {title!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
imdb_id_raw = external_ids.get("imdb_id")
|
||||||
|
imdb_id = imdb_id_raw if isinstance(imdb_id_raw, str) and imdb_id_raw else None
|
||||||
|
|
||||||
|
release_year = _parse_release_year(details.get("release_date"))
|
||||||
|
|
||||||
|
return TmdbMovieInfo(
|
||||||
|
tmdb_id=tmdb_id,
|
||||||
|
imdb_id=imdb_id,
|
||||||
|
title=title,
|
||||||
|
release_year=release_year,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_release_year(release_date_raw: Any) -> int | None:
|
||||||
|
"""Extract the year from a TMDB ``release_date`` (YYYY-MM-DD).
|
||||||
|
|
||||||
|
Returns ``None`` for missing, empty, or unparseable values.
|
||||||
|
"""
|
||||||
|
if not isinstance(release_date_raw, str) or len(release_date_raw) < 4:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(release_date_raw[:4])
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|||||||
@@ -360,6 +360,46 @@ class TestGetTvShowInfo:
|
|||||||
assert info.seasons == ()
|
assert info.seasons == ()
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetMovieInfo:
|
||||||
|
"""``get_movie_info`` aggregates ``/movie/{id}`` + external_ids."""
|
||||||
|
|
||||||
|
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
|
||||||
|
def test_happy_path(self, mock_get, client):
|
||||||
|
details = {
|
||||||
|
"id": 27205,
|
||||||
|
"title": "Inception",
|
||||||
|
"release_date": "2010-07-16",
|
||||||
|
}
|
||||||
|
external = {"imdb_id": "tt1375666"}
|
||||||
|
mock_get.side_effect = [
|
||||||
|
_ok_response(details),
|
||||||
|
_ok_response(external),
|
||||||
|
]
|
||||||
|
|
||||||
|
info = client.get_movie_info(27205)
|
||||||
|
|
||||||
|
assert info.tmdb_id == 27205
|
||||||
|
assert info.imdb_id == "tt1375666"
|
||||||
|
assert info.title == "Inception"
|
||||||
|
assert info.release_year == 2010
|
||||||
|
|
||||||
|
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
|
||||||
|
def test_missing_imdb_id_becomes_none(self, mock_get, client):
|
||||||
|
mock_get.side_effect = [
|
||||||
|
_ok_response(
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"title": "X",
|
||||||
|
"release_date": "2024-01-01",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
_ok_response({}),
|
||||||
|
]
|
||||||
|
info = client.get_movie_info(1)
|
||||||
|
assert info.imdb_id is None
|
||||||
|
assert info.release_year == 2024
|
||||||
|
|
||||||
|
|
||||||
class TestIsConfigured:
|
class TestIsConfigured:
|
||||||
def test_true_when_complete(self, client):
|
def test_true_when_complete(self, client):
|
||||||
assert client.is_configured() is True
|
assert client.is_configured() is True
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ from datetime import date
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from alfred.infrastructure.api.tmdb.dto import (
|
from alfred.infrastructure.api.tmdb.dto import (
|
||||||
|
TmdbMovieInfo,
|
||||||
TmdbSeasonInfo,
|
TmdbSeasonInfo,
|
||||||
TmdbShowInfo,
|
TmdbShowInfo,
|
||||||
|
parse_movie_info,
|
||||||
parse_tv_show_info,
|
parse_tv_show_info,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -166,3 +168,90 @@ class TestParseTvShowInfoErrors:
|
|||||||
{},
|
{},
|
||||||
today=REF_DATE,
|
today=REF_DATE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# parse_movie_info
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
def _movie_details(**overrides):
|
||||||
|
base = {
|
||||||
|
"id": 27205,
|
||||||
|
"title": "Inception",
|
||||||
|
"release_date": "2010-07-16",
|
||||||
|
}
|
||||||
|
base.update(overrides)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseMovieInfoHappyPath:
|
||||||
|
def test_minimal(self):
|
||||||
|
info = parse_movie_info(
|
||||||
|
_movie_details(),
|
||||||
|
{"imdb_id": "tt1375666"},
|
||||||
|
)
|
||||||
|
assert info == TmdbMovieInfo(
|
||||||
|
tmdb_id=27205,
|
||||||
|
imdb_id="tt1375666",
|
||||||
|
title="Inception",
|
||||||
|
release_year=2010,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_release_year_extracted_from_release_date(self):
|
||||||
|
info = parse_movie_info(
|
||||||
|
_movie_details(release_date="1999-03-31"),
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
assert info.release_year == 1999
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseMovieInfoImdb:
|
||||||
|
def test_missing_imdb_id_becomes_none(self):
|
||||||
|
info = parse_movie_info(_movie_details(), {})
|
||||||
|
assert info.imdb_id is None
|
||||||
|
|
||||||
|
def test_null_imdb_id_becomes_none(self):
|
||||||
|
info = parse_movie_info(_movie_details(), {"imdb_id": None})
|
||||||
|
assert info.imdb_id is None
|
||||||
|
|
||||||
|
def test_empty_string_imdb_id_becomes_none(self):
|
||||||
|
info = parse_movie_info(_movie_details(), {"imdb_id": ""})
|
||||||
|
assert info.imdb_id is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseMovieInfoReleaseYear:
|
||||||
|
def test_no_release_date_yields_none(self):
|
||||||
|
info = parse_movie_info(_movie_details(release_date=None), {})
|
||||||
|
assert info.release_year is None
|
||||||
|
|
||||||
|
def test_empty_release_date_yields_none(self):
|
||||||
|
info = parse_movie_info(_movie_details(release_date=""), {})
|
||||||
|
assert info.release_year is None
|
||||||
|
|
||||||
|
def test_release_date_too_short_yields_none(self):
|
||||||
|
info = parse_movie_info(_movie_details(release_date="201"), {})
|
||||||
|
assert info.release_year is None
|
||||||
|
|
||||||
|
def test_release_date_non_numeric_prefix_yields_none(self):
|
||||||
|
info = parse_movie_info(_movie_details(release_date="soon"), {})
|
||||||
|
assert info.release_year is None
|
||||||
|
|
||||||
|
def test_missing_release_date_yields_none(self):
|
||||||
|
# release_date key absent from the payload.
|
||||||
|
info = parse_movie_info({"id": 1, "title": "X"}, {})
|
||||||
|
assert info.release_year is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseMovieInfoErrors:
|
||||||
|
def test_missing_id_raises(self):
|
||||||
|
with pytest.raises(ValueError, match="'id'"):
|
||||||
|
parse_movie_info({"title": "X"}, {})
|
||||||
|
|
||||||
|
def test_missing_title_raises(self):
|
||||||
|
with pytest.raises(ValueError, match="'title'"):
|
||||||
|
parse_movie_info({"id": 1}, {})
|
||||||
|
|
||||||
|
def test_empty_title_raises(self):
|
||||||
|
with pytest.raises(ValueError, match="'title'"):
|
||||||
|
parse_movie_info({"id": 1, "title": ""}, {})
|
||||||
|
|||||||
Reference in New Issue
Block a user