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,