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

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:
NixReaper
2026-09-04 17:34:03 -05:00
co-authored by Claude Sonnet 5
commit c20df1c503
111 changed files with 10041 additions and 0 deletions
View File
+61
View File
@@ -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
+75
View File
@@ -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
+105
View File
@@ -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
+80
View File
@@ -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
View File
+314
View File
@@ -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
+115
View File
@@ -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
+103
View File
@@ -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()
View File
+37
View File
@@ -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
+341
View File
@@ -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,
)
+147
View File
@@ -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
+127
View File
@@ -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
View File
+40
View File
@@ -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: ...
+121
View File
@@ -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),
)
+49
View File
@@ -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),
)
+229
View File
@@ -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