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,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
|
||||
Reference in New Issue
Block a user