Files
Aurora-Core/app/main.py
T
NixReaperandClaude Sonnet 5 567b4ef068
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
Add keyword browsing/filtering and expose alternate titles
Keywords were ingested and shown read-only on detail pages, but had no
directory (GET /keywords) or filter param -- the same gap genres had
before today. Mirrors the genre browse pattern exactly: repository,
service, route, plus a `keyword` filter on GET /movies and GET /series.

Alternate/translated titles ("Also Known As") were normalized down to
bare title strings and never written to MovieAlternateTitle/
SeriesAlternateTitle at all. Normalizer now captures country_code + type
per TMDb's actual shape; ingestion writes them; movie/series detail now
expose an alternate_titles list.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-06 04:40:32 -05:00

183 lines
5.8 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 (
admin,
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",
)
@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(genres.router, prefix=API_V1_PREFIX)
app.include_router(keywords.router, prefix=API_V1_PREFIX)
app.include_router(admin.router, prefix=API_V1_PREFIX)