From 0dc053881a8b9cba2df332ff0a1cd9dac764b990 Mon Sep 17 00:00:00 2001 From: Francwa Date: Tue, 26 May 2026 00:35:42 +0200 Subject: [PATCH] feat(tmdb): add TmdbMovieInfo DTO and get_movie_info MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- alfred/infrastructure/api/tmdb/client.py | 32 ++++++- alfred/infrastructure/api/tmdb/dto.py | 82 ++++++++++++++++++ tests/infrastructure/api/test_tmdb_client.py | 40 +++++++++ tests/infrastructure/api/test_tmdb_dto.py | 89 ++++++++++++++++++++ 4 files changed, 242 insertions(+), 1 deletion(-) diff --git a/alfred/infrastructure/api/tmdb/client.py b/alfred/infrastructure/api/tmdb/client.py index 18e19c7..9e99a3f 100644 --- a/alfred/infrastructure/api/tmdb/client.py +++ b/alfred/infrastructure/api/tmdb/client.py @@ -8,7 +8,13 @@ from requests.exceptions import HTTPError, RequestException, Timeout 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 ( TMDBAPIError, TMDBConfigurationError, @@ -302,6 +308,30 @@ class TMDBClient: external = self.get_external_ids("tv", tmdb_id) 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: """ Check if TMDB client is properly configured. diff --git a/alfred/infrastructure/api/tmdb/dto.py b/alfred/infrastructure/api/tmdb/dto.py index 211545e..f1215e3 100644 --- a/alfred/infrastructure/api/tmdb/dto.py +++ b/alfred/infrastructure/api/tmdb/dto.py @@ -156,3 +156,85 @@ def _is_aired(air_date_raw: Any, today: date) -> bool: return date.fromisoformat(air_date_raw) <= today except ValueError: 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 diff --git a/tests/infrastructure/api/test_tmdb_client.py b/tests/infrastructure/api/test_tmdb_client.py index f466264..e8a2e58 100644 --- a/tests/infrastructure/api/test_tmdb_client.py +++ b/tests/infrastructure/api/test_tmdb_client.py @@ -360,6 +360,46 @@ class TestGetTvShowInfo: 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: def test_true_when_complete(self, client): assert client.is_configured() is True diff --git a/tests/infrastructure/api/test_tmdb_dto.py b/tests/infrastructure/api/test_tmdb_dto.py index aa6ace3..4784360 100644 --- a/tests/infrastructure/api/test_tmdb_dto.py +++ b/tests/infrastructure/api/test_tmdb_dto.py @@ -13,8 +13,10 @@ from datetime import date import pytest from alfred.infrastructure.api.tmdb.dto import ( + TmdbMovieInfo, TmdbSeasonInfo, TmdbShowInfo, + parse_movie_info, parse_tv_show_info, ) @@ -166,3 +168,90 @@ class TestParseTvShowInfoErrors: {}, 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": ""}, {})