From e1a36ce2279ae500beca42cd62266895b3966e8e Mon Sep 17 00:00:00 2001 From: NixReaper Date: Sun, 6 Sep 2026 04:07:18 -0500 Subject: [PATCH] Expose certifications and per-country release dates TMDb's release_dates/content_ratings blocks were already fetched via append_to_response but discarded at normalization -- every downstream layer (ingestion write, repository read, schema, service) was unbuilt. Adds the full pipeline: NormalizedReleaseDate + get_or_create_certification, a shared polymorphic sync/read (movie and series both use it), and MovieDetailOut/SeriesDetailOut certification + release_dates fields. Series content_ratings has no date of its own, so each rating is anchored to the show's first_air_date with release_type="tv". Co-Authored-By: Claude Sonnet 5 --- app/models/movie.py | 3 + app/repositories/polymorphic.py | 71 ++++++++++- app/repositories/reference_repository.py | 28 ++++- app/schemas/common.py | 13 ++ app/schemas/movie.py | 6 + app/schemas/tv.py | 5 + app/services/common.py | 34 ++++++ app/services/ingestion/movie_ingestion.py | 3 + app/services/ingestion/series_ingestion.py | 3 + app/services/metadata/normalizer.py | 97 ++++++++++++++- app/services/movie_service.py | 12 +- app/services/tv_service.py | 12 +- docs/api.md | 6 + tests/integration/test_release_dates.py | 134 +++++++++++++++++++++ tests/unit/test_normalizer.py | 88 ++++++++++++++ 15 files changed, 510 insertions(+), 5 deletions(-) create mode 100644 tests/integration/test_release_dates.py diff --git a/app/models/movie.py b/app/models/movie.py index 410d567..30102a2 100644 --- a/app/models/movie.py +++ b/app/models/movie.py @@ -11,6 +11,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin from app.models.collection import Collection from app.models.enums import MovieStatus +from app.models.language_country import Certification class Movie(Base, UUIDPrimaryKeyMixin, TimestampMixin): @@ -101,6 +102,8 @@ class ReleaseDate(Base, UUIDPrimaryKeyMixin): ) note: Mapped[str | None] = mapped_column(Text, nullable=True) + certification: Mapped[Certification | None] = relationship() + class MovieRecommendation(Base): """Recommendations/similar-title edges between movies.""" diff --git a/app/repositories/polymorphic.py b/app/repositories/polymorphic.py index 0e4ec9d..312c8a0 100644 --- a/app/repositories/polymorphic.py +++ b/app/repositories/polymorphic.py @@ -7,9 +7,10 @@ from __future__ import annotations import uuid from collections.abc import Sequence +from datetime import date from typing import Protocol -from sqlalchemy import select +from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -17,12 +18,27 @@ from app.models.artwork import Artwork from app.models.credit import Credit from app.models.enums import ArtworkType from app.models.external_id import ExternalID +from app.models.movie import ReleaseDate from app.models.provider import Provider from app.models.video import Video +from app.repositories import reference_repository as refs _VALID_ARTWORK_TYPES = {t.value for t in ArtworkType} +class _NormalizedReleaseDateLike(Protocol): + """Structural type for + app.services.metadata.normalizer.NormalizedReleaseDate — see + _NormalizedArtworkLike above for why a repository takes a Protocol + instead of importing the services-layer dataclass directly.""" + + country_code: str + release_date: date + release_type: str | None + certification: tuple[str, str] | None + note: str | None + + class _NormalizedArtworkLike(Protocol): """Structural type for app.services.metadata.normalizer.NormalizedArtwork — a repository shouldn't import from the services layer, so this @@ -219,3 +235,56 @@ async def get_credits( ) result = await session.execute(stmt) return list(result.scalars().all()) + + +async def get_release_dates( + session: AsyncSession, entity_type: str, entity_id: uuid.UUID +) -> list[ReleaseDate]: + stmt = ( + select(ReleaseDate) + .options(selectinload(ReleaseDate.certification)) + .where(ReleaseDate.entity_type == entity_type, ReleaseDate.entity_id == entity_id) + .order_by(ReleaseDate.release_date.asc()) + ) + result = await session.execute(stmt) + return list(result.scalars().all()) + + +async def sync_release_dates( + session: AsyncSession, + *, + entity_type: str, + entity_id: uuid.UUID, + release_dates: Sequence[_NormalizedReleaseDateLike], +) -> None: + """Replace an entity's release-date/certification rows wholesale, same + delete-then-recreate approach as credits/videos (see module docstring) + -- unlike Artwork, a ReleaseDate has no local-file state worth + preserving across re-syncs.""" + + await session.execute( + delete(ReleaseDate).where( + ReleaseDate.entity_type == entity_type, ReleaseDate.entity_id == entity_id + ) + ) + for rd in release_dates: + await refs.get_or_create_country(session, rd.country_code, rd.country_code) + certification_id = None + if rd.certification is not None: + system, rating = rd.certification + certification = await refs.get_or_create_certification( + session, country_code=rd.country_code, system=system, rating=rating + ) + certification_id = certification.id + session.add( + ReleaseDate( + entity_type=entity_type, + entity_id=entity_id, + country_code=rd.country_code, + release_date=rd.release_date, + release_type=rd.release_type, + certification_id=certification_id, + note=rd.note, + ) + ) + await session.flush() diff --git a/app/repositories/reference_repository.py b/app/repositories/reference_repository.py index 786ef36..db8cf64 100644 --- a/app/repositories/reference_repository.py +++ b/app/repositories/reference_repository.py @@ -22,7 +22,7 @@ from app.models.enums import CompanyType from app.models.external_id import ExternalID from app.models.genre import Genre from app.models.keyword import Keyword -from app.models.language_country import Country, Language +from app.models.language_country import Certification, Country, Language from app.models.person import Person from app.repositories.concurrency import create_or_recover as _create_or_recover @@ -108,6 +108,32 @@ async def get_or_create_country(session: AsyncSession, code: str, name: str) -> return await _create_or_recover(session, _create, _lookup) +async def get_or_create_certification( + session: AsyncSession, *, country_code: str, system: str, rating: str +) -> Certification: + async def _lookup() -> Certification | None: + result = await session.execute( + select(Certification).where( + Certification.country_code == country_code, + Certification.system == system, + Certification.rating == rating, + ) + ) + return result.scalar_one_or_none() + + existing = await _lookup() + if existing is not None: + return existing + + async def _create() -> Certification: + certification = Certification(country_code=country_code, system=system, rating=rating) + session.add(certification) + await session.flush() + return certification + + return await _create_or_recover(session, _create, _lookup) + + async def find_company_by_external_id( session: AsyncSession, provider_id: uuid.UUID, external_id: str ) -> Company | None: diff --git a/app/schemas/common.py b/app/schemas/common.py index 51adcdd..eacc275 100644 --- a/app/schemas/common.py +++ b/app/schemas/common.py @@ -112,6 +112,19 @@ class CollectionBriefOut(BaseModel): poster_path: str | None = None +class CertificationOut(BaseModel): + system: str + rating: str + + +class ReleaseDateOut(BaseModel): + country_code: str + release_date: date + release_type: str | None = None + certification: CertificationOut | None = None + note: str | None = None + + class ErrorDetail(BaseModel): code: str message: str diff --git a/app/schemas/movie.py b/app/schemas/movie.py index de5d6a5..cb66e79 100644 --- a/app/schemas/movie.py +++ b/app/schemas/movie.py @@ -14,6 +14,7 @@ from app.schemas.common import ( GenreOut, ImagesOut, KeywordOut, + ReleaseDateOut, VideoOut, ) @@ -49,6 +50,10 @@ class MovieDetailOut(BaseModel): vote_average: float | None = None vote_count: int | None = None adult: bool = False + # The US MPA rating (e.g. "PG-13"), the badge TMDb shows next to a + # title by default -- convenience pick out of `release_dates` below for + # a client that just wants a single rating without picking a country. + certification: str | None = None genres: list[GenreOut] = [] keywords: list[KeywordOut] = [] @@ -58,6 +63,7 @@ class MovieDetailOut(BaseModel): collection: CollectionBriefOut | None = None images: ImagesOut = ImagesOut() videos: list[VideoOut] = [] + release_dates: list[ReleaseDateOut] = [] external_ids: dict[str, str] = {} created_at: datetime diff --git a/app/schemas/tv.py b/app/schemas/tv.py index 3a10343..0e7ec37 100644 --- a/app/schemas/tv.py +++ b/app/schemas/tv.py @@ -13,6 +13,7 @@ from app.schemas.common import ( GenreOut, ImagesOut, KeywordOut, + ReleaseDateOut, VideoOut, ) @@ -54,6 +55,9 @@ class SeriesDetailOut(BaseModel): popularity: float | None = None vote_average: float | None = None vote_count: int | None = None + # The US TV rating (e.g. "TV-MA") -- convenience pick out of + # `release_dates` below, same as MovieDetailOut.certification. + certification: str | None = None genres: list[GenreOut] = [] keywords: list[KeywordOut] = [] @@ -65,6 +69,7 @@ class SeriesDetailOut(BaseModel): seasons: list[SeasonBriefOut] = [] images: ImagesOut = ImagesOut() videos: list[VideoOut] = [] + release_dates: list[ReleaseDateOut] = [] external_ids: dict[str, str] = {} created_at: datetime diff --git a/app/services/common.py b/app/services/common.py index f36e68e..f0d0803 100644 --- a/app/services/common.py +++ b/app/services/common.py @@ -6,13 +6,16 @@ from app.core.ids import aurora_id from app.models.artwork import Artwork from app.models.credit import Credit from app.models.enums import ArtworkType +from app.models.movie import ReleaseDate from app.models.video import Video from app.schemas.common import ( ArtworkOut, CastCreditOut, + CertificationOut, CrewCreditOut, ImagesOut, PersonBriefOut, + ReleaseDateOut, VideoOut, ) @@ -70,6 +73,37 @@ def to_video_out(video: Video) -> VideoOut: ) +def to_release_date_out(release_date: ReleaseDate) -> ReleaseDateOut: + certification = None + if release_date.certification is not None: + certification = CertificationOut( + system=release_date.certification.system, rating=release_date.certification.rating + ) + return ReleaseDateOut( + country_code=release_date.country_code, + release_date=release_date.release_date, + release_type=release_date.release_type, + certification=certification, + note=release_date.note, + ) + + +def pick_certification(release_dates: list[ReleaseDate], *, preferred_country: str = "US") -> str | None: + """The single rating a client wants when it isn't picking a country + itself -- TMDb's own site defaults to the US rating badge, so this + does too, falling back to the first country that has one at all.""" + + fallback: str | None = None + for rd in release_dates: + if rd.certification is None: + continue + if rd.country_code == preferred_country: + return rd.certification.rating + if fallback is None: + fallback = rd.certification.rating + return fallback + + def split_credits( credits_: list[Credit], profile_paths: dict | None = None ) -> tuple[list[CastCreditOut], list[CrewCreditOut]]: diff --git a/app/services/ingestion/movie_ingestion.py b/app/services/ingestion/movie_ingestion.py index 625de39..971f7ae 100644 --- a/app/services/ingestion/movie_ingestion.py +++ b/app/services/ingestion/movie_ingestion.py @@ -249,6 +249,9 @@ async def ingest_movie( artwork_list=normalized.artwork, ) await _replace_movie_videos(session, movie.id, tmdb_provider.id, normalized.videos) + await polymorphic.sync_release_dates( + session, entity_type="movie", entity_id=movie.id, release_dates=normalized.release_dates + ) await _link_external_ids(session, movie.id, tmdb_provider.id, normalized) await session.execute( diff --git a/app/services/ingestion/series_ingestion.py b/app/services/ingestion/series_ingestion.py index 84d9cd5..d61a2b0 100644 --- a/app/services/ingestion/series_ingestion.py +++ b/app/services/ingestion/series_ingestion.py @@ -244,6 +244,9 @@ async def ingest_series( artwork_list=normalized.artwork, ) await _replace_entity_videos(session, "series", series.id, tmdb_provider.id, normalized.videos) + await polymorphic.sync_release_dates( + session, entity_type="series", entity_id=series.id, release_dates=normalized.release_dates + ) for stub in normalized.seasons: season = await tv_repository.get_or_create_season(session, series.id, stub.season_number) diff --git a/app/services/metadata/normalizer.py b/app/services/metadata/normalizer.py index a71e9cc..9f8cdc6 100644 --- a/app/services/metadata/normalizer.py +++ b/app/services/metadata/normalizer.py @@ -96,6 +96,95 @@ def company_logo_artwork(logo_path: str | None) -> NormalizedArtwork | None: ) +# TMDb's per-country rating body isn't returned alongside a release_dates +# certification or a content_ratings rating -- only the country and the +# rating string itself are. This maps the handful of markets Aurora can +# name with confidence; everywhere else falls back to the country code +# itself (see NormalizedReleaseDate.certification) rather than fabricating +# a body name TMDb never told us. +_CERTIFICATION_SYSTEMS = { + "US": "MPA", + "GB": "BBFC", + "DE": "FSK", + "FR": "CNC", + "JP": "Eirin", + "AU": "ACB", + "CA": "CHVRS", + "KR": "KMRB", + "BR": "ANCINE", + "ES": "ICAA", + "IT": "MiC", + "NZ": "OFLC", + "IE": "IFCO", + "IN": "CBFC", +} + +# TMDb's release_dates[].type: 1=Premiere, 2=Theatrical (limited), +# 3=Theatrical, 4=Digital, 5=Physical, 6=TV -- collapsed onto the five +# release_type values ReleaseDate.release_type documents. +_RELEASE_TYPE_MAP = {1: "premiere", 2: "theatrical", 3: "theatrical", 4: "digital", 5: "physical", 6: "tv"} + + +@dataclass +class NormalizedReleaseDate: + country_code: str + release_date: date + release_type: str | None + certification: tuple[str, str] | None = None # (system, rating) + note: str | None = None + + +def _movie_release_dates(raw: dict) -> list[NormalizedReleaseDate]: + out: list[NormalizedReleaseDate] = [] + for country_block in (raw.get("release_dates") or {}).get("results", []): + country_code = country_block.get("iso_3166_1") + if not country_code: + continue + for entry in country_block.get("release_dates", []): + release_date = _parse_date((entry.get("release_date") or "")[:10] or None) + if release_date is None: + continue + rating = entry.get("certification") + certification = ( + (_CERTIFICATION_SYSTEMS.get(country_code, country_code), rating) if rating else None + ) + out.append( + NormalizedReleaseDate( + country_code=country_code, + release_date=release_date, + release_type=_RELEASE_TYPE_MAP.get(entry.get("type")), + certification=certification, + note=entry.get("note") or None, + ) + ) + return out + + +def _series_release_dates(raw: dict, first_air_date: date | None) -> list[NormalizedReleaseDate]: + # Unlike /movie's release_dates, TMDb's /tv content_ratings has no date + # at all -- just a country and a rating. ReleaseDate.release_date is + # required, so each rating is anchored to the show's own first_air_date + # (release_type="tv") rather than left dateless; a show with no + # first_air_date yet has nothing to anchor to and is skipped. + if first_air_date is None: + return [] + out: list[NormalizedReleaseDate] = [] + for entry in (raw.get("content_ratings") or {}).get("results", []): + country_code = entry.get("iso_3166_1") + rating = entry.get("rating") + if not country_code or not rating: + continue + out.append( + NormalizedReleaseDate( + country_code=country_code, + release_date=first_air_date, + release_type="tv", + certification=(_CERTIFICATION_SYSTEMS.get(country_code, country_code), rating), + ) + ) + return out + + @dataclass class NormalizedVideo: video_type: str @@ -136,6 +225,7 @@ class NormalizedMovie: external_ids: dict[str, str] = field(default_factory=dict) collection: tuple[str, str] | None = None # (external_id, name) recommendation_external_ids: list[str] = field(default_factory=list) + release_dates: list[NormalizedReleaseDate] = field(default_factory=list) @dataclass @@ -338,6 +428,7 @@ def normalize_movie(raw: dict) -> NormalizedMovie: external_ids=external_ids, collection=(str(collection["id"]), collection["name"]) if collection else None, recommendation_external_ids=[str(m["id"]) for m in recommendations], + release_dates=_movie_release_dates(raw), ) @@ -486,6 +577,7 @@ class NormalizedSeries: videos: list[NormalizedVideo] = field(default_factory=list) external_ids: dict[str, str] = field(default_factory=dict) seasons: list[NormalizedSeasonStub] = field(default_factory=list) + release_dates: list[NormalizedReleaseDate] = field(default_factory=list) def normalize_series(raw: dict) -> NormalizedSeries: @@ -637,6 +729,8 @@ def normalize_series(raw: dict) -> NormalizedSeries: if s.get("season_number") is not None ] + first_air_date = _parse_date(raw.get("first_air_date")) + return NormalizedSeries( external_id=str(raw["id"]), title=raw.get("name") or raw.get("original_name") or "", @@ -645,7 +739,7 @@ def normalize_series(raw: dict) -> NormalizedSeries: tagline=raw.get("tagline") or None, status=_SERIES_STATUS_MAP.get(raw.get("status", "Returning Series"), "returning_series"), original_language=raw.get("original_language"), - first_air_date=_parse_date(raw.get("first_air_date")), + first_air_date=first_air_date, last_air_date=_parse_date(raw.get("last_air_date")), number_of_seasons=raw.get("number_of_seasons"), number_of_episodes=raw.get("number_of_episodes"), @@ -664,6 +758,7 @@ def normalize_series(raw: dict) -> NormalizedSeries: videos=normalized_videos, external_ids=external_ids, seasons=seasons, + release_dates=_series_release_dates(raw, first_air_date), ) diff --git a/app/services/movie_service.py b/app/services/movie_service.py index 4d8b9bf..5eabd02 100644 --- a/app/services/movie_service.py +++ b/app/services/movie_service.py @@ -20,7 +20,14 @@ from app.schemas.movie import ( MovieSummaryOut, MovieVideosOut, ) -from app.services.common import primary_artwork_path, split_credits, to_images_out, to_video_out +from app.services.common import ( + pick_certification, + primary_artwork_path, + split_credits, + to_images_out, + to_release_date_out, + to_video_out, +) class MovieNotFoundError(Exception): @@ -50,6 +57,7 @@ async def get_movie_detail(session: AsyncSession, movie_aurora_id: str) -> Movie credits_ = await polymorphic.get_credits(session, "movie", movie_id) artwork = await polymorphic.get_artwork(session, "movie", movie_id) videos = await polymorphic.get_videos(session, "movie", movie_id) + release_dates = await polymorphic.get_release_dates(session, "movie", movie_id) external_ids = await polymorphic.get_external_ids(session, "movie", movie_id) cast, crew = split_credits(credits_) @@ -93,6 +101,7 @@ async def get_movie_detail(session: AsyncSession, movie_aurora_id: str) -> Movie vote_average=movie.vote_average, vote_count=movie.vote_count, adult=movie.adult, + certification=pick_certification(release_dates), genres=[GenreOut(id=aurora_id("genre", str(g.id)), name=g.name) for g in genres], keywords=[KeywordOut(id=aurora_id("keyword", str(k.id)), name=k.name) for k in keywords], cast=cast, @@ -101,6 +110,7 @@ async def get_movie_detail(session: AsyncSession, movie_aurora_id: str) -> Movie collection=collection_out, images=to_images_out(artwork), videos=[to_video_out(v) for v in videos], + release_dates=[to_release_date_out(rd) for rd in release_dates], external_ids=external_ids, created_at=movie.created_at, updated_at=movie.updated_at, diff --git a/app/services/tv_service.py b/app/services/tv_service.py index d32e17b..d24e428 100644 --- a/app/services/tv_service.py +++ b/app/services/tv_service.py @@ -25,7 +25,14 @@ from app.schemas.tv import ( SeriesSummaryOut, SeriesVideosOut, ) -from app.services.common import primary_artwork_path, split_credits, to_images_out, to_video_out +from app.services.common import ( + pick_certification, + primary_artwork_path, + split_credits, + to_images_out, + to_release_date_out, + to_video_out, +) class SeriesNotFoundError(Exception): @@ -99,6 +106,7 @@ async def get_series_detail(session: AsyncSession, series_aurora_id: str) -> Ser credits_ = await polymorphic.get_credits(session, "series", series_id) artwork = await polymorphic.get_artwork(session, "series", series_id) videos = await polymorphic.get_videos(session, "series", series_id) + release_dates = await polymorphic.get_release_dates(session, "series", series_id) external_ids = await polymorphic.get_external_ids(session, "series", series_id) cast, crew, creators = _split_series_credits(credits_) @@ -122,6 +130,7 @@ async def get_series_detail(session: AsyncSession, series_aurora_id: str) -> Ser popularity=series.popularity, vote_average=series.vote_average, vote_count=series.vote_count, + certification=pick_certification(release_dates), genres=[GenreOut(id=aurora_id("genre", str(g.id)), name=g.name) for g in genres], keywords=[KeywordOut(id=aurora_id("keyword", str(k.id)), name=k.name) for k in keywords], creators=creators, @@ -132,6 +141,7 @@ async def get_series_detail(session: AsyncSession, series_aurora_id: str) -> Ser seasons=season_briefs, images=to_images_out(artwork), videos=[to_video_out(v) for v in videos], + release_dates=[to_release_date_out(rd) for rd in release_dates], external_ids=external_ids, created_at=series.created_at, updated_at=series.updated_at, diff --git a/docs/api.md b/docs/api.md index cb5c3fd..35676a9 100644 --- a/docs/api.md +++ b/docs/api.md @@ -97,12 +97,18 @@ unknown status) is a 422, not a silently-empty result. Response: "collection": { "id": "aur_collection_...", "name": "The Matrix Collection", "poster_path": "https://..." }, "images": { "posters": [...], "backdrops": [...], "logos": [...] }, "videos": [{ "id": "aur_vid_...", "type": "trailer", "site": "YouTube", "url": "https://www.youtube.com/watch?v=...", "is_official": true }], + "certification": "R", + "release_dates": [ + { "country_code": "US", "release_date": "1999-03-31", "release_type": "theatrical", "certification": { "system": "MPA", "rating": "R" }, "note": null } + ], "external_ids": { "tmdb": "603", "imdb": "tt0133093" }, "created_at": "...", "updated_at": "..." } ``` +`certification` is a convenience pick — the US (MPA) rating, TMDb's own default badge — out of the full `release_dates` list, which carries every country/type/rating TMDb reports (`release_type` one of `premiere` | `theatrical` | `digital` | `physical` | `tv`, collapsed from TMDb's six numeric codes). A rating body name (`system`, e.g. `"MPA"`/`"BBFC"`) is only known for a handful of major markets — everywhere else falls back to the country code itself rather than fabricating a body TMDb never named. Series detail carries the same two fields, sourced from TMDb's `content_ratings` instead — since that endpoint gives no date at all, each rating is anchored to the show's `first_air_date` with `release_type: "tv"`. + `GET /api/v1/movies/{aurora_id}/recommendations` returns TMDb's ranked recommendations, but only the ones already ingested into Aurora — a recommendation pointing at a movie Aurora hasn't seen yet is silently diff --git a/tests/integration/test_release_dates.py b/tests/integration/test_release_dates.py new file mode 100644 index 0000000..02f5e03 --- /dev/null +++ b/tests/integration/test_release_dates.py @@ -0,0 +1,134 @@ +"""Integration tests for release-dates/certifications (section 17, +additive): TMDb's release_dates/content_ratings blocks were already being +fetched via append_to_response, but dropped at the normalizer step -- every +downstream layer (ingestion write, repository read, schema, service) was +unbuilt. Mirrors test_movie_ingestion.py/test_series_ingestion.py's fake +provider pattern to exercise the full write -> read path against +PostgreSQL.""" + +from __future__ import annotations + +from typing import Any + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.ids import aurora_id +from app.models.language_country import Certification +from app.models.movie import ReleaseDate +from app.providers.base import MetadataProvider +from app.services import movie_service, tv_service +from app.services.ingestion.movie_ingestion import ingest_movie +from app.services.ingestion.series_ingestion import ingest_series +from tests.unit.test_normalizer import FAKE_MOVIE, FAKE_SERIES + + +class FakeTMDbProvider(MetadataProvider): + slug = "tmdb" + name = "The Movie Database (TMDb)" + + def __init__(self, movie_payload: dict[str, Any] | None = None, series_payload: dict[str, Any] | None = None) -> None: + self._movie_payload = movie_payload + self._series_payload = series_payload + + async def get_movie(self, external_id: str) -> dict[str, Any]: + assert self._movie_payload is not None + return self._movie_payload + + async def get_series(self, external_id: str) -> dict[str, Any]: + assert self._series_payload is not None + return self._series_payload + + async def get_season(self, series_external_id: str, season_number: int) -> dict[str, Any]: + raise NotImplementedError + + async def get_person(self, external_id: str) -> dict[str, Any]: + raise NotImplementedError + + async def get_collection(self, external_id: str) -> dict[str, Any]: + raise NotImplementedError + + async def search_movie(self, query: str) -> list[dict[str, Any]]: + raise NotImplementedError + + +@pytest.mark.asyncio +async def test_ingest_movie_stores_release_dates_and_certifications(db_session: AsyncSession): + movie_id = await ingest_movie(db_session, FakeTMDbProvider(movie_payload=FAKE_MOVIE), "603") + await db_session.commit() + + rows = ( + (await db_session.execute(select(ReleaseDate).where(ReleaseDate.entity_id == movie_id))) + .scalars() + .all() + ) + assert len(rows) == 4 + + us_theatrical = next(r for r in rows if r.country_code == "US" and r.release_type == "theatrical") + cert = await db_session.get(Certification, us_theatrical.certification_id) + assert cert.system == "MPA" + assert cert.rating == "R" + + +@pytest.mark.asyncio +async def test_ingest_movie_reingestion_replaces_release_dates(db_session: AsyncSession): + """Wholesale replace on re-sync, same as credits/videos -- re-running + ingestion must not accumulate duplicate release-date rows.""" + + provider = FakeTMDbProvider(movie_payload=FAKE_MOVIE) + movie_id = await ingest_movie(db_session, provider, "603") + await db_session.commit() + + movie_id_again = await ingest_movie(db_session, provider, "603") + await db_session.commit() + assert movie_id == movie_id_again + + rows = ( + (await db_session.execute(select(ReleaseDate).where(ReleaseDate.entity_id == movie_id))) + .scalars() + .all() + ) + assert len(rows) == 4 + + +@pytest.mark.asyncio +async def test_movie_detail_surfaces_certification_and_release_dates(db_session: AsyncSession): + movie_id = await ingest_movie(db_session, FakeTMDbProvider(movie_payload=FAKE_MOVIE), "603") + await db_session.commit() + + detail = await movie_service.get_movie_detail(db_session, aurora_id("movie", str(movie_id))) + + assert detail.certification == "R" # US MPA rating, the default badge + assert len(detail.release_dates) == 4 + us_theatrical = next( + rd for rd in detail.release_dates if rd.country_code == "US" and rd.release_type == "theatrical" + ) + assert us_theatrical.certification.system == "MPA" + assert us_theatrical.certification.rating == "R" + + +@pytest.mark.asyncio +async def test_ingest_series_anchors_content_ratings_to_first_air_date(db_session: AsyncSession): + series_id = await ingest_series(db_session, FakeTMDbProvider(series_payload=FAKE_SERIES), "1396") + await db_session.commit() + + rows = ( + (await db_session.execute(select(ReleaseDate).where(ReleaseDate.entity_id == series_id))) + .scalars() + .all() + ) + assert len(rows) == 2 + assert all(r.release_type == "tv" for r in rows) + assert all(str(r.release_date) == "2008-01-20" for r in rows) + + +@pytest.mark.asyncio +async def test_series_detail_surfaces_certification(db_session: AsyncSession): + series_id = await ingest_series(db_session, FakeTMDbProvider(series_payload=FAKE_SERIES), "1396") + await db_session.commit() + + detail = await tv_service.get_series_detail(db_session, aurora_id("series", str(series_id))) + + assert detail.certification == "TV-MA" + assert len(detail.release_dates) == 2 diff --git a/tests/unit/test_normalizer.py b/tests/unit/test_normalizer.py index 17a2f34..c832281 100644 --- a/tests/unit/test_normalizer.py +++ b/tests/unit/test_normalizer.py @@ -89,6 +89,39 @@ FAKE_MOVIE = { "alternative_titles": {"titles": [{"title": "Matrix", "iso_3166_1": "US"}]}, "external_ids": {"imdb_id": "tt0133093"}, "recommendations": {"results": [{"id": 604}]}, + "release_dates": { + "results": [ + { + "iso_3166_1": "US", + "release_dates": [ + { + "certification": "R", + "release_date": "1999-03-31T00:00:00.000Z", + "type": 3, + "note": "", + }, + { + "certification": "", + "release_date": "1999-03-01T00:00:00.000Z", + "type": 1, + "note": "Premiere", + }, + ], + }, + { + "iso_3166_1": "GB", + "release_dates": [ + {"certification": "15", "release_date": "1999-06-11T00:00:00.000Z", "type": 3} + ], + }, + { + "iso_3166_1": "MX", + "release_dates": [ + {"certification": "B", "release_date": "1999-04-02T00:00:00.000Z", "type": 3} + ], + }, + ] + }, } FAKE_PERSON = { @@ -192,6 +225,40 @@ def test_normalize_movie_maps_collection_and_recommendations(): assert movie.recommendation_external_ids == ["604"] +def test_normalize_movie_maps_release_dates_and_certification(): + movie = normalize_movie(FAKE_MOVIE) + assert len(movie.release_dates) == 4 + + us_theatrical = next( + rd for rd in movie.release_dates if rd.country_code == "US" and rd.release_type == "theatrical" + ) + assert us_theatrical.release_date == date(1999, 3, 31) + assert us_theatrical.certification == ("MPA", "R") + + us_premiere = next(rd for rd in movie.release_dates if rd.release_type == "premiere") + assert us_premiere.certification is None + assert us_premiere.note == "Premiere" + + gb_entry = next(rd for rd in movie.release_dates if rd.country_code == "GB") + assert gb_entry.certification == ("BBFC", "15") + + mx_entry = next(rd for rd in movie.release_dates if rd.country_code == "MX") + # MX has no entry in the small known certification-system map -- falls + # back to the country code itself rather than fabricating a body name. + assert mx_entry.certification == ("MX", "B") + + +def test_normalize_movie_release_dates_skip_entries_without_a_date(): + minimal = { + **FAKE_MOVIE, + "release_dates": { + "results": [{"iso_3166_1": "US", "release_dates": [{"certification": "R", "release_date": ""}]}] + }, + } + movie = normalize_movie(minimal) + assert movie.release_dates == [] + + def test_normalize_movie_handles_missing_optional_sections(): minimal = {"id": 1, "title": "Minimal Movie"} movie = normalize_movie(minimal) @@ -264,6 +331,9 @@ FAKE_SERIES = { "keywords": {"results": [{"id": 1, "name": "drug dealer"}]}, "alternative_titles": {"results": [{"title": "Breaking Bad", "iso_3166_1": "US"}]}, "external_ids": {"imdb_id": "tt0903747", "tvdb_id": 81189}, + "content_ratings": { + "results": [{"iso_3166_1": "US", "rating": "TV-MA"}, {"iso_3166_1": "GB", "rating": "15"}] + }, "seasons": [ { "season_number": 1, @@ -376,6 +446,24 @@ def test_normalize_series_external_ids_include_tvdb(): assert series.external_ids["tvdb"] == "81189" +def test_normalize_series_anchors_content_ratings_to_first_air_date(): + # TMDb's /tv content_ratings carries no date of its own -- each rating + # is anchored to the show's first_air_date (release_type="tv") rather + # than left with no release_date at all. + series = normalize_series(FAKE_SERIES) + assert len(series.release_dates) == 2 + us = next(rd for rd in series.release_dates if rd.country_code == "US") + assert us.release_date == date(2008, 1, 20) + assert us.release_type == "tv" + assert us.certification == ("MPA", "TV-MA") + + +def test_normalize_series_content_ratings_skipped_without_first_air_date(): + no_air_date = {**FAKE_SERIES, "first_air_date": None} + series = normalize_series(no_air_date) + assert series.release_dates == [] + + def test_normalize_season_maps_episodes(): season = normalize_season(FAKE_SEASON) assert season.season_number == 1