Initial Aurora Core 0.1 implementation
CI / lint (push) Canceled after 0s
CI / typecheck (push) Canceled after 0s
CI / unit-tests (push) Canceled after 0s
CI / integration-tests (push) Canceled after 0s
CI / container-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s

Production-grade media metadata backend per docs/aurora-core-spec.md:
Aurora-owned UUIDv7 identity system, normalized movie/TV/people/credits
schema, provider abstraction with a TMDb adapter, an idempotent async
ingestion pipeline (normalize -> match -> upsert -> provenance), a
Postgres FTS/trigram search layer, a stateless FastAPI v1 API with
Redis caching/rate-limiting, arq workers, dev/prod Docker Compose
topologies, unit + integration + load tests, CI, and architecture/
database/API/ERD documentation.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
NixReaper
2026-09-04 17:34:03 -05:00
co-authored by Claude Sonnet 5
commit c20df1c503
111 changed files with 10041 additions and 0 deletions
View File
+37
View File
@@ -0,0 +1,37 @@
"""Shared FastAPI dependencies and helpers for the v1 API."""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from fastapi import HTTPException, status
from pydantic import BaseModel
from redis.asyncio import Redis
from app.services.cache import cache_get_json, cache_set_json
def not_found(entity_type: str, aurora_id_str: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"code": f"{entity_type}_not_found", "message": f"No such {entity_type}: {aurora_id_str}"},
)
async def cached_response[T: BaseModel](
redis: Redis,
key: str,
model: type[T],
loader: Callable[[], Awaitable[T]],
ttl: int | None = None,
) -> T:
"""Serve `model` from Redis if present, otherwise call `loader`, cache
the result, and return it. Fails open to `loader` on any cache error."""
cached = await cache_get_json(redis, key)
if cached is not None:
return model.model_validate(cached)
result = await loader()
await cache_set_json(redis, key, result.model_dump(mode="json"), ttl=ttl)
return result
View File
+93
View File
@@ -0,0 +1,93 @@
"""Administrative/provider endpoints (section 17): trigger ingestion and
check job status. Separately protected by an admin API key — never mounted
on the public read path.
"""
from __future__ import annotations
import uuid
from arq import ArqRedis
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import not_found
from app.core.database import get_db
from app.core.queue import get_queue
from app.core.security import require_admin_api_key
from app.repositories.provider_repository import get_ingestion_job
router = APIRouter(
prefix="/admin",
tags=["admin"],
dependencies=[Depends(require_admin_api_key)],
)
class IngestMovieRequest(BaseModel):
tmdb_id: str
class IngestPersonRequest(BaseModel):
tmdb_id: str
class IngestionEnqueuedOut(BaseModel):
job_id: str
arq_job_id: str
status: str = "enqueued"
class IngestionJobOut(BaseModel):
id: str
job_type: str
entity_type: str | None
external_id: str | None
status: str
attempts: int
error: str | None
result_aurora_id: str | None
@router.post("/ingest/movie", response_model=IngestionEnqueuedOut)
async def enqueue_movie_ingestion(
body: IngestMovieRequest, queue: ArqRedis = Depends(get_queue)
) -> IngestionEnqueuedOut:
job = await queue.enqueue_job("ingest_movie_task", body.tmdb_id)
if job is None:
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE, "Could not enqueue ingestion job (queue busy)."
)
return IngestionEnqueuedOut(job_id=job.job_id, arq_job_id=job.job_id)
@router.post("/ingest/person", response_model=IngestionEnqueuedOut)
async def enqueue_person_ingestion(
body: IngestPersonRequest, queue: ArqRedis = Depends(get_queue)
) -> IngestionEnqueuedOut:
job = await queue.enqueue_job("ingest_person_task", body.tmdb_id)
if job is None:
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE, "Could not enqueue ingestion job (queue busy)."
)
return IngestionEnqueuedOut(job_id=job.job_id, arq_job_id=job.job_id)
@router.get("/ingest/jobs/{job_id}", response_model=IngestionJobOut)
async def get_ingestion_job_status(
job_id: uuid.UUID, session: AsyncSession = Depends(get_db)
) -> IngestionJobOut:
job = await get_ingestion_job(session, job_id)
if job is None:
raise not_found("ingestion_job", str(job_id))
return IngestionJobOut(
id=str(job.id),
job_type=job.job_type,
entity_type=job.entity_type,
external_id=job.external_id,
status=job.status.value,
attempts=job.attempts,
error=job.error,
result_aurora_id=str(job.result_aurora_id) if job.result_aurora_id else None,
)
+26
View File
@@ -0,0 +1,26 @@
"""Collection/franchise endpoints (section 17)."""
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import not_found
from app.core.database import get_db
from app.core.security import enforce_rate_limit
from app.schemas.collection import CollectionDetailOut
from app.services import collection_service
router = APIRouter(
prefix="/collections", tags=["collections"], dependencies=[Depends(enforce_rate_limit)]
)
@router.get("/{aurora_id}", response_model=CollectionDetailOut)
async def get_collection(
aurora_id: str, session: AsyncSession = Depends(get_db)
) -> CollectionDetailOut:
try:
return await collection_service.get_collection_detail(session, aurora_id)
except collection_service.CollectionNotFoundError as exc:
raise not_found("collection", aurora_id) from exc
+26
View File
@@ -0,0 +1,26 @@
"""Company endpoints (section 17)."""
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import not_found
from app.core.database import get_db
from app.core.security import enforce_rate_limit
from app.schemas.company import CompanyDetailOut
from app.services import company_service
router = APIRouter(
prefix="/companies", tags=["companies"], dependencies=[Depends(enforce_rate_limit)]
)
@router.get("/{aurora_id}", response_model=CompanyDetailOut)
async def get_company(
aurora_id: str, session: AsyncSession = Depends(get_db)
) -> CompanyDetailOut:
try:
return await company_service.get_company_detail(session, aurora_id)
except company_service.CompanyNotFoundError as exc:
raise not_found("company", aurora_id) from exc
+26
View File
@@ -0,0 +1,26 @@
"""Episode endpoints (section 17)."""
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import not_found
from app.core.database import get_db
from app.core.security import enforce_rate_limit
from app.schemas.tv import EpisodeDetailOut
from app.services import tv_service
router = APIRouter(
prefix="/episodes", tags=["episodes"], dependencies=[Depends(enforce_rate_limit)]
)
@router.get("/{aurora_id}", response_model=EpisodeDetailOut)
async def get_episode(
aurora_id: str, session: AsyncSession = Depends(get_db)
) -> EpisodeDetailOut:
try:
return await tv_service.get_episode_detail(session, aurora_id)
except tv_service.EpisodeNotFoundError as exc:
raise not_found("episode", aurora_id) from exc
+36
View File
@@ -0,0 +1,36 @@
"""Liveness and readiness endpoints (section 24)."""
from __future__ import annotations
from fastapi import APIRouter
from app.core.config import get_settings
from app.core.database import database_is_ready
from app.core.redis import redis_is_ready
from app.schemas.health import DependencyStatus, HealthOut, ReadyOut
router = APIRouter(tags=["health"])
@router.get("/health", response_model=HealthOut)
async def health() -> HealthOut:
"""Liveness probe: process is up and able to respond. No dependency
checks — that's /ready."""
return HealthOut(status="ok")
@router.get("/ready", response_model=ReadyOut)
async def ready() -> ReadyOut:
settings = get_settings()
db_ok = await database_is_ready()
redis_ok = await redis_is_ready()
dependencies = [
DependencyStatus(name="postgresql", ok=db_ok, required=True),
DependencyStatus(name="redis", ok=redis_ok, required=settings.REDIS_REQUIRED),
]
is_ready = db_ok and (redis_ok or not settings.REDIS_REQUIRED)
return ReadyOut(status="ready" if is_ready else "not_ready", dependencies=dependencies)
+64
View File
@@ -0,0 +1,64 @@
"""Movie endpoints (section 17)."""
from __future__ import annotations
from fastapi import APIRouter, Depends
from redis.asyncio import Redis
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import cached_response, not_found
from app.core.database import get_db
from app.core.redis import get_redis
from app.core.security import enforce_rate_limit
from app.schemas.movie import MovieCreditsOut, MovieDetailOut, MovieImagesOut, MovieVideosOut
from app.services import movie_service
from app.services.cache import build_key
router = APIRouter(prefix="/movies", tags=["movies"], dependencies=[Depends(enforce_rate_limit)])
@router.get("/{aurora_id}", response_model=MovieDetailOut)
async def get_movie(
aurora_id: str,
session: AsyncSession = Depends(get_db),
redis: Redis = Depends(get_redis),
) -> MovieDetailOut:
try:
return await cached_response(
redis,
build_key("movie", aurora_id),
MovieDetailOut,
lambda: movie_service.get_movie_detail(session, aurora_id),
)
except movie_service.MovieNotFoundError as exc:
raise not_found("movie", aurora_id) from exc
@router.get("/{aurora_id}/credits", response_model=MovieCreditsOut)
async def get_movie_credits(
aurora_id: str, session: AsyncSession = Depends(get_db)
) -> MovieCreditsOut:
try:
return await movie_service.get_movie_credits(session, aurora_id)
except movie_service.MovieNotFoundError as exc:
raise not_found("movie", aurora_id) from exc
@router.get("/{aurora_id}/images", response_model=MovieImagesOut)
async def get_movie_images(
aurora_id: str, session: AsyncSession = Depends(get_db)
) -> MovieImagesOut:
try:
return await movie_service.get_movie_images(session, aurora_id)
except movie_service.MovieNotFoundError as exc:
raise not_found("movie", aurora_id) from exc
@router.get("/{aurora_id}/videos", response_model=MovieVideosOut)
async def get_movie_videos(
aurora_id: str, session: AsyncSession = Depends(get_db)
) -> MovieVideosOut:
try:
return await movie_service.get_movie_videos(session, aurora_id)
except movie_service.MovieNotFoundError as exc:
raise not_found("movie", aurora_id) from exc
+44
View File
@@ -0,0 +1,44 @@
"""Person endpoints (section 17)."""
from __future__ import annotations
from fastapi import APIRouter, Depends
from redis.asyncio import Redis
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import cached_response, not_found
from app.core.database import get_db
from app.core.redis import get_redis
from app.core.security import enforce_rate_limit
from app.schemas.person import PersonCreditsOut, PersonDetailOut
from app.services import person_service
from app.services.cache import build_key
router = APIRouter(prefix="/people", tags=["people"], dependencies=[Depends(enforce_rate_limit)])
@router.get("/{aurora_id}", response_model=PersonDetailOut)
async def get_person(
aurora_id: str,
session: AsyncSession = Depends(get_db),
redis: Redis = Depends(get_redis),
) -> PersonDetailOut:
try:
return await cached_response(
redis,
build_key("person", aurora_id),
PersonDetailOut,
lambda: person_service.get_person_detail(session, aurora_id),
)
except person_service.PersonNotFoundError as exc:
raise not_found("person", aurora_id) from exc
@router.get("/{aurora_id}/credits", response_model=PersonCreditsOut)
async def get_person_credits(
aurora_id: str, session: AsyncSession = Depends(get_db)
) -> PersonCreditsOut:
try:
return await person_service.get_person_credits(session, aurora_id)
except person_service.PersonNotFoundError as exc:
raise not_found("person", aurora_id) from exc
+25
View File
@@ -0,0 +1,25 @@
"""Search endpoint (section 16, section 17)."""
from __future__ import annotations
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import enforce_rate_limit
from app.schemas.search import SearchResultsOut
from app.services.search.postgresql_provider import PostgreSQLSearchProvider
from app.services.search.service import SearchService
router = APIRouter(prefix="/search", tags=["search"], dependencies=[Depends(enforce_rate_limit)])
_search_service = SearchService(PostgreSQLSearchProvider())
@router.get("", response_model=SearchResultsOut)
async def search(
q: str = Query(..., min_length=1, max_length=300),
limit: int = Query(10, ge=1, le=50),
session: AsyncSession = Depends(get_db),
) -> SearchResultsOut:
return await _search_service.search(session, q, limit_per_type=limit)
+22
View File
@@ -0,0 +1,22 @@
"""Season endpoints (section 17)."""
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import not_found
from app.core.database import get_db
from app.core.security import enforce_rate_limit
from app.schemas.tv import SeasonDetailOut
from app.services import tv_service
router = APIRouter(prefix="/seasons", tags=["seasons"], dependencies=[Depends(enforce_rate_limit)])
@router.get("/{aurora_id}", response_model=SeasonDetailOut)
async def get_season(aurora_id: str, session: AsyncSession = Depends(get_db)) -> SeasonDetailOut:
try:
return await tv_service.get_season_detail(session, aurora_id)
except tv_service.SeasonNotFoundError as exc:
raise not_found("season", aurora_id) from exc
+44
View File
@@ -0,0 +1,44 @@
"""Series endpoints (section 17)."""
from __future__ import annotations
from fastapi import APIRouter, Depends
from redis.asyncio import Redis
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import cached_response, not_found
from app.core.database import get_db
from app.core.redis import get_redis
from app.core.security import enforce_rate_limit
from app.schemas.tv import SeriesDetailOut, SeriesSeasonsOut
from app.services import tv_service
from app.services.cache import build_key
router = APIRouter(prefix="/series", tags=["series"], dependencies=[Depends(enforce_rate_limit)])
@router.get("/{aurora_id}", response_model=SeriesDetailOut)
async def get_series(
aurora_id: str,
session: AsyncSession = Depends(get_db),
redis: Redis = Depends(get_redis),
) -> SeriesDetailOut:
try:
return await cached_response(
redis,
build_key("series", aurora_id),
SeriesDetailOut,
lambda: tv_service.get_series_detail(session, aurora_id),
)
except tv_service.SeriesNotFoundError as exc:
raise not_found("series", aurora_id) from exc
@router.get("/{aurora_id}/seasons", response_model=SeriesSeasonsOut)
async def get_series_seasons(
aurora_id: str, session: AsyncSession = Depends(get_db)
) -> SeriesSeasonsOut:
try:
return await tv_service.get_series_seasons(session, aurora_id)
except tv_service.SeriesNotFoundError as exc:
raise not_found("series", aurora_id) from exc