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
View File
+37
View File
@@ -0,0 +1,37 @@
"""Shared FastAPI dependencies and helpers for the v1 API."""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from fastapi import HTTPException, status
from pydantic import BaseModel
from redis.asyncio import Redis
from app.services.cache import cache_get_json, cache_set_json
def not_found(entity_type: str, aurora_id_str: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"code": f"{entity_type}_not_found", "message": f"No such {entity_type}: {aurora_id_str}"},
)
async def cached_response[T: BaseModel](
redis: Redis,
key: str,
model: type[T],
loader: Callable[[], Awaitable[T]],
ttl: int | None = None,
) -> T:
"""Serve `model` from Redis if present, otherwise call `loader`, cache
the result, and return it. Fails open to `loader` on any cache error."""
cached = await cache_get_json(redis, key)
if cached is not None:
return model.model_validate(cached)
result = await loader()
await cache_set_json(redis, key, result.model_dump(mode="json"), ttl=ttl)
return result
View File
+93
View File
@@ -0,0 +1,93 @@
"""Administrative/provider endpoints (section 17): trigger ingestion and
check job status. Separately protected by an admin API key — never mounted
on the public read path.
"""
from __future__ import annotations
import uuid
from arq import ArqRedis
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import not_found
from app.core.database import get_db
from app.core.queue import get_queue
from app.core.security import require_admin_api_key
from app.repositories.provider_repository import get_ingestion_job
router = APIRouter(
prefix="/admin",
tags=["admin"],
dependencies=[Depends(require_admin_api_key)],
)
class IngestMovieRequest(BaseModel):
tmdb_id: str
class IngestPersonRequest(BaseModel):
tmdb_id: str
class IngestionEnqueuedOut(BaseModel):
job_id: str
arq_job_id: str
status: str = "enqueued"
class IngestionJobOut(BaseModel):
id: str
job_type: str
entity_type: str | None
external_id: str | None
status: str
attempts: int
error: str | None
result_aurora_id: str | None
@router.post("/ingest/movie", response_model=IngestionEnqueuedOut)
async def enqueue_movie_ingestion(
body: IngestMovieRequest, queue: ArqRedis = Depends(get_queue)
) -> IngestionEnqueuedOut:
job = await queue.enqueue_job("ingest_movie_task", body.tmdb_id)
if job is None:
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE, "Could not enqueue ingestion job (queue busy)."
)
return IngestionEnqueuedOut(job_id=job.job_id, arq_job_id=job.job_id)
@router.post("/ingest/person", response_model=IngestionEnqueuedOut)
async def enqueue_person_ingestion(
body: IngestPersonRequest, queue: ArqRedis = Depends(get_queue)
) -> IngestionEnqueuedOut:
job = await queue.enqueue_job("ingest_person_task", body.tmdb_id)
if job is None:
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE, "Could not enqueue ingestion job (queue busy)."
)
return IngestionEnqueuedOut(job_id=job.job_id, arq_job_id=job.job_id)
@router.get("/ingest/jobs/{job_id}", response_model=IngestionJobOut)
async def get_ingestion_job_status(
job_id: uuid.UUID, session: AsyncSession = Depends(get_db)
) -> IngestionJobOut:
job = await get_ingestion_job(session, job_id)
if job is None:
raise not_found("ingestion_job", str(job_id))
return IngestionJobOut(
id=str(job.id),
job_type=job.job_type,
entity_type=job.entity_type,
external_id=job.external_id,
status=job.status.value,
attempts=job.attempts,
error=job.error,
result_aurora_id=str(job.result_aurora_id) if job.result_aurora_id else None,
)
+26
View File
@@ -0,0 +1,26 @@
"""Collection/franchise endpoints (section 17)."""
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import not_found
from app.core.database import get_db
from app.core.security import enforce_rate_limit
from app.schemas.collection import CollectionDetailOut
from app.services import collection_service
router = APIRouter(
prefix="/collections", tags=["collections"], dependencies=[Depends(enforce_rate_limit)]
)
@router.get("/{aurora_id}", response_model=CollectionDetailOut)
async def get_collection(
aurora_id: str, session: AsyncSession = Depends(get_db)
) -> CollectionDetailOut:
try:
return await collection_service.get_collection_detail(session, aurora_id)
except collection_service.CollectionNotFoundError as exc:
raise not_found("collection", aurora_id) from exc
+26
View File
@@ -0,0 +1,26 @@
"""Company endpoints (section 17)."""
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import not_found
from app.core.database import get_db
from app.core.security import enforce_rate_limit
from app.schemas.company import CompanyDetailOut
from app.services import company_service
router = APIRouter(
prefix="/companies", tags=["companies"], dependencies=[Depends(enforce_rate_limit)]
)
@router.get("/{aurora_id}", response_model=CompanyDetailOut)
async def get_company(
aurora_id: str, session: AsyncSession = Depends(get_db)
) -> CompanyDetailOut:
try:
return await company_service.get_company_detail(session, aurora_id)
except company_service.CompanyNotFoundError as exc:
raise not_found("company", aurora_id) from exc
+26
View File
@@ -0,0 +1,26 @@
"""Episode endpoints (section 17)."""
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import not_found
from app.core.database import get_db
from app.core.security import enforce_rate_limit
from app.schemas.tv import EpisodeDetailOut
from app.services import tv_service
router = APIRouter(
prefix="/episodes", tags=["episodes"], dependencies=[Depends(enforce_rate_limit)]
)
@router.get("/{aurora_id}", response_model=EpisodeDetailOut)
async def get_episode(
aurora_id: str, session: AsyncSession = Depends(get_db)
) -> EpisodeDetailOut:
try:
return await tv_service.get_episode_detail(session, aurora_id)
except tv_service.EpisodeNotFoundError as exc:
raise not_found("episode", aurora_id) from exc
+36
View File
@@ -0,0 +1,36 @@
"""Liveness and readiness endpoints (section 24)."""
from __future__ import annotations
from fastapi import APIRouter
from app.core.config import get_settings
from app.core.database import database_is_ready
from app.core.redis import redis_is_ready
from app.schemas.health import DependencyStatus, HealthOut, ReadyOut
router = APIRouter(tags=["health"])
@router.get("/health", response_model=HealthOut)
async def health() -> HealthOut:
"""Liveness probe: process is up and able to respond. No dependency
checks — that's /ready."""
return HealthOut(status="ok")
@router.get("/ready", response_model=ReadyOut)
async def ready() -> ReadyOut:
settings = get_settings()
db_ok = await database_is_ready()
redis_ok = await redis_is_ready()
dependencies = [
DependencyStatus(name="postgresql", ok=db_ok, required=True),
DependencyStatus(name="redis", ok=redis_ok, required=settings.REDIS_REQUIRED),
]
is_ready = db_ok and (redis_ok or not settings.REDIS_REQUIRED)
return ReadyOut(status="ready" if is_ready else "not_ready", dependencies=dependencies)
+64
View File
@@ -0,0 +1,64 @@
"""Movie endpoints (section 17)."""
from __future__ import annotations
from fastapi import APIRouter, Depends
from redis.asyncio import Redis
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import cached_response, not_found
from app.core.database import get_db
from app.core.redis import get_redis
from app.core.security import enforce_rate_limit
from app.schemas.movie import MovieCreditsOut, MovieDetailOut, MovieImagesOut, MovieVideosOut
from app.services import movie_service
from app.services.cache import build_key
router = APIRouter(prefix="/movies", tags=["movies"], dependencies=[Depends(enforce_rate_limit)])
@router.get("/{aurora_id}", response_model=MovieDetailOut)
async def get_movie(
aurora_id: str,
session: AsyncSession = Depends(get_db),
redis: Redis = Depends(get_redis),
) -> MovieDetailOut:
try:
return await cached_response(
redis,
build_key("movie", aurora_id),
MovieDetailOut,
lambda: movie_service.get_movie_detail(session, aurora_id),
)
except movie_service.MovieNotFoundError as exc:
raise not_found("movie", aurora_id) from exc
@router.get("/{aurora_id}/credits", response_model=MovieCreditsOut)
async def get_movie_credits(
aurora_id: str, session: AsyncSession = Depends(get_db)
) -> MovieCreditsOut:
try:
return await movie_service.get_movie_credits(session, aurora_id)
except movie_service.MovieNotFoundError as exc:
raise not_found("movie", aurora_id) from exc
@router.get("/{aurora_id}/images", response_model=MovieImagesOut)
async def get_movie_images(
aurora_id: str, session: AsyncSession = Depends(get_db)
) -> MovieImagesOut:
try:
return await movie_service.get_movie_images(session, aurora_id)
except movie_service.MovieNotFoundError as exc:
raise not_found("movie", aurora_id) from exc
@router.get("/{aurora_id}/videos", response_model=MovieVideosOut)
async def get_movie_videos(
aurora_id: str, session: AsyncSession = Depends(get_db)
) -> MovieVideosOut:
try:
return await movie_service.get_movie_videos(session, aurora_id)
except movie_service.MovieNotFoundError as exc:
raise not_found("movie", aurora_id) from exc
+44
View File
@@ -0,0 +1,44 @@
"""Person endpoints (section 17)."""
from __future__ import annotations
from fastapi import APIRouter, Depends
from redis.asyncio import Redis
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import cached_response, not_found
from app.core.database import get_db
from app.core.redis import get_redis
from app.core.security import enforce_rate_limit
from app.schemas.person import PersonCreditsOut, PersonDetailOut
from app.services import person_service
from app.services.cache import build_key
router = APIRouter(prefix="/people", tags=["people"], dependencies=[Depends(enforce_rate_limit)])
@router.get("/{aurora_id}", response_model=PersonDetailOut)
async def get_person(
aurora_id: str,
session: AsyncSession = Depends(get_db),
redis: Redis = Depends(get_redis),
) -> PersonDetailOut:
try:
return await cached_response(
redis,
build_key("person", aurora_id),
PersonDetailOut,
lambda: person_service.get_person_detail(session, aurora_id),
)
except person_service.PersonNotFoundError as exc:
raise not_found("person", aurora_id) from exc
@router.get("/{aurora_id}/credits", response_model=PersonCreditsOut)
async def get_person_credits(
aurora_id: str, session: AsyncSession = Depends(get_db)
) -> PersonCreditsOut:
try:
return await person_service.get_person_credits(session, aurora_id)
except person_service.PersonNotFoundError as exc:
raise not_found("person", aurora_id) from exc
+25
View File
@@ -0,0 +1,25 @@
"""Search endpoint (section 16, section 17)."""
from __future__ import annotations
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import enforce_rate_limit
from app.schemas.search import SearchResultsOut
from app.services.search.postgresql_provider import PostgreSQLSearchProvider
from app.services.search.service import SearchService
router = APIRouter(prefix="/search", tags=["search"], dependencies=[Depends(enforce_rate_limit)])
_search_service = SearchService(PostgreSQLSearchProvider())
@router.get("", response_model=SearchResultsOut)
async def search(
q: str = Query(..., min_length=1, max_length=300),
limit: int = Query(10, ge=1, le=50),
session: AsyncSession = Depends(get_db),
) -> SearchResultsOut:
return await _search_service.search(session, q, limit_per_type=limit)
+22
View File
@@ -0,0 +1,22 @@
"""Season endpoints (section 17)."""
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import not_found
from app.core.database import get_db
from app.core.security import enforce_rate_limit
from app.schemas.tv import SeasonDetailOut
from app.services import tv_service
router = APIRouter(prefix="/seasons", tags=["seasons"], dependencies=[Depends(enforce_rate_limit)])
@router.get("/{aurora_id}", response_model=SeasonDetailOut)
async def get_season(aurora_id: str, session: AsyncSession = Depends(get_db)) -> SeasonDetailOut:
try:
return await tv_service.get_season_detail(session, aurora_id)
except tv_service.SeasonNotFoundError as exc:
raise not_found("season", aurora_id) from exc
+44
View File
@@ -0,0 +1,44 @@
"""Series endpoints (section 17)."""
from __future__ import annotations
from fastapi import APIRouter, Depends
from redis.asyncio import Redis
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import cached_response, not_found
from app.core.database import get_db
from app.core.redis import get_redis
from app.core.security import enforce_rate_limit
from app.schemas.tv import SeriesDetailOut, SeriesSeasonsOut
from app.services import tv_service
from app.services.cache import build_key
router = APIRouter(prefix="/series", tags=["series"], dependencies=[Depends(enforce_rate_limit)])
@router.get("/{aurora_id}", response_model=SeriesDetailOut)
async def get_series(
aurora_id: str,
session: AsyncSession = Depends(get_db),
redis: Redis = Depends(get_redis),
) -> SeriesDetailOut:
try:
return await cached_response(
redis,
build_key("series", aurora_id),
SeriesDetailOut,
lambda: tv_service.get_series_detail(session, aurora_id),
)
except tv_service.SeriesNotFoundError as exc:
raise not_found("series", aurora_id) from exc
@router.get("/{aurora_id}/seasons", response_model=SeriesSeasonsOut)
async def get_series_seasons(
aurora_id: str, session: AsyncSession = Depends(get_db)
) -> SeriesSeasonsOut:
try:
return await tv_service.get_series_seasons(session, aurora_id)
except tv_service.SeriesNotFoundError as exc:
raise not_found("series", aurora_id) from exc
View File
+103
View File
@@ -0,0 +1,103 @@
"""Environment-based application configuration.
All runtime configuration flows through this module. Nothing else in the
codebase should read `os.environ` directly, so that configuration stays
auditable and testable in one place.
"""
from __future__ import annotations
from functools import lru_cache
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
# --- Runtime ---
AURORA_ENV: str = Field(default="development")
AURORA_LOG_LEVEL: str = Field(default="INFO")
AURORA_API_DOCS_ENABLED: bool = Field(default=True)
# --- Database ---
DATABASE_URL: str = Field(
default="postgresql+asyncpg://aurora:aurora@localhost:5432/aurora_core"
)
DATABASE_URL_SYNC: str = Field(
default="postgresql+psycopg2://aurora:aurora@localhost:5432/aurora_core"
)
DATABASE_POOL_SIZE: int = Field(default=10)
DATABASE_MAX_OVERFLOW: int = Field(default=20)
# Set true when DATABASE_URL points at PgBouncer in transaction pooling
# mode (docker-compose.prod.yml): asyncpg's server-side prepared
# statements don't survive connections being handed to different clients
# between transactions, so they must be disabled.
DATABASE_BEHIND_TRANSACTION_POOLER: bool = Field(default=False)
# --- Redis ---
REDIS_URL: str = Field(default="redis://localhost:6379/0")
REDIS_REQUIRED: bool = Field(default=False)
# --- Security ---
AURORA_SECRET_KEY: str = Field(default="changeme-generate-a-real-secret")
AURORA_ADMIN_API_KEYS: str = Field(default="")
# --- CORS ---
CORS_ORIGINS: str = Field(default="http://localhost:3000")
# --- Providers ---
TMDB_API_KEY: str = Field(default="")
# --- Workers ---
WORKER_CONCURRENCY: int = Field(default=4)
# --- Caching ---
CACHE_DEFAULT_TTL: int = Field(default=300)
# --- Rate limiting ---
RATE_LIMIT_ENABLED: bool = Field(default=True)
RATE_LIMIT_PER_MINUTE: int = Field(default=120)
@field_validator("AURORA_ENV")
@classmethod
def _validate_env(cls, v: str) -> str:
allowed = {"development", "test", "staging", "production"}
if v not in allowed:
raise ValueError(f"AURORA_ENV must be one of {allowed}, got {v!r}")
return v
@property
def cors_origins_list(self) -> list[str]:
return [origin.strip() for origin in self.CORS_ORIGINS.split(",") if origin.strip()]
@property
def admin_api_keys_set(self) -> set[str]:
return {key.strip() for key in self.AURORA_ADMIN_API_KEYS.split(",") if key.strip()}
@property
def is_production(self) -> bool:
return self.AURORA_ENV == "production"
@property
def docs_url(self) -> str | None:
if not self.AURORA_API_DOCS_ENABLED:
return None
return "/docs"
@property
def openapi_url(self) -> str | None:
if not self.AURORA_API_DOCS_ENABLED:
return None
return "/openapi.json"
@lru_cache
def get_settings() -> Settings:
return Settings()
+88
View File
@@ -0,0 +1,88 @@
"""Async SQLAlchemy engine/session management.
The API is stateless: every request gets its own AsyncSession from a shared
connection pool, and nothing is held across requests. This module never
creates tables — schema changes always go through Alembic (see
`migrations/`).
"""
from __future__ import annotations
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from app.core.config import get_settings
settings = get_settings()
_connect_args: dict = {}
if settings.DATABASE_BEHIND_TRANSACTION_POOLER:
_connect_args["statement_cache_size"] = 0
_engine: AsyncEngine = create_async_engine(
settings.DATABASE_URL,
pool_size=settings.DATABASE_POOL_SIZE,
max_overflow=settings.DATABASE_MAX_OVERFLOW,
pool_pre_ping=True,
pool_recycle=1800,
connect_args=_connect_args,
echo=False,
)
async_session_factory = async_sessionmaker(
bind=_engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False,
)
def get_engine() -> AsyncEngine:
return _engine
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""FastAPI dependency yielding a request-scoped session."""
async with async_session_factory() as session:
yield session
@asynccontextmanager
async def session_scope() -> AsyncGenerator[AsyncSession, None]:
"""Context manager for use outside request handlers (workers, scripts).
Commits on success, rolls back on exception.
"""
async with async_session_factory() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
async def database_is_ready() -> bool:
"""Cheap connectivity check used by the /ready endpoint."""
from sqlalchemy import text
try:
async with _engine.connect() as conn:
await conn.execute(text("SELECT 1"))
return True
except Exception:
return False
async def dispose_engine() -> None:
await _engine.dispose()
+135
View File
@@ -0,0 +1,135 @@
"""Aurora's own identity system.
Aurora Core owns its entity identities. Provider identifiers (TMDb, IMDb,
TVDb, ...) are stored as external references, never as primary keys — see
`app.models.external_id`.
Every Aurora entity has:
- a UUIDv7 database primary key (time-ordered, efficient as a btree index,
generated with :func:`new_uuid7`)
- a public "Aurora ID" string derived from that same UUID, e.g.
``aur_movie_1jz8f2n3k4m5p6q7r8s9t0v1w2``, produced by :func:`aurora_id`
Because the public ID is a direct encoding of the UUIDv7 primary key, the two
never drift apart, and Aurora IDs sort lexicographically in creation order —
useful for cursors and debugging without leaking a sequential integer.
"""
from __future__ import annotations
import re
from uuid6 import uuid7
# Crockford base32: excludes I, L, O, U to avoid visual ambiguity in IDs.
_CROCKFORD_ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz"
_CROCKFORD_INDEX = {ch: i for i, ch in enumerate(_CROCKFORD_ALPHABET)}
# Aurora entity type -> ID prefix. Keep stable once shipped; these are
# effectively part of the public API contract.
ENTITY_PREFIXES: dict[str, str] = {
"movie": "movie",
"series": "series",
"season": "season",
"episode": "episode",
"person": "person",
"credit": "credit",
"company": "company",
"collection": "collection",
"genre": "genre",
"keyword": "keyword",
"artwork": "art",
"video": "vid",
"external_id": "ext",
"provider": "provider",
"provider_record": "prec",
"ingestion_job": "job",
"certification": "cert",
"language": "lang",
"country": "country",
"alternate_title": "altt",
"release": "rel",
}
def new_uuid7() -> str:
"""Return a fresh UUIDv7 as its canonical hyphenated string form."""
return str(uuid7())
def _encode_uuid(uuid_str: str) -> str:
"""Encode a UUID's 128 bits as lowercase Crockford base32 (26 chars).
Byte-order-preserving: since the UUID is time-ordered (v7) and this
encoding processes bytes most-significant-first, the resulting strings
sort lexicographically the same way the UUIDs (and thus creation time)
do.
"""
raw = bytes.fromhex(uuid_str.replace("-", ""))
value = int.from_bytes(raw, "big")
chars: list[str] = []
# 128 bits / 5 bits-per-symbol = 25.6 -> 26 symbols, top symbol uses 3 bits.
for shift in range(125, -5, -5):
chars.append(_CROCKFORD_ALPHABET[(value >> shift) & 0b11111])
return "".join(chars)
def _decode_to_uuid(encoded: str) -> str:
if len(encoded) != 26:
raise ValueError(f"invalid Aurora ID segment length: {encoded!r}")
value = 0
for ch in encoded:
try:
value = (value << 5) | _CROCKFORD_INDEX[ch]
except KeyError as exc:
raise ValueError(f"invalid Aurora ID character: {ch!r}") from exc
try:
raw = value.to_bytes(16, "big")
except OverflowError as exc:
raise ValueError(f"invalid Aurora ID segment: {encoded!r}") from exc
hex_str = raw.hex()
return f"{hex_str[0:8]}-{hex_str[8:12]}-{hex_str[12:16]}-{hex_str[16:20]}-{hex_str[20:32]}"
def aurora_id(entity_type: str, uuid_str: str) -> str:
"""Build a public Aurora ID string from an entity type and UUIDv7 PK."""
prefix = ENTITY_PREFIXES.get(entity_type)
if prefix is None:
raise ValueError(f"unknown Aurora entity type: {entity_type!r}")
return f"aur_{prefix}_{_encode_uuid(uuid_str)}"
_AURORA_ID_RE = re.compile(r"^aur_(?P<prefix>[a-z_]+)_(?P<encoded>[0-9a-hjkmnp-tv-z]{26})$")
_PREFIX_TO_ENTITY: dict[str, str] = {v: k for k, v in ENTITY_PREFIXES.items()}
def parse_aurora_id(aid: str, expected_type: str | None = None) -> str:
"""Validate an Aurora ID and return the underlying UUID string.
Raises ValueError if the ID is malformed or (when `expected_type` is
given) does not belong to that entity type.
"""
match = _AURORA_ID_RE.match(aid)
if not match:
raise ValueError(f"malformed Aurora ID: {aid!r}")
prefix = match.group("prefix")
entity_type = _PREFIX_TO_ENTITY.get(prefix)
if entity_type is None:
raise ValueError(f"unknown Aurora ID prefix: {prefix!r}")
if expected_type is not None and entity_type != expected_type:
raise ValueError(f"expected Aurora ID of type {expected_type!r}, got {entity_type!r}")
return _decode_to_uuid(match.group("encoded"))
def is_valid_aurora_id(aid: str, expected_type: str | None = None) -> bool:
try:
parse_aurora_id(aid, expected_type=expected_type)
except ValueError:
return False
return True
+62
View File
@@ -0,0 +1,62 @@
"""Structured, JSON-compatible logging with per-request correlation IDs.
Every request gets a request ID (from the `X-Request-ID` header if present,
otherwise generated) bound into the log context for the lifetime of that
request, via `RequestIDMiddleware` in `app.main`.
"""
from __future__ import annotations
import logging
import sys
from contextvars import ContextVar
import structlog
from app.core.config import get_settings
request_id_ctx: ContextVar[str | None] = ContextVar("request_id", default=None)
def _add_request_id(_logger: object, _method_name: str, event_dict: dict) -> dict:
request_id = request_id_ctx.get()
if request_id is not None:
event_dict["request_id"] = request_id
return event_dict
def configure_logging() -> None:
settings = get_settings()
level = getattr(logging, settings.AURORA_LOG_LEVEL.upper(), logging.INFO)
logging.basicConfig(
format="%(message)s",
stream=sys.stdout,
level=level,
)
shared_processors: list = [
structlog.contextvars.merge_contextvars,
_add_request_id,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.StackInfoRenderer(),
]
renderer: structlog.types.Processor
if settings.is_production or settings.AURORA_ENV != "development":
renderer = structlog.processors.JSONRenderer()
else:
renderer = structlog.dev.ConsoleRenderer()
structlog.configure(
processors=[*shared_processors, structlog.processors.format_exc_info, renderer],
wrapper_class=structlog.make_filtering_bound_logger(level),
context_class=dict,
logger_factory=structlog.PrintLoggerFactory(),
cache_logger_on_first_use=True,
)
def get_logger(name: str | None = None) -> structlog.stdlib.BoundLogger:
return structlog.get_logger(name)
+35
View File
@@ -0,0 +1,35 @@
"""arq job queue connection (Redis-backed), shared by the API (enqueuing)
and the worker process (consuming) — see app/workers/worker.py."""
from __future__ import annotations
from collections.abc import AsyncGenerator
from arq import ArqRedis, create_pool
from arq.connections import RedisSettings
from app.core.config import get_settings
_pool: ArqRedis | None = None
def redis_settings() -> RedisSettings:
return RedisSettings.from_dsn(get_settings().REDIS_URL)
async def get_arq_pool() -> ArqRedis:
global _pool
if _pool is None:
_pool = await create_pool(redis_settings())
return _pool
async def get_queue() -> AsyncGenerator[ArqRedis, None]:
yield await get_arq_pool()
async def close_arq_pool() -> None:
global _pool
if _pool is not None:
await _pool.aclose() # type: ignore[attr-defined] # present at runtime; stubs lag redis-py 5.x
_pool = None
+50
View File
@@ -0,0 +1,50 @@
"""Redis connection management.
Redis is disposable acceleration/coordination — caching, rate limiting,
distributed locks, and the arq job queue. It is never the system of record.
If `REDIS_REQUIRED` is false, the application degrades gracefully (caching
becomes a no-op, readiness does not depend on Redis) instead of failing when
Redis is unavailable.
"""
from __future__ import annotations
from collections.abc import AsyncGenerator
from functools import lru_cache
import redis.asyncio as redis_asyncio
from redis.asyncio import Redis
from app.core.config import get_settings
settings = get_settings()
@lru_cache
def get_redis_pool() -> Redis:
return redis_asyncio.from_url(
settings.REDIS_URL,
encoding="utf-8",
decode_responses=True,
socket_connect_timeout=2,
socket_timeout=2,
)
async def get_redis() -> AsyncGenerator[Redis, None]:
"""FastAPI dependency yielding the shared Redis client."""
yield get_redis_pool()
async def redis_is_ready() -> bool:
try:
client = get_redis_pool()
return bool(await client.ping())
except Exception:
return False
async def close_redis() -> None:
client = get_redis_pool()
await client.aclose() # type: ignore[attr-defined] # present at runtime; stubs lag redis-py 5.x
+76
View File
@@ -0,0 +1,76 @@
"""Administrative auth and Redis-backed rate limiting.
Public read endpoints (`/api/v1/movies`, `/search`, ...) are unauthenticated
but rate-limited. Administrative/provider endpoints (ingestion triggers,
provider config) require an API key via the `X-API-Key` header.
"""
from __future__ import annotations
import time
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import APIKeyHeader
from redis.asyncio import Redis
from app.core.config import get_settings
from app.core.redis import get_redis
_api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
async def require_admin_api_key(
api_key: str | None = Depends(_api_key_header),
) -> str:
settings = get_settings()
allowed = settings.admin_api_keys_set
if not allowed:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Administrative API is not configured (AURORA_ADMIN_API_KEYS unset).",
)
if not api_key or api_key not in allowed:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing or invalid X-API-Key.",
)
return api_key
class RedisRateLimiter:
"""Fixed-window rate limiter backed by Redis `INCR` + `EXPIRE`.
Fails open: if Redis is unreachable, requests are allowed through rather
than rejected, since Redis is disposable infrastructure and must never
become a hard dependency for serving reads.
"""
def __init__(self, requests_per_minute: int) -> None:
self.requests_per_minute = requests_per_minute
async def check(self, redis: Redis, key: str) -> tuple[bool, int]:
window = int(time.time() // 60)
redis_key = f"ratelimit:{key}:{window}"
try:
count = await redis.incr(redis_key)
if count == 1:
await redis.expire(redis_key, 60)
remaining = max(0, self.requests_per_minute - count)
return count <= self.requests_per_minute, remaining
except Exception:
return True, self.requests_per_minute
async def enforce_rate_limit(request: Request, redis: Redis = Depends(get_redis)) -> None:
settings = get_settings()
if not settings.RATE_LIMIT_ENABLED:
return
limiter = RedisRateLimiter(settings.RATE_LIMIT_PER_MINUTE)
client_id = request.client.host if request.client else "unknown"
allowed, remaining = await limiter.check(redis, client_id)
if not allowed:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Rate limit exceeded.",
headers={"Retry-After": "60"},
)
+164
View File
@@ -0,0 +1,164 @@
"""Aurora Core FastAPI application factory.
Stateless by design (section 1): nothing here holds request-scoped state in
process memory, so any number of API replicas can run behind Traefik.
"""
from __future__ import annotations
import time
import uuid
from collections.abc import Awaitable, Callable
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request, Response
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from prometheus_client import CONTENT_TYPE_LATEST, Counter, Histogram, generate_latest
from starlette.exceptions import HTTPException as StarletteHTTPException
from app.api.v1 import (
admin,
collections,
companies,
episodes,
health,
movies,
people,
search,
seasons,
series,
)
from app.core.config import get_settings
from app.core.database import dispose_engine
from app.core.logging import configure_logging, get_logger, request_id_ctx
from app.core.queue import close_arq_pool
from app.core.redis import close_redis
settings = get_settings()
REQUEST_COUNT = Counter(
"aurora_http_requests_total", "HTTP requests", ["method", "path", "status"]
)
REQUEST_LATENCY = Histogram(
"aurora_http_request_duration_seconds", "HTTP request latency", ["method", "path"]
)
MAX_REQUEST_BODY_BYTES = 2 * 1024 * 1024 # 2 MiB
@asynccontextmanager
async def lifespan(app: FastAPI):
configure_logging()
logger = get_logger(__name__)
logger.info("aurora_core_starting", env=settings.AURORA_ENV)
yield
logger.info("aurora_core_stopping")
await dispose_engine()
await close_redis()
await close_arq_pool()
app = FastAPI(
title="Aurora Core",
description="Aurora Dragon Studio's media metadata, discovery, and intelligence backend.",
version="0.1.0",
docs_url=settings.docs_url,
redoc_url=None,
openapi_url=settings.openapi_url,
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins_list,
allow_credentials=True,
allow_methods=["GET", "POST"],
allow_headers=["*"],
)
@app.middleware("http")
async def request_context_middleware(
request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response:
request_id = request.headers.get("X-Request-ID", str(uuid.uuid4()))
token = request_id_ctx.set(request_id)
start = time.perf_counter()
try:
response = await call_next(request)
finally:
request_id_ctx.reset(token)
duration = time.perf_counter() - start
route = request.scope.get("route")
path_template = route.path if route else request.url.path
REQUEST_COUNT.labels(request.method, path_template, "n/a").inc()
REQUEST_LATENCY.labels(request.method, path_template).observe(duration)
response.headers["X-Request-ID"] = request_id
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "no-referrer"
return response
@app.middleware("http")
async def request_size_limit_middleware(
request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response:
content_length = request.headers.get("content-length")
if content_length and int(content_length) > MAX_REQUEST_BODY_BYTES:
return JSONResponse(
status_code=413,
content={"error": {"code": "payload_too_large", "message": "Request body too large."}},
)
return await call_next(request)
@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(request: Request, exc: StarletteHTTPException) -> JSONResponse:
detail = exc.detail
if isinstance(detail, dict) and "code" in detail:
error = detail
else:
error = {"code": "http_error", "message": str(detail)}
return JSONResponse(status_code=exc.status_code, content={"error": error})
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(
request: Request, exc: RequestValidationError
) -> JSONResponse:
return JSONResponse(
status_code=422,
content={
"error": {
"code": "validation_error",
"message": "Request validation failed.",
"details": exc.errors(),
}
},
)
@app.get("/metrics", include_in_schema=False)
async def metrics() -> Response:
return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST)
# Unversioned liveness/readiness for infra probes (Docker/Traefik healthchecks).
app.include_router(health.router)
API_V1_PREFIX = "/api/v1"
app.include_router(health.router, prefix=API_V1_PREFIX)
app.include_router(search.router, prefix=API_V1_PREFIX)
app.include_router(movies.router, prefix=API_V1_PREFIX)
app.include_router(series.router, prefix=API_V1_PREFIX)
app.include_router(seasons.router, prefix=API_V1_PREFIX)
app.include_router(episodes.router, prefix=API_V1_PREFIX)
app.include_router(people.router, prefix=API_V1_PREFIX)
app.include_router(collections.router, prefix=API_V1_PREFIX)
app.include_router(companies.router, prefix=API_V1_PREFIX)
app.include_router(admin.router, prefix=API_V1_PREFIX)
+75
View File
@@ -0,0 +1,75 @@
"""SQLAlchemy models.
Import every model module here so `Base.metadata` is fully populated for
Alembic autogeneration and `Base.metadata.create_all()` (used only in
tests) regardless of which module happens to trigger the import first.
"""
from app.models.artwork import Artwork
from app.models.base import Base
from app.models.collection import Collection, CollectionMovie, CollectionRelation, CollectionSeries
from app.models.company import Company, MovieCompany, SeriesCompany
from app.models.credit import Credit
from app.models.external_id import ExternalID
from app.models.genre import Genre, MovieGenre, SeriesGenre
from app.models.keyword import Keyword, MovieKeyword, SeriesKeyword
from app.models.language_country import Certification, Country, Language
from app.models.movie import (
Movie,
MovieAlternateTitle,
MovieProductionCountry,
MovieRecommendation,
MovieSpokenLanguage,
ReleaseDate,
)
from app.models.person import Person
from app.models.provider import IngestionJob, Provider, ProviderRecord
from app.models.tv import (
Episode,
Season,
Series,
SeriesAlternateTitle,
SeriesProductionCountry,
SeriesSpokenLanguage,
)
from app.models.video import Video
__all__ = [
"Base",
"Artwork",
"Collection",
"CollectionMovie",
"CollectionSeries",
"CollectionRelation",
"Company",
"MovieCompany",
"SeriesCompany",
"Credit",
"ExternalID",
"Genre",
"MovieGenre",
"SeriesGenre",
"Keyword",
"MovieKeyword",
"SeriesKeyword",
"Language",
"Country",
"Certification",
"Movie",
"MovieAlternateTitle",
"MovieSpokenLanguage",
"MovieProductionCountry",
"ReleaseDate",
"MovieRecommendation",
"Person",
"Provider",
"ProviderRecord",
"IngestionJob",
"Series",
"SeriesAlternateTitle",
"SeriesSpokenLanguage",
"SeriesProductionCountry",
"Season",
"Episode",
"Video",
]
+52
View File
@@ -0,0 +1,52 @@
"""Artwork as a first-class entity (section 10) — never a `poster_url`
column on media tables, so provenance/licensing can be tracked per asset."""
from __future__ import annotations
import uuid
from sqlalchemy import Boolean, Float, ForeignKey, Index, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
from app.models.enums import ArtworkType
from app.models.provider import Provider
class Artwork(Base, UUIDPrimaryKeyMixin, TimestampMixin):
__tablename__ = "artwork"
__table_args__ = (
Index("ix_artwork_entity", "entity_type", "entity_id", "artwork_type"),
)
# Polymorphic target: "movie", "series", "season", "episode", "person",
# "company", "collection".
entity_type: Mapped[str] = mapped_column(String(30), nullable=False)
entity_id: Mapped[uuid.UUID] = mapped_column(nullable=False)
artwork_type: Mapped[ArtworkType] = mapped_column(nullable=False)
language_code: Mapped[str | None] = mapped_column(
ForeignKey("languages.code"), nullable=True
)
width: Mapped[int | None] = mapped_column(Integer, nullable=True)
height: Mapped[int | None] = mapped_column(Integer, nullable=True)
aspect_ratio: Mapped[float | None] = mapped_column(Float, nullable=True)
vote_average: Mapped[float | None] = mapped_column(Float, nullable=True)
is_primary: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
provider_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("providers.id", ondelete="CASCADE"), nullable=False
)
provider_asset_id: Mapped[str | None] = mapped_column(String(200), nullable=True)
original_url: Mapped[str] = mapped_column(String(1000), nullable=False)
local_path: Mapped[str | None] = mapped_column(String(1000), nullable=True)
checksum: Mapped[str | None] = mapped_column(String(128), nullable=True)
attribution: Mapped[str | None] = mapped_column(Text, nullable=True)
license: Mapped[str | None] = mapped_column(Text, nullable=True)
provider: Mapped[Provider] = relationship()
__all__ = ["Artwork"]
+80
View File
@@ -0,0 +1,80 @@
"""Declarative base and shared mixins for all Aurora Core models."""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Enum, MetaData, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from uuid6 import uuid7
from app.models.enums import (
ArtworkType,
CompanyType,
IngestionJobStatus,
MatchConfidence,
MovieStatus,
SeriesStatus,
VideoType,
)
NAMING_CONVENTION = {
"ix": "ix_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s",
}
def _enum_column(enum_cls: type) -> Enum:
"""Store our str-Enum members by `.value` (lowercase snake_case), not
`.name`, which is what SQLAlchemy's Enum type does by default. Without
this, the DB would store e.g. "RELEASED" while every part of the
codebase (schemas, ingestion, API filters) works with "released"."""
return Enum(
enum_cls,
values_callable=lambda obj: [e.value for e in obj],
name=f"{enum_cls.__name__.lower()}_enum",
)
class Base(DeclarativeBase):
"""Base class for all ORM models. Naming convention keeps Alembic's
autogenerated constraint names stable across databases."""
metadata = MetaData(naming_convention=NAMING_CONVENTION)
type_annotation_map = {
MovieStatus: _enum_column(MovieStatus),
SeriesStatus: _enum_column(SeriesStatus),
ArtworkType: _enum_column(ArtworkType),
VideoType: _enum_column(VideoType),
CompanyType: _enum_column(CompanyType),
IngestionJobStatus: _enum_column(IngestionJobStatus),
MatchConfidence: _enum_column(MatchConfidence),
}
class UUIDPrimaryKeyMixin:
"""UUIDv7 primary key. Time-ordered so it stays btree-friendly and can
double as a creation-order cursor, unlike a random UUIDv4."""
id: Mapped[uuid.UUID] = mapped_column(
primary_key=True,
default=lambda: uuid.UUID(str(uuid7())),
)
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
+59
View File
@@ -0,0 +1,59 @@
"""Collections/franchises as first-class entities (e.g. "Alien", "MCU")."""
from __future__ import annotations
import uuid
from sqlalchemy import ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
class Collection(Base, UUIDPrimaryKeyMixin, TimestampMixin):
__tablename__ = "collections"
name: Mapped[str] = mapped_column(String(300), nullable=False, index=True)
overview: Mapped[str | None] = mapped_column(Text, nullable=True)
slug: Mapped[str] = mapped_column(String(350), unique=True, nullable=False)
class CollectionMovie(Base):
__tablename__ = "collection_movies"
collection_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("collections.id", ondelete="CASCADE"), primary_key=True
)
movie_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("movies.id", ondelete="CASCADE"), primary_key=True
)
sort_order: Mapped[int | None] = mapped_column(Integer, nullable=True)
class CollectionSeries(Base):
__tablename__ = "collection_series"
collection_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("collections.id", ondelete="CASCADE"), primary_key=True
)
series_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("series.id", ondelete="CASCADE"), primary_key=True
)
sort_order: Mapped[int | None] = mapped_column(Integer, nullable=True)
class CollectionRelation(Base):
"""Relationships between collections (e.g. shared universe)."""
__tablename__ = "collection_relations"
collection_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("collections.id", ondelete="CASCADE"), primary_key=True
)
related_collection_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("collections.id", ondelete="CASCADE"), primary_key=True
)
relation_type: Mapped[str] = mapped_column(String(50), default="related", nullable=False)
__all__ = ["Collection", "CollectionMovie", "CollectionSeries", "CollectionRelation"]
+62
View File
@@ -0,0 +1,62 @@
"""Production companies, studios, networks, and distributors.
Modeled as a single `Company` table distinguished by `company_type` per the
spec (section 8), rather than a separate Network entity — a TV network and a
studio share the same shape (name, logo, country, parent company)."""
from __future__ import annotations
import uuid
from sqlalchemy import ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
from app.models.enums import CompanyType
class Company(Base, UUIDPrimaryKeyMixin, TimestampMixin):
__tablename__ = "companies"
name: Mapped[str] = mapped_column(String(300), nullable=False, index=True)
company_type: Mapped[CompanyType] = mapped_column(
default=CompanyType.PRODUCTION_COMPANY, nullable=False
)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
country_code: Mapped[str | None] = mapped_column(
ForeignKey("countries.code"), nullable=True
)
headquarters: Mapped[str | None] = mapped_column(String(300), nullable=True)
homepage: Mapped[str | None] = mapped_column(String(500), nullable=True)
parent_company_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("companies.id", ondelete="SET NULL"), nullable=True
)
parent_company: Mapped[Company | None] = relationship(remote_side="Company.id")
class MovieCompany(Base):
__tablename__ = "movie_companies"
movie_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("movies.id", ondelete="CASCADE"), primary_key=True
)
company_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("companies.id", ondelete="CASCADE"), primary_key=True
)
role: Mapped[str | None] = mapped_column(String(50), nullable=True)
class SeriesCompany(Base):
__tablename__ = "series_companies"
series_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("series.id", ondelete="CASCADE"), primary_key=True
)
company_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("companies.id", ondelete="CASCADE"), primary_key=True
)
role: Mapped[str | None] = mapped_column(String(50), nullable=True)
__all__ = ["Company", "MovieCompany", "SeriesCompany"]
+50
View File
@@ -0,0 +1,50 @@
"""Flexible, normalized credit system (section 7).
A `Credit` links a `Person` to any media entity (movie, series, episode)
with a department/job/character, instead of duplicating cast/crew as text
blobs on the media tables.
"""
from __future__ import annotations
import uuid
from sqlalchemy import ForeignKey, Index, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
from app.models.person import Person
from app.models.provider import Provider
class Credit(Base, UUIDPrimaryKeyMixin, TimestampMixin):
__tablename__ = "credits"
__table_args__ = (Index("ix_credits_entity", "entity_type", "entity_id"),)
person_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("people.id", ondelete="CASCADE"), nullable=False
)
# Polymorphic target: "movie", "series", "episode".
entity_type: Mapped[str] = mapped_column(String(30), nullable=False)
entity_id: Mapped[uuid.UUID] = mapped_column(nullable=False)
# "Acting", "Directing", "Writing", "Production", "Camera", "Sound",
# "Editing", "Art", "Costume & Make-Up", "Crew", "Creator", ...
department: Mapped[str] = mapped_column(String(100), nullable=False)
job: Mapped[str | None] = mapped_column(String(150), nullable=True)
character: Mapped[str | None] = mapped_column(String(300), nullable=True)
billing_order: Mapped[int | None] = mapped_column(Integer, nullable=True)
is_guest: Mapped[bool] = mapped_column(default=False, nullable=False)
episode_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
provider_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("providers.id", ondelete="SET NULL"), nullable=True
)
person: Mapped[Person] = relationship()
provider: Mapped[Provider | None] = relationship()
__all__ = ["Credit"]
+84
View File
@@ -0,0 +1,84 @@
"""Shared enumerations used across Aurora Core models."""
from __future__ import annotations
import enum
class MovieStatus(enum.StrEnum):
RUMORED = "rumored"
PLANNED = "planned"
IN_PRODUCTION = "in_production"
POST_PRODUCTION = "post_production"
RELEASED = "released"
CANCELED = "canceled"
class SeriesStatus(enum.StrEnum):
PLANNED = "planned"
PILOT = "pilot"
IN_PRODUCTION = "in_production"
RETURNING_SERIES = "returning_series"
ENDED = "ended"
CANCELED = "canceled"
class ArtworkType(enum.StrEnum):
POSTER = "poster"
BACKDROP = "backdrop"
LOGO = "logo"
PROFILE = "profile"
STILL = "still"
BANNER = "banner"
THUMBNAIL = "thumbnail"
class VideoType(enum.StrEnum):
TRAILER = "trailer"
TEASER = "teaser"
CLIP = "clip"
FEATURETTE = "featurette"
BEHIND_THE_SCENES = "behind_the_scenes"
INTERVIEW = "interview"
OTHER = "other"
class CompanyType(enum.StrEnum):
PRODUCTION_COMPANY = "production_company"
NETWORK = "network"
STUDIO = "studio"
DISTRIBUTOR = "distributor"
class EntityType(enum.StrEnum):
"""Kinds of entity that polymorphic tables (credits, artwork, videos,
external IDs, release dates, provider records) can attach to."""
MOVIE = "movie"
SERIES = "series"
SEASON = "season"
EPISODE = "episode"
PERSON = "person"
COMPANY = "company"
COLLECTION = "collection"
class IngestionJobStatus(enum.StrEnum):
PENDING = "pending"
RUNNING = "running"
SUCCEEDED = "succeeded"
FAILED = "failed"
RETRYING = "retrying"
class MatchConfidence(enum.StrEnum):
"""How confident the entity matcher was when linking a provider record
to an Aurora entity. Anything below HIGH should be reviewable rather
than silently merged — see docs/architecture.md."""
EXACT = "exact"
HIGH = "high"
MEDIUM = "medium"
LOW = "low"
AMBIGUOUS = "ambiguous"
NEW = "new"
+45
View File
@@ -0,0 +1,45 @@
"""Generalized external identifier system.
Links an Aurora entity (any type) to its identifier in an external provider
(TMDb, IMDb, TVDb, Wikidata, ...). External IDs are references, never
primary keys — see app/core/ids.py and docs/architecture.md.
"""
from __future__ import annotations
import uuid
from sqlalchemy import ForeignKey, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
from app.models.provider import Provider
class ExternalID(Base, UUIDPrimaryKeyMixin, TimestampMixin):
__tablename__ = "external_ids"
__table_args__ = (
# A provider's ID for a given entity type must be unique (one Aurora
# entity per provider-native ID).
UniqueConstraint(
"provider_id", "entity_type", "external_id", name="uq_external_id_source"
),
# An Aurora entity has at most one mapping per provider.
UniqueConstraint(
"entity_type", "entity_id", "provider_id", name="uq_external_id_entity_provider"
),
)
entity_type: Mapped[str] = mapped_column(String(30), nullable=False)
entity_id: Mapped[uuid.UUID] = mapped_column(nullable=False)
provider_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("providers.id", ondelete="CASCADE"), nullable=False
)
external_id: Mapped[str] = mapped_column(String(200), nullable=False)
url: Mapped[str | None] = mapped_column(String(500), nullable=True)
provider: Mapped[Provider] = relationship()
__all__ = ["ExternalID"]
+42
View File
@@ -0,0 +1,42 @@
"""Genres and their movie/series associations."""
from __future__ import annotations
import uuid
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, UUIDPrimaryKeyMixin
class Genre(Base, UUIDPrimaryKeyMixin):
__tablename__ = "genres"
name: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
class MovieGenre(Base):
__tablename__ = "movie_genres"
movie_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("movies.id", ondelete="CASCADE"), primary_key=True
)
genre_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("genres.id", ondelete="CASCADE"), primary_key=True
)
class SeriesGenre(Base):
__tablename__ = "series_genres"
series_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("series.id", ondelete="CASCADE"), primary_key=True
)
genre_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("genres.id", ondelete="CASCADE"), primary_key=True
)
__all__ = ["Genre", "MovieGenre", "SeriesGenre"]
+42
View File
@@ -0,0 +1,42 @@
"""Keywords/tags and their movie/series associations."""
from __future__ import annotations
import uuid
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, UUIDPrimaryKeyMixin
class Keyword(Base, UUIDPrimaryKeyMixin):
__tablename__ = "keywords"
name: Mapped[str] = mapped_column(String(150), unique=True, nullable=False)
slug: Mapped[str] = mapped_column(String(150), unique=True, nullable=False)
class MovieKeyword(Base):
__tablename__ = "movie_keywords"
movie_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("movies.id", ondelete="CASCADE"), primary_key=True
)
keyword_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("keywords.id", ondelete="CASCADE"), primary_key=True
)
class SeriesKeyword(Base):
__tablename__ = "series_keywords"
series_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("series.id", ondelete="CASCADE"), primary_key=True
)
keyword_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("keywords.id", ondelete="CASCADE"), primary_key=True
)
__all__ = ["Keyword", "MovieKeyword", "SeriesKeyword"]
+46
View File
@@ -0,0 +1,46 @@
"""Reference data: languages, countries, certifications."""
from __future__ import annotations
from sqlalchemy import ForeignKey, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
class Language(Base):
"""ISO 639-1 language codes (e.g. 'en', 'ja')."""
__tablename__ = "languages"
code: Mapped[str] = mapped_column(String(10), primary_key=True)
english_name: Mapped[str] = mapped_column(String(200), nullable=False)
native_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
class Country(Base):
"""ISO 3166-1 alpha-2 country codes (e.g. 'US', 'GB')."""
__tablename__ = "countries"
code: Mapped[str] = mapped_column(String(2), primary_key=True)
name: Mapped[str] = mapped_column(String(200), nullable=False)
class Certification(Base, UUIDPrimaryKeyMixin, TimestampMixin):
"""Content rating within a country's rating system, e.g. US/MPA/PG-13."""
__tablename__ = "certifications"
__table_args__ = (
UniqueConstraint("country_code", "system", "rating", name="uq_certification_scope"),
)
country_code: Mapped[str] = mapped_column(ForeignKey("countries.code"), nullable=False)
system: Mapped[str] = mapped_column(String(50), nullable=False) # e.g. "MPA", "BBFC"
rating: Mapped[str] = mapped_column(String(20), nullable=False) # e.g. "PG-13"
meaning: Mapped[str | None] = mapped_column(Text, nullable=True)
country: Mapped[Country] = relationship()
__all__ = ["Language", "Country", "Certification"]
+127
View File
@@ -0,0 +1,127 @@
"""Movie entity and its directly-owned satellite tables (section 4)."""
from __future__ import annotations
import uuid
from datetime import date
from sqlalchemy import BigInteger, Date, Float, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
from app.models.collection import Collection
from app.models.enums import MovieStatus
class Movie(Base, UUIDPrimaryKeyMixin, TimestampMixin):
__tablename__ = "movies"
title: Mapped[str] = mapped_column(String(500), nullable=False, index=True)
original_title: Mapped[str | None] = mapped_column(String(500), nullable=True)
# Title with leading articles ("The", "A") stripped, for stable sorting.
sort_title: Mapped[str] = mapped_column(String(500), nullable=False, index=True)
overview: Mapped[str | None] = mapped_column(Text, nullable=True)
tagline: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[MovieStatus] = mapped_column(default=MovieStatus.RELEASED, nullable=False)
original_language_code: Mapped[str | None] = mapped_column(
ForeignKey("languages.code"), nullable=True
)
primary_release_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True)
runtime_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True)
budget: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
revenue: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
popularity: Mapped[float | None] = mapped_column(Float, nullable=True)
vote_average: Mapped[float | None] = mapped_column(Float, nullable=True)
vote_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
adult: Mapped[bool] = mapped_column(default=False, nullable=False)
collection_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("collections.id", ondelete="SET NULL"), nullable=True
)
# Best-known authoritative source for this record at creation time. Full
# field-level provenance lives in provider_records; this is a coarse
# pointer for quick display. See docs/architecture.md.
source_provider_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("providers.id", ondelete="SET NULL"), nullable=True
)
collection: Mapped[Collection | None] = relationship()
class MovieAlternateTitle(Base, UUIDPrimaryKeyMixin):
__tablename__ = "movie_alternate_titles"
movie_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("movies.id", ondelete="CASCADE"), nullable=False, index=True
)
title: Mapped[str] = mapped_column(String(500), nullable=False)
country_code: Mapped[str | None] = mapped_column(ForeignKey("countries.code"), nullable=True)
type: Mapped[str | None] = mapped_column(String(50), nullable=True) # e.g. "working_title"
class MovieSpokenLanguage(Base):
__tablename__ = "movie_spoken_languages"
movie_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("movies.id", ondelete="CASCADE"), primary_key=True
)
language_code: Mapped[str] = mapped_column(
ForeignKey("languages.code"), primary_key=True
)
class MovieProductionCountry(Base):
__tablename__ = "movie_production_countries"
movie_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("movies.id", ondelete="CASCADE"), primary_key=True
)
country_code: Mapped[str] = mapped_column(ForeignKey("countries.code"), primary_key=True)
class ReleaseDate(Base, UUIDPrimaryKeyMixin):
"""Per-country/per-type release dates and certifications for a movie."""
__tablename__ = "release_dates"
entity_type: Mapped[str] = mapped_column(String(30), default="movie", nullable=False)
entity_id: Mapped[uuid.UUID] = mapped_column(nullable=False, index=True)
country_code: Mapped[str] = mapped_column(ForeignKey("countries.code"), nullable=False)
release_date: Mapped[date] = mapped_column(Date, nullable=False)
# "theatrical", "digital", "physical", "premiere", "tv"
release_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
certification_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("certifications.id", ondelete="SET NULL"), nullable=True
)
note: Mapped[str | None] = mapped_column(Text, nullable=True)
class MovieRecommendation(Base):
"""Recommendations/similar-title edges between movies."""
__tablename__ = "movie_recommendations"
movie_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("movies.id", ondelete="CASCADE"), primary_key=True
)
recommended_movie_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("movies.id", ondelete="CASCADE"), primary_key=True
)
rank: Mapped[int | None] = mapped_column(Integer, nullable=True)
source: Mapped[str | None] = mapped_column(String(50), nullable=True)
__all__ = [
"Movie",
"MovieAlternateTitle",
"MovieSpokenLanguage",
"MovieProductionCountry",
"ReleaseDate",
"MovieRecommendation",
]
+32
View File
@@ -0,0 +1,32 @@
"""People — first-class Aurora entities (section 6)."""
from __future__ import annotations
from datetime import date
from sqlalchemy import JSON, Date, Float, String, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
JSONVariant = JSONB().with_variant(JSON(), "sqlite")
class Person(Base, UUIDPrimaryKeyMixin, TimestampMixin):
__tablename__ = "people"
name: Mapped[str] = mapped_column(String(300), nullable=False, index=True)
aliases: Mapped[list[str]] = mapped_column(JSONVariant, default=list)
biography: Mapped[str | None] = mapped_column(Text, nullable=True)
birthday: Mapped[date | None] = mapped_column(Date, nullable=True)
deathday: Mapped[date | None] = mapped_column(Date, nullable=True)
birthplace: Mapped[str | None] = mapped_column(String(300), nullable=True)
# Free-text, only populated when a source legitimately provides it —
# never inferred.
gender: Mapped[str | None] = mapped_column(String(30), nullable=True)
known_for_department: Mapped[str | None] = mapped_column(String(100), nullable=True)
popularity: Mapped[float | None] = mapped_column(Float, nullable=True)
__all__ = ["Person"]
+108
View File
@@ -0,0 +1,108 @@
"""Provider abstraction and provenance tables.
`Provider` identifies a metadata source (TMDb, IMDb dataset export, ...).
`ProviderRecord` is the provenance ledger: one row per (provider, entity
type, provider-native ID) linking to the Aurora entity it was matched to,
retaining the raw payload and match confidence so ambiguous matches stay
reviewable instead of being silently merged (see docs/architecture.md).
`IngestionJob` tracks async ingestion work so jobs are observable, retryable,
and protected against duplicate processing via `idempotency_key`.
"""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
from app.models.enums import IngestionJobStatus, MatchConfidence
JSONVariant = JSONB().with_variant(JSON(), "sqlite")
class Provider(Base, UUIDPrimaryKeyMixin, TimestampMixin):
"""A metadata data source. Providers are replaceable adapters, never
Aurora's identity system — see app/providers/base.py."""
__tablename__ = "providers"
slug: Mapped[str] = mapped_column(String(50), unique=True, nullable=False) # "tmdb", "imdb"
name: Mapped[str] = mapped_column(String(200), nullable=False)
base_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
is_active: Mapped[bool] = mapped_column(default=True, nullable=False)
config: Mapped[dict] = mapped_column(JSONVariant, default=dict)
class ProviderRecord(Base, UUIDPrimaryKeyMixin, TimestampMixin):
"""Provenance ledger row: this provider's native record, and what Aurora
entity (if any) it has been matched to."""
__tablename__ = "provider_records"
__table_args__ = (
UniqueConstraint(
"provider_id", "entity_type", "external_id", name="uq_provider_record_source"
),
)
provider_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("providers.id", ondelete="CASCADE"), nullable=False
)
entity_type: Mapped[str] = mapped_column(String(30), nullable=False)
external_id: Mapped[str] = mapped_column(String(200), nullable=False)
# Polymorphic reference to the Aurora entity this record resolved to.
# No DB-level FK (the target table varies by entity_type); integrity is
# enforced by the entity matcher / repositories, not the schema.
aurora_entity_id: Mapped[uuid.UUID | None] = mapped_column(nullable=True)
raw_data: Mapped[dict] = mapped_column(JSONVariant, default=dict)
source_version: Mapped[str | None] = mapped_column(String(100), nullable=True)
match_confidence: Mapped[MatchConfidence] = mapped_column(
default=MatchConfidence.NEW, nullable=False
)
match_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
ingestion_job_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("ingestion_jobs.id", ondelete="SET NULL"), nullable=True
)
last_synced_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
provider: Mapped[Provider] = relationship()
class IngestionJob(Base, UUIDPrimaryKeyMixin, TimestampMixin):
"""A unit of asynchronous ingestion work processed by workers."""
__tablename__ = "ingestion_jobs"
__table_args__ = (UniqueConstraint("idempotency_key", name="uq_ingestion_job_idempotency"),)
provider_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("providers.id", ondelete="CASCADE"), nullable=False
)
job_type: Mapped[str] = mapped_column(String(50), nullable=False) # "movie_sync", "full_sync"
entity_type: Mapped[str | None] = mapped_column(String(30), nullable=True)
external_id: Mapped[str | None] = mapped_column(String(200), nullable=True)
idempotency_key: Mapped[str] = mapped_column(String(300), nullable=False)
status: Mapped[IngestionJobStatus] = mapped_column(
default=IngestionJobStatus.PENDING, nullable=False
)
attempts: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
max_attempts: Mapped[int] = mapped_column(Integer, default=5, nullable=False)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
result_aurora_id: Mapped[uuid.UUID | None] = mapped_column(nullable=True)
provider: Mapped[Provider] = relationship()
__all__ = ["Provider", "ProviderRecord", "IngestionJob"]
+118
View File
@@ -0,0 +1,118 @@
"""Television model: Series -> Seasons -> Episodes (section 5)."""
from __future__ import annotations
import uuid
from datetime import date
from sqlalchemy import Date, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
from app.models.enums import SeriesStatus
class Series(Base, UUIDPrimaryKeyMixin, TimestampMixin):
__tablename__ = "series"
title: Mapped[str] = mapped_column(String(500), nullable=False, index=True)
original_title: Mapped[str | None] = mapped_column(String(500), nullable=True)
sort_title: Mapped[str] = mapped_column(String(500), nullable=False, index=True)
overview: Mapped[str | None] = mapped_column(Text, nullable=True)
tagline: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[SeriesStatus] = mapped_column(
default=SeriesStatus.RETURNING_SERIES, nullable=False
)
original_language_code: Mapped[str | None] = mapped_column(
ForeignKey("languages.code"), nullable=True
)
first_air_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True)
last_air_date: Mapped[date | None] = mapped_column(Date, nullable=True)
number_of_seasons: Mapped[int | None] = mapped_column(Integer, nullable=True)
number_of_episodes: Mapped[int | None] = mapped_column(Integer, nullable=True)
popularity: Mapped[float | None] = mapped_column(nullable=True)
vote_average: Mapped[float | None] = mapped_column(nullable=True)
vote_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
source_provider_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("providers.id", ondelete="SET NULL"), nullable=True
)
class SeriesAlternateTitle(Base, UUIDPrimaryKeyMixin):
__tablename__ = "series_alternate_titles"
series_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("series.id", ondelete="CASCADE"), nullable=False, index=True
)
title: Mapped[str] = mapped_column(String(500), nullable=False)
country_code: Mapped[str | None] = mapped_column(ForeignKey("countries.code"), nullable=True)
type: Mapped[str | None] = mapped_column(String(50), nullable=True)
class SeriesSpokenLanguage(Base):
__tablename__ = "series_spoken_languages"
series_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("series.id", ondelete="CASCADE"), primary_key=True
)
language_code: Mapped[str] = mapped_column(ForeignKey("languages.code"), primary_key=True)
class SeriesProductionCountry(Base):
__tablename__ = "series_production_countries"
series_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("series.id", ondelete="CASCADE"), primary_key=True
)
country_code: Mapped[str] = mapped_column(ForeignKey("countries.code"), primary_key=True)
class Season(Base, UUIDPrimaryKeyMixin, TimestampMixin):
__tablename__ = "seasons"
__table_args__ = (UniqueConstraint("series_id", "season_number", name="uq_season_number"),)
series_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("series.id", ondelete="CASCADE"), nullable=False, index=True
)
season_number: Mapped[int] = mapped_column(Integer, nullable=False)
title: Mapped[str | None] = mapped_column(String(500), nullable=True)
overview: Mapped[str | None] = mapped_column(Text, nullable=True)
air_date: Mapped[date | None] = mapped_column(Date, nullable=True)
episode_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
class Episode(Base, UUIDPrimaryKeyMixin, TimestampMixin):
__tablename__ = "episodes"
__table_args__ = (
UniqueConstraint("season_id", "episode_number", name="uq_episode_number"),
)
season_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("seasons.id", ondelete="CASCADE"), nullable=False, index=True
)
# Denormalized for direct filtering/joins without hopping through seasons.
series_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("series.id", ondelete="CASCADE"), nullable=False, index=True
)
season_number: Mapped[int] = mapped_column(Integer, nullable=False)
episode_number: Mapped[int] = mapped_column(Integer, nullable=False)
title: Mapped[str | None] = mapped_column(String(500), nullable=True)
overview: Mapped[str | None] = mapped_column(Text, nullable=True)
air_date: Mapped[date | None] = mapped_column(Date, nullable=True)
runtime_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True)
__all__ = [
"Series",
"SeriesAlternateTitle",
"SeriesSpokenLanguage",
"SeriesProductionCountry",
"Season",
"Episode",
]
+50
View File
@@ -0,0 +1,50 @@
"""Videos/trailers as normalized entities (section 11).
Aurora never downloads copyrighted video media — only stores the
provider's reference/URL and metadata about it.
"""
from __future__ import annotations
import uuid
from datetime import date
from sqlalchemy import Boolean, Date, ForeignKey, Index, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
from app.models.enums import VideoType
from app.models.provider import Provider
class Video(Base, UUIDPrimaryKeyMixin, TimestampMixin):
__tablename__ = "videos"
__table_args__ = (
Index("ix_videos_entity", "entity_type", "entity_id"),
UniqueConstraint("provider_id", "provider_video_id", name="uq_video_provider_source"),
)
# Polymorphic target: "movie", "series", "season", "episode".
entity_type: Mapped[str] = mapped_column(String(30), nullable=False)
entity_id: Mapped[uuid.UUID] = mapped_column(nullable=False)
video_type: Mapped[VideoType] = mapped_column(nullable=False)
provider_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("providers.id", ondelete="CASCADE"), nullable=False
)
provider_video_id: Mapped[str] = mapped_column(String(200), nullable=False)
site: Mapped[str | None] = mapped_column(String(100), nullable=True) # "YouTube", "Vimeo"
url: Mapped[str] = mapped_column(String(1000), nullable=False)
language_code: Mapped[str | None] = mapped_column(
ForeignKey("languages.code"), nullable=True
)
country_code: Mapped[str | None] = mapped_column(ForeignKey("countries.code"), nullable=True)
is_official: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
published_at: Mapped[date | None] = mapped_column(Date, nullable=True)
provider: Mapped[Provider] = relationship()
__all__ = ["Video"]
View File
+58
View File
@@ -0,0 +1,58 @@
"""Provider abstraction (section 13).
A `MetadataProvider` fetches **raw**, unnormalized data from one external
source. It never writes to the database and never returns Aurora entities —
that's the normalizer/matcher/ingestion pipeline's job (see
`app/services/metadata/` and `app/services/ingestion/`). This keeps adapters
replaceable: swapping TMDb for another provider means writing a new class
here, not touching ingestion logic.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
class ProviderError(Exception):
"""Raised for any provider-side failure (auth, not-found, rate limit,
network). Ingestion jobs catch this and record it, they don't crash."""
class ProviderNotConfiguredError(ProviderError):
"""Raised when a required credential (e.g. TMDB_API_KEY) is missing."""
class ProviderNotFoundError(ProviderError):
"""Raised when the provider has no record for the given external ID."""
class MetadataProvider(ABC):
slug: str
name: str
@abstractmethod
async def get_movie(self, external_id: str) -> dict[str, Any]:
"""Fetch raw movie data, including credits/images/videos/keywords/
alternate titles/external IDs/release dates where the provider
supports fetching them in one call."""
@abstractmethod
async def get_series(self, external_id: str) -> dict[str, Any]:
"""Fetch raw series data (without season/episode bodies)."""
@abstractmethod
async def get_season(self, series_external_id: str, season_number: int) -> dict[str, Any]:
"""Fetch raw season data, including its episodes."""
@abstractmethod
async def get_person(self, external_id: str) -> dict[str, Any]:
"""Fetch raw person data, including external IDs and images."""
@abstractmethod
async def search_movie(self, query: str) -> list[dict[str, Any]]:
"""Search this provider's catalog for movies matching `query`. Used
to seed ingestion, not exposed directly to API clients."""
async def aclose(self) -> None:
"""Release any held resources (HTTP clients, ...). Optional."""
+89
View File
@@ -0,0 +1,89 @@
"""TMDb metadata provider adapter.
Requires the `TMDB_API_KEY` environment variable (a TMDb v3 API key —
https://www.themoviedb.org/settings/api). If it is not configured, every
method raises `ProviderNotConfiguredError` rather than returning fake data.
"""
from __future__ import annotations
from typing import Any
import httpx
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
from app.core.config import get_settings
from app.providers.base import (
MetadataProvider,
ProviderError,
ProviderNotConfiguredError,
ProviderNotFoundError,
)
_MOVIE_APPEND = "credits,images,videos,keywords,alternative_titles,external_ids,release_dates,recommendations"
_SERIES_APPEND = "credits,images,videos,keywords,alternative_titles,external_ids,content_ratings"
_PERSON_APPEND = "external_ids,images,combined_credits"
class TMDbProvider(MetadataProvider):
slug = "tmdb"
name = "The Movie Database (TMDb)"
base_url = "https://api.themoviedb.org/3"
def __init__(self, api_key: str | None = None, client: httpx.AsyncClient | None = None) -> None:
settings = get_settings()
self._api_key = api_key if api_key is not None else settings.TMDB_API_KEY
self._client = client or httpx.AsyncClient(base_url=self.base_url, timeout=15.0)
self._owns_client = client is None
def _require_api_key(self) -> None:
if not self._api_key:
raise ProviderNotConfiguredError(
"TMDB_API_KEY is not set. Configure it in the environment to use the TMDb "
"provider — see .env.example."
)
@retry(
reraise=True,
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=0.5, max=5),
retry=retry_if_exception_type(httpx.TransportError),
)
async def _get(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
self._require_api_key()
query = {"api_key": self._api_key, **(params or {})}
try:
response = await self._client.get(path, params=query)
except httpx.TransportError as exc:
raise ProviderError(f"TMDb request failed: {exc}") from exc
if response.status_code == 404:
raise ProviderNotFoundError(f"TMDb has no record at {path}")
if response.status_code == 401:
raise ProviderNotConfiguredError("TMDb rejected the configured TMDB_API_KEY.")
if response.status_code >= 400:
raise ProviderError(f"TMDb error {response.status_code} at {path}: {response.text[:300]}")
return response.json()
async def get_movie(self, external_id: str) -> dict[str, Any]:
return await self._get(f"/movie/{external_id}", {"append_to_response": _MOVIE_APPEND})
async def get_series(self, external_id: str) -> dict[str, Any]:
return await self._get(f"/tv/{external_id}", {"append_to_response": _SERIES_APPEND})
async def get_season(self, series_external_id: str, season_number: int) -> dict[str, Any]:
return await self._get(
f"/tv/{series_external_id}/season/{season_number}",
{"append_to_response": "credits,images,external_ids"},
)
async def get_person(self, external_id: str) -> dict[str, Any]:
return await self._get(f"/person/{external_id}", {"append_to_response": _PERSON_APPEND})
async def search_movie(self, query: str) -> list[dict[str, Any]]:
data = await self._get("/search/movie", {"query": query})
return data.get("results", [])
async def aclose(self) -> None:
if self._owns_client:
await self._client.aclose()
View File
+39
View File
@@ -0,0 +1,39 @@
"""Data access for Collection/franchise."""
from __future__ import annotations
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.collection import Collection, CollectionMovie, CollectionSeries
from app.models.movie import Movie
from app.models.tv import Series
async def get_collection_by_id(session: AsyncSession, collection_id: uuid.UUID) -> Collection | None:
result = await session.execute(select(Collection).where(Collection.id == collection_id))
return result.scalar_one_or_none()
async def get_collection_movies(session: AsyncSession, collection_id: uuid.UUID) -> list[Movie]:
stmt = (
select(Movie)
.join(CollectionMovie, CollectionMovie.movie_id == Movie.id)
.where(CollectionMovie.collection_id == collection_id)
.order_by(CollectionMovie.sort_order.asc().nulls_last(), Movie.primary_release_date.asc())
)
result = await session.execute(stmt)
return list(result.scalars().all())
async def get_collection_series(session: AsyncSession, collection_id: uuid.UUID) -> list[Series]:
stmt = (
select(Series)
.join(CollectionSeries, CollectionSeries.series_id == Series.id)
.where(CollectionSeries.collection_id == collection_id)
.order_by(CollectionSeries.sort_order.asc().nulls_last())
)
result = await session.execute(stmt)
return list(result.scalars().all())
+15
View File
@@ -0,0 +1,15 @@
"""Data access for Company."""
from __future__ import annotations
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.company import Company
async def get_company_by_id(session: AsyncSession, company_id: uuid.UUID) -> Company | None:
result = await session.execute(select(Company).where(Company.id == company_id))
return result.scalar_one_or_none()
+61
View File
@@ -0,0 +1,61 @@
"""Data access for Movie and its directly-owned satellite tables."""
from __future__ import annotations
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.company import Company, MovieCompany
from app.models.genre import Genre, MovieGenre
from app.models.keyword import Keyword, MovieKeyword
from app.models.movie import Movie
async def get_movie_by_id(session: AsyncSession, movie_id: uuid.UUID) -> Movie | None:
stmt = (
select(Movie)
.options(selectinload(Movie.collection))
.where(Movie.id == movie_id)
)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def get_movies_by_ids(session: AsyncSession, movie_ids: list[uuid.UUID]) -> list[Movie]:
if not movie_ids:
return []
stmt = select(Movie).where(Movie.id.in_(movie_ids))
result = await session.execute(stmt)
by_id = {m.id: m for m in result.scalars().all()}
return [by_id[i] for i in movie_ids if i in by_id]
async def get_movie_genres(session: AsyncSession, movie_id: uuid.UUID) -> list[Genre]:
stmt = select(Genre).join(MovieGenre, MovieGenre.genre_id == Genre.id).where(
MovieGenre.movie_id == movie_id
)
result = await session.execute(stmt)
return list(result.scalars().all())
async def get_movie_keywords(session: AsyncSession, movie_id: uuid.UUID) -> list[Keyword]:
stmt = select(Keyword).join(MovieKeyword, MovieKeyword.keyword_id == Keyword.id).where(
MovieKeyword.movie_id == movie_id
)
result = await session.execute(stmt)
return list(result.scalars().all())
async def get_movie_companies(
session: AsyncSession, movie_id: uuid.UUID
) -> list[tuple[Company, str | None]]:
stmt = (
select(Company, MovieCompany.role)
.join(MovieCompany, MovieCompany.company_id == Company.id)
.where(MovieCompany.movie_id == movie_id)
)
result = await session.execute(stmt)
return [(company, role) for company, role in result.all()]
+26
View File
@@ -0,0 +1,26 @@
"""Data access for Person."""
from __future__ import annotations
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.credit import Credit
from app.models.person import Person
async def get_person_by_id(session: AsyncSession, person_id: uuid.UUID) -> Person | None:
result = await session.execute(select(Person).where(Person.id == person_id))
return result.scalar_one_or_none()
async def get_person_credits(session: AsyncSession, person_id: uuid.UUID) -> list[Credit]:
stmt = (
select(Credit)
.where(Credit.person_id == person_id)
.order_by(Credit.entity_type.asc())
)
result = await session.execute(stmt)
return list(result.scalars().all())
+90
View File
@@ -0,0 +1,90 @@
"""Shared data access for the polymorphic satellite tables — credits,
artwork, videos, external IDs — that attach to more than one entity type via
an (entity_type, entity_id) pair rather than a typed foreign key.
"""
from __future__ import annotations
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.artwork import Artwork
from app.models.credit import Credit
from app.models.enums import ArtworkType
from app.models.external_id import ExternalID
from app.models.provider import Provider
from app.models.video import Video
async def get_external_ids(
session: AsyncSession, entity_type: str, entity_id: uuid.UUID
) -> dict[str, str]:
stmt = (
select(Provider.slug, ExternalID.external_id)
.join(Provider, Provider.id == ExternalID.provider_id)
.where(ExternalID.entity_type == entity_type, ExternalID.entity_id == entity_id)
)
result = await session.execute(stmt)
return {slug: external_id for slug, external_id in result.all()}
async def get_external_ids_bulk(
session: AsyncSession, entity_type: str, entity_ids: list[uuid.UUID]
) -> dict[uuid.UUID, dict[str, str]]:
if not entity_ids:
return {}
stmt = (
select(ExternalID.entity_id, Provider.slug, ExternalID.external_id)
.join(Provider, Provider.id == ExternalID.provider_id)
.where(ExternalID.entity_type == entity_type, ExternalID.entity_id.in_(entity_ids))
)
result = await session.execute(stmt)
out: dict[uuid.UUID, dict[str, str]] = {eid: {} for eid in entity_ids}
for entity_id, slug, external_id in result.all():
out.setdefault(entity_id, {})[slug] = external_id
return out
async def get_artwork(
session: AsyncSession,
entity_type: str,
entity_id: uuid.UUID,
artwork_type: ArtworkType | None = None,
) -> list[Artwork]:
stmt = (
select(Artwork)
.options(selectinload(Artwork.provider))
.where(Artwork.entity_type == entity_type, Artwork.entity_id == entity_id)
.order_by(Artwork.is_primary.desc(), Artwork.vote_average.desc().nulls_last())
)
if artwork_type is not None:
stmt = stmt.where(Artwork.artwork_type == artwork_type)
result = await session.execute(stmt)
return list(result.scalars().all())
async def get_videos(session: AsyncSession, entity_type: str, entity_id: uuid.UUID) -> list[Video]:
stmt = (
select(Video)
.options(selectinload(Video.provider))
.where(Video.entity_type == entity_type, Video.entity_id == entity_id)
.order_by(Video.is_official.desc(), Video.published_at.desc().nulls_last())
)
result = await session.execute(stmt)
return list(result.scalars().all())
async def get_credits(
session: AsyncSession, entity_type: str, entity_id: uuid.UUID
) -> list[Credit]:
stmt = (
select(Credit)
.options(selectinload(Credit.person))
.where(Credit.entity_type == entity_type, Credit.entity_id == entity_id)
.order_by(Credit.billing_order.asc().nulls_last())
)
result = await session.execute(stmt)
return list(result.scalars().all())
+145
View File
@@ -0,0 +1,145 @@
"""Data access for Provider, ProviderRecord, and IngestionJob."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.enums import IngestionJobStatus, MatchConfidence
from app.models.provider import IngestionJob, Provider, ProviderRecord
async def get_provider_by_slug(session: AsyncSession, slug: str) -> Provider | None:
result = await session.execute(select(Provider).where(Provider.slug == slug))
return result.scalar_one_or_none()
async def get_or_create_provider(
session: AsyncSession, slug: str, name: str, base_url: str | None = None
) -> Provider:
provider = await get_provider_by_slug(session, slug)
if provider is not None:
return provider
provider = Provider(slug=slug, name=name, base_url=base_url)
session.add(provider)
await session.flush()
return provider
async def get_provider_record(
session: AsyncSession, provider_id: uuid.UUID, entity_type: str, external_id: str
) -> ProviderRecord | None:
stmt = select(ProviderRecord).where(
ProviderRecord.provider_id == provider_id,
ProviderRecord.entity_type == entity_type,
ProviderRecord.external_id == external_id,
)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def upsert_provider_record(
session: AsyncSession,
*,
provider_id: uuid.UUID,
entity_type: str,
external_id: str,
raw_data: dict,
aurora_entity_id: uuid.UUID | None,
match_confidence: MatchConfidence,
source_version: str | None = None,
ingestion_job_id: uuid.UUID | None = None,
) -> ProviderRecord:
record = await get_provider_record(session, provider_id, entity_type, external_id)
now = datetime.now(UTC)
if record is None:
record = ProviderRecord(
provider_id=provider_id,
entity_type=entity_type,
external_id=external_id,
raw_data=raw_data,
aurora_entity_id=aurora_entity_id,
match_confidence=match_confidence,
source_version=source_version,
ingestion_job_id=ingestion_job_id,
last_synced_at=now,
)
session.add(record)
else:
record.raw_data = raw_data
record.aurora_entity_id = aurora_entity_id
record.match_confidence = match_confidence
record.source_version = source_version
record.ingestion_job_id = ingestion_job_id
record.last_synced_at = now
await session.flush()
return record
async def get_ingestion_job_by_idempotency_key(
session: AsyncSession, idempotency_key: str
) -> IngestionJob | None:
stmt = select(IngestionJob).where(IngestionJob.idempotency_key == idempotency_key)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def create_ingestion_job(
session: AsyncSession,
*,
provider_id: uuid.UUID,
job_type: str,
idempotency_key: str,
entity_type: str | None = None,
external_id: str | None = None,
) -> IngestionJob:
existing = await get_ingestion_job_by_idempotency_key(session, idempotency_key)
if existing is not None:
return existing
job = IngestionJob(
provider_id=provider_id,
job_type=job_type,
entity_type=entity_type,
external_id=external_id,
idempotency_key=idempotency_key,
status=IngestionJobStatus.PENDING,
)
session.add(job)
await session.flush()
return job
async def get_ingestion_job(session: AsyncSession, job_id: uuid.UUID) -> IngestionJob | None:
result = await session.execute(select(IngestionJob).where(IngestionJob.id == job_id))
return result.scalar_one_or_none()
async def mark_job_running(session: AsyncSession, job: IngestionJob) -> None:
job.status = IngestionJobStatus.RUNNING
job.attempts += 1
job.started_at = datetime.now(UTC)
await session.flush()
async def mark_job_succeeded(
session: AsyncSession, job: IngestionJob, result_aurora_id: uuid.UUID | None
) -> None:
job.status = IngestionJobStatus.SUCCEEDED
job.finished_at = datetime.now(UTC)
job.result_aurora_id = result_aurora_id
job.error = None
await session.flush()
async def mark_job_failed(session: AsyncSession, job: IngestionJob, error: str) -> None:
job.error = error[:4000]
job.finished_at = datetime.now(UTC)
job.status = (
IngestionJobStatus.RETRYING
if job.attempts < job.max_attempts
else IngestionJobStatus.FAILED
)
await session.flush()
+270
View File
@@ -0,0 +1,270 @@
"""Get-or-create helpers for shared reference/lookup data used during
ingestion: genres, keywords, languages, countries, companies, collections,
people, and external ID links."""
from __future__ import annotations
import re
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.collection import Collection
from app.models.company import Company
from app.models.enums import CompanyType
from app.models.external_id import ExternalID
from app.models.genre import Genre
from app.models.keyword import Keyword
from app.models.language_country import Country, Language
from app.models.person import Person
def slugify(value: str) -> str:
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
return slug or "n-a"
async def get_or_create_genre(session: AsyncSession, name: str) -> Genre:
slug = slugify(name)
result = await session.execute(select(Genre).where(Genre.slug == slug))
genre = result.scalar_one_or_none()
if genre is not None:
return genre
genre = Genre(name=name, slug=slug)
session.add(genre)
await session.flush()
return genre
async def get_or_create_keyword(session: AsyncSession, name: str) -> Keyword:
slug = slugify(name)
result = await session.execute(select(Keyword).where(Keyword.slug == slug))
keyword = result.scalar_one_or_none()
if keyword is not None:
return keyword
keyword = Keyword(name=name, slug=slug)
session.add(keyword)
await session.flush()
return keyword
async def get_or_create_language(session: AsyncSession, code: str, english_name: str) -> Language:
result = await session.execute(select(Language).where(Language.code == code))
language = result.scalar_one_or_none()
if language is not None:
return language
language = Language(code=code, english_name=english_name)
session.add(language)
await session.flush()
return language
async def get_or_create_country(session: AsyncSession, code: str, name: str) -> Country:
result = await session.execute(select(Country).where(Country.code == code))
country = result.scalar_one_or_none()
if country is not None:
return country
country = Country(code=code, name=name)
session.add(country)
await session.flush()
return country
async def find_company_by_external_id(
session: AsyncSession, provider_id: uuid.UUID, external_id: str
) -> Company | None:
stmt = (
select(Company)
.join(
ExternalID,
(ExternalID.entity_id == Company.id) & (ExternalID.entity_type == "company"),
)
.where(ExternalID.provider_id == provider_id, ExternalID.external_id == external_id)
)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def get_or_create_company(
session: AsyncSession,
*,
provider_id: uuid.UUID,
external_id: str,
name: str,
company_type: CompanyType = CompanyType.PRODUCTION_COMPANY,
country_code: str | None = None,
) -> Company:
company = await find_company_by_external_id(session, provider_id, external_id)
if company is not None:
return company
company = Company(name=name, company_type=company_type, country_code=country_code)
session.add(company)
await session.flush()
session.add(
ExternalID(
entity_type="company",
entity_id=company.id,
provider_id=provider_id,
external_id=external_id,
)
)
await session.flush()
return company
async def find_collection_by_external_id(
session: AsyncSession, provider_id: uuid.UUID, external_id: str
) -> Collection | None:
stmt = (
select(Collection)
.join(
ExternalID,
(ExternalID.entity_id == Collection.id) & (ExternalID.entity_type == "collection"),
)
.where(ExternalID.provider_id == provider_id, ExternalID.external_id == external_id)
)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def get_or_create_collection(
session: AsyncSession,
*,
provider_id: uuid.UUID,
external_id: str,
name: str,
overview: str | None = None,
) -> Collection:
collection = await find_collection_by_external_id(session, provider_id, external_id)
if collection is not None:
return collection
base_slug = slugify(name)
slug = base_slug
suffix = 1
while (
await session.execute(select(Collection.id).where(Collection.slug == slug))
).scalar_one_or_none() is not None:
suffix += 1
slug = f"{base_slug}-{suffix}"
collection = Collection(name=name, overview=overview, slug=slug)
session.add(collection)
await session.flush()
session.add(
ExternalID(
entity_type="collection",
entity_id=collection.id,
provider_id=provider_id,
external_id=external_id,
)
)
await session.flush()
return collection
async def find_person_by_external_id(
session: AsyncSession, provider_id: uuid.UUID, external_id: str
) -> Person | None:
stmt = (
select(Person)
.join(
ExternalID,
(ExternalID.entity_id == Person.id) & (ExternalID.entity_type == "person"),
)
.where(ExternalID.provider_id == provider_id, ExternalID.external_id == external_id)
)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def get_or_create_person(
session: AsyncSession,
*,
provider_id: uuid.UUID,
external_id: str,
name: str,
**extra_fields: object,
) -> Person:
person = await find_person_by_external_id(session, provider_id, external_id)
if person is not None:
for key, value in extra_fields.items():
if value is not None:
setattr(person, key, value)
await session.flush()
return person
person = Person(name=name, **extra_fields)
session.add(person)
await session.flush()
session.add(
ExternalID(
entity_type="person",
entity_id=person.id,
provider_id=provider_id,
external_id=external_id,
)
)
await session.flush()
return person
async def find_entity_by_external_id(
session: AsyncSession, provider_id: uuid.UUID, entity_type: str, external_id: str
) -> uuid.UUID | None:
stmt = select(ExternalID.entity_id).where(
ExternalID.provider_id == provider_id,
ExternalID.entity_type == entity_type,
ExternalID.external_id == external_id,
)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def find_entities_by_external_ids(
session: AsyncSession, provider_id: uuid.UUID, entity_type: str, external_ids: list[str]
) -> list[uuid.UUID]:
"""Bulk lookup preserving `external_ids` order; unmatched IDs are
dropped rather than raising, since a recommendation pointing at a movie
Aurora hasn't ingested yet is expected, not an error."""
if not external_ids:
return []
stmt = select(ExternalID.external_id, ExternalID.entity_id).where(
ExternalID.provider_id == provider_id,
ExternalID.entity_type == entity_type,
ExternalID.external_id.in_(external_ids),
)
result = await session.execute(stmt)
found = {external_id: entity_id for external_id, entity_id in result.all()}
return [found[eid] for eid in external_ids if eid in found]
async def link_external_id(
session: AsyncSession,
*,
entity_type: str,
entity_id: uuid.UUID,
provider_id: uuid.UUID,
external_id: str,
url: str | None = None,
) -> None:
stmt = select(ExternalID).where(
ExternalID.entity_type == entity_type,
ExternalID.entity_id == entity_id,
ExternalID.provider_id == provider_id,
)
result = await session.execute(stmt)
existing = result.scalar_one_or_none()
if existing is not None:
existing.external_id = external_id
existing.url = url
else:
session.add(
ExternalID(
entity_type=entity_type,
entity_id=entity_id,
provider_id=provider_id,
external_id=external_id,
url=url,
)
)
await session.flush()
+79
View File
@@ -0,0 +1,79 @@
"""Data access for Series -> Season -> Episode."""
from __future__ import annotations
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.company import Company, SeriesCompany
from app.models.enums import CompanyType
from app.models.genre import Genre, SeriesGenre
from app.models.keyword import Keyword, SeriesKeyword
from app.models.tv import Episode, Season, Series
async def get_series_by_id(session: AsyncSession, series_id: uuid.UUID) -> Series | None:
result = await session.execute(select(Series).where(Series.id == series_id))
return result.scalar_one_or_none()
async def get_seasons_for_series(session: AsyncSession, series_id: uuid.UUID) -> list[Season]:
stmt = (
select(Season)
.where(Season.series_id == series_id)
.order_by(Season.season_number.asc())
)
result = await session.execute(stmt)
return list(result.scalars().all())
async def get_season_by_id(session: AsyncSession, season_id: uuid.UUID) -> Season | None:
result = await session.execute(select(Season).where(Season.id == season_id))
return result.scalar_one_or_none()
async def get_episodes_for_season(session: AsyncSession, season_id: uuid.UUID) -> list[Episode]:
stmt = (
select(Episode)
.where(Episode.season_id == season_id)
.order_by(Episode.episode_number.asc())
)
result = await session.execute(stmt)
return list(result.scalars().all())
async def get_episode_by_id(session: AsyncSession, episode_id: uuid.UUID) -> Episode | None:
result = await session.execute(select(Episode).where(Episode.id == episode_id))
return result.scalar_one_or_none()
async def get_series_genres(session: AsyncSession, series_id: uuid.UUID) -> list[Genre]:
stmt = select(Genre).join(SeriesGenre, SeriesGenre.genre_id == Genre.id).where(
SeriesGenre.series_id == series_id
)
result = await session.execute(stmt)
return list(result.scalars().all())
async def get_series_keywords(session: AsyncSession, series_id: uuid.UUID) -> list[Keyword]:
stmt = select(Keyword).join(SeriesKeyword, SeriesKeyword.keyword_id == Keyword.id).where(
SeriesKeyword.series_id == series_id
)
result = await session.execute(stmt)
return list(result.scalars().all())
async def get_series_companies(
session: AsyncSession, series_id: uuid.UUID, company_type: CompanyType | None = None
) -> list[tuple[Company, str | None]]:
stmt = (
select(Company, SeriesCompany.role)
.join(SeriesCompany, SeriesCompany.company_id == Company.id)
.where(SeriesCompany.series_id == series_id)
)
if company_type is not None:
stmt = stmt.where(Company.company_type == company_type)
result = await session.execute(stmt)
return [(company, role) for company, role in result.all()]
View File
+24
View File
@@ -0,0 +1,24 @@
"""Collection/franchise response schemas."""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel
from app.schemas.movie import MovieSummaryOut
from app.schemas.tv import SeriesSummaryOut
class CollectionDetailOut(BaseModel):
id: str
type: str = "collection"
name: str
overview: str | None = None
poster_path: str | None = None
backdrop_path: str | None = None
movies: list[MovieSummaryOut] = []
series: list[SeriesSummaryOut] = []
created_at: datetime
updated_at: datetime
+119
View File
@@ -0,0 +1,119 @@
"""Shared response building blocks used across entity schemas.
These are hand-written Pydantic models, never SQLAlchemy models serialized
directly — see section 18 of the build spec.
"""
from __future__ import annotations
from datetime import date
from pydantic import BaseModel, ConfigDict
class ORMModel(BaseModel):
"""Base for schemas built from ORM rows via `model_validate(obj)`."""
model_config = ConfigDict(from_attributes=True)
class GenreOut(ORMModel):
id: str
name: str
class KeywordOut(ORMModel):
id: str
name: str
class ArtworkOut(BaseModel):
id: str
type: str
language: str | None = None
width: int | None = None
height: int | None = None
aspect_ratio: float | None = None
url: str
provider: str | None = None
is_primary: bool = False
vote_average: float | None = None
class ImagesOut(BaseModel):
posters: list[ArtworkOut] = []
backdrops: list[ArtworkOut] = []
logos: list[ArtworkOut] = []
class VideoOut(BaseModel):
id: str
type: str
site: str | None = None
url: str
language: str | None = None
country: str | None = None
is_official: bool = False
published_at: date | None = None
provider: str | None = None
class ExternalIDsOut(BaseModel):
"""Provider slug -> native ID, e.g. {"tmdb": "550", "imdb": "tt0137523"}."""
model_config = ConfigDict(extra="allow")
class PersonBriefOut(BaseModel):
id: str
name: str
profile_path: str | None = None
class CastCreditOut(BaseModel):
id: str
person: PersonBriefOut
character: str | None = None
billing_order: int | None = None
episode_count: int | None = None
class CrewCreditOut(BaseModel):
id: str
person: PersonBriefOut
department: str
job: str | None = None
episode_count: int | None = None
class CreditsOut(BaseModel):
cast: list[CastCreditOut] = []
crew: list[CrewCreditOut] = []
class CompanyBriefOut(BaseModel):
id: str
name: str
company_type: str
logo_path: str | None = None
class CollectionBriefOut(BaseModel):
id: str
name: str
class PageMeta(BaseModel):
page: int
page_size: int
total: int
total_pages: int
class ErrorDetail(BaseModel):
code: str
message: str
class ErrorResponse(BaseModel):
error: ErrorDetail
+24
View File
@@ -0,0 +1,24 @@
"""Company response schemas."""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel
class CompanyDetailOut(BaseModel):
id: str
type: str = "company"
name: str
company_type: str
description: str | None = None
country: str | None = None
headquarters: str | None = None
homepage: str | None = None
logo_path: str | None = None
parent_company_id: str | None = None
external_ids: dict[str, str] = {}
created_at: datetime
updated_at: datetime
+20
View File
@@ -0,0 +1,20 @@
"""Health/readiness response schemas."""
from __future__ import annotations
from pydantic import BaseModel
class HealthOut(BaseModel):
status: str = "ok"
class DependencyStatus(BaseModel):
name: str
ok: bool
required: bool
class ReadyOut(BaseModel):
status: str
dependencies: list[DependencyStatus]
+80
View File
@@ -0,0 +1,80 @@
"""Movie response schemas."""
from __future__ import annotations
from datetime import date, datetime
from pydantic import BaseModel
from app.schemas.common import (
CastCreditOut,
CollectionBriefOut,
CompanyBriefOut,
CrewCreditOut,
GenreOut,
ImagesOut,
KeywordOut,
VideoOut,
)
class MovieSummaryOut(BaseModel):
"""Compact movie representation used in lists and search results."""
id: str
type: str = "movie"
title: str
original_title: str | None = None
overview: str | None = None
release_date: date | None = None
poster_path: str | None = None
popularity: float | None = None
vote_average: float | None = None
class MovieDetailOut(BaseModel):
id: str
type: str = "movie"
title: str
original_title: str | None = None
overview: str | None = None
tagline: str | None = None
status: str
original_language: str | None = None
release_date: date | None = None
runtime: int | None = None
budget: int | None = None
revenue: int | None = None
popularity: float | None = None
vote_average: float | None = None
vote_count: int | None = None
adult: bool = False
genres: list[GenreOut] = []
keywords: list[KeywordOut] = []
cast: list[CastCreditOut] = []
crew: list[CrewCreditOut] = []
companies: list[CompanyBriefOut] = []
collection: CollectionBriefOut | None = None
images: ImagesOut = ImagesOut()
videos: list[VideoOut] = []
external_ids: dict[str, str] = {}
created_at: datetime
updated_at: datetime
class MovieCreditsOut(BaseModel):
id: str
cast: list[CastCreditOut] = []
crew: list[CrewCreditOut] = []
class MovieImagesOut(BaseModel):
id: str
images: ImagesOut = ImagesOut()
class MovieVideosOut(BaseModel):
id: str
videos: list[VideoOut] = []
+55
View File
@@ -0,0 +1,55 @@
"""Person response schemas."""
from __future__ import annotations
from datetime import date, datetime
from pydantic import BaseModel
from app.schemas.common import ArtworkOut
class PersonSummaryOut(BaseModel):
id: str
type: str = "person"
name: str
known_for_department: str | None = None
profile_path: str | None = None
popularity: float | None = None
class FilmographyCreditOut(BaseModel):
id: str
media_id: str
media_type: str
title: str
department: str
job: str | None = None
character: str | None = None
release_date: date | None = None
poster_path: str | None = None
class PersonDetailOut(BaseModel):
id: str
type: str = "person"
name: str
aliases: list[str] = []
biography: str | None = None
birthday: date | None = None
deathday: date | None = None
birthplace: str | None = None
gender: str | None = None
known_for_department: str | None = None
popularity: float | None = None
profile_images: list[ArtworkOut] = []
external_ids: dict[str, str] = {}
created_at: datetime
updated_at: datetime
class PersonCreditsOut(BaseModel):
id: str
cast: list[FilmographyCreditOut] = []
crew: list[FilmographyCreditOut] = []
+20
View File
@@ -0,0 +1,20 @@
"""Search response schemas."""
from __future__ import annotations
from pydantic import BaseModel
from app.schemas.common import CollectionBriefOut, CompanyBriefOut
from app.schemas.movie import MovieSummaryOut
from app.schemas.person import PersonSummaryOut
from app.schemas.tv import SeriesSummaryOut
class SearchResultsOut(BaseModel):
query: str
movies: list[MovieSummaryOut] = []
series: list[SeriesSummaryOut] = []
people: list[PersonSummaryOut] = []
collections: list[CollectionBriefOut] = []
companies: list[CompanyBriefOut] = []
total_results: int = 0
+123
View File
@@ -0,0 +1,123 @@
"""Series/season/episode response schemas."""
from __future__ import annotations
from datetime import date, datetime
from pydantic import BaseModel
from app.schemas.common import (
CastCreditOut,
CompanyBriefOut,
CrewCreditOut,
GenreOut,
ImagesOut,
KeywordOut,
VideoOut,
)
class SeriesSummaryOut(BaseModel):
id: str
type: str = "series"
title: str
original_title: str | None = None
overview: str | None = None
first_air_date: date | None = None
poster_path: str | None = None
popularity: float | None = None
vote_average: float | None = None
class SeasonBriefOut(BaseModel):
id: str
season_number: int
title: str | None = None
episode_count: int | None = None
air_date: date | None = None
poster_path: str | None = None
class SeriesDetailOut(BaseModel):
id: str
type: str = "series"
title: str
original_title: str | None = None
overview: str | None = None
tagline: str | None = None
status: str
original_language: str | None = None
first_air_date: date | None = None
last_air_date: date | None = None
number_of_seasons: int | None = None
number_of_episodes: int | None = None
popularity: float | None = None
vote_average: float | None = None
vote_count: int | None = None
genres: list[GenreOut] = []
keywords: list[KeywordOut] = []
creators: list[CrewCreditOut] = []
cast: list[CastCreditOut] = []
crew: list[CrewCreditOut] = []
networks: list[CompanyBriefOut] = []
companies: list[CompanyBriefOut] = []
seasons: list[SeasonBriefOut] = []
images: ImagesOut = ImagesOut()
videos: list[VideoOut] = []
external_ids: dict[str, str] = {}
created_at: datetime
updated_at: datetime
class SeriesSeasonsOut(BaseModel):
id: str
seasons: list[SeasonBriefOut] = []
class EpisodeBriefOut(BaseModel):
id: str
episode_number: int
title: str | None = None
air_date: date | None = None
still_path: str | None = None
class SeasonDetailOut(BaseModel):
id: str
type: str = "season"
series_id: str
season_number: int
title: str | None = None
overview: str | None = None
air_date: date | None = None
episode_count: int | None = None
episodes: list[EpisodeBriefOut] = []
images: ImagesOut = ImagesOut()
external_ids: dict[str, str] = {}
created_at: datetime
updated_at: datetime
class EpisodeDetailOut(BaseModel):
id: str
type: str = "episode"
series_id: str
season_id: str
season_number: int
episode_number: int
title: str | None = None
overview: str | None = None
air_date: date | None = None
runtime: int | None = None
cast: list[CastCreditOut] = []
guest_stars: list[CastCreditOut] = []
crew: list[CrewCreditOut] = []
images: ImagesOut = ImagesOut()
external_ids: dict[str, str] = {}
created_at: datetime
updated_at: datetime
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
View File
+22
View File
@@ -0,0 +1,22 @@
"""arq task functions. Thin wrappers around the ingestion runner so retry/
concurrency/observability behavior lives in arq's worker loop, not here.
"""
from __future__ import annotations
from typing import Any
from app.core.logging import get_logger
from app.services.ingestion.runner import run_movie_ingestion, run_person_ingestion
logger = get_logger(__name__)
async def ingest_movie_task(ctx: dict[str, Any], external_id: str) -> str:
movie_id = await run_movie_ingestion(external_id)
return str(movie_id)
async def ingest_person_task(ctx: dict[str, Any], external_id: str) -> str:
person_id = await run_person_ingestion(external_id)
return str(person_id)
+37
View File
@@ -0,0 +1,37 @@
"""arq worker process entrypoint.
Run with: `arq app.workers.worker.WorkerSettings`
Workers scale independently of the API (section 1) — run as many worker
processes/containers as needed; arq's Redis-backed queue coordinates them so
each job is picked up by exactly one worker.
"""
from __future__ import annotations
from arq.connections import RedisSettings
from app.core.config import get_settings
from app.core.logging import configure_logging, get_logger
from app.workers.tasks import ingest_movie_task, ingest_person_task
logger = get_logger(__name__)
async def startup(ctx: dict) -> None:
configure_logging()
logger.info("worker_started")
async def shutdown(ctx: dict) -> None:
logger.info("worker_stopped")
class WorkerSettings:
functions = [ingest_movie_task, ingest_person_task]
on_startup = startup
on_shutdown = shutdown
redis_settings = RedisSettings.from_dsn(get_settings().REDIS_URL)
max_jobs = get_settings().WORKER_CONCURRENCY
job_timeout = 300
max_tries = 5