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
Section 10: every image API responses reference has pointed straight at TMDb's CDN since day one -- Aurora never actually downloaded or served artwork itself, despite storing dimensions/checksum/local_path columns for exactly that. Closes the gap: - is_primary was declared on the Artwork model but never actually set anywhere -- every row defaulted to False. Now the normalizer marks the one image each provider designates as *the* image for an entity (TMDb's poster_path/backdrop_path/profile_path) as primary, distinct from the dozens of alternates also ingested, including correctly marking an existing row primary (not inserting a duplicate) when that image already appears in the alternates list. - app/services/artwork/: an ArtworkStorage interface with a LocalDiskStorage implementation (checksum-named files, single-host deployment via a Docker volume shared between api and worker -- see docs/architecture.md for the S3/R2 upgrade path), and a download pipeline with content-type validation, a size cap, and path-traversal protection. - After movie/series/person ingestion succeeds, tasks.py cascades one download_artwork_task per primary artwork id via arq's ctx['redis'] (same fan-out pattern as the season cascade) -- fire-and-forget, not tracked in ingestion_jobs, since a failed image cache is a known-safe fallback to the provider URL, not a user-facing failure. - app/main.py serves MEDIA_ROOT at MEDIA_URL_PREFIX; local_path is preferred over original_url once set (already-existing fallback logic in app/services/common.py:artwork_url, now actually exercised). Also fixes a real interaction bug this surfaced: every ingestion pipeline deleted and recreated all of an entity's Artwork rows on each re-sync, which would have silently wiped local_path/checksum on every re-ingestion, forcing a full re-download every time and defeating the point of caching locally at all. app/repositories/polymorphic.py's new sync_artwork upserts by provider_asset_id (TMDb's stable per-image path) instead, preserving downloaded copies across re-sync, and replaces three near-identical delete+recreate helpers that used to live separately in movie_ingestion.py, series_ingestion.py, and person_ingestion.py. Co-Authored-By: Claude Sonnet 5 <[email protected]>
178 lines
5.6 KiB
Python
178 lines
5.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 Traefik.
|
|
"""
|
|
|
|
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,
|
|
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=["*"],
|
|
)
|
|
|
|
# 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(admin.router, prefix=API_V1_PREFIX)
|