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
+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: