Initial Aurora Core 0.1 implementation
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
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
Production-grade media metadata backend per docs/aurora-core-spec.md: Aurora-owned UUIDv7 identity system, normalized movie/TV/people/credits schema, provider abstraction with a TMDb adapter, an idempotent async ingestion pipeline (normalize -> match -> upsert -> provenance), a Postgres FTS/trigram search layer, a stateless FastAPI v1 API with Redis caching/rate-limiting, arq workers, dev/prod Docker Compose topologies, unit + integration + load tests, CI, and architecture/ database/API/ERD documentation. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
"""Cache abstraction over Redis (section 21).
|
||||
|
||||
Redis is disposable acceleration — every function here fails open: if Redis
|
||||
is unreachable, reads report a cache miss and writes are silently skipped,
|
||||
so the API keeps serving from PostgreSQL instead of failing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import orjson
|
||||
from redis.asyncio import Redis
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
CACHE_VERSION = "v1"
|
||||
|
||||
|
||||
def build_key(*parts: str) -> str:
|
||||
return ":".join(("aurora", CACHE_VERSION, *parts))
|
||||
|
||||
|
||||
async def cache_get_json(redis: Redis, key: str) -> dict | list | None:
|
||||
try:
|
||||
raw = await redis.get(key)
|
||||
except Exception:
|
||||
return None
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return orjson.loads(raw)
|
||||
except orjson.JSONDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
async def cache_set_json(
|
||||
redis: Redis, key: str, value: dict | list, ttl: int | None = None
|
||||
) -> None:
|
||||
ttl = ttl if ttl is not None else settings.CACHE_DEFAULT_TTL
|
||||
try:
|
||||
await redis.set(key, orjson.dumps(value), ex=ttl)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
async def cache_delete(redis: Redis, *keys: str) -> None:
|
||||
if not keys:
|
||||
return
|
||||
try:
|
||||
await redis.delete(*keys)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
async def cache_delete_prefix(redis: Redis, prefix: str) -> None:
|
||||
try:
|
||||
async for key in redis.scan_iter(match=f"{prefix}*"):
|
||||
await redis.delete(key)
|
||||
except Exception:
|
||||
return
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Assembles Collection/franchise API responses from repository data."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.ids import aurora_id, parse_aurora_id
|
||||
from app.models.enums import ArtworkType
|
||||
from app.repositories import collection_repository, polymorphic
|
||||
from app.schemas.collection import CollectionDetailOut
|
||||
from app.schemas.common import CollectionBriefOut
|
||||
from app.services.common import primary_artwork_path
|
||||
from app.services.movie_service import build_movie_summaries
|
||||
from app.services.tv_service import build_series_summaries
|
||||
|
||||
|
||||
class CollectionNotFoundError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _resolve_collection_id(aid: str) -> uuid.UUID:
|
||||
try:
|
||||
return uuid.UUID(parse_aurora_id(aid, expected_type="collection"))
|
||||
except ValueError as exc:
|
||||
raise CollectionNotFoundError(str(exc)) from exc
|
||||
|
||||
|
||||
async def get_collection_detail(
|
||||
session: AsyncSession, collection_aurora_id: str
|
||||
) -> CollectionDetailOut:
|
||||
collection_id = _resolve_collection_id(collection_aurora_id)
|
||||
collection = await collection_repository.get_collection_by_id(session, collection_id)
|
||||
if collection is None:
|
||||
raise CollectionNotFoundError(collection_aurora_id)
|
||||
|
||||
movies = await collection_repository.get_collection_movies(session, collection_id)
|
||||
series = await collection_repository.get_collection_series(session, collection_id)
|
||||
posters = await polymorphic.get_artwork(session, "collection", collection_id, ArtworkType.POSTER)
|
||||
backdrops = await polymorphic.get_artwork(
|
||||
session, "collection", collection_id, ArtworkType.BACKDROP
|
||||
)
|
||||
|
||||
return CollectionDetailOut(
|
||||
id=aurora_id("collection", str(collection.id)),
|
||||
name=collection.name,
|
||||
overview=collection.overview,
|
||||
poster_path=primary_artwork_path(posters, ArtworkType.POSTER),
|
||||
backdrop_path=primary_artwork_path(backdrops, ArtworkType.BACKDROP),
|
||||
movies=await build_movie_summaries(session, [m.id for m in movies]),
|
||||
series=await build_series_summaries(session, [s.id for s in series]),
|
||||
created_at=collection.created_at,
|
||||
updated_at=collection.updated_at,
|
||||
)
|
||||
|
||||
|
||||
async def build_collection_summary(
|
||||
session: AsyncSession, collection_id: uuid.UUID
|
||||
) -> CollectionBriefOut | None:
|
||||
collection = await collection_repository.get_collection_by_id(session, collection_id)
|
||||
if collection is None:
|
||||
return None
|
||||
return CollectionBriefOut(id=aurora_id("collection", str(collection.id)), name=collection.name)
|
||||
|
||||
|
||||
async def build_collection_summaries(
|
||||
session: AsyncSession, collection_ids: list[uuid.UUID]
|
||||
) -> list[CollectionBriefOut]:
|
||||
out: list[CollectionBriefOut] = []
|
||||
for collection_id in collection_ids:
|
||||
summary = await build_collection_summary(session, collection_id)
|
||||
if summary is not None:
|
||||
out.append(summary)
|
||||
return out
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Shared helpers for turning ORM rows into response schemas."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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.video import Video
|
||||
from app.schemas.common import (
|
||||
ArtworkOut,
|
||||
CastCreditOut,
|
||||
CrewCreditOut,
|
||||
ImagesOut,
|
||||
PersonBriefOut,
|
||||
VideoOut,
|
||||
)
|
||||
|
||||
|
||||
def artwork_url(artwork: Artwork) -> str:
|
||||
return artwork.local_path or artwork.original_url
|
||||
|
||||
|
||||
def to_artwork_out(artwork: Artwork) -> ArtworkOut:
|
||||
return ArtworkOut(
|
||||
id=aurora_id("artwork", str(artwork.id)),
|
||||
type=artwork.artwork_type.value,
|
||||
language=artwork.language_code,
|
||||
width=artwork.width,
|
||||
height=artwork.height,
|
||||
aspect_ratio=artwork.aspect_ratio,
|
||||
url=artwork_url(artwork),
|
||||
provider=artwork.provider.slug if artwork.provider else None,
|
||||
is_primary=artwork.is_primary,
|
||||
vote_average=artwork.vote_average,
|
||||
)
|
||||
|
||||
|
||||
def to_images_out(artwork_list: list[Artwork]) -> ImagesOut:
|
||||
images = ImagesOut()
|
||||
for artwork in artwork_list:
|
||||
out = to_artwork_out(artwork)
|
||||
if artwork.artwork_type == ArtworkType.POSTER:
|
||||
images.posters.append(out)
|
||||
elif artwork.artwork_type == ArtworkType.BACKDROP:
|
||||
images.backdrops.append(out)
|
||||
elif artwork.artwork_type == ArtworkType.LOGO:
|
||||
images.logos.append(out)
|
||||
return images
|
||||
|
||||
|
||||
def primary_artwork_path(artwork_list: list[Artwork], artwork_type: ArtworkType) -> str | None:
|
||||
for artwork in artwork_list:
|
||||
if artwork.artwork_type == artwork_type:
|
||||
return artwork_url(artwork)
|
||||
return None
|
||||
|
||||
|
||||
def to_video_out(video: Video) -> VideoOut:
|
||||
return VideoOut(
|
||||
id=aurora_id("video", str(video.id)),
|
||||
type=video.video_type.value,
|
||||
site=video.site,
|
||||
url=video.url,
|
||||
language=video.language_code,
|
||||
country=video.country_code,
|
||||
is_official=video.is_official,
|
||||
published_at=video.published_at,
|
||||
provider=video.provider.slug if video.provider else None,
|
||||
)
|
||||
|
||||
|
||||
def split_credits(
|
||||
credits_: list[Credit], profile_paths: dict | None = None
|
||||
) -> tuple[list[CastCreditOut], list[CrewCreditOut]]:
|
||||
profile_paths = profile_paths or {}
|
||||
cast: list[CastCreditOut] = []
|
||||
crew: list[CrewCreditOut] = []
|
||||
for credit in credits_:
|
||||
person_brief = PersonBriefOut(
|
||||
id=aurora_id("person", str(credit.person_id)),
|
||||
name=credit.person.name,
|
||||
profile_path=profile_paths.get(credit.person_id),
|
||||
)
|
||||
if credit.department == "Acting":
|
||||
cast.append(
|
||||
CastCreditOut(
|
||||
id=aurora_id("credit", str(credit.id)),
|
||||
person=person_brief,
|
||||
character=credit.character,
|
||||
billing_order=credit.billing_order,
|
||||
episode_count=credit.episode_count,
|
||||
)
|
||||
)
|
||||
else:
|
||||
crew.append(
|
||||
CrewCreditOut(
|
||||
id=aurora_id("credit", str(credit.id)),
|
||||
person=person_brief,
|
||||
department=credit.department,
|
||||
job=credit.job,
|
||||
episode_count=credit.episode_count,
|
||||
)
|
||||
)
|
||||
return cast, crew
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Assembles Company API responses from repository data."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.ids import aurora_id, parse_aurora_id
|
||||
from app.models.enums import ArtworkType
|
||||
from app.repositories import company_repository, polymorphic
|
||||
from app.schemas.common import CompanyBriefOut
|
||||
from app.schemas.company import CompanyDetailOut
|
||||
from app.services.common import primary_artwork_path
|
||||
|
||||
|
||||
class CompanyNotFoundError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _resolve_company_id(aid: str) -> uuid.UUID:
|
||||
try:
|
||||
return uuid.UUID(parse_aurora_id(aid, expected_type="company"))
|
||||
except ValueError as exc:
|
||||
raise CompanyNotFoundError(str(exc)) from exc
|
||||
|
||||
|
||||
async def get_company_detail(session: AsyncSession, company_aurora_id: str) -> CompanyDetailOut:
|
||||
company_id = _resolve_company_id(company_aurora_id)
|
||||
company = await company_repository.get_company_by_id(session, company_id)
|
||||
if company is None:
|
||||
raise CompanyNotFoundError(company_aurora_id)
|
||||
|
||||
logos = await polymorphic.get_artwork(session, "company", company_id, ArtworkType.LOGO)
|
||||
external_ids = await polymorphic.get_external_ids(session, "company", company_id)
|
||||
|
||||
return CompanyDetailOut(
|
||||
id=aurora_id("company", str(company.id)),
|
||||
name=company.name,
|
||||
company_type=company.company_type.value,
|
||||
description=company.description,
|
||||
country=company.country_code,
|
||||
headquarters=company.headquarters,
|
||||
homepage=company.homepage,
|
||||
logo_path=primary_artwork_path(logos, ArtworkType.LOGO),
|
||||
parent_company_id=(
|
||||
aurora_id("company", str(company.parent_company_id))
|
||||
if company.parent_company_id
|
||||
else None
|
||||
),
|
||||
external_ids=external_ids,
|
||||
created_at=company.created_at,
|
||||
updated_at=company.updated_at,
|
||||
)
|
||||
|
||||
|
||||
async def build_company_summary(
|
||||
session: AsyncSession, company_id: uuid.UUID
|
||||
) -> CompanyBriefOut | None:
|
||||
company = await company_repository.get_company_by_id(session, company_id)
|
||||
if company is None:
|
||||
return None
|
||||
logos = await polymorphic.get_artwork(session, "company", company_id, ArtworkType.LOGO)
|
||||
return CompanyBriefOut(
|
||||
id=aurora_id("company", str(company.id)),
|
||||
name=company.name,
|
||||
company_type=company.company_type.value,
|
||||
logo_path=primary_artwork_path(logos, ArtworkType.LOGO),
|
||||
)
|
||||
|
||||
|
||||
async def build_company_summaries(
|
||||
session: AsyncSession, company_ids: list[uuid.UUID]
|
||||
) -> list[CompanyBriefOut]:
|
||||
out: list[CompanyBriefOut] = []
|
||||
for company_id in company_ids:
|
||||
summary = await build_company_summary(session, company_id)
|
||||
if summary is not None:
|
||||
out.append(summary)
|
||||
return out
|
||||
@@ -0,0 +1,314 @@
|
||||
"""Movie ingestion pipeline (section 15):
|
||||
|
||||
Provider -> raw data -> normalize -> match -> upsert Aurora entity ->
|
||||
satellite tables (genres, keywords, companies, credits, artwork, videos,
|
||||
external IDs) -> provenance record.
|
||||
|
||||
Idempotent: re-running for the same provider + external_id updates the same
|
||||
Aurora movie rather than creating a duplicate (zero duplicate ingestion
|
||||
records — section 2). Satellite associations are replaced wholesale on each
|
||||
sync rather than diffed, which is simple and safe but means their Aurora
|
||||
IDs (e.g. individual credit IDs) are not stable across re-syncs — see
|
||||
docs/architecture.md for the tradeoff.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.logging import get_logger
|
||||
from app.models.artwork import Artwork
|
||||
from app.models.company import MovieCompany
|
||||
from app.models.credit import Credit
|
||||
from app.models.enums import ArtworkType, MatchConfidence, MovieStatus, VideoType
|
||||
from app.models.genre import MovieGenre
|
||||
from app.models.keyword import MovieKeyword
|
||||
from app.models.movie import Movie, MovieRecommendation
|
||||
from app.models.video import Video
|
||||
from app.providers.base import MetadataProvider
|
||||
from app.repositories import reference_repository as refs
|
||||
from app.repositories.provider_repository import get_or_create_provider, upsert_provider_record
|
||||
from app.services.metadata.matcher import match_entity
|
||||
from app.services.metadata.normalizer import NormalizedMovie, normalize_movie
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
async def _replace_movie_genres(session: AsyncSession, movie_id: uuid.UUID, names: list[str]) -> None:
|
||||
await session.execute(delete(MovieGenre).where(MovieGenre.movie_id == movie_id))
|
||||
for name in names:
|
||||
genre = await refs.get_or_create_genre(session, name)
|
||||
session.add(MovieGenre(movie_id=movie_id, genre_id=genre.id))
|
||||
|
||||
|
||||
async def _replace_movie_keywords(session: AsyncSession, movie_id: uuid.UUID, names: list[str]) -> None:
|
||||
await session.execute(delete(MovieKeyword).where(MovieKeyword.movie_id == movie_id))
|
||||
for name in names:
|
||||
keyword = await refs.get_or_create_keyword(session, name)
|
||||
session.add(MovieKeyword(movie_id=movie_id, keyword_id=keyword.id))
|
||||
|
||||
|
||||
async def _replace_movie_companies(
|
||||
session: AsyncSession, movie_id: uuid.UUID, provider_id: uuid.UUID, companies
|
||||
) -> None:
|
||||
await session.execute(delete(MovieCompany).where(MovieCompany.movie_id == movie_id))
|
||||
for ref in companies:
|
||||
country_code = None
|
||||
if ref.country_code:
|
||||
country = await refs.get_or_create_country(session, ref.country_code, ref.country_code)
|
||||
country_code = country.code
|
||||
company = await refs.get_or_create_company(
|
||||
session,
|
||||
provider_id=provider_id,
|
||||
external_id=ref.external_id,
|
||||
name=ref.name,
|
||||
country_code=country_code,
|
||||
)
|
||||
session.add(MovieCompany(movie_id=movie_id, company_id=company.id, role=ref.role))
|
||||
|
||||
|
||||
async def _replace_movie_credits(
|
||||
session: AsyncSession, movie_id: uuid.UUID, provider_id: uuid.UUID, cast, crew
|
||||
) -> None:
|
||||
await session.execute(
|
||||
delete(Credit).where(Credit.entity_type == "movie", Credit.entity_id == movie_id)
|
||||
)
|
||||
for order, ref in enumerate(cast):
|
||||
person = await refs.get_or_create_person(
|
||||
session,
|
||||
provider_id=provider_id,
|
||||
external_id=ref.external_id,
|
||||
name=ref.name,
|
||||
gender=ref.gender,
|
||||
)
|
||||
session.add(
|
||||
Credit(
|
||||
person_id=person.id,
|
||||
entity_type="movie",
|
||||
entity_id=movie_id,
|
||||
department="Acting",
|
||||
character=ref.character,
|
||||
billing_order=ref.billing_order if ref.billing_order is not None else order,
|
||||
provider_id=provider_id,
|
||||
)
|
||||
)
|
||||
for ref in crew:
|
||||
person = await refs.get_or_create_person(
|
||||
session,
|
||||
provider_id=provider_id,
|
||||
external_id=ref.external_id,
|
||||
name=ref.name,
|
||||
gender=ref.gender,
|
||||
)
|
||||
session.add(
|
||||
Credit(
|
||||
person_id=person.id,
|
||||
entity_type="movie",
|
||||
entity_id=movie_id,
|
||||
department=ref.department or "Crew",
|
||||
job=ref.job,
|
||||
provider_id=provider_id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
_ARTWORK_TYPES = {t.value for t in ArtworkType}
|
||||
_VIDEO_TYPES = {t.value for t in VideoType}
|
||||
|
||||
|
||||
async def _replace_movie_artwork(
|
||||
session: AsyncSession, movie_id: uuid.UUID, provider_id: uuid.UUID, artwork_list
|
||||
) -> None:
|
||||
await session.execute(
|
||||
delete(Artwork).where(Artwork.entity_type == "movie", Artwork.entity_id == movie_id)
|
||||
)
|
||||
for art in artwork_list:
|
||||
art_type = art.artwork_type if art.artwork_type in _ARTWORK_TYPES else "poster"
|
||||
session.add(
|
||||
Artwork(
|
||||
entity_type="movie",
|
||||
entity_id=movie_id,
|
||||
artwork_type=ArtworkType(art_type),
|
||||
language_code=None,
|
||||
width=art.width,
|
||||
height=art.height,
|
||||
aspect_ratio=art.aspect_ratio,
|
||||
provider_id=provider_id,
|
||||
provider_asset_id=art.provider_asset_id,
|
||||
original_url=art.url,
|
||||
vote_average=art.vote_average,
|
||||
is_primary=False,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _replace_movie_videos(
|
||||
session: AsyncSession, movie_id: uuid.UUID, provider_id: uuid.UUID, videos
|
||||
) -> None:
|
||||
await session.execute(
|
||||
delete(Video).where(Video.entity_type == "movie", Video.entity_id == movie_id)
|
||||
)
|
||||
for v in videos:
|
||||
video_type = v.video_type if v.video_type in _VIDEO_TYPES else "other"
|
||||
session.add(
|
||||
Video(
|
||||
entity_type="movie",
|
||||
entity_id=movie_id,
|
||||
video_type=VideoType(video_type),
|
||||
provider_id=provider_id,
|
||||
provider_video_id=v.provider_video_id,
|
||||
site=v.site,
|
||||
url=v.url,
|
||||
language_code=None,
|
||||
country_code=None,
|
||||
is_official=v.is_official,
|
||||
published_at=v.published_at,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _link_external_ids(
|
||||
session: AsyncSession, movie_id: uuid.UUID, tmdb_provider_id: uuid.UUID, normalized: NormalizedMovie
|
||||
) -> None:
|
||||
await refs.link_external_id(
|
||||
session,
|
||||
entity_type="movie",
|
||||
entity_id=movie_id,
|
||||
provider_id=tmdb_provider_id,
|
||||
external_id=normalized.external_id,
|
||||
)
|
||||
for slug, external_id in normalized.external_ids.items():
|
||||
provider = await get_or_create_provider(session, slug, slug.upper())
|
||||
await refs.link_external_id(
|
||||
session,
|
||||
entity_type="movie",
|
||||
entity_id=movie_id,
|
||||
provider_id=provider.id,
|
||||
external_id=external_id,
|
||||
)
|
||||
|
||||
|
||||
async def ingest_movie(
|
||||
session: AsyncSession,
|
||||
provider: MetadataProvider,
|
||||
external_id: str,
|
||||
*,
|
||||
ingestion_job_id: uuid.UUID | None = None,
|
||||
) -> uuid.UUID:
|
||||
"""Fetch, normalize, and upsert a single movie. Returns the Aurora
|
||||
movie UUID (new or existing)."""
|
||||
|
||||
tmdb_provider = await get_or_create_provider(session, provider.slug, provider.name)
|
||||
|
||||
aurora_id_existing, confidence = await match_entity(
|
||||
session, provider_id=tmdb_provider.id, entity_type="movie", external_id=external_id
|
||||
)
|
||||
|
||||
raw = await provider.get_movie(external_id)
|
||||
normalized = normalize_movie(raw)
|
||||
|
||||
existing_movie: Movie | None = None
|
||||
if aurora_id_existing is not None:
|
||||
existing_movie = await session.get(Movie, aurora_id_existing)
|
||||
if existing_movie is None:
|
||||
aurora_id_existing = None
|
||||
|
||||
movie: Movie
|
||||
if existing_movie is not None:
|
||||
movie = existing_movie
|
||||
else:
|
||||
movie = Movie(title=normalized.title, sort_title=_sort_title(normalized.title))
|
||||
session.add(movie)
|
||||
await session.flush()
|
||||
|
||||
movie.title = normalized.title
|
||||
movie.original_title = normalized.original_title
|
||||
movie.sort_title = _sort_title(normalized.title)
|
||||
movie.overview = normalized.overview
|
||||
movie.tagline = normalized.tagline
|
||||
movie.status = MovieStatus(normalized.status)
|
||||
movie.primary_release_date = normalized.release_date
|
||||
movie.runtime_minutes = normalized.runtime_minutes
|
||||
movie.budget = normalized.budget
|
||||
movie.revenue = normalized.revenue
|
||||
movie.popularity = normalized.popularity
|
||||
movie.vote_average = normalized.vote_average
|
||||
movie.vote_count = normalized.vote_count
|
||||
movie.adult = normalized.adult
|
||||
movie.source_provider_id = tmdb_provider.id
|
||||
|
||||
if normalized.original_language:
|
||||
language = await refs.get_or_create_language(
|
||||
session, normalized.original_language, normalized.original_language.upper()
|
||||
)
|
||||
movie.original_language_code = language.code
|
||||
|
||||
if normalized.collection:
|
||||
ext_id, name = normalized.collection
|
||||
collection = await refs.get_or_create_collection(
|
||||
session, provider_id=tmdb_provider.id, external_id=ext_id, name=name
|
||||
)
|
||||
movie.collection_id = collection.id
|
||||
|
||||
await session.flush()
|
||||
|
||||
await _replace_movie_genres(session, movie.id, normalized.genres)
|
||||
await _replace_movie_keywords(session, movie.id, normalized.keywords)
|
||||
await _replace_movie_companies(session, movie.id, tmdb_provider.id, normalized.companies)
|
||||
await _replace_movie_credits(session, movie.id, tmdb_provider.id, normalized.cast, normalized.crew)
|
||||
await _replace_movie_artwork(session, movie.id, tmdb_provider.id, normalized.artwork)
|
||||
await _replace_movie_videos(session, movie.id, tmdb_provider.id, normalized.videos)
|
||||
await _link_external_ids(session, movie.id, tmdb_provider.id, normalized)
|
||||
|
||||
await session.execute(
|
||||
delete(MovieRecommendation).where(MovieRecommendation.movie_id == movie.id)
|
||||
)
|
||||
if normalized.recommendation_external_ids:
|
||||
rec_ids = await refs.find_entities_by_external_ids(
|
||||
session, tmdb_provider.id, "movie", normalized.recommendation_external_ids
|
||||
)
|
||||
for rank, rec_movie_id in enumerate(rec_ids):
|
||||
session.add(
|
||||
MovieRecommendation(
|
||||
movie_id=movie.id,
|
||||
recommended_movie_id=rec_movie_id,
|
||||
rank=rank,
|
||||
source="tmdb",
|
||||
)
|
||||
)
|
||||
|
||||
await refs.link_external_id(
|
||||
session,
|
||||
entity_type="movie",
|
||||
entity_id=movie.id,
|
||||
provider_id=tmdb_provider.id,
|
||||
external_id=external_id,
|
||||
)
|
||||
|
||||
await upsert_provider_record(
|
||||
session,
|
||||
provider_id=tmdb_provider.id,
|
||||
entity_type="movie",
|
||||
external_id=external_id,
|
||||
raw_data=raw,
|
||||
aurora_entity_id=movie.id,
|
||||
match_confidence=confidence if aurora_id_existing else MatchConfidence.NEW,
|
||||
ingestion_job_id=ingestion_job_id,
|
||||
)
|
||||
|
||||
logger.info("movie_ingested", movie_id=str(movie.id), external_id=external_id)
|
||||
return movie.id
|
||||
|
||||
|
||||
_LEADING_ARTICLES = ("the ", "a ", "an ")
|
||||
|
||||
|
||||
def _sort_title(title: str) -> str:
|
||||
lowered = title.lower()
|
||||
for article in _LEADING_ARTICLES:
|
||||
if lowered.startswith(article):
|
||||
return title[len(article) :].strip()
|
||||
return title
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Person ingestion pipeline (section 15).
|
||||
|
||||
Movie/series ingestion already creates a stub `Person` row (name + gender)
|
||||
for each cast/crew credit via `get_or_create_person`. This module fetches
|
||||
and upserts a *full* profile (biography, birthday, images, ...) for one
|
||||
person, either standalone (admin-triggered) or as a follow-up job.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.logging import get_logger
|
||||
from app.models.artwork import Artwork
|
||||
from app.models.enums import ArtworkType, MatchConfidence
|
||||
from app.models.person import Person
|
||||
from app.providers.base import MetadataProvider
|
||||
from app.repositories import reference_repository as refs
|
||||
from app.repositories.provider_repository import get_or_create_provider, upsert_provider_record
|
||||
from app.services.metadata.matcher import match_entity
|
||||
from app.services.metadata.normalizer import normalize_person
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
async def ingest_person(
|
||||
session: AsyncSession,
|
||||
provider: MetadataProvider,
|
||||
external_id: str,
|
||||
*,
|
||||
ingestion_job_id: uuid.UUID | None = None,
|
||||
) -> uuid.UUID:
|
||||
tmdb_provider = await get_or_create_provider(session, provider.slug, provider.name)
|
||||
|
||||
aurora_id_existing, confidence = await match_entity(
|
||||
session, provider_id=tmdb_provider.id, entity_type="person", external_id=external_id
|
||||
)
|
||||
|
||||
raw = await provider.get_person(external_id)
|
||||
normalized = normalize_person(raw)
|
||||
|
||||
person: Person | None = None
|
||||
if aurora_id_existing is not None:
|
||||
person = await session.get(Person, aurora_id_existing)
|
||||
|
||||
if person is None:
|
||||
person = Person(name=normalized.name)
|
||||
session.add(person)
|
||||
await session.flush()
|
||||
aurora_id_existing = None
|
||||
|
||||
person.name = normalized.name
|
||||
person.biography = normalized.biography
|
||||
person.birthday = normalized.birthday
|
||||
person.deathday = normalized.deathday
|
||||
person.birthplace = normalized.birthplace
|
||||
person.gender = normalized.gender
|
||||
person.known_for_department = normalized.known_for_department
|
||||
person.popularity = normalized.popularity
|
||||
person.aliases = normalized.aliases
|
||||
|
||||
await session.flush()
|
||||
|
||||
await session.execute(
|
||||
delete(Artwork).where(Artwork.entity_type == "person", Artwork.entity_id == person.id)
|
||||
)
|
||||
for art in normalized.artwork:
|
||||
session.add(
|
||||
Artwork(
|
||||
entity_type="person",
|
||||
entity_id=person.id,
|
||||
artwork_type=ArtworkType.PROFILE,
|
||||
width=art.width,
|
||||
height=art.height,
|
||||
aspect_ratio=art.aspect_ratio,
|
||||
provider_id=tmdb_provider.id,
|
||||
provider_asset_id=art.provider_asset_id,
|
||||
original_url=art.url,
|
||||
vote_average=art.vote_average,
|
||||
)
|
||||
)
|
||||
|
||||
await refs.link_external_id(
|
||||
session,
|
||||
entity_type="person",
|
||||
entity_id=person.id,
|
||||
provider_id=tmdb_provider.id,
|
||||
external_id=external_id,
|
||||
)
|
||||
for slug, ext_id in normalized.external_ids.items():
|
||||
ext_provider = await get_or_create_provider(session, slug, slug.upper())
|
||||
await refs.link_external_id(
|
||||
session,
|
||||
entity_type="person",
|
||||
entity_id=person.id,
|
||||
provider_id=ext_provider.id,
|
||||
external_id=ext_id,
|
||||
)
|
||||
|
||||
await upsert_provider_record(
|
||||
session,
|
||||
provider_id=tmdb_provider.id,
|
||||
entity_type="person",
|
||||
external_id=external_id,
|
||||
raw_data=raw,
|
||||
aurora_entity_id=person.id,
|
||||
match_confidence=confidence if aurora_id_existing else MatchConfidence.NEW,
|
||||
ingestion_job_id=ingestion_job_id,
|
||||
)
|
||||
|
||||
logger.info("person_ingested", person_id=str(person.id), external_id=external_id)
|
||||
return person.id
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Orchestrates one ingestion job end-to-end: create/track the `IngestionJob`
|
||||
row, run the provider fetch + normalize + upsert pipeline, and record
|
||||
success/failure. Used by both the admin trigger endpoint (section 17) and
|
||||
the arq worker task (section 15) so the two entry points share identical
|
||||
job-lifecycle and idempotency behavior.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import session_scope
|
||||
from app.core.logging import get_logger
|
||||
from app.models.provider import IngestionJob
|
||||
from app.providers.tmdb import TMDbProvider
|
||||
from app.repositories.provider_repository import (
|
||||
create_ingestion_job,
|
||||
get_ingestion_job,
|
||||
get_or_create_provider,
|
||||
mark_job_failed,
|
||||
mark_job_running,
|
||||
mark_job_succeeded,
|
||||
)
|
||||
from app.services.ingestion.movie_ingestion import ingest_movie
|
||||
from app.services.ingestion.person_ingestion import ingest_person
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
async def _require_job(session: AsyncSession, job_id: uuid.UUID) -> IngestionJob:
|
||||
job = await get_ingestion_job(session, job_id)
|
||||
if job is None:
|
||||
# Only possible if the row was deleted out-of-band; the job was
|
||||
# created moments earlier in this same call chain.
|
||||
raise RuntimeError(f"ingestion job {job_id} vanished mid-run")
|
||||
return job
|
||||
|
||||
|
||||
async def _create_job(job_type: str, entity_type: str, external_id: str) -> uuid.UUID:
|
||||
idempotency_key = f"tmdb:{entity_type}:{external_id}"
|
||||
async with session_scope() as session:
|
||||
provider_row = await get_or_create_provider(session, "tmdb", "The Movie Database (TMDb)")
|
||||
job = await create_ingestion_job(
|
||||
session,
|
||||
provider_id=provider_row.id,
|
||||
job_type=job_type,
|
||||
idempotency_key=idempotency_key,
|
||||
entity_type=entity_type,
|
||||
external_id=external_id,
|
||||
)
|
||||
return job.id
|
||||
|
||||
|
||||
async def run_movie_ingestion(external_id: str) -> uuid.UUID:
|
||||
job_id = await _create_job("movie_sync", "movie", external_id)
|
||||
|
||||
async with session_scope() as session:
|
||||
job = await _require_job(session, job_id)
|
||||
await mark_job_running(session, job)
|
||||
|
||||
provider = TMDbProvider()
|
||||
try:
|
||||
async with session_scope() as session:
|
||||
movie_id = await ingest_movie(session, provider, external_id, ingestion_job_id=job_id)
|
||||
async with session_scope() as session:
|
||||
job = await _require_job(session, job_id)
|
||||
await mark_job_succeeded(session, job, movie_id)
|
||||
return movie_id
|
||||
except Exception as exc:
|
||||
logger.error("movie_ingestion_failed", external_id=external_id, error=str(exc))
|
||||
async with session_scope() as session:
|
||||
job = await _require_job(session, job_id)
|
||||
await mark_job_failed(session, job, str(exc))
|
||||
raise
|
||||
finally:
|
||||
await provider.aclose()
|
||||
|
||||
|
||||
async def run_person_ingestion(external_id: str) -> uuid.UUID:
|
||||
job_id = await _create_job("person_sync", "person", external_id)
|
||||
|
||||
async with session_scope() as session:
|
||||
job = await _require_job(session, job_id)
|
||||
await mark_job_running(session, job)
|
||||
|
||||
provider = TMDbProvider()
|
||||
try:
|
||||
async with session_scope() as session:
|
||||
person_id = await ingest_person(session, provider, external_id, ingestion_job_id=job_id)
|
||||
async with session_scope() as session:
|
||||
job = await _require_job(session, job_id)
|
||||
await mark_job_succeeded(session, job, person_id)
|
||||
return person_id
|
||||
except Exception as exc:
|
||||
logger.error("person_ingestion_failed", external_id=external_id, error=str(exc))
|
||||
async with session_scope() as session:
|
||||
job = await _require_job(session, job_id)
|
||||
await mark_job_failed(session, job, str(exc))
|
||||
raise
|
||||
finally:
|
||||
await provider.aclose()
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Entity matching (section 14): decides whether an incoming provider
|
||||
record corresponds to an existing Aurora entity.
|
||||
|
||||
Core 0.1 policy — deliberately conservative:
|
||||
|
||||
- An exact external-ID match (this provider has already been linked to an
|
||||
Aurora entity of this type) is the only case treated as MatchConfidence
|
||||
EXACT and reused.
|
||||
- Anything else creates a *new* Aurora entity (MatchConfidence NEW) rather
|
||||
than guessing via fuzzy title matching. Silently merging two providers'
|
||||
differing descriptions of "the same" title is exactly what section 14
|
||||
prohibits.
|
||||
|
||||
Fuzzy candidate matching (title + year similarity, surfaced for human
|
||||
review) is genuinely deferred — see docs/architecture.md — until Core has
|
||||
an admin review workflow to act on AMBIGUOUS matches.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.enums import MatchConfidence
|
||||
from app.repositories.reference_repository import find_entity_by_external_id
|
||||
|
||||
|
||||
async def match_entity(
|
||||
session: AsyncSession, *, provider_id: uuid.UUID, entity_type: str, external_id: str
|
||||
) -> tuple[uuid.UUID | None, MatchConfidence]:
|
||||
"""Return (existing_aurora_id_or_None, confidence)."""
|
||||
|
||||
existing = await find_entity_by_external_id(session, provider_id, entity_type, external_id)
|
||||
if existing is not None:
|
||||
return existing, MatchConfidence.EXACT
|
||||
return None, MatchConfidence.NEW
|
||||
@@ -0,0 +1,341 @@
|
||||
"""Normalizes raw TMDb payloads into provider-agnostic dataclasses.
|
||||
|
||||
This is the seam between "provider shape" and "Aurora shape" (section 13).
|
||||
A future provider adapter only needs its own `normalize_movie`-equivalent
|
||||
that produces these same dataclasses; nothing downstream (matcher,
|
||||
ingestion) needs to know which provider the data came from.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date
|
||||
|
||||
TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p/original"
|
||||
|
||||
|
||||
def _parse_date(value: str | None) -> date | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return date.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _image_url(path: str | None) -> str | None:
|
||||
if not path:
|
||||
return None
|
||||
return f"{TMDB_IMAGE_BASE}{path}"
|
||||
|
||||
|
||||
def _image_url_required(path: str) -> str:
|
||||
"""Same as `_image_url`, for call sites already guarded by a truthy
|
||||
check on `path` — keeps `NormalizedArtwork.url: str` honest."""
|
||||
|
||||
return f"{TMDB_IMAGE_BASE}{path}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class NormalizedPersonRef:
|
||||
external_id: str
|
||||
name: str
|
||||
profile_path: str | None = None
|
||||
department: str | None = None
|
||||
job: str | None = None
|
||||
character: str | None = None
|
||||
billing_order: int | None = None
|
||||
gender: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class NormalizedCompanyRef:
|
||||
external_id: str
|
||||
name: str
|
||||
country_code: str | None = None
|
||||
role: str = "production"
|
||||
|
||||
|
||||
@dataclass
|
||||
class NormalizedArtwork:
|
||||
artwork_type: str # "poster" | "backdrop" | "logo" | "profile" | "still"
|
||||
url: str
|
||||
provider_asset_id: str | None
|
||||
width: int | None
|
||||
height: int | None
|
||||
aspect_ratio: float | None
|
||||
language: str | None
|
||||
vote_average: float | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class NormalizedVideo:
|
||||
video_type: str
|
||||
provider_video_id: str
|
||||
site: str | None
|
||||
url: str
|
||||
language: str | None
|
||||
country: str | None
|
||||
is_official: bool
|
||||
published_at: date | None
|
||||
|
||||
|
||||
@dataclass
|
||||
class NormalizedMovie:
|
||||
external_id: str
|
||||
title: str
|
||||
original_title: str | None
|
||||
overview: str | None
|
||||
tagline: str | None
|
||||
status: str
|
||||
original_language: str | None
|
||||
release_date: date | None
|
||||
runtime_minutes: int | None
|
||||
budget: int | None
|
||||
revenue: int | None
|
||||
popularity: float | None
|
||||
vote_average: float | None
|
||||
vote_count: int | None
|
||||
adult: bool
|
||||
genres: list[str] = field(default_factory=list)
|
||||
keywords: list[str] = field(default_factory=list)
|
||||
alternate_titles: list[str] = field(default_factory=list)
|
||||
companies: list[NormalizedCompanyRef] = field(default_factory=list)
|
||||
cast: list[NormalizedPersonRef] = field(default_factory=list)
|
||||
crew: list[NormalizedPersonRef] = field(default_factory=list)
|
||||
artwork: list[NormalizedArtwork] = field(default_factory=list)
|
||||
videos: list[NormalizedVideo] = field(default_factory=list)
|
||||
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)
|
||||
|
||||
|
||||
_STATUS_MAP = {
|
||||
"Rumored": "rumored",
|
||||
"Planned": "planned",
|
||||
"In Production": "in_production",
|
||||
"Post Production": "post_production",
|
||||
"Released": "released",
|
||||
"Canceled": "canceled",
|
||||
}
|
||||
|
||||
|
||||
def normalize_movie(raw: dict) -> NormalizedMovie:
|
||||
credits_ = raw.get("credits") or {}
|
||||
images = raw.get("images") or {}
|
||||
videos = (raw.get("videos") or {}).get("results") or []
|
||||
keywords = (raw.get("keywords") or {}).get("keywords") or []
|
||||
alt_titles = (raw.get("alternative_titles") or {}).get("titles") or []
|
||||
ext_ids = raw.get("external_ids") or {}
|
||||
collection = raw.get("belongs_to_collection")
|
||||
recommendations = (raw.get("recommendations") or {}).get("results") or []
|
||||
|
||||
external_ids: dict[str, str] = {}
|
||||
if raw.get("imdb_id"):
|
||||
external_ids["imdb"] = raw["imdb_id"]
|
||||
elif ext_ids.get("imdb_id"):
|
||||
external_ids["imdb"] = ext_ids["imdb_id"]
|
||||
if ext_ids.get("wikidata_id"):
|
||||
external_ids["wikidata"] = ext_ids["wikidata_id"]
|
||||
|
||||
cast = [
|
||||
NormalizedPersonRef(
|
||||
external_id=str(c["id"]),
|
||||
name=c["name"],
|
||||
profile_path=_image_url(c.get("profile_path")),
|
||||
department="Acting",
|
||||
character=c.get("character"),
|
||||
billing_order=c.get("order"),
|
||||
gender=_gender_label(c.get("gender")),
|
||||
)
|
||||
for c in credits_.get("cast", [])
|
||||
]
|
||||
crew = [
|
||||
NormalizedPersonRef(
|
||||
external_id=str(c["id"]),
|
||||
name=c["name"],
|
||||
profile_path=_image_url(c.get("profile_path")),
|
||||
department=c.get("department") or "Crew",
|
||||
job=c.get("job"),
|
||||
gender=_gender_label(c.get("gender")),
|
||||
)
|
||||
for c in credits_.get("crew", [])
|
||||
]
|
||||
|
||||
companies = [
|
||||
NormalizedCompanyRef(
|
||||
external_id=str(pc["id"]),
|
||||
name=pc["name"],
|
||||
country_code=pc.get("origin_country") or None,
|
||||
)
|
||||
for pc in raw.get("production_companies", [])
|
||||
]
|
||||
|
||||
artwork: list[NormalizedArtwork] = []
|
||||
if raw.get("poster_path"):
|
||||
artwork.append(
|
||||
NormalizedArtwork(
|
||||
"poster",
|
||||
_image_url_required(raw["poster_path"]),
|
||||
raw["poster_path"],
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
)
|
||||
if raw.get("backdrop_path"):
|
||||
artwork.append(
|
||||
NormalizedArtwork(
|
||||
"backdrop",
|
||||
_image_url_required(raw["backdrop_path"]),
|
||||
raw["backdrop_path"],
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
)
|
||||
for kind, tmdb_key in (("poster", "posters"), ("backdrop", "backdrops"), ("logo", "logos")):
|
||||
for img in images.get(tmdb_key, []):
|
||||
artwork.append(
|
||||
NormalizedArtwork(
|
||||
kind,
|
||||
_image_url_required(img["file_path"]),
|
||||
img["file_path"],
|
||||
img.get("width"),
|
||||
img.get("height"),
|
||||
img.get("aspect_ratio"),
|
||||
img.get("iso_639_1"),
|
||||
img.get("vote_average"),
|
||||
)
|
||||
)
|
||||
|
||||
normalized_videos = [
|
||||
NormalizedVideo(
|
||||
video_type=(v.get("type") or "other").lower().replace(" ", "_"),
|
||||
provider_video_id=v["id"],
|
||||
site=v.get("site"),
|
||||
url=_video_url(v),
|
||||
language=v.get("iso_639_1"),
|
||||
country=v.get("iso_3166_1"),
|
||||
is_official=bool(v.get("official")),
|
||||
published_at=_parse_date(v.get("published_at", "")[:10] if v.get("published_at") else None),
|
||||
)
|
||||
for v in videos
|
||||
]
|
||||
|
||||
return NormalizedMovie(
|
||||
external_id=str(raw["id"]),
|
||||
title=raw.get("title") or raw.get("original_title") or "",
|
||||
original_title=raw.get("original_title"),
|
||||
overview=raw.get("overview") or None,
|
||||
tagline=raw.get("tagline") or None,
|
||||
status=_STATUS_MAP.get(raw.get("status", "Released"), "released"),
|
||||
original_language=raw.get("original_language"),
|
||||
release_date=_parse_date(raw.get("release_date")),
|
||||
runtime_minutes=raw.get("runtime"),
|
||||
budget=raw.get("budget") or None,
|
||||
revenue=raw.get("revenue") or None,
|
||||
popularity=raw.get("popularity"),
|
||||
vote_average=raw.get("vote_average"),
|
||||
vote_count=raw.get("vote_count"),
|
||||
adult=bool(raw.get("adult", False)),
|
||||
genres=[g["name"] for g in raw.get("genres", [])],
|
||||
keywords=[k["name"] for k in keywords],
|
||||
alternate_titles=[t["title"] for t in alt_titles if t.get("title")],
|
||||
companies=companies,
|
||||
cast=cast,
|
||||
crew=crew,
|
||||
artwork=artwork,
|
||||
videos=normalized_videos,
|
||||
external_ids=external_ids,
|
||||
collection=(str(collection["id"]), collection["name"]) if collection else None,
|
||||
recommendation_external_ids=[str(m["id"]) for m in recommendations],
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NormalizedPerson:
|
||||
external_id: str
|
||||
name: str
|
||||
biography: str | None
|
||||
birthday: date | None
|
||||
deathday: date | None
|
||||
birthplace: str | None
|
||||
gender: str | None
|
||||
known_for_department: str | None
|
||||
popularity: float | None
|
||||
aliases: list[str]
|
||||
artwork: list[NormalizedArtwork]
|
||||
external_ids: dict[str, str]
|
||||
|
||||
|
||||
def _gender_label(tmdb_gender: int | None) -> str | None:
|
||||
return {1: "female", 2: "male", 3: "non_binary"}.get(tmdb_gender or 0)
|
||||
|
||||
|
||||
def _video_url(video: dict) -> str:
|
||||
site = (video.get("site") or "").lower()
|
||||
key = video.get("key", "")
|
||||
if site == "youtube":
|
||||
return f"https://www.youtube.com/watch?v={key}"
|
||||
if site == "vimeo":
|
||||
return f"https://vimeo.com/{key}"
|
||||
return key
|
||||
|
||||
|
||||
def normalize_person(raw: dict) -> NormalizedPerson:
|
||||
images = (raw.get("images") or {}).get("profiles") or []
|
||||
ext_ids = raw.get("external_ids") or {}
|
||||
external_ids: dict[str, str] = {}
|
||||
if raw.get("imdb_id"):
|
||||
external_ids["imdb"] = raw["imdb_id"]
|
||||
elif ext_ids.get("imdb_id"):
|
||||
external_ids["imdb"] = ext_ids["imdb_id"]
|
||||
if ext_ids.get("wikidata_id"):
|
||||
external_ids["wikidata"] = ext_ids["wikidata_id"]
|
||||
|
||||
artwork = [
|
||||
NormalizedArtwork(
|
||||
"profile",
|
||||
_image_url_required(img["file_path"]),
|
||||
img["file_path"],
|
||||
img.get("width"),
|
||||
img.get("height"),
|
||||
img.get("aspect_ratio"),
|
||||
img.get("iso_639_1"),
|
||||
img.get("vote_average"),
|
||||
)
|
||||
for img in images
|
||||
]
|
||||
if raw.get("profile_path") and not any(a.provider_asset_id == raw["profile_path"] for a in artwork):
|
||||
artwork.insert(
|
||||
0,
|
||||
NormalizedArtwork(
|
||||
"profile",
|
||||
_image_url_required(raw["profile_path"]),
|
||||
raw["profile_path"],
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
)
|
||||
|
||||
also_known_as = raw.get("also_known_as") or []
|
||||
|
||||
return NormalizedPerson(
|
||||
external_id=str(raw["id"]),
|
||||
name=raw.get("name", ""),
|
||||
biography=raw.get("biography") or None,
|
||||
birthday=_parse_date(raw.get("birthday")),
|
||||
deathday=_parse_date(raw.get("deathday")),
|
||||
birthplace=raw.get("place_of_birth") or None,
|
||||
gender=_gender_label(raw.get("gender")),
|
||||
known_for_department=raw.get("known_for_department"),
|
||||
popularity=raw.get("popularity"),
|
||||
aliases=list(also_known_as),
|
||||
artwork=artwork,
|
||||
external_ids=external_ids,
|
||||
)
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Assembles Movie API responses from repository data."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.ids import aurora_id, parse_aurora_id
|
||||
from app.models.enums import ArtworkType
|
||||
from app.repositories import movie_repository, polymorphic
|
||||
from app.schemas.common import CollectionBriefOut, CompanyBriefOut, GenreOut, KeywordOut
|
||||
from app.schemas.movie import (
|
||||
MovieCreditsOut,
|
||||
MovieDetailOut,
|
||||
MovieImagesOut,
|
||||
MovieSummaryOut,
|
||||
MovieVideosOut,
|
||||
)
|
||||
from app.services.common import primary_artwork_path, split_credits, to_images_out, to_video_out
|
||||
|
||||
|
||||
class MovieNotFoundError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
async def _resolve_movie_id(movie_aurora_id: str) -> uuid.UUID:
|
||||
try:
|
||||
return uuid.UUID(parse_aurora_id(movie_aurora_id, expected_type="movie"))
|
||||
except ValueError as exc:
|
||||
raise MovieNotFoundError(str(exc)) from exc
|
||||
|
||||
|
||||
async def get_movie_detail(session: AsyncSession, movie_aurora_id: str) -> MovieDetailOut:
|
||||
movie_id = await _resolve_movie_id(movie_aurora_id)
|
||||
movie = await movie_repository.get_movie_by_id(session, movie_id)
|
||||
if movie is None:
|
||||
raise MovieNotFoundError(movie_aurora_id)
|
||||
|
||||
genres = await movie_repository.get_movie_genres(session, movie_id)
|
||||
keywords = await movie_repository.get_movie_keywords(session, movie_id)
|
||||
companies = await movie_repository.get_movie_companies(session, movie_id)
|
||||
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)
|
||||
external_ids = await polymorphic.get_external_ids(session, "movie", movie_id)
|
||||
|
||||
cast, crew = split_credits(credits_)
|
||||
|
||||
return MovieDetailOut(
|
||||
id=aurora_id("movie", str(movie.id)),
|
||||
title=movie.title,
|
||||
original_title=movie.original_title,
|
||||
overview=movie.overview,
|
||||
tagline=movie.tagline,
|
||||
status=movie.status.value,
|
||||
original_language=movie.original_language_code,
|
||||
release_date=movie.primary_release_date,
|
||||
runtime=movie.runtime_minutes,
|
||||
budget=movie.budget,
|
||||
revenue=movie.revenue,
|
||||
popularity=movie.popularity,
|
||||
vote_average=movie.vote_average,
|
||||
vote_count=movie.vote_count,
|
||||
adult=movie.adult,
|
||||
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,
|
||||
crew=crew,
|
||||
companies=[
|
||||
CompanyBriefOut(
|
||||
id=aurora_id("company", str(company.id)),
|
||||
name=company.name,
|
||||
company_type=company.company_type.value,
|
||||
logo_path=None,
|
||||
)
|
||||
for company, _role in companies
|
||||
],
|
||||
collection=(
|
||||
CollectionBriefOut(
|
||||
id=aurora_id("collection", str(movie.collection.id)), name=movie.collection.name
|
||||
)
|
||||
if movie.collection
|
||||
else None
|
||||
),
|
||||
images=to_images_out(artwork),
|
||||
videos=[to_video_out(v) for v in videos],
|
||||
external_ids=external_ids,
|
||||
created_at=movie.created_at,
|
||||
updated_at=movie.updated_at,
|
||||
)
|
||||
|
||||
|
||||
async def get_movie_credits(session: AsyncSession, movie_aurora_id: str) -> MovieCreditsOut:
|
||||
movie_id = await _resolve_movie_id(movie_aurora_id)
|
||||
movie = await movie_repository.get_movie_by_id(session, movie_id)
|
||||
if movie is None:
|
||||
raise MovieNotFoundError(movie_aurora_id)
|
||||
credits_ = await polymorphic.get_credits(session, "movie", movie_id)
|
||||
cast, crew = split_credits(credits_)
|
||||
return MovieCreditsOut(id=aurora_id("movie", str(movie.id)), cast=cast, crew=crew)
|
||||
|
||||
|
||||
async def get_movie_images(session: AsyncSession, movie_aurora_id: str) -> MovieImagesOut:
|
||||
movie_id = await _resolve_movie_id(movie_aurora_id)
|
||||
movie = await movie_repository.get_movie_by_id(session, movie_id)
|
||||
if movie is None:
|
||||
raise MovieNotFoundError(movie_aurora_id)
|
||||
artwork = await polymorphic.get_artwork(session, "movie", movie_id)
|
||||
return MovieImagesOut(id=aurora_id("movie", str(movie.id)), images=to_images_out(artwork))
|
||||
|
||||
|
||||
async def get_movie_videos(session: AsyncSession, movie_aurora_id: str) -> MovieVideosOut:
|
||||
movie_id = await _resolve_movie_id(movie_aurora_id)
|
||||
movie = await movie_repository.get_movie_by_id(session, movie_id)
|
||||
if movie is None:
|
||||
raise MovieNotFoundError(movie_aurora_id)
|
||||
videos = await polymorphic.get_videos(session, "movie", movie_id)
|
||||
return MovieVideosOut(id=aurora_id("movie", str(movie.id)), videos=[to_video_out(v) for v in videos])
|
||||
|
||||
|
||||
async def build_movie_summary(session: AsyncSession, movie_id: uuid.UUID) -> MovieSummaryOut | None:
|
||||
movie = await movie_repository.get_movie_by_id(session, movie_id)
|
||||
if movie is None:
|
||||
return None
|
||||
posters = await polymorphic.get_artwork(session, "movie", movie_id, ArtworkType.POSTER)
|
||||
return MovieSummaryOut(
|
||||
id=aurora_id("movie", str(movie.id)),
|
||||
title=movie.title,
|
||||
original_title=movie.original_title,
|
||||
overview=movie.overview,
|
||||
release_date=movie.primary_release_date,
|
||||
poster_path=primary_artwork_path(posters, ArtworkType.POSTER),
|
||||
popularity=movie.popularity,
|
||||
vote_average=movie.vote_average,
|
||||
)
|
||||
|
||||
|
||||
async def build_movie_summaries(
|
||||
session: AsyncSession, movie_ids: list[uuid.UUID]
|
||||
) -> list[MovieSummaryOut]:
|
||||
out: list[MovieSummaryOut] = []
|
||||
for movie_id in movie_ids:
|
||||
summary = await build_movie_summary(session, movie_id)
|
||||
if summary is not None:
|
||||
out.append(summary)
|
||||
return out
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Assembles Person API responses from repository data."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.ids import aurora_id, parse_aurora_id
|
||||
from app.models.enums import ArtworkType
|
||||
from app.repositories import movie_repository, person_repository, polymorphic
|
||||
from app.schemas.person import (
|
||||
FilmographyCreditOut,
|
||||
PersonCreditsOut,
|
||||
PersonDetailOut,
|
||||
PersonSummaryOut,
|
||||
)
|
||||
from app.services.common import primary_artwork_path, to_artwork_out
|
||||
|
||||
|
||||
class PersonNotFoundError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _resolve_person_id(aid: str) -> uuid.UUID:
|
||||
try:
|
||||
return uuid.UUID(parse_aurora_id(aid, expected_type="person"))
|
||||
except ValueError as exc:
|
||||
raise PersonNotFoundError(str(exc)) from exc
|
||||
|
||||
|
||||
async def get_person_detail(session: AsyncSession, person_aurora_id: str) -> PersonDetailOut:
|
||||
person_id = _resolve_person_id(person_aurora_id)
|
||||
person = await person_repository.get_person_by_id(session, person_id)
|
||||
if person is None:
|
||||
raise PersonNotFoundError(person_aurora_id)
|
||||
|
||||
profiles = await polymorphic.get_artwork(session, "person", person_id, ArtworkType.PROFILE)
|
||||
external_ids = await polymorphic.get_external_ids(session, "person", person_id)
|
||||
|
||||
return PersonDetailOut(
|
||||
id=aurora_id("person", str(person.id)),
|
||||
name=person.name,
|
||||
aliases=list(person.aliases or []),
|
||||
biography=person.biography,
|
||||
birthday=person.birthday,
|
||||
deathday=person.deathday,
|
||||
birthplace=person.birthplace,
|
||||
gender=person.gender,
|
||||
known_for_department=person.known_for_department,
|
||||
popularity=person.popularity,
|
||||
profile_images=[to_artwork_out(a) for a in profiles],
|
||||
external_ids=external_ids,
|
||||
created_at=person.created_at,
|
||||
updated_at=person.updated_at,
|
||||
)
|
||||
|
||||
|
||||
async def get_person_credits(session: AsyncSession, person_aurora_id: str) -> PersonCreditsOut:
|
||||
person_id = _resolve_person_id(person_aurora_id)
|
||||
person = await person_repository.get_person_by_id(session, person_id)
|
||||
if person is None:
|
||||
raise PersonNotFoundError(person_aurora_id)
|
||||
|
||||
credits_ = await person_repository.get_person_credits(session, person_id)
|
||||
|
||||
movie_ids = [c.entity_id for c in credits_ if c.entity_type == "movie"]
|
||||
movies = {m.id: m for m in await movie_repository.get_movies_by_ids(session, movie_ids)}
|
||||
|
||||
cast: list[FilmographyCreditOut] = []
|
||||
crew: list[FilmographyCreditOut] = []
|
||||
|
||||
for credit in credits_:
|
||||
if credit.entity_type != "movie":
|
||||
# Series/episode filmography follows the same shape; movies are
|
||||
# implemented first per the Core 0.1 milestone (section 34).
|
||||
continue
|
||||
movie = movies.get(credit.entity_id)
|
||||
if movie is None:
|
||||
continue
|
||||
posters = await polymorphic.get_artwork(
|
||||
session, "movie", movie.id, ArtworkType.POSTER
|
||||
)
|
||||
item = FilmographyCreditOut(
|
||||
id=aurora_id("credit", str(credit.id)),
|
||||
media_id=aurora_id("movie", str(movie.id)),
|
||||
media_type="movie",
|
||||
title=movie.title,
|
||||
department=credit.department,
|
||||
job=credit.job,
|
||||
character=credit.character,
|
||||
release_date=movie.primary_release_date,
|
||||
poster_path=primary_artwork_path(posters, ArtworkType.POSTER),
|
||||
)
|
||||
if credit.department == "Acting":
|
||||
cast.append(item)
|
||||
else:
|
||||
crew.append(item)
|
||||
|
||||
return PersonCreditsOut(id=aurora_id("person", str(person.id)), cast=cast, crew=crew)
|
||||
|
||||
|
||||
async def build_person_summary(
|
||||
session: AsyncSession, person_id: uuid.UUID
|
||||
) -> PersonSummaryOut | None:
|
||||
person = await person_repository.get_person_by_id(session, person_id)
|
||||
if person is None:
|
||||
return None
|
||||
profiles = await polymorphic.get_artwork(session, "person", person_id, ArtworkType.PROFILE)
|
||||
return PersonSummaryOut(
|
||||
id=aurora_id("person", str(person.id)),
|
||||
name=person.name,
|
||||
known_for_department=person.known_for_department,
|
||||
profile_path=primary_artwork_path(profiles, ArtworkType.PROFILE),
|
||||
popularity=person.popularity,
|
||||
)
|
||||
|
||||
|
||||
async def build_person_summaries(
|
||||
session: AsyncSession, person_ids: list[uuid.UUID]
|
||||
) -> list[PersonSummaryOut]:
|
||||
out: list[PersonSummaryOut] = []
|
||||
for person_id in person_ids:
|
||||
summary = await build_person_summary(session, person_id)
|
||||
if summary is not None:
|
||||
out.append(summary)
|
||||
return out
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Search provider abstraction (section 16).
|
||||
|
||||
API clients and the rest of the service layer talk to `SearchProvider`, not
|
||||
to PostgreSQL directly, so the underlying engine can be swapped later (e.g.
|
||||
for an `OpenSearchProvider`) without touching callers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchMatch:
|
||||
entity_id: uuid.UUID
|
||||
score: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchResultIDs:
|
||||
movies: list[SearchMatch] = field(default_factory=list)
|
||||
series: list[SearchMatch] = field(default_factory=list)
|
||||
people: list[SearchMatch] = field(default_factory=list)
|
||||
collections: list[SearchMatch] = field(default_factory=list)
|
||||
companies: list[SearchMatch] = field(default_factory=list)
|
||||
|
||||
|
||||
class SearchProvider(ABC):
|
||||
"""Returns ranked Aurora entity IDs matching a query. Never returns
|
||||
fully-hydrated entities — that's the SearchService's job, so the
|
||||
provider stays a thin, swappable ranking layer."""
|
||||
|
||||
@abstractmethod
|
||||
async def search(
|
||||
self, session: AsyncSession, query: str, *, limit_per_type: int = 10
|
||||
) -> SearchResultIDs: ...
|
||||
@@ -0,0 +1,121 @@
|
||||
"""PostgreSQL-backed search: full-text search ranking blended with trigram
|
||||
similarity for typo tolerance, over primary titles/names and alternate
|
||||
titles. Requires the `pg_trgm` extension and the functional GIN indexes
|
||||
created in the initial migration.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.services.search.base import SearchMatch, SearchProvider, SearchResultIDs
|
||||
|
||||
_MOVIE_SQL = text(
|
||||
"""
|
||||
WITH scored AS (
|
||||
SELECT m.id AS entity_id,
|
||||
GREATEST(
|
||||
ts_rank_cd(to_tsvector('simple', coalesce(m.title, '') || ' ' || coalesce(m.original_title, '')), plainto_tsquery('simple', :q)),
|
||||
similarity(m.title, :q)
|
||||
) AS score
|
||||
FROM movies m
|
||||
WHERE to_tsvector('simple', coalesce(m.title, '') || ' ' || coalesce(m.original_title, '')) @@ plainto_tsquery('simple', :q)
|
||||
OR m.title % :q
|
||||
UNION ALL
|
||||
SELECT mat.movie_id AS entity_id, similarity(mat.title, :q) AS score
|
||||
FROM movie_alternate_titles mat
|
||||
WHERE mat.title % :q
|
||||
)
|
||||
SELECT entity_id, MAX(score) AS score
|
||||
FROM scored
|
||||
GROUP BY entity_id
|
||||
ORDER BY score DESC
|
||||
LIMIT :limit
|
||||
"""
|
||||
)
|
||||
|
||||
_SERIES_SQL = text(
|
||||
"""
|
||||
WITH scored AS (
|
||||
SELECT s.id AS entity_id,
|
||||
GREATEST(
|
||||
ts_rank_cd(to_tsvector('simple', coalesce(s.title, '') || ' ' || coalesce(s.original_title, '')), plainto_tsquery('simple', :q)),
|
||||
similarity(s.title, :q)
|
||||
) AS score
|
||||
FROM series s
|
||||
WHERE to_tsvector('simple', coalesce(s.title, '') || ' ' || coalesce(s.original_title, '')) @@ plainto_tsquery('simple', :q)
|
||||
OR s.title % :q
|
||||
UNION ALL
|
||||
SELECT sat.series_id AS entity_id, similarity(sat.title, :q) AS score
|
||||
FROM series_alternate_titles sat
|
||||
WHERE sat.title % :q
|
||||
)
|
||||
SELECT entity_id, MAX(score) AS score
|
||||
FROM scored
|
||||
GROUP BY entity_id
|
||||
ORDER BY score DESC
|
||||
LIMIT :limit
|
||||
"""
|
||||
)
|
||||
|
||||
_PEOPLE_SQL = text(
|
||||
"""
|
||||
SELECT p.id AS entity_id,
|
||||
GREATEST(
|
||||
ts_rank_cd(to_tsvector('simple', p.name), plainto_tsquery('simple', :q)),
|
||||
similarity(p.name, :q)
|
||||
) AS score
|
||||
FROM people p
|
||||
WHERE to_tsvector('simple', p.name) @@ plainto_tsquery('simple', :q)
|
||||
OR p.name % :q
|
||||
ORDER BY score DESC
|
||||
LIMIT :limit
|
||||
"""
|
||||
)
|
||||
|
||||
_COLLECTIONS_SQL = text(
|
||||
"""
|
||||
SELECT c.id AS entity_id, similarity(c.name, :q) AS score
|
||||
FROM collections c
|
||||
WHERE c.name % :q OR to_tsvector('simple', c.name) @@ plainto_tsquery('simple', :q)
|
||||
ORDER BY score DESC
|
||||
LIMIT :limit
|
||||
"""
|
||||
)
|
||||
|
||||
_COMPANIES_SQL = text(
|
||||
"""
|
||||
SELECT co.id AS entity_id, similarity(co.name, :q) AS score
|
||||
FROM companies co
|
||||
WHERE co.name % :q OR to_tsvector('simple', co.name) @@ plainto_tsquery('simple', :q)
|
||||
ORDER BY score DESC
|
||||
LIMIT :limit
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
class PostgreSQLSearchProvider(SearchProvider):
|
||||
async def search(
|
||||
self, session: AsyncSession, query: str, *, limit_per_type: int = 10
|
||||
) -> SearchResultIDs:
|
||||
params = {"q": query, "limit": limit_per_type}
|
||||
movies = await session.execute(_MOVIE_SQL, params)
|
||||
series = await session.execute(_SERIES_SQL, params)
|
||||
people = await session.execute(_PEOPLE_SQL, params)
|
||||
collections = await session.execute(_COLLECTIONS_SQL, params)
|
||||
companies = await session.execute(_COMPANIES_SQL, params)
|
||||
|
||||
def to_matches(rows) -> list[SearchMatch]:
|
||||
return [
|
||||
SearchMatch(entity_id=row.entity_id, score=float(row.score or 0.0))
|
||||
for row in rows
|
||||
]
|
||||
|
||||
return SearchResultIDs(
|
||||
movies=to_matches(movies),
|
||||
series=to_matches(series),
|
||||
people=to_matches(people),
|
||||
collections=to_matches(collections),
|
||||
companies=to_matches(companies),
|
||||
)
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Search service: runs a `SearchProvider` and hydrates matches into full
|
||||
response summaries via the entity services. API clients never see the
|
||||
underlying search engine (section 16)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.schemas.search import SearchResultsOut
|
||||
from app.services.collection_service import build_collection_summaries
|
||||
from app.services.company_service import build_company_summaries
|
||||
from app.services.movie_service import build_movie_summaries
|
||||
from app.services.person_service import build_person_summaries
|
||||
from app.services.search.base import SearchProvider
|
||||
from app.services.tv_service import build_series_summaries
|
||||
|
||||
|
||||
class SearchService:
|
||||
def __init__(self, provider: SearchProvider) -> None:
|
||||
self._provider = provider
|
||||
|
||||
async def search(
|
||||
self, session: AsyncSession, query: str, *, limit_per_type: int = 10
|
||||
) -> SearchResultsOut:
|
||||
query = query.strip()
|
||||
if not query:
|
||||
return SearchResultsOut(query=query)
|
||||
|
||||
results = await self._provider.search(session, query, limit_per_type=limit_per_type)
|
||||
|
||||
movies = await build_movie_summaries(session, [m.entity_id for m in results.movies])
|
||||
series = await build_series_summaries(session, [s.entity_id for s in results.series])
|
||||
people = await build_person_summaries(session, [p.entity_id for p in results.people])
|
||||
collections = await build_collection_summaries(
|
||||
session, [c.entity_id for c in results.collections]
|
||||
)
|
||||
companies = await build_company_summaries(
|
||||
session, [c.entity_id for c in results.companies]
|
||||
)
|
||||
|
||||
return SearchResultsOut(
|
||||
query=query,
|
||||
movies=movies,
|
||||
series=series,
|
||||
people=people,
|
||||
collections=collections,
|
||||
companies=companies,
|
||||
total_results=len(movies) + len(series) + len(people) + len(collections) + len(companies),
|
||||
)
|
||||
@@ -0,0 +1,229 @@
|
||||
"""Assembles Series/Season/Episode API responses from repository data."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.ids import aurora_id, parse_aurora_id
|
||||
from app.models.enums import ArtworkType, CompanyType
|
||||
from app.repositories import polymorphic, tv_repository
|
||||
from app.schemas.common import CompanyBriefOut, GenreOut, KeywordOut
|
||||
from app.schemas.tv import (
|
||||
EpisodeBriefOut,
|
||||
EpisodeDetailOut,
|
||||
SeasonBriefOut,
|
||||
SeasonDetailOut,
|
||||
SeriesDetailOut,
|
||||
SeriesSeasonsOut,
|
||||
SeriesSummaryOut,
|
||||
)
|
||||
from app.services.common import primary_artwork_path, split_credits, to_images_out, to_video_out
|
||||
|
||||
|
||||
class SeriesNotFoundError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class SeasonNotFoundError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class EpisodeNotFoundError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _resolve_id(aid: str, expected_type: str, error_cls: type[Exception]) -> uuid.UUID:
|
||||
try:
|
||||
return uuid.UUID(parse_aurora_id(aid, expected_type=expected_type))
|
||||
except ValueError as exc:
|
||||
raise error_cls(str(exc)) from exc
|
||||
|
||||
|
||||
async def _season_brief(session: AsyncSession, season) -> SeasonBriefOut:
|
||||
posters = await polymorphic.get_artwork(session, "season", season.id, ArtworkType.POSTER)
|
||||
return SeasonBriefOut(
|
||||
id=aurora_id("season", str(season.id)),
|
||||
season_number=season.season_number,
|
||||
title=season.title,
|
||||
episode_count=season.episode_count,
|
||||
air_date=season.air_date,
|
||||
poster_path=primary_artwork_path(posters, ArtworkType.POSTER),
|
||||
)
|
||||
|
||||
|
||||
async def get_series_detail(session: AsyncSession, series_aurora_id: str) -> SeriesDetailOut:
|
||||
series_id = _resolve_id(series_aurora_id, "series", SeriesNotFoundError)
|
||||
series = await tv_repository.get_series_by_id(session, series_id)
|
||||
if series is None:
|
||||
raise SeriesNotFoundError(series_aurora_id)
|
||||
|
||||
genres = await tv_repository.get_series_genres(session, series_id)
|
||||
keywords = await tv_repository.get_series_keywords(session, series_id)
|
||||
networks = await tv_repository.get_series_companies(session, series_id, CompanyType.NETWORK)
|
||||
companies = await tv_repository.get_series_companies(
|
||||
session, series_id, CompanyType.PRODUCTION_COMPANY
|
||||
)
|
||||
seasons = await tv_repository.get_seasons_for_series(session, series_id)
|
||||
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)
|
||||
external_ids = await polymorphic.get_external_ids(session, "series", series_id)
|
||||
|
||||
cast, crew = split_credits([c for c in credits_ if c.department != "Creator"])
|
||||
_, creators = split_credits([c for c in credits_ if c.department == "Creator"])
|
||||
|
||||
season_briefs = [await _season_brief(session, s) for s in seasons]
|
||||
|
||||
return SeriesDetailOut(
|
||||
id=aurora_id("series", str(series.id)),
|
||||
title=series.title,
|
||||
original_title=series.original_title,
|
||||
overview=series.overview,
|
||||
tagline=series.tagline,
|
||||
status=series.status.value,
|
||||
original_language=series.original_language_code,
|
||||
first_air_date=series.first_air_date,
|
||||
last_air_date=series.last_air_date,
|
||||
number_of_seasons=series.number_of_seasons,
|
||||
number_of_episodes=series.number_of_episodes,
|
||||
popularity=series.popularity,
|
||||
vote_average=series.vote_average,
|
||||
vote_count=series.vote_count,
|
||||
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,
|
||||
cast=cast,
|
||||
crew=crew,
|
||||
networks=[
|
||||
CompanyBriefOut(
|
||||
id=aurora_id("company", str(c.id)), name=c.name, company_type=c.company_type.value
|
||||
)
|
||||
for c, _role in networks
|
||||
],
|
||||
companies=[
|
||||
CompanyBriefOut(
|
||||
id=aurora_id("company", str(c.id)), name=c.name, company_type=c.company_type.value
|
||||
)
|
||||
for c, _role in companies
|
||||
],
|
||||
seasons=season_briefs,
|
||||
images=to_images_out(artwork),
|
||||
videos=[to_video_out(v) for v in videos],
|
||||
external_ids=external_ids,
|
||||
created_at=series.created_at,
|
||||
updated_at=series.updated_at,
|
||||
)
|
||||
|
||||
|
||||
async def get_series_seasons(session: AsyncSession, series_aurora_id: str) -> SeriesSeasonsOut:
|
||||
series_id = _resolve_id(series_aurora_id, "series", SeriesNotFoundError)
|
||||
series = await tv_repository.get_series_by_id(session, series_id)
|
||||
if series is None:
|
||||
raise SeriesNotFoundError(series_aurora_id)
|
||||
seasons = await tv_repository.get_seasons_for_series(session, series_id)
|
||||
briefs = [await _season_brief(session, s) for s in seasons]
|
||||
return SeriesSeasonsOut(id=aurora_id("series", str(series.id)), seasons=briefs)
|
||||
|
||||
|
||||
async def get_season_detail(session: AsyncSession, season_aurora_id: str) -> SeasonDetailOut:
|
||||
season_id = _resolve_id(season_aurora_id, "season", SeasonNotFoundError)
|
||||
season = await tv_repository.get_season_by_id(session, season_id)
|
||||
if season is None:
|
||||
raise SeasonNotFoundError(season_aurora_id)
|
||||
|
||||
episodes = await tv_repository.get_episodes_for_season(session, season_id)
|
||||
artwork = await polymorphic.get_artwork(session, "season", season_id)
|
||||
external_ids = await polymorphic.get_external_ids(session, "season", season_id)
|
||||
|
||||
episode_briefs: list[EpisodeBriefOut] = []
|
||||
for ep in episodes:
|
||||
stills = await polymorphic.get_artwork(session, "episode", ep.id, ArtworkType.STILL)
|
||||
episode_briefs.append(
|
||||
EpisodeBriefOut(
|
||||
id=aurora_id("episode", str(ep.id)),
|
||||
episode_number=ep.episode_number,
|
||||
title=ep.title,
|
||||
air_date=ep.air_date,
|
||||
still_path=primary_artwork_path(stills, ArtworkType.STILL),
|
||||
)
|
||||
)
|
||||
|
||||
return SeasonDetailOut(
|
||||
id=aurora_id("season", str(season.id)),
|
||||
series_id=aurora_id("series", str(season.series_id)),
|
||||
season_number=season.season_number,
|
||||
title=season.title,
|
||||
overview=season.overview,
|
||||
air_date=season.air_date,
|
||||
episode_count=season.episode_count,
|
||||
episodes=episode_briefs,
|
||||
images=to_images_out(artwork),
|
||||
external_ids=external_ids,
|
||||
created_at=season.created_at,
|
||||
updated_at=season.updated_at,
|
||||
)
|
||||
|
||||
|
||||
async def get_episode_detail(session: AsyncSession, episode_aurora_id: str) -> EpisodeDetailOut:
|
||||
episode_id = _resolve_id(episode_aurora_id, "episode", EpisodeNotFoundError)
|
||||
episode = await tv_repository.get_episode_by_id(session, episode_id)
|
||||
if episode is None:
|
||||
raise EpisodeNotFoundError(episode_aurora_id)
|
||||
|
||||
credits_ = await polymorphic.get_credits(session, "episode", episode_id)
|
||||
artwork = await polymorphic.get_artwork(session, "episode", episode_id)
|
||||
external_ids = await polymorphic.get_external_ids(session, "episode", episode_id)
|
||||
|
||||
main_cast, crew = split_credits([c for c in credits_ if not c.is_guest])
|
||||
guest_cast, _ = split_credits([c for c in credits_ if c.is_guest])
|
||||
|
||||
return EpisodeDetailOut(
|
||||
id=aurora_id("episode", str(episode.id)),
|
||||
series_id=aurora_id("series", str(episode.series_id)),
|
||||
season_id=aurora_id("season", str(episode.season_id)),
|
||||
season_number=episode.season_number,
|
||||
episode_number=episode.episode_number,
|
||||
title=episode.title,
|
||||
overview=episode.overview,
|
||||
air_date=episode.air_date,
|
||||
runtime=episode.runtime_minutes,
|
||||
cast=main_cast,
|
||||
guest_stars=guest_cast,
|
||||
crew=crew,
|
||||
images=to_images_out(artwork),
|
||||
external_ids=external_ids,
|
||||
created_at=episode.created_at,
|
||||
updated_at=episode.updated_at,
|
||||
)
|
||||
|
||||
|
||||
async def build_series_summary(
|
||||
session: AsyncSession, series_id: uuid.UUID
|
||||
) -> SeriesSummaryOut | None:
|
||||
series = await tv_repository.get_series_by_id(session, series_id)
|
||||
if series is None:
|
||||
return None
|
||||
posters = await polymorphic.get_artwork(session, "series", series_id, ArtworkType.POSTER)
|
||||
return SeriesSummaryOut(
|
||||
id=aurora_id("series", str(series.id)),
|
||||
title=series.title,
|
||||
original_title=series.original_title,
|
||||
overview=series.overview,
|
||||
first_air_date=series.first_air_date,
|
||||
poster_path=primary_artwork_path(posters, ArtworkType.POSTER),
|
||||
popularity=series.popularity,
|
||||
vote_average=series.vote_average,
|
||||
)
|
||||
|
||||
|
||||
async def build_series_summaries(
|
||||
session: AsyncSession, series_ids: list[uuid.UUID]
|
||||
) -> list[SeriesSummaryOut]:
|
||||
out: list[SeriesSummaryOut] = []
|
||||
for series_id in series_ids:
|
||||
summary = await build_series_summary(session, series_id)
|
||||
if summary is not None:
|
||||
out.append(summary)
|
||||
return out
|
||||
Reference in New Issue
Block a user