Expose certifications and per-country release dates
CI / lint (push) Canceled after 0s
CI / typecheck (push) Canceled after 0s
CI / unit-tests (push) Canceled after 0s
CI / integration-tests (push) Canceled after 0s
CI / container-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s

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 <[email protected]>
This commit is contained in:
NixReaper
2026-09-06 04:07:18 -05:00
co-authored by Claude Sonnet 5
parent 340cadd99d
commit e1a36ce227
15 changed files with 510 additions and 5 deletions
+3
View File
@@ -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."""
+70 -1
View File
@@ -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()
+27 -1
View File
@@ -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:
+13
View File
@@ -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
+6
View File
@@ -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
+5
View File
@@ -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
+34
View File
@@ -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]]:
@@ -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(
@@ -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)
+96 -1
View File
@@ -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),
)
+11 -1
View File
@@ -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,
+11 -1
View File
@@ -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,
+6
View File
@@ -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
+134
View File
@@ -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
+88
View File
@@ -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