Files
Aurora-Core/app/main.py
T
NixReaperandClaude Sonnet 5 0ab31a314c Add movie/series ratings and watchlist
Builds on the accounts foundation: PUT/DELETE a rating (0.5-10.0) or a
watchlist entry on any movie or series, all scoped to the bearer token's
own user, plus GET /account/ratings|watchlist/{movies,series} to list
them back. Both are polymorphic entity_type/entity_id tables, same shape
as watch_provider_offerings and trending_entries, so movie and series
share one repository/router each rather than duplicating per type.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-06 10:47:41 -05:00

198 lines
6.6 KiB
Python

"""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 a reverse
proxy without sticky sessions.
"""
from __future__ import annotations
import time
import uuid
from collections.abc import Awaitable, Callable
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, Request, Response
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from prometheus_client import CONTENT_TYPE_LATEST, Counter, Histogram, generate_latest
from starlette.exceptions import HTTPException as StarletteHTTPException
from app.api.v1 import (
account,
admin,
auth,
collections,
companies,
episodes,
genres,
health,
keywords,
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=["*"],
)
# Serves what app/services/artwork/ downloads (section 10: self-hosted
# artwork). The directory must exist before StaticFiles will mount — the
# worker creates subdirectories as it writes files, but the root itself
# needs to be there even before the first download.
Path(settings.MEDIA_ROOT).mkdir(parents=True, exist_ok=True)
app.mount(
settings.MEDIA_URL_PREFIX,
StaticFiles(directory=settings.MEDIA_ROOT),
name="media",
)
# Public API documentation (mkdocs, built from docs/ -- see
# docker/api.Dockerfile's docs-builder stage and mkdocs.yml). Unlike /docs
# (the auto-generated OpenAPI schema, gated by AURORA_API_DOCS_ENABLED
# since it can reveal more raw internal detail), this is curated,
# intentionally public content -- always mounted regardless of that flag.
# The directory only exists in the built Docker image; ensure it's present
# so a local dev server that hasn't run `mkdocs build` doesn't crash on
# startup, same reasoning as MEDIA_ROOT above.
Path("wiki").mkdir(parents=True, exist_ok=True)
app.mount("/wiki", StaticFiles(directory="wiki", html=True), name="wiki")
@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(auth.router, prefix=API_V1_PREFIX)
app.include_router(account.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(genres.router, prefix=API_V1_PREFIX)
app.include_router(keywords.router, prefix=API_V1_PREFIX)
app.include_router(admin.router, prefix=API_V1_PREFIX)