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