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
+44
View File
@@ -0,0 +1,44 @@
# Copy to .env and fill in real values for local development.
# NEVER commit a real .env file.
# --- Runtime ---
AURORA_ENV=development
AURORA_LOG_LEVEL=INFO
AURORA_API_DOCS_ENABLED=true
# --- Database ---
# Async URL used by the application (asyncpg driver).
DATABASE_URL=postgresql+asyncpg://aurora:aurora@localhost:5432/aurora_core
# Sync URL used by Alembic migrations (psycopg2 driver).
DATABASE_URL_SYNC=postgresql+psycopg2://aurora:aurora@localhost:5432/aurora_core
DATABASE_POOL_SIZE=10
DATABASE_MAX_OVERFLOW=20
# --- Redis ---
REDIS_URL=redis://localhost:6379/0
# If false, the app runs without caching/rate-limiting when Redis is unreachable
# instead of failing readiness checks.
REDIS_REQUIRED=false
# --- Security ---
# Generate with: python -c "import secrets; print(secrets.token_urlsafe(48))"
AURORA_SECRET_KEY=changeme-generate-a-real-secret
# Comma-separated API keys allowed to call /api/v1/admin/* endpoints.
AURORA_ADMIN_API_KEYS=changeme-admin-key
# --- CORS ---
CORS_ORIGINS=http://localhost:3000,http://localhost:5173
# --- Providers ---
# https://www.themoviedb.org/settings/api
TMDB_API_KEY=
# --- Workers ---
WORKER_CONCURRENCY=4
# --- Caching ---
CACHE_DEFAULT_TTL=300
# --- Rate limiting ---
RATE_LIMIT_ENABLED=true
RATE_LIMIT_PER_MINUTE=120
+140
View File
@@ -0,0 +1,140 @@
name: CI
on:
push:
branches: [main]
pull_request:
env:
PYTHON_VERSION: "3.12"
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: pip
- run: pip install -e ".[dev]"
- run: ruff check app migrations tests
typecheck:
runs-on: ubuntu-latest
needs: lint
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: pip
- run: pip install -e ".[dev]"
- run: mypy app
unit-tests:
runs-on: ubuntu-latest
needs: lint
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: pip
- run: pip install -e ".[dev]"
- run: pytest tests/unit -v
# Also proves migrations apply cleanly to an empty database (section 27):
# this job's Postgres service starts empty, and the first step to touch
# it is `alembic upgrade head`.
integration-tests:
runs-on: ubuntu-latest
needs: unit-tests
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_USER: aurora
POSTGRES_PASSWORD: aurora
POSTGRES_DB: aurora_core_test
ports: ["5432:5432"]
options: >-
--health-cmd "pg_isready -U aurora"
--health-interval 5s
--health-timeout 5s
--health-retries 10
redis:
image: redis:7-alpine
ports: ["6379:6379"]
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
DATABASE_URL: postgresql+asyncpg://aurora:aurora@localhost:5432/aurora_core_test
DATABASE_URL_SYNC: postgresql+psycopg2://aurora:aurora@localhost:5432/aurora_core_test
TEST_DATABASE_URL: postgresql+asyncpg://aurora:aurora@localhost:5432/aurora_core_test
REDIS_URL: redis://localhost:6379/0
TEST_REDIS_URL: redis://localhost:6379/15
AURORA_SECRET_KEY: ci-secret
AURORA_ADMIN_API_KEYS: ci-admin-key
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: pip
- run: pip install -e ".[dev]"
- name: Run migrations against an empty database
run: alembic upgrade head
- name: Start the API and smoke-test it
run: |
uvicorn app.main:app --host 0.0.0.0 --port 8000 &
for i in $(seq 1 20); do
curl -fsS http://localhost:8000/health && break
sleep 1
done
curl -fsS http://localhost:8000/ready
curl -fsS "http://localhost:8000/api/v1/search?q=test"
kill %1
- name: Run integration tests
run: pytest tests/integration -v
container-build:
runs-on: ubuntu-latest
needs: [typecheck, integration-tests]
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Build API image
uses: docker/build-push-action@v6
with:
context: .
file: docker/api.Dockerfile
push: false
tags: aurora-core-api:ci
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Build worker image
uses: docker/build-push-action@v6
with:
context: .
file: docker/worker.Dockerfile
push: false
tags: aurora-core-worker:ci
cache-from: type=gha
cache-to: type=gha,mode=max
dependency-audit:
runs-on: ubuntu-latest
needs: lint
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: pip
- run: pip install -e "."
- run: pip install pip-audit
- run: pip-audit --strict
+23
View File
@@ -0,0 +1,23 @@
__pycache__/
*.py[cod]
*.egg-info/
.eggs/
.venv/
venv/
.env
.env.*
!.env.example
.pytest_cache/
.mypy_cache/
.ruff_cache/
htmlcov/
.coverage
.coverage.*
dist/
build/
*.log
.DS_Store
Thumbs.db
.idea/
.vscode/
media_cache/
+183
View File
@@ -0,0 +1,183 @@
# Aurora Core
Aurora Core is Aurora Dragon Studio's production-grade media metadata,
discovery, and intelligence backend — a TMDb/IMDb-style canonical data layer
for movies, TV, people, and everything that connects them. It is **not** a
Plex application: Plex, Overseerr, Radarr, Sonarr, and future Aurora
software are consumers built around Core, never dependencies of it.
See [`docs/aurora-core-spec.md`](docs/aurora-core-spec.md) for the full
build specification this repository implements, and
[`docs/architecture.md`](docs/architecture.md) for how it's put together.
## Architecture at a glance
A modular monolith (not microservices): a stateless FastAPI API behind
Traefik, PostgreSQL as the sole source of truth, Redis for disposable
caching/rate-limiting/queueing, and arq workers for async metadata
ingestion. Full diagram and reasoning in
[`docs/architecture.md`](docs/architecture.md).
- **API**: FastAPI + Pydantic v2, stateless, horizontally scalable
- **Database**: PostgreSQL via SQLAlchemy 2.0 (async) + Alembic migrations
- **Aurora IDs**: UUIDv7 primary keys, never a provider's ID — see
[`docs/database.md`](docs/database.md)
- **Cache/queue**: Redis + arq
- **Search**: PostgreSQL full-text + trigram, behind a swappable
`SearchProvider` abstraction
- **Providers**: TMDb today (`app/providers/tmdb.py`), pluggable
## Prerequisites
- Docker + Docker Compose (recommended path)
- *or*, for running outside Docker: Python 3.12, a local PostgreSQL 17 with
`pg_trgm` available, and Redis 7
## Local setup (Docker — recommended)
```bash
cp .env.example .env
# edit .env: set AURORA_SECRET_KEY, AURORA_ADMIN_API_KEYS, and (optionally)
# TMDB_API_KEY — see https://www.themoviedb.org/settings/api
docker compose up --build
```
This starts PostgreSQL, Redis, runs `alembic upgrade head` (the `migrate`
service), then the API (`http://localhost:8000`) and a worker. API docs:
`http://localhost:8000/docs`.
## Local setup (without Docker)
```bash
python -m venv .venv
.venv/Scripts/activate # Windows; use `source .venv/bin/activate` on macOS/Linux
pip install -e ".[dev]"
cp .env.example .env # point DATABASE_URL / DATABASE_URL_SYNC / REDIS_URL at your own instances
alembic upgrade head
uvicorn app.main:app --reload
```
Run a worker in a second terminal:
```bash
arq app.workers.worker.WorkerSettings
```
## Environment configuration
All configuration is environment-based (`app/core/config.py`); see
[`.env.example`](.env.example) for every variable and its meaning. Never
commit a real `.env` — only `.env.example` (placeholders) belongs in Git.
## Database migrations
```bash
alembic upgrade head # apply all migrations
alembic revision --autogenerate -m "add xyz" # generate the next one after changing models
alembic downgrade -1 # roll back one revision
```
The application never creates tables at startup — see
[`docs/database.md`](docs/database.md#migrations).
## Ingesting data
Ingestion always runs asynchronously through a worker, never inline in a
request (see [`docs/architecture.md`](docs/architecture.md)). With
`TMDB_API_KEY` set and a worker running:
```bash
curl -X POST http://localhost:8000/api/v1/admin/ingest/movie \
-H "X-API-Key: $AURORA_ADMIN_API_KEYS" \
-H "Content-Type: application/json" \
-d '{"tmdb_id": "603"}'
# -> {"job_id": "...", "arq_job_id": "...", "status": "enqueued"}
curl http://localhost:8000/api/v1/admin/ingest/jobs/<job_id> \
-H "X-API-Key: $AURORA_ADMIN_API_KEYS"
# -> poll until "status": "succeeded"
curl "http://localhost:8000/api/v1/search?q=the%20matrix"
```
## Tests
```bash
pytest tests/unit -v # no external dependencies
# Integration/API tests need a real Postgres + Redis:
docker compose up -d postgres redis
createdb -h localhost -U aurora aurora_core_test # or: docker exec -it aurora-core-postgres-1 createdb -U aurora aurora_core_test
export TEST_DATABASE_URL=postgresql+asyncpg://aurora:aurora@localhost:5432/aurora_core_test
export TEST_REDIS_URL=redis://localhost:6379/15
pytest tests/integration -v
```
Lint and type-check:
```bash
ruff check app migrations tests
mypy app
```
Load testing (targets: 500 concurrent users, search p95 < 500ms, other
endpoints p95 < 250ms, 5xx rate < 0.1% — see
[`docs/aurora-core-spec.md`](docs/aurora-core-spec.md) section 2):
```bash
pip install -e ".[load]"
locust -f tests/load/locustfile.py --host http://localhost:8000
# open http://localhost:8089, seed some data first (see tests/load/locustfile.py)
```
## API documentation
- Interactive (OpenAPI/Swagger): `/docs`, controlled by
`AURORA_API_DOCS_ENABLED` (set `false` in production if you don't want it
publicly exposed)
- Reference: [`docs/api.md`](docs/api.md)
- Schema/ERD: [`docs/database.md`](docs/database.md),
[`docs/erd.md`](docs/erd.md)
## Worker operation
Workers (arq, `app/workers/worker.py`) scale independently of the API —
`docker-compose.prod.yml` runs 2 worker replicas and 3 API replicas as an
example. Concurrency per worker process is controlled by
`WORKER_CONCURRENCY`. Jobs are idempotent (keyed by
`tmdb:<entity_type>:<external_id>`), retryable (`max_tries` in
`app/workers/worker.py`), and observable via
`GET /api/v1/admin/ingest/jobs/{job_id}`.
## Production deployment
```bash
docker compose -f docker-compose.prod.yml up --build -d
```
Traefik-ready (labels on the `api` service), PgBouncer in front of
PostgreSQL (transaction pooling — `DATABASE_BEHIND_TRANSACTION_POOLER=true`
disables asyncpg's server-side prepared statements, which don't survive
transaction-mode pooling), horizontally-scaled API and worker replicas. See
[`docs/architecture.md`](docs/architecture.md).
## Known limitations
See [`docs/database.md#known-limitations`](docs/database.md#known-limitations)
and [`docs/api.md#known-limitations`](docs/api.md#known-limitations) for the
current, tracked list (no curated language/country seed data, no fuzzy
entity matching, no field-level provenance, no pagination beyond search,
person credits limited to movies). None of these block the Core 0.1
milestone (section 34): search → movie/person detail works end-to-end.
This repository was built in an environment without Docker or a
pre-existing PostgreSQL instance; the initial Alembic migration was
hand-written from the models (not autogenerated against a live database)
and validated with `alembic history`, but has not yet been executed against
a running PostgreSQL instance. **Run `docker compose up --build` and confirm
`alembic upgrade head` succeeds against a genuinely empty database before
treating the schema as final** — see
[`docs/database.md#migrations`](docs/database.md#migrations).
+41
View File
@@ -0,0 +1,41 @@
[alembic]
script_location = migrations
prepend_sys_path = .
version_path_separator = os
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
View File
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
View File
+103
View File
@@ -0,0 +1,103 @@
"""Environment-based application configuration.
All runtime configuration flows through this module. Nothing else in the
codebase should read `os.environ` directly, so that configuration stays
auditable and testable in one place.
"""
from __future__ import annotations
from functools import lru_cache
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
# --- Runtime ---
AURORA_ENV: str = Field(default="development")
AURORA_LOG_LEVEL: str = Field(default="INFO")
AURORA_API_DOCS_ENABLED: bool = Field(default=True)
# --- Database ---
DATABASE_URL: str = Field(
default="postgresql+asyncpg://aurora:aurora@localhost:5432/aurora_core"
)
DATABASE_URL_SYNC: str = Field(
default="postgresql+psycopg2://aurora:aurora@localhost:5432/aurora_core"
)
DATABASE_POOL_SIZE: int = Field(default=10)
DATABASE_MAX_OVERFLOW: int = Field(default=20)
# Set true when DATABASE_URL points at PgBouncer in transaction pooling
# mode (docker-compose.prod.yml): asyncpg's server-side prepared
# statements don't survive connections being handed to different clients
# between transactions, so they must be disabled.
DATABASE_BEHIND_TRANSACTION_POOLER: bool = Field(default=False)
# --- Redis ---
REDIS_URL: str = Field(default="redis://localhost:6379/0")
REDIS_REQUIRED: bool = Field(default=False)
# --- Security ---
AURORA_SECRET_KEY: str = Field(default="changeme-generate-a-real-secret")
AURORA_ADMIN_API_KEYS: str = Field(default="")
# --- CORS ---
CORS_ORIGINS: str = Field(default="http://localhost:3000")
# --- Providers ---
TMDB_API_KEY: str = Field(default="")
# --- Workers ---
WORKER_CONCURRENCY: int = Field(default=4)
# --- Caching ---
CACHE_DEFAULT_TTL: int = Field(default=300)
# --- Rate limiting ---
RATE_LIMIT_ENABLED: bool = Field(default=True)
RATE_LIMIT_PER_MINUTE: int = Field(default=120)
@field_validator("AURORA_ENV")
@classmethod
def _validate_env(cls, v: str) -> str:
allowed = {"development", "test", "staging", "production"}
if v not in allowed:
raise ValueError(f"AURORA_ENV must be one of {allowed}, got {v!r}")
return v
@property
def cors_origins_list(self) -> list[str]:
return [origin.strip() for origin in self.CORS_ORIGINS.split(",") if origin.strip()]
@property
def admin_api_keys_set(self) -> set[str]:
return {key.strip() for key in self.AURORA_ADMIN_API_KEYS.split(",") if key.strip()}
@property
def is_production(self) -> bool:
return self.AURORA_ENV == "production"
@property
def docs_url(self) -> str | None:
if not self.AURORA_API_DOCS_ENABLED:
return None
return "/docs"
@property
def openapi_url(self) -> str | None:
if not self.AURORA_API_DOCS_ENABLED:
return None
return "/openapi.json"
@lru_cache
def get_settings() -> Settings:
return Settings()
+88
View File
@@ -0,0 +1,88 @@
"""Async SQLAlchemy engine/session management.
The API is stateless: every request gets its own AsyncSession from a shared
connection pool, and nothing is held across requests. This module never
creates tables — schema changes always go through Alembic (see
`migrations/`).
"""
from __future__ import annotations
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from app.core.config import get_settings
settings = get_settings()
_connect_args: dict = {}
if settings.DATABASE_BEHIND_TRANSACTION_POOLER:
_connect_args["statement_cache_size"] = 0
_engine: AsyncEngine = create_async_engine(
settings.DATABASE_URL,
pool_size=settings.DATABASE_POOL_SIZE,
max_overflow=settings.DATABASE_MAX_OVERFLOW,
pool_pre_ping=True,
pool_recycle=1800,
connect_args=_connect_args,
echo=False,
)
async_session_factory = async_sessionmaker(
bind=_engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False,
)
def get_engine() -> AsyncEngine:
return _engine
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""FastAPI dependency yielding a request-scoped session."""
async with async_session_factory() as session:
yield session
@asynccontextmanager
async def session_scope() -> AsyncGenerator[AsyncSession, None]:
"""Context manager for use outside request handlers (workers, scripts).
Commits on success, rolls back on exception.
"""
async with async_session_factory() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
async def database_is_ready() -> bool:
"""Cheap connectivity check used by the /ready endpoint."""
from sqlalchemy import text
try:
async with _engine.connect() as conn:
await conn.execute(text("SELECT 1"))
return True
except Exception:
return False
async def dispose_engine() -> None:
await _engine.dispose()
+135
View File
@@ -0,0 +1,135 @@
"""Aurora's own identity system.
Aurora Core owns its entity identities. Provider identifiers (TMDb, IMDb,
TVDb, ...) are stored as external references, never as primary keys — see
`app.models.external_id`.
Every Aurora entity has:
- a UUIDv7 database primary key (time-ordered, efficient as a btree index,
generated with :func:`new_uuid7`)
- a public "Aurora ID" string derived from that same UUID, e.g.
``aur_movie_1jz8f2n3k4m5p6q7r8s9t0v1w2``, produced by :func:`aurora_id`
Because the public ID is a direct encoding of the UUIDv7 primary key, the two
never drift apart, and Aurora IDs sort lexicographically in creation order —
useful for cursors and debugging without leaking a sequential integer.
"""
from __future__ import annotations
import re
from uuid6 import uuid7
# Crockford base32: excludes I, L, O, U to avoid visual ambiguity in IDs.
_CROCKFORD_ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz"
_CROCKFORD_INDEX = {ch: i for i, ch in enumerate(_CROCKFORD_ALPHABET)}
# Aurora entity type -> ID prefix. Keep stable once shipped; these are
# effectively part of the public API contract.
ENTITY_PREFIXES: dict[str, str] = {
"movie": "movie",
"series": "series",
"season": "season",
"episode": "episode",
"person": "person",
"credit": "credit",
"company": "company",
"collection": "collection",
"genre": "genre",
"keyword": "keyword",
"artwork": "art",
"video": "vid",
"external_id": "ext",
"provider": "provider",
"provider_record": "prec",
"ingestion_job": "job",
"certification": "cert",
"language": "lang",
"country": "country",
"alternate_title": "altt",
"release": "rel",
}
def new_uuid7() -> str:
"""Return a fresh UUIDv7 as its canonical hyphenated string form."""
return str(uuid7())
def _encode_uuid(uuid_str: str) -> str:
"""Encode a UUID's 128 bits as lowercase Crockford base32 (26 chars).
Byte-order-preserving: since the UUID is time-ordered (v7) and this
encoding processes bytes most-significant-first, the resulting strings
sort lexicographically the same way the UUIDs (and thus creation time)
do.
"""
raw = bytes.fromhex(uuid_str.replace("-", ""))
value = int.from_bytes(raw, "big")
chars: list[str] = []
# 128 bits / 5 bits-per-symbol = 25.6 -> 26 symbols, top symbol uses 3 bits.
for shift in range(125, -5, -5):
chars.append(_CROCKFORD_ALPHABET[(value >> shift) & 0b11111])
return "".join(chars)
def _decode_to_uuid(encoded: str) -> str:
if len(encoded) != 26:
raise ValueError(f"invalid Aurora ID segment length: {encoded!r}")
value = 0
for ch in encoded:
try:
value = (value << 5) | _CROCKFORD_INDEX[ch]
except KeyError as exc:
raise ValueError(f"invalid Aurora ID character: {ch!r}") from exc
try:
raw = value.to_bytes(16, "big")
except OverflowError as exc:
raise ValueError(f"invalid Aurora ID segment: {encoded!r}") from exc
hex_str = raw.hex()
return f"{hex_str[0:8]}-{hex_str[8:12]}-{hex_str[12:16]}-{hex_str[16:20]}-{hex_str[20:32]}"
def aurora_id(entity_type: str, uuid_str: str) -> str:
"""Build a public Aurora ID string from an entity type and UUIDv7 PK."""
prefix = ENTITY_PREFIXES.get(entity_type)
if prefix is None:
raise ValueError(f"unknown Aurora entity type: {entity_type!r}")
return f"aur_{prefix}_{_encode_uuid(uuid_str)}"
_AURORA_ID_RE = re.compile(r"^aur_(?P<prefix>[a-z_]+)_(?P<encoded>[0-9a-hjkmnp-tv-z]{26})$")
_PREFIX_TO_ENTITY: dict[str, str] = {v: k for k, v in ENTITY_PREFIXES.items()}
def parse_aurora_id(aid: str, expected_type: str | None = None) -> str:
"""Validate an Aurora ID and return the underlying UUID string.
Raises ValueError if the ID is malformed or (when `expected_type` is
given) does not belong to that entity type.
"""
match = _AURORA_ID_RE.match(aid)
if not match:
raise ValueError(f"malformed Aurora ID: {aid!r}")
prefix = match.group("prefix")
entity_type = _PREFIX_TO_ENTITY.get(prefix)
if entity_type is None:
raise ValueError(f"unknown Aurora ID prefix: {prefix!r}")
if expected_type is not None and entity_type != expected_type:
raise ValueError(f"expected Aurora ID of type {expected_type!r}, got {entity_type!r}")
return _decode_to_uuid(match.group("encoded"))
def is_valid_aurora_id(aid: str, expected_type: str | None = None) -> bool:
try:
parse_aurora_id(aid, expected_type=expected_type)
except ValueError:
return False
return True
+62
View File
@@ -0,0 +1,62 @@
"""Structured, JSON-compatible logging with per-request correlation IDs.
Every request gets a request ID (from the `X-Request-ID` header if present,
otherwise generated) bound into the log context for the lifetime of that
request, via `RequestIDMiddleware` in `app.main`.
"""
from __future__ import annotations
import logging
import sys
from contextvars import ContextVar
import structlog
from app.core.config import get_settings
request_id_ctx: ContextVar[str | None] = ContextVar("request_id", default=None)
def _add_request_id(_logger: object, _method_name: str, event_dict: dict) -> dict:
request_id = request_id_ctx.get()
if request_id is not None:
event_dict["request_id"] = request_id
return event_dict
def configure_logging() -> None:
settings = get_settings()
level = getattr(logging, settings.AURORA_LOG_LEVEL.upper(), logging.INFO)
logging.basicConfig(
format="%(message)s",
stream=sys.stdout,
level=level,
)
shared_processors: list = [
structlog.contextvars.merge_contextvars,
_add_request_id,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.StackInfoRenderer(),
]
renderer: structlog.types.Processor
if settings.is_production or settings.AURORA_ENV != "development":
renderer = structlog.processors.JSONRenderer()
else:
renderer = structlog.dev.ConsoleRenderer()
structlog.configure(
processors=[*shared_processors, structlog.processors.format_exc_info, renderer],
wrapper_class=structlog.make_filtering_bound_logger(level),
context_class=dict,
logger_factory=structlog.PrintLoggerFactory(),
cache_logger_on_first_use=True,
)
def get_logger(name: str | None = None) -> structlog.stdlib.BoundLogger:
return structlog.get_logger(name)
+35
View File
@@ -0,0 +1,35 @@
"""arq job queue connection (Redis-backed), shared by the API (enqueuing)
and the worker process (consuming) — see app/workers/worker.py."""
from __future__ import annotations
from collections.abc import AsyncGenerator
from arq import ArqRedis, create_pool
from arq.connections import RedisSettings
from app.core.config import get_settings
_pool: ArqRedis | None = None
def redis_settings() -> RedisSettings:
return RedisSettings.from_dsn(get_settings().REDIS_URL)
async def get_arq_pool() -> ArqRedis:
global _pool
if _pool is None:
_pool = await create_pool(redis_settings())
return _pool
async def get_queue() -> AsyncGenerator[ArqRedis, None]:
yield await get_arq_pool()
async def close_arq_pool() -> None:
global _pool
if _pool is not None:
await _pool.aclose() # type: ignore[attr-defined] # present at runtime; stubs lag redis-py 5.x
_pool = None
+50
View File
@@ -0,0 +1,50 @@
"""Redis connection management.
Redis is disposable acceleration/coordination — caching, rate limiting,
distributed locks, and the arq job queue. It is never the system of record.
If `REDIS_REQUIRED` is false, the application degrades gracefully (caching
becomes a no-op, readiness does not depend on Redis) instead of failing when
Redis is unavailable.
"""
from __future__ import annotations
from collections.abc import AsyncGenerator
from functools import lru_cache
import redis.asyncio as redis_asyncio
from redis.asyncio import Redis
from app.core.config import get_settings
settings = get_settings()
@lru_cache
def get_redis_pool() -> Redis:
return redis_asyncio.from_url(
settings.REDIS_URL,
encoding="utf-8",
decode_responses=True,
socket_connect_timeout=2,
socket_timeout=2,
)
async def get_redis() -> AsyncGenerator[Redis, None]:
"""FastAPI dependency yielding the shared Redis client."""
yield get_redis_pool()
async def redis_is_ready() -> bool:
try:
client = get_redis_pool()
return bool(await client.ping())
except Exception:
return False
async def close_redis() -> None:
client = get_redis_pool()
await client.aclose() # type: ignore[attr-defined] # present at runtime; stubs lag redis-py 5.x
+76
View File
@@ -0,0 +1,76 @@
"""Administrative auth and Redis-backed rate limiting.
Public read endpoints (`/api/v1/movies`, `/search`, ...) are unauthenticated
but rate-limited. Administrative/provider endpoints (ingestion triggers,
provider config) require an API key via the `X-API-Key` header.
"""
from __future__ import annotations
import time
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import APIKeyHeader
from redis.asyncio import Redis
from app.core.config import get_settings
from app.core.redis import get_redis
_api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
async def require_admin_api_key(
api_key: str | None = Depends(_api_key_header),
) -> str:
settings = get_settings()
allowed = settings.admin_api_keys_set
if not allowed:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Administrative API is not configured (AURORA_ADMIN_API_KEYS unset).",
)
if not api_key or api_key not in allowed:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing or invalid X-API-Key.",
)
return api_key
class RedisRateLimiter:
"""Fixed-window rate limiter backed by Redis `INCR` + `EXPIRE`.
Fails open: if Redis is unreachable, requests are allowed through rather
than rejected, since Redis is disposable infrastructure and must never
become a hard dependency for serving reads.
"""
def __init__(self, requests_per_minute: int) -> None:
self.requests_per_minute = requests_per_minute
async def check(self, redis: Redis, key: str) -> tuple[bool, int]:
window = int(time.time() // 60)
redis_key = f"ratelimit:{key}:{window}"
try:
count = await redis.incr(redis_key)
if count == 1:
await redis.expire(redis_key, 60)
remaining = max(0, self.requests_per_minute - count)
return count <= self.requests_per_minute, remaining
except Exception:
return True, self.requests_per_minute
async def enforce_rate_limit(request: Request, redis: Redis = Depends(get_redis)) -> None:
settings = get_settings()
if not settings.RATE_LIMIT_ENABLED:
return
limiter = RedisRateLimiter(settings.RATE_LIMIT_PER_MINUTE)
client_id = request.client.host if request.client else "unknown"
allowed, remaining = await limiter.check(redis, client_id)
if not allowed:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Rate limit exceeded.",
headers={"Retry-After": "60"},
)
+164
View File
@@ -0,0 +1,164 @@
"""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 fastapi import FastAPI, Request, Response
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
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=["*"],
)
@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)
+75
View File
@@ -0,0 +1,75 @@
"""SQLAlchemy models.
Import every model module here so `Base.metadata` is fully populated for
Alembic autogeneration and `Base.metadata.create_all()` (used only in
tests) regardless of which module happens to trigger the import first.
"""
from app.models.artwork import Artwork
from app.models.base import Base
from app.models.collection import Collection, CollectionMovie, CollectionRelation, CollectionSeries
from app.models.company import Company, MovieCompany, SeriesCompany
from app.models.credit import Credit
from app.models.external_id import ExternalID
from app.models.genre import Genre, MovieGenre, SeriesGenre
from app.models.keyword import Keyword, MovieKeyword, SeriesKeyword
from app.models.language_country import Certification, Country, Language
from app.models.movie import (
Movie,
MovieAlternateTitle,
MovieProductionCountry,
MovieRecommendation,
MovieSpokenLanguage,
ReleaseDate,
)
from app.models.person import Person
from app.models.provider import IngestionJob, Provider, ProviderRecord
from app.models.tv import (
Episode,
Season,
Series,
SeriesAlternateTitle,
SeriesProductionCountry,
SeriesSpokenLanguage,
)
from app.models.video import Video
__all__ = [
"Base",
"Artwork",
"Collection",
"CollectionMovie",
"CollectionSeries",
"CollectionRelation",
"Company",
"MovieCompany",
"SeriesCompany",
"Credit",
"ExternalID",
"Genre",
"MovieGenre",
"SeriesGenre",
"Keyword",
"MovieKeyword",
"SeriesKeyword",
"Language",
"Country",
"Certification",
"Movie",
"MovieAlternateTitle",
"MovieSpokenLanguage",
"MovieProductionCountry",
"ReleaseDate",
"MovieRecommendation",
"Person",
"Provider",
"ProviderRecord",
"IngestionJob",
"Series",
"SeriesAlternateTitle",
"SeriesSpokenLanguage",
"SeriesProductionCountry",
"Season",
"Episode",
"Video",
]
+52
View File
@@ -0,0 +1,52 @@
"""Artwork as a first-class entity (section 10) — never a `poster_url`
column on media tables, so provenance/licensing can be tracked per asset."""
from __future__ import annotations
import uuid
from sqlalchemy import Boolean, Float, ForeignKey, Index, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
from app.models.enums import ArtworkType
from app.models.provider import Provider
class Artwork(Base, UUIDPrimaryKeyMixin, TimestampMixin):
__tablename__ = "artwork"
__table_args__ = (
Index("ix_artwork_entity", "entity_type", "entity_id", "artwork_type"),
)
# Polymorphic target: "movie", "series", "season", "episode", "person",
# "company", "collection".
entity_type: Mapped[str] = mapped_column(String(30), nullable=False)
entity_id: Mapped[uuid.UUID] = mapped_column(nullable=False)
artwork_type: Mapped[ArtworkType] = mapped_column(nullable=False)
language_code: Mapped[str | None] = mapped_column(
ForeignKey("languages.code"), nullable=True
)
width: Mapped[int | None] = mapped_column(Integer, nullable=True)
height: Mapped[int | None] = mapped_column(Integer, nullable=True)
aspect_ratio: Mapped[float | None] = mapped_column(Float, nullable=True)
vote_average: Mapped[float | None] = mapped_column(Float, nullable=True)
is_primary: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
provider_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("providers.id", ondelete="CASCADE"), nullable=False
)
provider_asset_id: Mapped[str | None] = mapped_column(String(200), nullable=True)
original_url: Mapped[str] = mapped_column(String(1000), nullable=False)
local_path: Mapped[str | None] = mapped_column(String(1000), nullable=True)
checksum: Mapped[str | None] = mapped_column(String(128), nullable=True)
attribution: Mapped[str | None] = mapped_column(Text, nullable=True)
license: Mapped[str | None] = mapped_column(Text, nullable=True)
provider: Mapped[Provider] = relationship()
__all__ = ["Artwork"]
+80
View File
@@ -0,0 +1,80 @@
"""Declarative base and shared mixins for all Aurora Core models."""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Enum, MetaData, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from uuid6 import uuid7
from app.models.enums import (
ArtworkType,
CompanyType,
IngestionJobStatus,
MatchConfidence,
MovieStatus,
SeriesStatus,
VideoType,
)
NAMING_CONVENTION = {
"ix": "ix_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s",
}
def _enum_column(enum_cls: type) -> Enum:
"""Store our str-Enum members by `.value` (lowercase snake_case), not
`.name`, which is what SQLAlchemy's Enum type does by default. Without
this, the DB would store e.g. "RELEASED" while every part of the
codebase (schemas, ingestion, API filters) works with "released"."""
return Enum(
enum_cls,
values_callable=lambda obj: [e.value for e in obj],
name=f"{enum_cls.__name__.lower()}_enum",
)
class Base(DeclarativeBase):
"""Base class for all ORM models. Naming convention keeps Alembic's
autogenerated constraint names stable across databases."""
metadata = MetaData(naming_convention=NAMING_CONVENTION)
type_annotation_map = {
MovieStatus: _enum_column(MovieStatus),
SeriesStatus: _enum_column(SeriesStatus),
ArtworkType: _enum_column(ArtworkType),
VideoType: _enum_column(VideoType),
CompanyType: _enum_column(CompanyType),
IngestionJobStatus: _enum_column(IngestionJobStatus),
MatchConfidence: _enum_column(MatchConfidence),
}
class UUIDPrimaryKeyMixin:
"""UUIDv7 primary key. Time-ordered so it stays btree-friendly and can
double as a creation-order cursor, unlike a random UUIDv4."""
id: Mapped[uuid.UUID] = mapped_column(
primary_key=True,
default=lambda: uuid.UUID(str(uuid7())),
)
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
+59
View File
@@ -0,0 +1,59 @@
"""Collections/franchises as first-class entities (e.g. "Alien", "MCU")."""
from __future__ import annotations
import uuid
from sqlalchemy import ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
class Collection(Base, UUIDPrimaryKeyMixin, TimestampMixin):
__tablename__ = "collections"
name: Mapped[str] = mapped_column(String(300), nullable=False, index=True)
overview: Mapped[str | None] = mapped_column(Text, nullable=True)
slug: Mapped[str] = mapped_column(String(350), unique=True, nullable=False)
class CollectionMovie(Base):
__tablename__ = "collection_movies"
collection_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("collections.id", ondelete="CASCADE"), primary_key=True
)
movie_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("movies.id", ondelete="CASCADE"), primary_key=True
)
sort_order: Mapped[int | None] = mapped_column(Integer, nullable=True)
class CollectionSeries(Base):
__tablename__ = "collection_series"
collection_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("collections.id", ondelete="CASCADE"), primary_key=True
)
series_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("series.id", ondelete="CASCADE"), primary_key=True
)
sort_order: Mapped[int | None] = mapped_column(Integer, nullable=True)
class CollectionRelation(Base):
"""Relationships between collections (e.g. shared universe)."""
__tablename__ = "collection_relations"
collection_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("collections.id", ondelete="CASCADE"), primary_key=True
)
related_collection_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("collections.id", ondelete="CASCADE"), primary_key=True
)
relation_type: Mapped[str] = mapped_column(String(50), default="related", nullable=False)
__all__ = ["Collection", "CollectionMovie", "CollectionSeries", "CollectionRelation"]
+62
View File
@@ -0,0 +1,62 @@
"""Production companies, studios, networks, and distributors.
Modeled as a single `Company` table distinguished by `company_type` per the
spec (section 8), rather than a separate Network entity — a TV network and a
studio share the same shape (name, logo, country, parent company)."""
from __future__ import annotations
import uuid
from sqlalchemy import ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
from app.models.enums import CompanyType
class Company(Base, UUIDPrimaryKeyMixin, TimestampMixin):
__tablename__ = "companies"
name: Mapped[str] = mapped_column(String(300), nullable=False, index=True)
company_type: Mapped[CompanyType] = mapped_column(
default=CompanyType.PRODUCTION_COMPANY, nullable=False
)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
country_code: Mapped[str | None] = mapped_column(
ForeignKey("countries.code"), nullable=True
)
headquarters: Mapped[str | None] = mapped_column(String(300), nullable=True)
homepage: Mapped[str | None] = mapped_column(String(500), nullable=True)
parent_company_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("companies.id", ondelete="SET NULL"), nullable=True
)
parent_company: Mapped[Company | None] = relationship(remote_side="Company.id")
class MovieCompany(Base):
__tablename__ = "movie_companies"
movie_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("movies.id", ondelete="CASCADE"), primary_key=True
)
company_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("companies.id", ondelete="CASCADE"), primary_key=True
)
role: Mapped[str | None] = mapped_column(String(50), nullable=True)
class SeriesCompany(Base):
__tablename__ = "series_companies"
series_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("series.id", ondelete="CASCADE"), primary_key=True
)
company_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("companies.id", ondelete="CASCADE"), primary_key=True
)
role: Mapped[str | None] = mapped_column(String(50), nullable=True)
__all__ = ["Company", "MovieCompany", "SeriesCompany"]
+50
View File
@@ -0,0 +1,50 @@
"""Flexible, normalized credit system (section 7).
A `Credit` links a `Person` to any media entity (movie, series, episode)
with a department/job/character, instead of duplicating cast/crew as text
blobs on the media tables.
"""
from __future__ import annotations
import uuid
from sqlalchemy import ForeignKey, Index, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
from app.models.person import Person
from app.models.provider import Provider
class Credit(Base, UUIDPrimaryKeyMixin, TimestampMixin):
__tablename__ = "credits"
__table_args__ = (Index("ix_credits_entity", "entity_type", "entity_id"),)
person_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("people.id", ondelete="CASCADE"), nullable=False
)
# Polymorphic target: "movie", "series", "episode".
entity_type: Mapped[str] = mapped_column(String(30), nullable=False)
entity_id: Mapped[uuid.UUID] = mapped_column(nullable=False)
# "Acting", "Directing", "Writing", "Production", "Camera", "Sound",
# "Editing", "Art", "Costume & Make-Up", "Crew", "Creator", ...
department: Mapped[str] = mapped_column(String(100), nullable=False)
job: Mapped[str | None] = mapped_column(String(150), nullable=True)
character: Mapped[str | None] = mapped_column(String(300), nullable=True)
billing_order: Mapped[int | None] = mapped_column(Integer, nullable=True)
is_guest: Mapped[bool] = mapped_column(default=False, nullable=False)
episode_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
provider_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("providers.id", ondelete="SET NULL"), nullable=True
)
person: Mapped[Person] = relationship()
provider: Mapped[Provider | None] = relationship()
__all__ = ["Credit"]
+84
View File
@@ -0,0 +1,84 @@
"""Shared enumerations used across Aurora Core models."""
from __future__ import annotations
import enum
class MovieStatus(enum.StrEnum):
RUMORED = "rumored"
PLANNED = "planned"
IN_PRODUCTION = "in_production"
POST_PRODUCTION = "post_production"
RELEASED = "released"
CANCELED = "canceled"
class SeriesStatus(enum.StrEnum):
PLANNED = "planned"
PILOT = "pilot"
IN_PRODUCTION = "in_production"
RETURNING_SERIES = "returning_series"
ENDED = "ended"
CANCELED = "canceled"
class ArtworkType(enum.StrEnum):
POSTER = "poster"
BACKDROP = "backdrop"
LOGO = "logo"
PROFILE = "profile"
STILL = "still"
BANNER = "banner"
THUMBNAIL = "thumbnail"
class VideoType(enum.StrEnum):
TRAILER = "trailer"
TEASER = "teaser"
CLIP = "clip"
FEATURETTE = "featurette"
BEHIND_THE_SCENES = "behind_the_scenes"
INTERVIEW = "interview"
OTHER = "other"
class CompanyType(enum.StrEnum):
PRODUCTION_COMPANY = "production_company"
NETWORK = "network"
STUDIO = "studio"
DISTRIBUTOR = "distributor"
class EntityType(enum.StrEnum):
"""Kinds of entity that polymorphic tables (credits, artwork, videos,
external IDs, release dates, provider records) can attach to."""
MOVIE = "movie"
SERIES = "series"
SEASON = "season"
EPISODE = "episode"
PERSON = "person"
COMPANY = "company"
COLLECTION = "collection"
class IngestionJobStatus(enum.StrEnum):
PENDING = "pending"
RUNNING = "running"
SUCCEEDED = "succeeded"
FAILED = "failed"
RETRYING = "retrying"
class MatchConfidence(enum.StrEnum):
"""How confident the entity matcher was when linking a provider record
to an Aurora entity. Anything below HIGH should be reviewable rather
than silently merged — see docs/architecture.md."""
EXACT = "exact"
HIGH = "high"
MEDIUM = "medium"
LOW = "low"
AMBIGUOUS = "ambiguous"
NEW = "new"
+45
View File
@@ -0,0 +1,45 @@
"""Generalized external identifier system.
Links an Aurora entity (any type) to its identifier in an external provider
(TMDb, IMDb, TVDb, Wikidata, ...). External IDs are references, never
primary keys — see app/core/ids.py and docs/architecture.md.
"""
from __future__ import annotations
import uuid
from sqlalchemy import ForeignKey, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
from app.models.provider import Provider
class ExternalID(Base, UUIDPrimaryKeyMixin, TimestampMixin):
__tablename__ = "external_ids"
__table_args__ = (
# A provider's ID for a given entity type must be unique (one Aurora
# entity per provider-native ID).
UniqueConstraint(
"provider_id", "entity_type", "external_id", name="uq_external_id_source"
),
# An Aurora entity has at most one mapping per provider.
UniqueConstraint(
"entity_type", "entity_id", "provider_id", name="uq_external_id_entity_provider"
),
)
entity_type: Mapped[str] = mapped_column(String(30), nullable=False)
entity_id: Mapped[uuid.UUID] = mapped_column(nullable=False)
provider_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("providers.id", ondelete="CASCADE"), nullable=False
)
external_id: Mapped[str] = mapped_column(String(200), nullable=False)
url: Mapped[str | None] = mapped_column(String(500), nullable=True)
provider: Mapped[Provider] = relationship()
__all__ = ["ExternalID"]
+42
View File
@@ -0,0 +1,42 @@
"""Genres and their movie/series associations."""
from __future__ import annotations
import uuid
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, UUIDPrimaryKeyMixin
class Genre(Base, UUIDPrimaryKeyMixin):
__tablename__ = "genres"
name: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
class MovieGenre(Base):
__tablename__ = "movie_genres"
movie_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("movies.id", ondelete="CASCADE"), primary_key=True
)
genre_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("genres.id", ondelete="CASCADE"), primary_key=True
)
class SeriesGenre(Base):
__tablename__ = "series_genres"
series_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("series.id", ondelete="CASCADE"), primary_key=True
)
genre_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("genres.id", ondelete="CASCADE"), primary_key=True
)
__all__ = ["Genre", "MovieGenre", "SeriesGenre"]
+42
View File
@@ -0,0 +1,42 @@
"""Keywords/tags and their movie/series associations."""
from __future__ import annotations
import uuid
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, UUIDPrimaryKeyMixin
class Keyword(Base, UUIDPrimaryKeyMixin):
__tablename__ = "keywords"
name: Mapped[str] = mapped_column(String(150), unique=True, nullable=False)
slug: Mapped[str] = mapped_column(String(150), unique=True, nullable=False)
class MovieKeyword(Base):
__tablename__ = "movie_keywords"
movie_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("movies.id", ondelete="CASCADE"), primary_key=True
)
keyword_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("keywords.id", ondelete="CASCADE"), primary_key=True
)
class SeriesKeyword(Base):
__tablename__ = "series_keywords"
series_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("series.id", ondelete="CASCADE"), primary_key=True
)
keyword_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("keywords.id", ondelete="CASCADE"), primary_key=True
)
__all__ = ["Keyword", "MovieKeyword", "SeriesKeyword"]
+46
View File
@@ -0,0 +1,46 @@
"""Reference data: languages, countries, certifications."""
from __future__ import annotations
from sqlalchemy import ForeignKey, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
class Language(Base):
"""ISO 639-1 language codes (e.g. 'en', 'ja')."""
__tablename__ = "languages"
code: Mapped[str] = mapped_column(String(10), primary_key=True)
english_name: Mapped[str] = mapped_column(String(200), nullable=False)
native_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
class Country(Base):
"""ISO 3166-1 alpha-2 country codes (e.g. 'US', 'GB')."""
__tablename__ = "countries"
code: Mapped[str] = mapped_column(String(2), primary_key=True)
name: Mapped[str] = mapped_column(String(200), nullable=False)
class Certification(Base, UUIDPrimaryKeyMixin, TimestampMixin):
"""Content rating within a country's rating system, e.g. US/MPA/PG-13."""
__tablename__ = "certifications"
__table_args__ = (
UniqueConstraint("country_code", "system", "rating", name="uq_certification_scope"),
)
country_code: Mapped[str] = mapped_column(ForeignKey("countries.code"), nullable=False)
system: Mapped[str] = mapped_column(String(50), nullable=False) # e.g. "MPA", "BBFC"
rating: Mapped[str] = mapped_column(String(20), nullable=False) # e.g. "PG-13"
meaning: Mapped[str | None] = mapped_column(Text, nullable=True)
country: Mapped[Country] = relationship()
__all__ = ["Language", "Country", "Certification"]
+127
View File
@@ -0,0 +1,127 @@
"""Movie entity and its directly-owned satellite tables (section 4)."""
from __future__ import annotations
import uuid
from datetime import date
from sqlalchemy import BigInteger, Date, Float, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
from app.models.collection import Collection
from app.models.enums import MovieStatus
class Movie(Base, UUIDPrimaryKeyMixin, TimestampMixin):
__tablename__ = "movies"
title: Mapped[str] = mapped_column(String(500), nullable=False, index=True)
original_title: Mapped[str | None] = mapped_column(String(500), nullable=True)
# Title with leading articles ("The", "A") stripped, for stable sorting.
sort_title: Mapped[str] = mapped_column(String(500), nullable=False, index=True)
overview: Mapped[str | None] = mapped_column(Text, nullable=True)
tagline: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[MovieStatus] = mapped_column(default=MovieStatus.RELEASED, nullable=False)
original_language_code: Mapped[str | None] = mapped_column(
ForeignKey("languages.code"), nullable=True
)
primary_release_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True)
runtime_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True)
budget: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
revenue: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
popularity: Mapped[float | None] = mapped_column(Float, nullable=True)
vote_average: Mapped[float | None] = mapped_column(Float, nullable=True)
vote_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
adult: Mapped[bool] = mapped_column(default=False, nullable=False)
collection_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("collections.id", ondelete="SET NULL"), nullable=True
)
# Best-known authoritative source for this record at creation time. Full
# field-level provenance lives in provider_records; this is a coarse
# pointer for quick display. See docs/architecture.md.
source_provider_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("providers.id", ondelete="SET NULL"), nullable=True
)
collection: Mapped[Collection | None] = relationship()
class MovieAlternateTitle(Base, UUIDPrimaryKeyMixin):
__tablename__ = "movie_alternate_titles"
movie_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("movies.id", ondelete="CASCADE"), nullable=False, index=True
)
title: Mapped[str] = mapped_column(String(500), nullable=False)
country_code: Mapped[str | None] = mapped_column(ForeignKey("countries.code"), nullable=True)
type: Mapped[str | None] = mapped_column(String(50), nullable=True) # e.g. "working_title"
class MovieSpokenLanguage(Base):
__tablename__ = "movie_spoken_languages"
movie_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("movies.id", ondelete="CASCADE"), primary_key=True
)
language_code: Mapped[str] = mapped_column(
ForeignKey("languages.code"), primary_key=True
)
class MovieProductionCountry(Base):
__tablename__ = "movie_production_countries"
movie_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("movies.id", ondelete="CASCADE"), primary_key=True
)
country_code: Mapped[str] = mapped_column(ForeignKey("countries.code"), primary_key=True)
class ReleaseDate(Base, UUIDPrimaryKeyMixin):
"""Per-country/per-type release dates and certifications for a movie."""
__tablename__ = "release_dates"
entity_type: Mapped[str] = mapped_column(String(30), default="movie", nullable=False)
entity_id: Mapped[uuid.UUID] = mapped_column(nullable=False, index=True)
country_code: Mapped[str] = mapped_column(ForeignKey("countries.code"), nullable=False)
release_date: Mapped[date] = mapped_column(Date, nullable=False)
# "theatrical", "digital", "physical", "premiere", "tv"
release_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
certification_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("certifications.id", ondelete="SET NULL"), nullable=True
)
note: Mapped[str | None] = mapped_column(Text, nullable=True)
class MovieRecommendation(Base):
"""Recommendations/similar-title edges between movies."""
__tablename__ = "movie_recommendations"
movie_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("movies.id", ondelete="CASCADE"), primary_key=True
)
recommended_movie_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("movies.id", ondelete="CASCADE"), primary_key=True
)
rank: Mapped[int | None] = mapped_column(Integer, nullable=True)
source: Mapped[str | None] = mapped_column(String(50), nullable=True)
__all__ = [
"Movie",
"MovieAlternateTitle",
"MovieSpokenLanguage",
"MovieProductionCountry",
"ReleaseDate",
"MovieRecommendation",
]
+32
View File
@@ -0,0 +1,32 @@
"""People — first-class Aurora entities (section 6)."""
from __future__ import annotations
from datetime import date
from sqlalchemy import JSON, Date, Float, String, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
JSONVariant = JSONB().with_variant(JSON(), "sqlite")
class Person(Base, UUIDPrimaryKeyMixin, TimestampMixin):
__tablename__ = "people"
name: Mapped[str] = mapped_column(String(300), nullable=False, index=True)
aliases: Mapped[list[str]] = mapped_column(JSONVariant, default=list)
biography: Mapped[str | None] = mapped_column(Text, nullable=True)
birthday: Mapped[date | None] = mapped_column(Date, nullable=True)
deathday: Mapped[date | None] = mapped_column(Date, nullable=True)
birthplace: Mapped[str | None] = mapped_column(String(300), nullable=True)
# Free-text, only populated when a source legitimately provides it —
# never inferred.
gender: Mapped[str | None] = mapped_column(String(30), nullable=True)
known_for_department: Mapped[str | None] = mapped_column(String(100), nullable=True)
popularity: Mapped[float | None] = mapped_column(Float, nullable=True)
__all__ = ["Person"]
+108
View File
@@ -0,0 +1,108 @@
"""Provider abstraction and provenance tables.
`Provider` identifies a metadata source (TMDb, IMDb dataset export, ...).
`ProviderRecord` is the provenance ledger: one row per (provider, entity
type, provider-native ID) linking to the Aurora entity it was matched to,
retaining the raw payload and match confidence so ambiguous matches stay
reviewable instead of being silently merged (see docs/architecture.md).
`IngestionJob` tracks async ingestion work so jobs are observable, retryable,
and protected against duplicate processing via `idempotency_key`.
"""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
from app.models.enums import IngestionJobStatus, MatchConfidence
JSONVariant = JSONB().with_variant(JSON(), "sqlite")
class Provider(Base, UUIDPrimaryKeyMixin, TimestampMixin):
"""A metadata data source. Providers are replaceable adapters, never
Aurora's identity system — see app/providers/base.py."""
__tablename__ = "providers"
slug: Mapped[str] = mapped_column(String(50), unique=True, nullable=False) # "tmdb", "imdb"
name: Mapped[str] = mapped_column(String(200), nullable=False)
base_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
is_active: Mapped[bool] = mapped_column(default=True, nullable=False)
config: Mapped[dict] = mapped_column(JSONVariant, default=dict)
class ProviderRecord(Base, UUIDPrimaryKeyMixin, TimestampMixin):
"""Provenance ledger row: this provider's native record, and what Aurora
entity (if any) it has been matched to."""
__tablename__ = "provider_records"
__table_args__ = (
UniqueConstraint(
"provider_id", "entity_type", "external_id", name="uq_provider_record_source"
),
)
provider_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("providers.id", ondelete="CASCADE"), nullable=False
)
entity_type: Mapped[str] = mapped_column(String(30), nullable=False)
external_id: Mapped[str] = mapped_column(String(200), nullable=False)
# Polymorphic reference to the Aurora entity this record resolved to.
# No DB-level FK (the target table varies by entity_type); integrity is
# enforced by the entity matcher / repositories, not the schema.
aurora_entity_id: Mapped[uuid.UUID | None] = mapped_column(nullable=True)
raw_data: Mapped[dict] = mapped_column(JSONVariant, default=dict)
source_version: Mapped[str | None] = mapped_column(String(100), nullable=True)
match_confidence: Mapped[MatchConfidence] = mapped_column(
default=MatchConfidence.NEW, nullable=False
)
match_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
ingestion_job_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("ingestion_jobs.id", ondelete="SET NULL"), nullable=True
)
last_synced_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
provider: Mapped[Provider] = relationship()
class IngestionJob(Base, UUIDPrimaryKeyMixin, TimestampMixin):
"""A unit of asynchronous ingestion work processed by workers."""
__tablename__ = "ingestion_jobs"
__table_args__ = (UniqueConstraint("idempotency_key", name="uq_ingestion_job_idempotency"),)
provider_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("providers.id", ondelete="CASCADE"), nullable=False
)
job_type: Mapped[str] = mapped_column(String(50), nullable=False) # "movie_sync", "full_sync"
entity_type: Mapped[str | None] = mapped_column(String(30), nullable=True)
external_id: Mapped[str | None] = mapped_column(String(200), nullable=True)
idempotency_key: Mapped[str] = mapped_column(String(300), nullable=False)
status: Mapped[IngestionJobStatus] = mapped_column(
default=IngestionJobStatus.PENDING, nullable=False
)
attempts: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
max_attempts: Mapped[int] = mapped_column(Integer, default=5, nullable=False)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
result_aurora_id: Mapped[uuid.UUID | None] = mapped_column(nullable=True)
provider: Mapped[Provider] = relationship()
__all__ = ["Provider", "ProviderRecord", "IngestionJob"]
+118
View File
@@ -0,0 +1,118 @@
"""Television model: Series -> Seasons -> Episodes (section 5)."""
from __future__ import annotations
import uuid
from datetime import date
from sqlalchemy import Date, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
from app.models.enums import SeriesStatus
class Series(Base, UUIDPrimaryKeyMixin, TimestampMixin):
__tablename__ = "series"
title: Mapped[str] = mapped_column(String(500), nullable=False, index=True)
original_title: Mapped[str | None] = mapped_column(String(500), nullable=True)
sort_title: Mapped[str] = mapped_column(String(500), nullable=False, index=True)
overview: Mapped[str | None] = mapped_column(Text, nullable=True)
tagline: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[SeriesStatus] = mapped_column(
default=SeriesStatus.RETURNING_SERIES, nullable=False
)
original_language_code: Mapped[str | None] = mapped_column(
ForeignKey("languages.code"), nullable=True
)
first_air_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True)
last_air_date: Mapped[date | None] = mapped_column(Date, nullable=True)
number_of_seasons: Mapped[int | None] = mapped_column(Integer, nullable=True)
number_of_episodes: Mapped[int | None] = mapped_column(Integer, nullable=True)
popularity: Mapped[float | None] = mapped_column(nullable=True)
vote_average: Mapped[float | None] = mapped_column(nullable=True)
vote_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
source_provider_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("providers.id", ondelete="SET NULL"), nullable=True
)
class SeriesAlternateTitle(Base, UUIDPrimaryKeyMixin):
__tablename__ = "series_alternate_titles"
series_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("series.id", ondelete="CASCADE"), nullable=False, index=True
)
title: Mapped[str] = mapped_column(String(500), nullable=False)
country_code: Mapped[str | None] = mapped_column(ForeignKey("countries.code"), nullable=True)
type: Mapped[str | None] = mapped_column(String(50), nullable=True)
class SeriesSpokenLanguage(Base):
__tablename__ = "series_spoken_languages"
series_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("series.id", ondelete="CASCADE"), primary_key=True
)
language_code: Mapped[str] = mapped_column(ForeignKey("languages.code"), primary_key=True)
class SeriesProductionCountry(Base):
__tablename__ = "series_production_countries"
series_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("series.id", ondelete="CASCADE"), primary_key=True
)
country_code: Mapped[str] = mapped_column(ForeignKey("countries.code"), primary_key=True)
class Season(Base, UUIDPrimaryKeyMixin, TimestampMixin):
__tablename__ = "seasons"
__table_args__ = (UniqueConstraint("series_id", "season_number", name="uq_season_number"),)
series_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("series.id", ondelete="CASCADE"), nullable=False, index=True
)
season_number: Mapped[int] = mapped_column(Integer, nullable=False)
title: Mapped[str | None] = mapped_column(String(500), nullable=True)
overview: Mapped[str | None] = mapped_column(Text, nullable=True)
air_date: Mapped[date | None] = mapped_column(Date, nullable=True)
episode_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
class Episode(Base, UUIDPrimaryKeyMixin, TimestampMixin):
__tablename__ = "episodes"
__table_args__ = (
UniqueConstraint("season_id", "episode_number", name="uq_episode_number"),
)
season_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("seasons.id", ondelete="CASCADE"), nullable=False, index=True
)
# Denormalized for direct filtering/joins without hopping through seasons.
series_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("series.id", ondelete="CASCADE"), nullable=False, index=True
)
season_number: Mapped[int] = mapped_column(Integer, nullable=False)
episode_number: Mapped[int] = mapped_column(Integer, nullable=False)
title: Mapped[str | None] = mapped_column(String(500), nullable=True)
overview: Mapped[str | None] = mapped_column(Text, nullable=True)
air_date: Mapped[date | None] = mapped_column(Date, nullable=True)
runtime_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True)
__all__ = [
"Series",
"SeriesAlternateTitle",
"SeriesSpokenLanguage",
"SeriesProductionCountry",
"Season",
"Episode",
]
+50
View File
@@ -0,0 +1,50 @@
"""Videos/trailers as normalized entities (section 11).
Aurora never downloads copyrighted video media — only stores the
provider's reference/URL and metadata about it.
"""
from __future__ import annotations
import uuid
from datetime import date
from sqlalchemy import Boolean, Date, ForeignKey, Index, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
from app.models.enums import VideoType
from app.models.provider import Provider
class Video(Base, UUIDPrimaryKeyMixin, TimestampMixin):
__tablename__ = "videos"
__table_args__ = (
Index("ix_videos_entity", "entity_type", "entity_id"),
UniqueConstraint("provider_id", "provider_video_id", name="uq_video_provider_source"),
)
# Polymorphic target: "movie", "series", "season", "episode".
entity_type: Mapped[str] = mapped_column(String(30), nullable=False)
entity_id: Mapped[uuid.UUID] = mapped_column(nullable=False)
video_type: Mapped[VideoType] = mapped_column(nullable=False)
provider_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("providers.id", ondelete="CASCADE"), nullable=False
)
provider_video_id: Mapped[str] = mapped_column(String(200), nullable=False)
site: Mapped[str | None] = mapped_column(String(100), nullable=True) # "YouTube", "Vimeo"
url: Mapped[str] = mapped_column(String(1000), nullable=False)
language_code: Mapped[str | None] = mapped_column(
ForeignKey("languages.code"), nullable=True
)
country_code: Mapped[str | None] = mapped_column(ForeignKey("countries.code"), nullable=True)
is_official: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
published_at: Mapped[date | None] = mapped_column(Date, nullable=True)
provider: Mapped[Provider] = relationship()
__all__ = ["Video"]
View File
+58
View File
@@ -0,0 +1,58 @@
"""Provider abstraction (section 13).
A `MetadataProvider` fetches **raw**, unnormalized data from one external
source. It never writes to the database and never returns Aurora entities —
that's the normalizer/matcher/ingestion pipeline's job (see
`app/services/metadata/` and `app/services/ingestion/`). This keeps adapters
replaceable: swapping TMDb for another provider means writing a new class
here, not touching ingestion logic.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
class ProviderError(Exception):
"""Raised for any provider-side failure (auth, not-found, rate limit,
network). Ingestion jobs catch this and record it, they don't crash."""
class ProviderNotConfiguredError(ProviderError):
"""Raised when a required credential (e.g. TMDB_API_KEY) is missing."""
class ProviderNotFoundError(ProviderError):
"""Raised when the provider has no record for the given external ID."""
class MetadataProvider(ABC):
slug: str
name: str
@abstractmethod
async def get_movie(self, external_id: str) -> dict[str, Any]:
"""Fetch raw movie data, including credits/images/videos/keywords/
alternate titles/external IDs/release dates where the provider
supports fetching them in one call."""
@abstractmethod
async def get_series(self, external_id: str) -> dict[str, Any]:
"""Fetch raw series data (without season/episode bodies)."""
@abstractmethod
async def get_season(self, series_external_id: str, season_number: int) -> dict[str, Any]:
"""Fetch raw season data, including its episodes."""
@abstractmethod
async def get_person(self, external_id: str) -> dict[str, Any]:
"""Fetch raw person data, including external IDs and images."""
@abstractmethod
async def search_movie(self, query: str) -> list[dict[str, Any]]:
"""Search this provider's catalog for movies matching `query`. Used
to seed ingestion, not exposed directly to API clients."""
async def aclose(self) -> None:
"""Release any held resources (HTTP clients, ...). Optional."""
+89
View File
@@ -0,0 +1,89 @@
"""TMDb metadata provider adapter.
Requires the `TMDB_API_KEY` environment variable (a TMDb v3 API key —
https://www.themoviedb.org/settings/api). If it is not configured, every
method raises `ProviderNotConfiguredError` rather than returning fake data.
"""
from __future__ import annotations
from typing import Any
import httpx
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
from app.core.config import get_settings
from app.providers.base import (
MetadataProvider,
ProviderError,
ProviderNotConfiguredError,
ProviderNotFoundError,
)
_MOVIE_APPEND = "credits,images,videos,keywords,alternative_titles,external_ids,release_dates,recommendations"
_SERIES_APPEND = "credits,images,videos,keywords,alternative_titles,external_ids,content_ratings"
_PERSON_APPEND = "external_ids,images,combined_credits"
class TMDbProvider(MetadataProvider):
slug = "tmdb"
name = "The Movie Database (TMDb)"
base_url = "https://api.themoviedb.org/3"
def __init__(self, api_key: str | None = None, client: httpx.AsyncClient | None = None) -> None:
settings = get_settings()
self._api_key = api_key if api_key is not None else settings.TMDB_API_KEY
self._client = client or httpx.AsyncClient(base_url=self.base_url, timeout=15.0)
self._owns_client = client is None
def _require_api_key(self) -> None:
if not self._api_key:
raise ProviderNotConfiguredError(
"TMDB_API_KEY is not set. Configure it in the environment to use the TMDb "
"provider — see .env.example."
)
@retry(
reraise=True,
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=0.5, max=5),
retry=retry_if_exception_type(httpx.TransportError),
)
async def _get(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
self._require_api_key()
query = {"api_key": self._api_key, **(params or {})}
try:
response = await self._client.get(path, params=query)
except httpx.TransportError as exc:
raise ProviderError(f"TMDb request failed: {exc}") from exc
if response.status_code == 404:
raise ProviderNotFoundError(f"TMDb has no record at {path}")
if response.status_code == 401:
raise ProviderNotConfiguredError("TMDb rejected the configured TMDB_API_KEY.")
if response.status_code >= 400:
raise ProviderError(f"TMDb error {response.status_code} at {path}: {response.text[:300]}")
return response.json()
async def get_movie(self, external_id: str) -> dict[str, Any]:
return await self._get(f"/movie/{external_id}", {"append_to_response": _MOVIE_APPEND})
async def get_series(self, external_id: str) -> dict[str, Any]:
return await self._get(f"/tv/{external_id}", {"append_to_response": _SERIES_APPEND})
async def get_season(self, series_external_id: str, season_number: int) -> dict[str, Any]:
return await self._get(
f"/tv/{series_external_id}/season/{season_number}",
{"append_to_response": "credits,images,external_ids"},
)
async def get_person(self, external_id: str) -> dict[str, Any]:
return await self._get(f"/person/{external_id}", {"append_to_response": _PERSON_APPEND})
async def search_movie(self, query: str) -> list[dict[str, Any]]:
data = await self._get("/search/movie", {"query": query})
return data.get("results", [])
async def aclose(self) -> None:
if self._owns_client:
await self._client.aclose()
View File
+39
View File
@@ -0,0 +1,39 @@
"""Data access for Collection/franchise."""
from __future__ import annotations
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.collection import Collection, CollectionMovie, CollectionSeries
from app.models.movie import Movie
from app.models.tv import Series
async def get_collection_by_id(session: AsyncSession, collection_id: uuid.UUID) -> Collection | None:
result = await session.execute(select(Collection).where(Collection.id == collection_id))
return result.scalar_one_or_none()
async def get_collection_movies(session: AsyncSession, collection_id: uuid.UUID) -> list[Movie]:
stmt = (
select(Movie)
.join(CollectionMovie, CollectionMovie.movie_id == Movie.id)
.where(CollectionMovie.collection_id == collection_id)
.order_by(CollectionMovie.sort_order.asc().nulls_last(), Movie.primary_release_date.asc())
)
result = await session.execute(stmt)
return list(result.scalars().all())
async def get_collection_series(session: AsyncSession, collection_id: uuid.UUID) -> list[Series]:
stmt = (
select(Series)
.join(CollectionSeries, CollectionSeries.series_id == Series.id)
.where(CollectionSeries.collection_id == collection_id)
.order_by(CollectionSeries.sort_order.asc().nulls_last())
)
result = await session.execute(stmt)
return list(result.scalars().all())
+15
View File
@@ -0,0 +1,15 @@
"""Data access for Company."""
from __future__ import annotations
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.company import Company
async def get_company_by_id(session: AsyncSession, company_id: uuid.UUID) -> Company | None:
result = await session.execute(select(Company).where(Company.id == company_id))
return result.scalar_one_or_none()
+61
View File
@@ -0,0 +1,61 @@
"""Data access for Movie and its directly-owned satellite tables."""
from __future__ import annotations
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.company import Company, MovieCompany
from app.models.genre import Genre, MovieGenre
from app.models.keyword import Keyword, MovieKeyword
from app.models.movie import Movie
async def get_movie_by_id(session: AsyncSession, movie_id: uuid.UUID) -> Movie | None:
stmt = (
select(Movie)
.options(selectinload(Movie.collection))
.where(Movie.id == movie_id)
)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def get_movies_by_ids(session: AsyncSession, movie_ids: list[uuid.UUID]) -> list[Movie]:
if not movie_ids:
return []
stmt = select(Movie).where(Movie.id.in_(movie_ids))
result = await session.execute(stmt)
by_id = {m.id: m for m in result.scalars().all()}
return [by_id[i] for i in movie_ids if i in by_id]
async def get_movie_genres(session: AsyncSession, movie_id: uuid.UUID) -> list[Genre]:
stmt = select(Genre).join(MovieGenre, MovieGenre.genre_id == Genre.id).where(
MovieGenre.movie_id == movie_id
)
result = await session.execute(stmt)
return list(result.scalars().all())
async def get_movie_keywords(session: AsyncSession, movie_id: uuid.UUID) -> list[Keyword]:
stmt = select(Keyword).join(MovieKeyword, MovieKeyword.keyword_id == Keyword.id).where(
MovieKeyword.movie_id == movie_id
)
result = await session.execute(stmt)
return list(result.scalars().all())
async def get_movie_companies(
session: AsyncSession, movie_id: uuid.UUID
) -> list[tuple[Company, str | None]]:
stmt = (
select(Company, MovieCompany.role)
.join(MovieCompany, MovieCompany.company_id == Company.id)
.where(MovieCompany.movie_id == movie_id)
)
result = await session.execute(stmt)
return [(company, role) for company, role in result.all()]
+26
View File
@@ -0,0 +1,26 @@
"""Data access for Person."""
from __future__ import annotations
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.credit import Credit
from app.models.person import Person
async def get_person_by_id(session: AsyncSession, person_id: uuid.UUID) -> Person | None:
result = await session.execute(select(Person).where(Person.id == person_id))
return result.scalar_one_or_none()
async def get_person_credits(session: AsyncSession, person_id: uuid.UUID) -> list[Credit]:
stmt = (
select(Credit)
.where(Credit.person_id == person_id)
.order_by(Credit.entity_type.asc())
)
result = await session.execute(stmt)
return list(result.scalars().all())
+90
View File
@@ -0,0 +1,90 @@
"""Shared data access for the polymorphic satellite tables — credits,
artwork, videos, external IDs — that attach to more than one entity type via
an (entity_type, entity_id) pair rather than a typed foreign key.
"""
from __future__ import annotations
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.artwork import Artwork
from app.models.credit import Credit
from app.models.enums import ArtworkType
from app.models.external_id import ExternalID
from app.models.provider import Provider
from app.models.video import Video
async def get_external_ids(
session: AsyncSession, entity_type: str, entity_id: uuid.UUID
) -> dict[str, str]:
stmt = (
select(Provider.slug, ExternalID.external_id)
.join(Provider, Provider.id == ExternalID.provider_id)
.where(ExternalID.entity_type == entity_type, ExternalID.entity_id == entity_id)
)
result = await session.execute(stmt)
return {slug: external_id for slug, external_id in result.all()}
async def get_external_ids_bulk(
session: AsyncSession, entity_type: str, entity_ids: list[uuid.UUID]
) -> dict[uuid.UUID, dict[str, str]]:
if not entity_ids:
return {}
stmt = (
select(ExternalID.entity_id, Provider.slug, ExternalID.external_id)
.join(Provider, Provider.id == ExternalID.provider_id)
.where(ExternalID.entity_type == entity_type, ExternalID.entity_id.in_(entity_ids))
)
result = await session.execute(stmt)
out: dict[uuid.UUID, dict[str, str]] = {eid: {} for eid in entity_ids}
for entity_id, slug, external_id in result.all():
out.setdefault(entity_id, {})[slug] = external_id
return out
async def get_artwork(
session: AsyncSession,
entity_type: str,
entity_id: uuid.UUID,
artwork_type: ArtworkType | None = None,
) -> list[Artwork]:
stmt = (
select(Artwork)
.options(selectinload(Artwork.provider))
.where(Artwork.entity_type == entity_type, Artwork.entity_id == entity_id)
.order_by(Artwork.is_primary.desc(), Artwork.vote_average.desc().nulls_last())
)
if artwork_type is not None:
stmt = stmt.where(Artwork.artwork_type == artwork_type)
result = await session.execute(stmt)
return list(result.scalars().all())
async def get_videos(session: AsyncSession, entity_type: str, entity_id: uuid.UUID) -> list[Video]:
stmt = (
select(Video)
.options(selectinload(Video.provider))
.where(Video.entity_type == entity_type, Video.entity_id == entity_id)
.order_by(Video.is_official.desc(), Video.published_at.desc().nulls_last())
)
result = await session.execute(stmt)
return list(result.scalars().all())
async def get_credits(
session: AsyncSession, entity_type: str, entity_id: uuid.UUID
) -> list[Credit]:
stmt = (
select(Credit)
.options(selectinload(Credit.person))
.where(Credit.entity_type == entity_type, Credit.entity_id == entity_id)
.order_by(Credit.billing_order.asc().nulls_last())
)
result = await session.execute(stmt)
return list(result.scalars().all())
+145
View File
@@ -0,0 +1,145 @@
"""Data access for Provider, ProviderRecord, and IngestionJob."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.enums import IngestionJobStatus, MatchConfidence
from app.models.provider import IngestionJob, Provider, ProviderRecord
async def get_provider_by_slug(session: AsyncSession, slug: str) -> Provider | None:
result = await session.execute(select(Provider).where(Provider.slug == slug))
return result.scalar_one_or_none()
async def get_or_create_provider(
session: AsyncSession, slug: str, name: str, base_url: str | None = None
) -> Provider:
provider = await get_provider_by_slug(session, slug)
if provider is not None:
return provider
provider = Provider(slug=slug, name=name, base_url=base_url)
session.add(provider)
await session.flush()
return provider
async def get_provider_record(
session: AsyncSession, provider_id: uuid.UUID, entity_type: str, external_id: str
) -> ProviderRecord | None:
stmt = select(ProviderRecord).where(
ProviderRecord.provider_id == provider_id,
ProviderRecord.entity_type == entity_type,
ProviderRecord.external_id == external_id,
)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def upsert_provider_record(
session: AsyncSession,
*,
provider_id: uuid.UUID,
entity_type: str,
external_id: str,
raw_data: dict,
aurora_entity_id: uuid.UUID | None,
match_confidence: MatchConfidence,
source_version: str | None = None,
ingestion_job_id: uuid.UUID | None = None,
) -> ProviderRecord:
record = await get_provider_record(session, provider_id, entity_type, external_id)
now = datetime.now(UTC)
if record is None:
record = ProviderRecord(
provider_id=provider_id,
entity_type=entity_type,
external_id=external_id,
raw_data=raw_data,
aurora_entity_id=aurora_entity_id,
match_confidence=match_confidence,
source_version=source_version,
ingestion_job_id=ingestion_job_id,
last_synced_at=now,
)
session.add(record)
else:
record.raw_data = raw_data
record.aurora_entity_id = aurora_entity_id
record.match_confidence = match_confidence
record.source_version = source_version
record.ingestion_job_id = ingestion_job_id
record.last_synced_at = now
await session.flush()
return record
async def get_ingestion_job_by_idempotency_key(
session: AsyncSession, idempotency_key: str
) -> IngestionJob | None:
stmt = select(IngestionJob).where(IngestionJob.idempotency_key == idempotency_key)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def create_ingestion_job(
session: AsyncSession,
*,
provider_id: uuid.UUID,
job_type: str,
idempotency_key: str,
entity_type: str | None = None,
external_id: str | None = None,
) -> IngestionJob:
existing = await get_ingestion_job_by_idempotency_key(session, idempotency_key)
if existing is not None:
return existing
job = IngestionJob(
provider_id=provider_id,
job_type=job_type,
entity_type=entity_type,
external_id=external_id,
idempotency_key=idempotency_key,
status=IngestionJobStatus.PENDING,
)
session.add(job)
await session.flush()
return job
async def get_ingestion_job(session: AsyncSession, job_id: uuid.UUID) -> IngestionJob | None:
result = await session.execute(select(IngestionJob).where(IngestionJob.id == job_id))
return result.scalar_one_or_none()
async def mark_job_running(session: AsyncSession, job: IngestionJob) -> None:
job.status = IngestionJobStatus.RUNNING
job.attempts += 1
job.started_at = datetime.now(UTC)
await session.flush()
async def mark_job_succeeded(
session: AsyncSession, job: IngestionJob, result_aurora_id: uuid.UUID | None
) -> None:
job.status = IngestionJobStatus.SUCCEEDED
job.finished_at = datetime.now(UTC)
job.result_aurora_id = result_aurora_id
job.error = None
await session.flush()
async def mark_job_failed(session: AsyncSession, job: IngestionJob, error: str) -> None:
job.error = error[:4000]
job.finished_at = datetime.now(UTC)
job.status = (
IngestionJobStatus.RETRYING
if job.attempts < job.max_attempts
else IngestionJobStatus.FAILED
)
await session.flush()
+270
View File
@@ -0,0 +1,270 @@
"""Get-or-create helpers for shared reference/lookup data used during
ingestion: genres, keywords, languages, countries, companies, collections,
people, and external ID links."""
from __future__ import annotations
import re
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.collection import Collection
from app.models.company import Company
from app.models.enums import CompanyType
from app.models.external_id import ExternalID
from app.models.genre import Genre
from app.models.keyword import Keyword
from app.models.language_country import Country, Language
from app.models.person import Person
def slugify(value: str) -> str:
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
return slug or "n-a"
async def get_or_create_genre(session: AsyncSession, name: str) -> Genre:
slug = slugify(name)
result = await session.execute(select(Genre).where(Genre.slug == slug))
genre = result.scalar_one_or_none()
if genre is not None:
return genre
genre = Genre(name=name, slug=slug)
session.add(genre)
await session.flush()
return genre
async def get_or_create_keyword(session: AsyncSession, name: str) -> Keyword:
slug = slugify(name)
result = await session.execute(select(Keyword).where(Keyword.slug == slug))
keyword = result.scalar_one_or_none()
if keyword is not None:
return keyword
keyword = Keyword(name=name, slug=slug)
session.add(keyword)
await session.flush()
return keyword
async def get_or_create_language(session: AsyncSession, code: str, english_name: str) -> Language:
result = await session.execute(select(Language).where(Language.code == code))
language = result.scalar_one_or_none()
if language is not None:
return language
language = Language(code=code, english_name=english_name)
session.add(language)
await session.flush()
return language
async def get_or_create_country(session: AsyncSession, code: str, name: str) -> Country:
result = await session.execute(select(Country).where(Country.code == code))
country = result.scalar_one_or_none()
if country is not None:
return country
country = Country(code=code, name=name)
session.add(country)
await session.flush()
return country
async def find_company_by_external_id(
session: AsyncSession, provider_id: uuid.UUID, external_id: str
) -> Company | None:
stmt = (
select(Company)
.join(
ExternalID,
(ExternalID.entity_id == Company.id) & (ExternalID.entity_type == "company"),
)
.where(ExternalID.provider_id == provider_id, ExternalID.external_id == external_id)
)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def get_or_create_company(
session: AsyncSession,
*,
provider_id: uuid.UUID,
external_id: str,
name: str,
company_type: CompanyType = CompanyType.PRODUCTION_COMPANY,
country_code: str | None = None,
) -> Company:
company = await find_company_by_external_id(session, provider_id, external_id)
if company is not None:
return company
company = Company(name=name, company_type=company_type, country_code=country_code)
session.add(company)
await session.flush()
session.add(
ExternalID(
entity_type="company",
entity_id=company.id,
provider_id=provider_id,
external_id=external_id,
)
)
await session.flush()
return company
async def find_collection_by_external_id(
session: AsyncSession, provider_id: uuid.UUID, external_id: str
) -> Collection | None:
stmt = (
select(Collection)
.join(
ExternalID,
(ExternalID.entity_id == Collection.id) & (ExternalID.entity_type == "collection"),
)
.where(ExternalID.provider_id == provider_id, ExternalID.external_id == external_id)
)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def get_or_create_collection(
session: AsyncSession,
*,
provider_id: uuid.UUID,
external_id: str,
name: str,
overview: str | None = None,
) -> Collection:
collection = await find_collection_by_external_id(session, provider_id, external_id)
if collection is not None:
return collection
base_slug = slugify(name)
slug = base_slug
suffix = 1
while (
await session.execute(select(Collection.id).where(Collection.slug == slug))
).scalar_one_or_none() is not None:
suffix += 1
slug = f"{base_slug}-{suffix}"
collection = Collection(name=name, overview=overview, slug=slug)
session.add(collection)
await session.flush()
session.add(
ExternalID(
entity_type="collection",
entity_id=collection.id,
provider_id=provider_id,
external_id=external_id,
)
)
await session.flush()
return collection
async def find_person_by_external_id(
session: AsyncSession, provider_id: uuid.UUID, external_id: str
) -> Person | None:
stmt = (
select(Person)
.join(
ExternalID,
(ExternalID.entity_id == Person.id) & (ExternalID.entity_type == "person"),
)
.where(ExternalID.provider_id == provider_id, ExternalID.external_id == external_id)
)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def get_or_create_person(
session: AsyncSession,
*,
provider_id: uuid.UUID,
external_id: str,
name: str,
**extra_fields: object,
) -> Person:
person = await find_person_by_external_id(session, provider_id, external_id)
if person is not None:
for key, value in extra_fields.items():
if value is not None:
setattr(person, key, value)
await session.flush()
return person
person = Person(name=name, **extra_fields)
session.add(person)
await session.flush()
session.add(
ExternalID(
entity_type="person",
entity_id=person.id,
provider_id=provider_id,
external_id=external_id,
)
)
await session.flush()
return person
async def find_entity_by_external_id(
session: AsyncSession, provider_id: uuid.UUID, entity_type: str, external_id: str
) -> uuid.UUID | None:
stmt = select(ExternalID.entity_id).where(
ExternalID.provider_id == provider_id,
ExternalID.entity_type == entity_type,
ExternalID.external_id == external_id,
)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def find_entities_by_external_ids(
session: AsyncSession, provider_id: uuid.UUID, entity_type: str, external_ids: list[str]
) -> list[uuid.UUID]:
"""Bulk lookup preserving `external_ids` order; unmatched IDs are
dropped rather than raising, since a recommendation pointing at a movie
Aurora hasn't ingested yet is expected, not an error."""
if not external_ids:
return []
stmt = select(ExternalID.external_id, ExternalID.entity_id).where(
ExternalID.provider_id == provider_id,
ExternalID.entity_type == entity_type,
ExternalID.external_id.in_(external_ids),
)
result = await session.execute(stmt)
found = {external_id: entity_id for external_id, entity_id in result.all()}
return [found[eid] for eid in external_ids if eid in found]
async def link_external_id(
session: AsyncSession,
*,
entity_type: str,
entity_id: uuid.UUID,
provider_id: uuid.UUID,
external_id: str,
url: str | None = None,
) -> None:
stmt = select(ExternalID).where(
ExternalID.entity_type == entity_type,
ExternalID.entity_id == entity_id,
ExternalID.provider_id == provider_id,
)
result = await session.execute(stmt)
existing = result.scalar_one_or_none()
if existing is not None:
existing.external_id = external_id
existing.url = url
else:
session.add(
ExternalID(
entity_type=entity_type,
entity_id=entity_id,
provider_id=provider_id,
external_id=external_id,
url=url,
)
)
await session.flush()
+79
View File
@@ -0,0 +1,79 @@
"""Data access for Series -> Season -> Episode."""
from __future__ import annotations
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.company import Company, SeriesCompany
from app.models.enums import CompanyType
from app.models.genre import Genre, SeriesGenre
from app.models.keyword import Keyword, SeriesKeyword
from app.models.tv import Episode, Season, Series
async def get_series_by_id(session: AsyncSession, series_id: uuid.UUID) -> Series | None:
result = await session.execute(select(Series).where(Series.id == series_id))
return result.scalar_one_or_none()
async def get_seasons_for_series(session: AsyncSession, series_id: uuid.UUID) -> list[Season]:
stmt = (
select(Season)
.where(Season.series_id == series_id)
.order_by(Season.season_number.asc())
)
result = await session.execute(stmt)
return list(result.scalars().all())
async def get_season_by_id(session: AsyncSession, season_id: uuid.UUID) -> Season | None:
result = await session.execute(select(Season).where(Season.id == season_id))
return result.scalar_one_or_none()
async def get_episodes_for_season(session: AsyncSession, season_id: uuid.UUID) -> list[Episode]:
stmt = (
select(Episode)
.where(Episode.season_id == season_id)
.order_by(Episode.episode_number.asc())
)
result = await session.execute(stmt)
return list(result.scalars().all())
async def get_episode_by_id(session: AsyncSession, episode_id: uuid.UUID) -> Episode | None:
result = await session.execute(select(Episode).where(Episode.id == episode_id))
return result.scalar_one_or_none()
async def get_series_genres(session: AsyncSession, series_id: uuid.UUID) -> list[Genre]:
stmt = select(Genre).join(SeriesGenre, SeriesGenre.genre_id == Genre.id).where(
SeriesGenre.series_id == series_id
)
result = await session.execute(stmt)
return list(result.scalars().all())
async def get_series_keywords(session: AsyncSession, series_id: uuid.UUID) -> list[Keyword]:
stmt = select(Keyword).join(SeriesKeyword, SeriesKeyword.keyword_id == Keyword.id).where(
SeriesKeyword.series_id == series_id
)
result = await session.execute(stmt)
return list(result.scalars().all())
async def get_series_companies(
session: AsyncSession, series_id: uuid.UUID, company_type: CompanyType | None = None
) -> list[tuple[Company, str | None]]:
stmt = (
select(Company, SeriesCompany.role)
.join(SeriesCompany, SeriesCompany.company_id == Company.id)
.where(SeriesCompany.series_id == series_id)
)
if company_type is not None:
stmt = stmt.where(Company.company_type == company_type)
result = await session.execute(stmt)
return [(company, role) for company, role in result.all()]
View File
+24
View File
@@ -0,0 +1,24 @@
"""Collection/franchise response schemas."""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel
from app.schemas.movie import MovieSummaryOut
from app.schemas.tv import SeriesSummaryOut
class CollectionDetailOut(BaseModel):
id: str
type: str = "collection"
name: str
overview: str | None = None
poster_path: str | None = None
backdrop_path: str | None = None
movies: list[MovieSummaryOut] = []
series: list[SeriesSummaryOut] = []
created_at: datetime
updated_at: datetime
+119
View File
@@ -0,0 +1,119 @@
"""Shared response building blocks used across entity schemas.
These are hand-written Pydantic models, never SQLAlchemy models serialized
directly — see section 18 of the build spec.
"""
from __future__ import annotations
from datetime import date
from pydantic import BaseModel, ConfigDict
class ORMModel(BaseModel):
"""Base for schemas built from ORM rows via `model_validate(obj)`."""
model_config = ConfigDict(from_attributes=True)
class GenreOut(ORMModel):
id: str
name: str
class KeywordOut(ORMModel):
id: str
name: str
class ArtworkOut(BaseModel):
id: str
type: str
language: str | None = None
width: int | None = None
height: int | None = None
aspect_ratio: float | None = None
url: str
provider: str | None = None
is_primary: bool = False
vote_average: float | None = None
class ImagesOut(BaseModel):
posters: list[ArtworkOut] = []
backdrops: list[ArtworkOut] = []
logos: list[ArtworkOut] = []
class VideoOut(BaseModel):
id: str
type: str
site: str | None = None
url: str
language: str | None = None
country: str | None = None
is_official: bool = False
published_at: date | None = None
provider: str | None = None
class ExternalIDsOut(BaseModel):
"""Provider slug -> native ID, e.g. {"tmdb": "550", "imdb": "tt0137523"}."""
model_config = ConfigDict(extra="allow")
class PersonBriefOut(BaseModel):
id: str
name: str
profile_path: str | None = None
class CastCreditOut(BaseModel):
id: str
person: PersonBriefOut
character: str | None = None
billing_order: int | None = None
episode_count: int | None = None
class CrewCreditOut(BaseModel):
id: str
person: PersonBriefOut
department: str
job: str | None = None
episode_count: int | None = None
class CreditsOut(BaseModel):
cast: list[CastCreditOut] = []
crew: list[CrewCreditOut] = []
class CompanyBriefOut(BaseModel):
id: str
name: str
company_type: str
logo_path: str | None = None
class CollectionBriefOut(BaseModel):
id: str
name: str
class PageMeta(BaseModel):
page: int
page_size: int
total: int
total_pages: int
class ErrorDetail(BaseModel):
code: str
message: str
class ErrorResponse(BaseModel):
error: ErrorDetail
+24
View File
@@ -0,0 +1,24 @@
"""Company response schemas."""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel
class CompanyDetailOut(BaseModel):
id: str
type: str = "company"
name: str
company_type: str
description: str | None = None
country: str | None = None
headquarters: str | None = None
homepage: str | None = None
logo_path: str | None = None
parent_company_id: str | None = None
external_ids: dict[str, str] = {}
created_at: datetime
updated_at: datetime
+20
View File
@@ -0,0 +1,20 @@
"""Health/readiness response schemas."""
from __future__ import annotations
from pydantic import BaseModel
class HealthOut(BaseModel):
status: str = "ok"
class DependencyStatus(BaseModel):
name: str
ok: bool
required: bool
class ReadyOut(BaseModel):
status: str
dependencies: list[DependencyStatus]
+80
View File
@@ -0,0 +1,80 @@
"""Movie response schemas."""
from __future__ import annotations
from datetime import date, datetime
from pydantic import BaseModel
from app.schemas.common import (
CastCreditOut,
CollectionBriefOut,
CompanyBriefOut,
CrewCreditOut,
GenreOut,
ImagesOut,
KeywordOut,
VideoOut,
)
class MovieSummaryOut(BaseModel):
"""Compact movie representation used in lists and search results."""
id: str
type: str = "movie"
title: str
original_title: str | None = None
overview: str | None = None
release_date: date | None = None
poster_path: str | None = None
popularity: float | None = None
vote_average: float | None = None
class MovieDetailOut(BaseModel):
id: str
type: str = "movie"
title: str
original_title: str | None = None
overview: str | None = None
tagline: str | None = None
status: str
original_language: str | None = None
release_date: date | None = None
runtime: int | None = None
budget: int | None = None
revenue: int | None = None
popularity: float | None = None
vote_average: float | None = None
vote_count: int | None = None
adult: bool = False
genres: list[GenreOut] = []
keywords: list[KeywordOut] = []
cast: list[CastCreditOut] = []
crew: list[CrewCreditOut] = []
companies: list[CompanyBriefOut] = []
collection: CollectionBriefOut | None = None
images: ImagesOut = ImagesOut()
videos: list[VideoOut] = []
external_ids: dict[str, str] = {}
created_at: datetime
updated_at: datetime
class MovieCreditsOut(BaseModel):
id: str
cast: list[CastCreditOut] = []
crew: list[CrewCreditOut] = []
class MovieImagesOut(BaseModel):
id: str
images: ImagesOut = ImagesOut()
class MovieVideosOut(BaseModel):
id: str
videos: list[VideoOut] = []
+55
View File
@@ -0,0 +1,55 @@
"""Person response schemas."""
from __future__ import annotations
from datetime import date, datetime
from pydantic import BaseModel
from app.schemas.common import ArtworkOut
class PersonSummaryOut(BaseModel):
id: str
type: str = "person"
name: str
known_for_department: str | None = None
profile_path: str | None = None
popularity: float | None = None
class FilmographyCreditOut(BaseModel):
id: str
media_id: str
media_type: str
title: str
department: str
job: str | None = None
character: str | None = None
release_date: date | None = None
poster_path: str | None = None
class PersonDetailOut(BaseModel):
id: str
type: str = "person"
name: str
aliases: list[str] = []
biography: str | None = None
birthday: date | None = None
deathday: date | None = None
birthplace: str | None = None
gender: str | None = None
known_for_department: str | None = None
popularity: float | None = None
profile_images: list[ArtworkOut] = []
external_ids: dict[str, str] = {}
created_at: datetime
updated_at: datetime
class PersonCreditsOut(BaseModel):
id: str
cast: list[FilmographyCreditOut] = []
crew: list[FilmographyCreditOut] = []
+20
View File
@@ -0,0 +1,20 @@
"""Search response schemas."""
from __future__ import annotations
from pydantic import BaseModel
from app.schemas.common import CollectionBriefOut, CompanyBriefOut
from app.schemas.movie import MovieSummaryOut
from app.schemas.person import PersonSummaryOut
from app.schemas.tv import SeriesSummaryOut
class SearchResultsOut(BaseModel):
query: str
movies: list[MovieSummaryOut] = []
series: list[SeriesSummaryOut] = []
people: list[PersonSummaryOut] = []
collections: list[CollectionBriefOut] = []
companies: list[CompanyBriefOut] = []
total_results: int = 0
+123
View File
@@ -0,0 +1,123 @@
"""Series/season/episode response schemas."""
from __future__ import annotations
from datetime import date, datetime
from pydantic import BaseModel
from app.schemas.common import (
CastCreditOut,
CompanyBriefOut,
CrewCreditOut,
GenreOut,
ImagesOut,
KeywordOut,
VideoOut,
)
class SeriesSummaryOut(BaseModel):
id: str
type: str = "series"
title: str
original_title: str | None = None
overview: str | None = None
first_air_date: date | None = None
poster_path: str | None = None
popularity: float | None = None
vote_average: float | None = None
class SeasonBriefOut(BaseModel):
id: str
season_number: int
title: str | None = None
episode_count: int | None = None
air_date: date | None = None
poster_path: str | None = None
class SeriesDetailOut(BaseModel):
id: str
type: str = "series"
title: str
original_title: str | None = None
overview: str | None = None
tagline: str | None = None
status: str
original_language: str | None = None
first_air_date: date | None = None
last_air_date: date | None = None
number_of_seasons: int | None = None
number_of_episodes: int | None = None
popularity: float | None = None
vote_average: float | None = None
vote_count: int | None = None
genres: list[GenreOut] = []
keywords: list[KeywordOut] = []
creators: list[CrewCreditOut] = []
cast: list[CastCreditOut] = []
crew: list[CrewCreditOut] = []
networks: list[CompanyBriefOut] = []
companies: list[CompanyBriefOut] = []
seasons: list[SeasonBriefOut] = []
images: ImagesOut = ImagesOut()
videos: list[VideoOut] = []
external_ids: dict[str, str] = {}
created_at: datetime
updated_at: datetime
class SeriesSeasonsOut(BaseModel):
id: str
seasons: list[SeasonBriefOut] = []
class EpisodeBriefOut(BaseModel):
id: str
episode_number: int
title: str | None = None
air_date: date | None = None
still_path: str | None = None
class SeasonDetailOut(BaseModel):
id: str
type: str = "season"
series_id: str
season_number: int
title: str | None = None
overview: str | None = None
air_date: date | None = None
episode_count: int | None = None
episodes: list[EpisodeBriefOut] = []
images: ImagesOut = ImagesOut()
external_ids: dict[str, str] = {}
created_at: datetime
updated_at: datetime
class EpisodeDetailOut(BaseModel):
id: str
type: str = "episode"
series_id: str
season_id: str
season_number: int
episode_number: int
title: str | None = None
overview: str | None = None
air_date: date | None = None
runtime: int | None = None
cast: list[CastCreditOut] = []
guest_stars: list[CastCreditOut] = []
crew: list[CrewCreditOut] = []
images: ImagesOut = ImagesOut()
external_ids: dict[str, str] = {}
created_at: datetime
updated_at: datetime
View File
+61
View File
@@ -0,0 +1,61 @@
"""Cache abstraction over Redis (section 21).
Redis is disposable acceleration — every function here fails open: if Redis
is unreachable, reads report a cache miss and writes are silently skipped,
so the API keeps serving from PostgreSQL instead of failing.
"""
from __future__ import annotations
import orjson
from redis.asyncio import Redis
from app.core.config import get_settings
settings = get_settings()
CACHE_VERSION = "v1"
def build_key(*parts: str) -> str:
return ":".join(("aurora", CACHE_VERSION, *parts))
async def cache_get_json(redis: Redis, key: str) -> dict | list | None:
try:
raw = await redis.get(key)
except Exception:
return None
if raw is None:
return None
try:
return orjson.loads(raw)
except orjson.JSONDecodeError:
return None
async def cache_set_json(
redis: Redis, key: str, value: dict | list, ttl: int | None = None
) -> None:
ttl = ttl if ttl is not None else settings.CACHE_DEFAULT_TTL
try:
await redis.set(key, orjson.dumps(value), ex=ttl)
except Exception:
return
async def cache_delete(redis: Redis, *keys: str) -> None:
if not keys:
return
try:
await redis.delete(*keys)
except Exception:
return
async def cache_delete_prefix(redis: Redis, prefix: str) -> None:
try:
async for key in redis.scan_iter(match=f"{prefix}*"):
await redis.delete(key)
except Exception:
return
+75
View File
@@ -0,0 +1,75 @@
"""Assembles Collection/franchise API responses from repository data."""
from __future__ import annotations
import uuid
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.ids import aurora_id, parse_aurora_id
from app.models.enums import ArtworkType
from app.repositories import collection_repository, polymorphic
from app.schemas.collection import CollectionDetailOut
from app.schemas.common import CollectionBriefOut
from app.services.common import primary_artwork_path
from app.services.movie_service import build_movie_summaries
from app.services.tv_service import build_series_summaries
class CollectionNotFoundError(Exception):
pass
def _resolve_collection_id(aid: str) -> uuid.UUID:
try:
return uuid.UUID(parse_aurora_id(aid, expected_type="collection"))
except ValueError as exc:
raise CollectionNotFoundError(str(exc)) from exc
async def get_collection_detail(
session: AsyncSession, collection_aurora_id: str
) -> CollectionDetailOut:
collection_id = _resolve_collection_id(collection_aurora_id)
collection = await collection_repository.get_collection_by_id(session, collection_id)
if collection is None:
raise CollectionNotFoundError(collection_aurora_id)
movies = await collection_repository.get_collection_movies(session, collection_id)
series = await collection_repository.get_collection_series(session, collection_id)
posters = await polymorphic.get_artwork(session, "collection", collection_id, ArtworkType.POSTER)
backdrops = await polymorphic.get_artwork(
session, "collection", collection_id, ArtworkType.BACKDROP
)
return CollectionDetailOut(
id=aurora_id("collection", str(collection.id)),
name=collection.name,
overview=collection.overview,
poster_path=primary_artwork_path(posters, ArtworkType.POSTER),
backdrop_path=primary_artwork_path(backdrops, ArtworkType.BACKDROP),
movies=await build_movie_summaries(session, [m.id for m in movies]),
series=await build_series_summaries(session, [s.id for s in series]),
created_at=collection.created_at,
updated_at=collection.updated_at,
)
async def build_collection_summary(
session: AsyncSession, collection_id: uuid.UUID
) -> CollectionBriefOut | None:
collection = await collection_repository.get_collection_by_id(session, collection_id)
if collection is None:
return None
return CollectionBriefOut(id=aurora_id("collection", str(collection.id)), name=collection.name)
async def build_collection_summaries(
session: AsyncSession, collection_ids: list[uuid.UUID]
) -> list[CollectionBriefOut]:
out: list[CollectionBriefOut] = []
for collection_id in collection_ids:
summary = await build_collection_summary(session, collection_id)
if summary is not None:
out.append(summary)
return out
+105
View File
@@ -0,0 +1,105 @@
"""Shared helpers for turning ORM rows into response schemas."""
from __future__ import annotations
from app.core.ids import aurora_id
from app.models.artwork import Artwork
from app.models.credit import Credit
from app.models.enums import ArtworkType
from app.models.video import Video
from app.schemas.common import (
ArtworkOut,
CastCreditOut,
CrewCreditOut,
ImagesOut,
PersonBriefOut,
VideoOut,
)
def artwork_url(artwork: Artwork) -> str:
return artwork.local_path or artwork.original_url
def to_artwork_out(artwork: Artwork) -> ArtworkOut:
return ArtworkOut(
id=aurora_id("artwork", str(artwork.id)),
type=artwork.artwork_type.value,
language=artwork.language_code,
width=artwork.width,
height=artwork.height,
aspect_ratio=artwork.aspect_ratio,
url=artwork_url(artwork),
provider=artwork.provider.slug if artwork.provider else None,
is_primary=artwork.is_primary,
vote_average=artwork.vote_average,
)
def to_images_out(artwork_list: list[Artwork]) -> ImagesOut:
images = ImagesOut()
for artwork in artwork_list:
out = to_artwork_out(artwork)
if artwork.artwork_type == ArtworkType.POSTER:
images.posters.append(out)
elif artwork.artwork_type == ArtworkType.BACKDROP:
images.backdrops.append(out)
elif artwork.artwork_type == ArtworkType.LOGO:
images.logos.append(out)
return images
def primary_artwork_path(artwork_list: list[Artwork], artwork_type: ArtworkType) -> str | None:
for artwork in artwork_list:
if artwork.artwork_type == artwork_type:
return artwork_url(artwork)
return None
def to_video_out(video: Video) -> VideoOut:
return VideoOut(
id=aurora_id("video", str(video.id)),
type=video.video_type.value,
site=video.site,
url=video.url,
language=video.language_code,
country=video.country_code,
is_official=video.is_official,
published_at=video.published_at,
provider=video.provider.slug if video.provider else None,
)
def split_credits(
credits_: list[Credit], profile_paths: dict | None = None
) -> tuple[list[CastCreditOut], list[CrewCreditOut]]:
profile_paths = profile_paths or {}
cast: list[CastCreditOut] = []
crew: list[CrewCreditOut] = []
for credit in credits_:
person_brief = PersonBriefOut(
id=aurora_id("person", str(credit.person_id)),
name=credit.person.name,
profile_path=profile_paths.get(credit.person_id),
)
if credit.department == "Acting":
cast.append(
CastCreditOut(
id=aurora_id("credit", str(credit.id)),
person=person_brief,
character=credit.character,
billing_order=credit.billing_order,
episode_count=credit.episode_count,
)
)
else:
crew.append(
CrewCreditOut(
id=aurora_id("credit", str(credit.id)),
person=person_brief,
department=credit.department,
job=credit.job,
episode_count=credit.episode_count,
)
)
return cast, crew
+80
View File
@@ -0,0 +1,80 @@
"""Assembles Company API responses from repository data."""
from __future__ import annotations
import uuid
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.ids import aurora_id, parse_aurora_id
from app.models.enums import ArtworkType
from app.repositories import company_repository, polymorphic
from app.schemas.common import CompanyBriefOut
from app.schemas.company import CompanyDetailOut
from app.services.common import primary_artwork_path
class CompanyNotFoundError(Exception):
pass
def _resolve_company_id(aid: str) -> uuid.UUID:
try:
return uuid.UUID(parse_aurora_id(aid, expected_type="company"))
except ValueError as exc:
raise CompanyNotFoundError(str(exc)) from exc
async def get_company_detail(session: AsyncSession, company_aurora_id: str) -> CompanyDetailOut:
company_id = _resolve_company_id(company_aurora_id)
company = await company_repository.get_company_by_id(session, company_id)
if company is None:
raise CompanyNotFoundError(company_aurora_id)
logos = await polymorphic.get_artwork(session, "company", company_id, ArtworkType.LOGO)
external_ids = await polymorphic.get_external_ids(session, "company", company_id)
return CompanyDetailOut(
id=aurora_id("company", str(company.id)),
name=company.name,
company_type=company.company_type.value,
description=company.description,
country=company.country_code,
headquarters=company.headquarters,
homepage=company.homepage,
logo_path=primary_artwork_path(logos, ArtworkType.LOGO),
parent_company_id=(
aurora_id("company", str(company.parent_company_id))
if company.parent_company_id
else None
),
external_ids=external_ids,
created_at=company.created_at,
updated_at=company.updated_at,
)
async def build_company_summary(
session: AsyncSession, company_id: uuid.UUID
) -> CompanyBriefOut | None:
company = await company_repository.get_company_by_id(session, company_id)
if company is None:
return None
logos = await polymorphic.get_artwork(session, "company", company_id, ArtworkType.LOGO)
return CompanyBriefOut(
id=aurora_id("company", str(company.id)),
name=company.name,
company_type=company.company_type.value,
logo_path=primary_artwork_path(logos, ArtworkType.LOGO),
)
async def build_company_summaries(
session: AsyncSession, company_ids: list[uuid.UUID]
) -> list[CompanyBriefOut]:
out: list[CompanyBriefOut] = []
for company_id in company_ids:
summary = await build_company_summary(session, company_id)
if summary is not None:
out.append(summary)
return out
View File
+314
View File
@@ -0,0 +1,314 @@
"""Movie ingestion pipeline (section 15):
Provider -> raw data -> normalize -> match -> upsert Aurora entity ->
satellite tables (genres, keywords, companies, credits, artwork, videos,
external IDs) -> provenance record.
Idempotent: re-running for the same provider + external_id updates the same
Aurora movie rather than creating a duplicate (zero duplicate ingestion
records — section 2). Satellite associations are replaced wholesale on each
sync rather than diffed, which is simple and safe but means their Aurora
IDs (e.g. individual credit IDs) are not stable across re-syncs — see
docs/architecture.md for the tradeoff.
"""
from __future__ import annotations
import uuid
from sqlalchemy import delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.logging import get_logger
from app.models.artwork import Artwork
from app.models.company import MovieCompany
from app.models.credit import Credit
from app.models.enums import ArtworkType, MatchConfidence, MovieStatus, VideoType
from app.models.genre import MovieGenre
from app.models.keyword import MovieKeyword
from app.models.movie import Movie, MovieRecommendation
from app.models.video import Video
from app.providers.base import MetadataProvider
from app.repositories import reference_repository as refs
from app.repositories.provider_repository import get_or_create_provider, upsert_provider_record
from app.services.metadata.matcher import match_entity
from app.services.metadata.normalizer import NormalizedMovie, normalize_movie
logger = get_logger(__name__)
async def _replace_movie_genres(session: AsyncSession, movie_id: uuid.UUID, names: list[str]) -> None:
await session.execute(delete(MovieGenre).where(MovieGenre.movie_id == movie_id))
for name in names:
genre = await refs.get_or_create_genre(session, name)
session.add(MovieGenre(movie_id=movie_id, genre_id=genre.id))
async def _replace_movie_keywords(session: AsyncSession, movie_id: uuid.UUID, names: list[str]) -> None:
await session.execute(delete(MovieKeyword).where(MovieKeyword.movie_id == movie_id))
for name in names:
keyword = await refs.get_or_create_keyword(session, name)
session.add(MovieKeyword(movie_id=movie_id, keyword_id=keyword.id))
async def _replace_movie_companies(
session: AsyncSession, movie_id: uuid.UUID, provider_id: uuid.UUID, companies
) -> None:
await session.execute(delete(MovieCompany).where(MovieCompany.movie_id == movie_id))
for ref in companies:
country_code = None
if ref.country_code:
country = await refs.get_or_create_country(session, ref.country_code, ref.country_code)
country_code = country.code
company = await refs.get_or_create_company(
session,
provider_id=provider_id,
external_id=ref.external_id,
name=ref.name,
country_code=country_code,
)
session.add(MovieCompany(movie_id=movie_id, company_id=company.id, role=ref.role))
async def _replace_movie_credits(
session: AsyncSession, movie_id: uuid.UUID, provider_id: uuid.UUID, cast, crew
) -> None:
await session.execute(
delete(Credit).where(Credit.entity_type == "movie", Credit.entity_id == movie_id)
)
for order, ref in enumerate(cast):
person = await refs.get_or_create_person(
session,
provider_id=provider_id,
external_id=ref.external_id,
name=ref.name,
gender=ref.gender,
)
session.add(
Credit(
person_id=person.id,
entity_type="movie",
entity_id=movie_id,
department="Acting",
character=ref.character,
billing_order=ref.billing_order if ref.billing_order is not None else order,
provider_id=provider_id,
)
)
for ref in crew:
person = await refs.get_or_create_person(
session,
provider_id=provider_id,
external_id=ref.external_id,
name=ref.name,
gender=ref.gender,
)
session.add(
Credit(
person_id=person.id,
entity_type="movie",
entity_id=movie_id,
department=ref.department or "Crew",
job=ref.job,
provider_id=provider_id,
)
)
_ARTWORK_TYPES = {t.value for t in ArtworkType}
_VIDEO_TYPES = {t.value for t in VideoType}
async def _replace_movie_artwork(
session: AsyncSession, movie_id: uuid.UUID, provider_id: uuid.UUID, artwork_list
) -> None:
await session.execute(
delete(Artwork).where(Artwork.entity_type == "movie", Artwork.entity_id == movie_id)
)
for art in artwork_list:
art_type = art.artwork_type if art.artwork_type in _ARTWORK_TYPES else "poster"
session.add(
Artwork(
entity_type="movie",
entity_id=movie_id,
artwork_type=ArtworkType(art_type),
language_code=None,
width=art.width,
height=art.height,
aspect_ratio=art.aspect_ratio,
provider_id=provider_id,
provider_asset_id=art.provider_asset_id,
original_url=art.url,
vote_average=art.vote_average,
is_primary=False,
)
)
async def _replace_movie_videos(
session: AsyncSession, movie_id: uuid.UUID, provider_id: uuid.UUID, videos
) -> None:
await session.execute(
delete(Video).where(Video.entity_type == "movie", Video.entity_id == movie_id)
)
for v in videos:
video_type = v.video_type if v.video_type in _VIDEO_TYPES else "other"
session.add(
Video(
entity_type="movie",
entity_id=movie_id,
video_type=VideoType(video_type),
provider_id=provider_id,
provider_video_id=v.provider_video_id,
site=v.site,
url=v.url,
language_code=None,
country_code=None,
is_official=v.is_official,
published_at=v.published_at,
)
)
async def _link_external_ids(
session: AsyncSession, movie_id: uuid.UUID, tmdb_provider_id: uuid.UUID, normalized: NormalizedMovie
) -> None:
await refs.link_external_id(
session,
entity_type="movie",
entity_id=movie_id,
provider_id=tmdb_provider_id,
external_id=normalized.external_id,
)
for slug, external_id in normalized.external_ids.items():
provider = await get_or_create_provider(session, slug, slug.upper())
await refs.link_external_id(
session,
entity_type="movie",
entity_id=movie_id,
provider_id=provider.id,
external_id=external_id,
)
async def ingest_movie(
session: AsyncSession,
provider: MetadataProvider,
external_id: str,
*,
ingestion_job_id: uuid.UUID | None = None,
) -> uuid.UUID:
"""Fetch, normalize, and upsert a single movie. Returns the Aurora
movie UUID (new or existing)."""
tmdb_provider = await get_or_create_provider(session, provider.slug, provider.name)
aurora_id_existing, confidence = await match_entity(
session, provider_id=tmdb_provider.id, entity_type="movie", external_id=external_id
)
raw = await provider.get_movie(external_id)
normalized = normalize_movie(raw)
existing_movie: Movie | None = None
if aurora_id_existing is not None:
existing_movie = await session.get(Movie, aurora_id_existing)
if existing_movie is None:
aurora_id_existing = None
movie: Movie
if existing_movie is not None:
movie = existing_movie
else:
movie = Movie(title=normalized.title, sort_title=_sort_title(normalized.title))
session.add(movie)
await session.flush()
movie.title = normalized.title
movie.original_title = normalized.original_title
movie.sort_title = _sort_title(normalized.title)
movie.overview = normalized.overview
movie.tagline = normalized.tagline
movie.status = MovieStatus(normalized.status)
movie.primary_release_date = normalized.release_date
movie.runtime_minutes = normalized.runtime_minutes
movie.budget = normalized.budget
movie.revenue = normalized.revenue
movie.popularity = normalized.popularity
movie.vote_average = normalized.vote_average
movie.vote_count = normalized.vote_count
movie.adult = normalized.adult
movie.source_provider_id = tmdb_provider.id
if normalized.original_language:
language = await refs.get_or_create_language(
session, normalized.original_language, normalized.original_language.upper()
)
movie.original_language_code = language.code
if normalized.collection:
ext_id, name = normalized.collection
collection = await refs.get_or_create_collection(
session, provider_id=tmdb_provider.id, external_id=ext_id, name=name
)
movie.collection_id = collection.id
await session.flush()
await _replace_movie_genres(session, movie.id, normalized.genres)
await _replace_movie_keywords(session, movie.id, normalized.keywords)
await _replace_movie_companies(session, movie.id, tmdb_provider.id, normalized.companies)
await _replace_movie_credits(session, movie.id, tmdb_provider.id, normalized.cast, normalized.crew)
await _replace_movie_artwork(session, movie.id, tmdb_provider.id, normalized.artwork)
await _replace_movie_videos(session, movie.id, tmdb_provider.id, normalized.videos)
await _link_external_ids(session, movie.id, tmdb_provider.id, normalized)
await session.execute(
delete(MovieRecommendation).where(MovieRecommendation.movie_id == movie.id)
)
if normalized.recommendation_external_ids:
rec_ids = await refs.find_entities_by_external_ids(
session, tmdb_provider.id, "movie", normalized.recommendation_external_ids
)
for rank, rec_movie_id in enumerate(rec_ids):
session.add(
MovieRecommendation(
movie_id=movie.id,
recommended_movie_id=rec_movie_id,
rank=rank,
source="tmdb",
)
)
await refs.link_external_id(
session,
entity_type="movie",
entity_id=movie.id,
provider_id=tmdb_provider.id,
external_id=external_id,
)
await upsert_provider_record(
session,
provider_id=tmdb_provider.id,
entity_type="movie",
external_id=external_id,
raw_data=raw,
aurora_entity_id=movie.id,
match_confidence=confidence if aurora_id_existing else MatchConfidence.NEW,
ingestion_job_id=ingestion_job_id,
)
logger.info("movie_ingested", movie_id=str(movie.id), external_id=external_id)
return movie.id
_LEADING_ARTICLES = ("the ", "a ", "an ")
def _sort_title(title: str) -> str:
lowered = title.lower()
for article in _LEADING_ARTICLES:
if lowered.startswith(article):
return title[len(article) :].strip()
return title
+115
View File
@@ -0,0 +1,115 @@
"""Person ingestion pipeline (section 15).
Movie/series ingestion already creates a stub `Person` row (name + gender)
for each cast/crew credit via `get_or_create_person`. This module fetches
and upserts a *full* profile (biography, birthday, images, ...) for one
person, either standalone (admin-triggered) or as a follow-up job.
"""
from __future__ import annotations
import uuid
from sqlalchemy import delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.logging import get_logger
from app.models.artwork import Artwork
from app.models.enums import ArtworkType, MatchConfidence
from app.models.person import Person
from app.providers.base import MetadataProvider
from app.repositories import reference_repository as refs
from app.repositories.provider_repository import get_or_create_provider, upsert_provider_record
from app.services.metadata.matcher import match_entity
from app.services.metadata.normalizer import normalize_person
logger = get_logger(__name__)
async def ingest_person(
session: AsyncSession,
provider: MetadataProvider,
external_id: str,
*,
ingestion_job_id: uuid.UUID | None = None,
) -> uuid.UUID:
tmdb_provider = await get_or_create_provider(session, provider.slug, provider.name)
aurora_id_existing, confidence = await match_entity(
session, provider_id=tmdb_provider.id, entity_type="person", external_id=external_id
)
raw = await provider.get_person(external_id)
normalized = normalize_person(raw)
person: Person | None = None
if aurora_id_existing is not None:
person = await session.get(Person, aurora_id_existing)
if person is None:
person = Person(name=normalized.name)
session.add(person)
await session.flush()
aurora_id_existing = None
person.name = normalized.name
person.biography = normalized.biography
person.birthday = normalized.birthday
person.deathday = normalized.deathday
person.birthplace = normalized.birthplace
person.gender = normalized.gender
person.known_for_department = normalized.known_for_department
person.popularity = normalized.popularity
person.aliases = normalized.aliases
await session.flush()
await session.execute(
delete(Artwork).where(Artwork.entity_type == "person", Artwork.entity_id == person.id)
)
for art in normalized.artwork:
session.add(
Artwork(
entity_type="person",
entity_id=person.id,
artwork_type=ArtworkType.PROFILE,
width=art.width,
height=art.height,
aspect_ratio=art.aspect_ratio,
provider_id=tmdb_provider.id,
provider_asset_id=art.provider_asset_id,
original_url=art.url,
vote_average=art.vote_average,
)
)
await refs.link_external_id(
session,
entity_type="person",
entity_id=person.id,
provider_id=tmdb_provider.id,
external_id=external_id,
)
for slug, ext_id in normalized.external_ids.items():
ext_provider = await get_or_create_provider(session, slug, slug.upper())
await refs.link_external_id(
session,
entity_type="person",
entity_id=person.id,
provider_id=ext_provider.id,
external_id=ext_id,
)
await upsert_provider_record(
session,
provider_id=tmdb_provider.id,
entity_type="person",
external_id=external_id,
raw_data=raw,
aurora_entity_id=person.id,
match_confidence=confidence if aurora_id_existing else MatchConfidence.NEW,
ingestion_job_id=ingestion_job_id,
)
logger.info("person_ingested", person_id=str(person.id), external_id=external_id)
return person.id
+103
View File
@@ -0,0 +1,103 @@
"""Orchestrates one ingestion job end-to-end: create/track the `IngestionJob`
row, run the provider fetch + normalize + upsert pipeline, and record
success/failure. Used by both the admin trigger endpoint (section 17) and
the arq worker task (section 15) so the two entry points share identical
job-lifecycle and idempotency behavior.
"""
from __future__ import annotations
import uuid
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import session_scope
from app.core.logging import get_logger
from app.models.provider import IngestionJob
from app.providers.tmdb import TMDbProvider
from app.repositories.provider_repository import (
create_ingestion_job,
get_ingestion_job,
get_or_create_provider,
mark_job_failed,
mark_job_running,
mark_job_succeeded,
)
from app.services.ingestion.movie_ingestion import ingest_movie
from app.services.ingestion.person_ingestion import ingest_person
logger = get_logger(__name__)
async def _require_job(session: AsyncSession, job_id: uuid.UUID) -> IngestionJob:
job = await get_ingestion_job(session, job_id)
if job is None:
# Only possible if the row was deleted out-of-band; the job was
# created moments earlier in this same call chain.
raise RuntimeError(f"ingestion job {job_id} vanished mid-run")
return job
async def _create_job(job_type: str, entity_type: str, external_id: str) -> uuid.UUID:
idempotency_key = f"tmdb:{entity_type}:{external_id}"
async with session_scope() as session:
provider_row = await get_or_create_provider(session, "tmdb", "The Movie Database (TMDb)")
job = await create_ingestion_job(
session,
provider_id=provider_row.id,
job_type=job_type,
idempotency_key=idempotency_key,
entity_type=entity_type,
external_id=external_id,
)
return job.id
async def run_movie_ingestion(external_id: str) -> uuid.UUID:
job_id = await _create_job("movie_sync", "movie", external_id)
async with session_scope() as session:
job = await _require_job(session, job_id)
await mark_job_running(session, job)
provider = TMDbProvider()
try:
async with session_scope() as session:
movie_id = await ingest_movie(session, provider, external_id, ingestion_job_id=job_id)
async with session_scope() as session:
job = await _require_job(session, job_id)
await mark_job_succeeded(session, job, movie_id)
return movie_id
except Exception as exc:
logger.error("movie_ingestion_failed", external_id=external_id, error=str(exc))
async with session_scope() as session:
job = await _require_job(session, job_id)
await mark_job_failed(session, job, str(exc))
raise
finally:
await provider.aclose()
async def run_person_ingestion(external_id: str) -> uuid.UUID:
job_id = await _create_job("person_sync", "person", external_id)
async with session_scope() as session:
job = await _require_job(session, job_id)
await mark_job_running(session, job)
provider = TMDbProvider()
try:
async with session_scope() as session:
person_id = await ingest_person(session, provider, external_id, ingestion_job_id=job_id)
async with session_scope() as session:
job = await _require_job(session, job_id)
await mark_job_succeeded(session, job, person_id)
return person_id
except Exception as exc:
logger.error("person_ingestion_failed", external_id=external_id, error=str(exc))
async with session_scope() as session:
job = await _require_job(session, job_id)
await mark_job_failed(session, job, str(exc))
raise
finally:
await provider.aclose()
View File
+37
View File
@@ -0,0 +1,37 @@
"""Entity matching (section 14): decides whether an incoming provider
record corresponds to an existing Aurora entity.
Core 0.1 policy — deliberately conservative:
- An exact external-ID match (this provider has already been linked to an
Aurora entity of this type) is the only case treated as MatchConfidence
EXACT and reused.
- Anything else creates a *new* Aurora entity (MatchConfidence NEW) rather
than guessing via fuzzy title matching. Silently merging two providers'
differing descriptions of "the same" title is exactly what section 14
prohibits.
Fuzzy candidate matching (title + year similarity, surfaced for human
review) is genuinely deferred — see docs/architecture.md — until Core has
an admin review workflow to act on AMBIGUOUS matches.
"""
from __future__ import annotations
import uuid
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.enums import MatchConfidence
from app.repositories.reference_repository import find_entity_by_external_id
async def match_entity(
session: AsyncSession, *, provider_id: uuid.UUID, entity_type: str, external_id: str
) -> tuple[uuid.UUID | None, MatchConfidence]:
"""Return (existing_aurora_id_or_None, confidence)."""
existing = await find_entity_by_external_id(session, provider_id, entity_type, external_id)
if existing is not None:
return existing, MatchConfidence.EXACT
return None, MatchConfidence.NEW
+341
View File
@@ -0,0 +1,341 @@
"""Normalizes raw TMDb payloads into provider-agnostic dataclasses.
This is the seam between "provider shape" and "Aurora shape" (section 13).
A future provider adapter only needs its own `normalize_movie`-equivalent
that produces these same dataclasses; nothing downstream (matcher,
ingestion) needs to know which provider the data came from.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import date
TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p/original"
def _parse_date(value: str | None) -> date | None:
if not value:
return None
try:
return date.fromisoformat(value)
except ValueError:
return None
def _image_url(path: str | None) -> str | None:
if not path:
return None
return f"{TMDB_IMAGE_BASE}{path}"
def _image_url_required(path: str) -> str:
"""Same as `_image_url`, for call sites already guarded by a truthy
check on `path` — keeps `NormalizedArtwork.url: str` honest."""
return f"{TMDB_IMAGE_BASE}{path}"
@dataclass
class NormalizedPersonRef:
external_id: str
name: str
profile_path: str | None = None
department: str | None = None
job: str | None = None
character: str | None = None
billing_order: int | None = None
gender: str | None = None
@dataclass
class NormalizedCompanyRef:
external_id: str
name: str
country_code: str | None = None
role: str = "production"
@dataclass
class NormalizedArtwork:
artwork_type: str # "poster" | "backdrop" | "logo" | "profile" | "still"
url: str
provider_asset_id: str | None
width: int | None
height: int | None
aspect_ratio: float | None
language: str | None
vote_average: float | None = None
@dataclass
class NormalizedVideo:
video_type: str
provider_video_id: str
site: str | None
url: str
language: str | None
country: str | None
is_official: bool
published_at: date | None
@dataclass
class NormalizedMovie:
external_id: str
title: str
original_title: str | None
overview: str | None
tagline: str | None
status: str
original_language: str | None
release_date: date | None
runtime_minutes: int | None
budget: int | None
revenue: int | None
popularity: float | None
vote_average: float | None
vote_count: int | None
adult: bool
genres: list[str] = field(default_factory=list)
keywords: list[str] = field(default_factory=list)
alternate_titles: list[str] = field(default_factory=list)
companies: list[NormalizedCompanyRef] = field(default_factory=list)
cast: list[NormalizedPersonRef] = field(default_factory=list)
crew: list[NormalizedPersonRef] = field(default_factory=list)
artwork: list[NormalizedArtwork] = field(default_factory=list)
videos: list[NormalizedVideo] = field(default_factory=list)
external_ids: dict[str, str] = field(default_factory=dict)
collection: tuple[str, str] | None = None # (external_id, name)
recommendation_external_ids: list[str] = field(default_factory=list)
_STATUS_MAP = {
"Rumored": "rumored",
"Planned": "planned",
"In Production": "in_production",
"Post Production": "post_production",
"Released": "released",
"Canceled": "canceled",
}
def normalize_movie(raw: dict) -> NormalizedMovie:
credits_ = raw.get("credits") or {}
images = raw.get("images") or {}
videos = (raw.get("videos") or {}).get("results") or []
keywords = (raw.get("keywords") or {}).get("keywords") or []
alt_titles = (raw.get("alternative_titles") or {}).get("titles") or []
ext_ids = raw.get("external_ids") or {}
collection = raw.get("belongs_to_collection")
recommendations = (raw.get("recommendations") or {}).get("results") or []
external_ids: dict[str, str] = {}
if raw.get("imdb_id"):
external_ids["imdb"] = raw["imdb_id"]
elif ext_ids.get("imdb_id"):
external_ids["imdb"] = ext_ids["imdb_id"]
if ext_ids.get("wikidata_id"):
external_ids["wikidata"] = ext_ids["wikidata_id"]
cast = [
NormalizedPersonRef(
external_id=str(c["id"]),
name=c["name"],
profile_path=_image_url(c.get("profile_path")),
department="Acting",
character=c.get("character"),
billing_order=c.get("order"),
gender=_gender_label(c.get("gender")),
)
for c in credits_.get("cast", [])
]
crew = [
NormalizedPersonRef(
external_id=str(c["id"]),
name=c["name"],
profile_path=_image_url(c.get("profile_path")),
department=c.get("department") or "Crew",
job=c.get("job"),
gender=_gender_label(c.get("gender")),
)
for c in credits_.get("crew", [])
]
companies = [
NormalizedCompanyRef(
external_id=str(pc["id"]),
name=pc["name"],
country_code=pc.get("origin_country") or None,
)
for pc in raw.get("production_companies", [])
]
artwork: list[NormalizedArtwork] = []
if raw.get("poster_path"):
artwork.append(
NormalizedArtwork(
"poster",
_image_url_required(raw["poster_path"]),
raw["poster_path"],
None,
None,
None,
None,
)
)
if raw.get("backdrop_path"):
artwork.append(
NormalizedArtwork(
"backdrop",
_image_url_required(raw["backdrop_path"]),
raw["backdrop_path"],
None,
None,
None,
None,
)
)
for kind, tmdb_key in (("poster", "posters"), ("backdrop", "backdrops"), ("logo", "logos")):
for img in images.get(tmdb_key, []):
artwork.append(
NormalizedArtwork(
kind,
_image_url_required(img["file_path"]),
img["file_path"],
img.get("width"),
img.get("height"),
img.get("aspect_ratio"),
img.get("iso_639_1"),
img.get("vote_average"),
)
)
normalized_videos = [
NormalizedVideo(
video_type=(v.get("type") or "other").lower().replace(" ", "_"),
provider_video_id=v["id"],
site=v.get("site"),
url=_video_url(v),
language=v.get("iso_639_1"),
country=v.get("iso_3166_1"),
is_official=bool(v.get("official")),
published_at=_parse_date(v.get("published_at", "")[:10] if v.get("published_at") else None),
)
for v in videos
]
return NormalizedMovie(
external_id=str(raw["id"]),
title=raw.get("title") or raw.get("original_title") or "",
original_title=raw.get("original_title"),
overview=raw.get("overview") or None,
tagline=raw.get("tagline") or None,
status=_STATUS_MAP.get(raw.get("status", "Released"), "released"),
original_language=raw.get("original_language"),
release_date=_parse_date(raw.get("release_date")),
runtime_minutes=raw.get("runtime"),
budget=raw.get("budget") or None,
revenue=raw.get("revenue") or None,
popularity=raw.get("popularity"),
vote_average=raw.get("vote_average"),
vote_count=raw.get("vote_count"),
adult=bool(raw.get("adult", False)),
genres=[g["name"] for g in raw.get("genres", [])],
keywords=[k["name"] for k in keywords],
alternate_titles=[t["title"] for t in alt_titles if t.get("title")],
companies=companies,
cast=cast,
crew=crew,
artwork=artwork,
videos=normalized_videos,
external_ids=external_ids,
collection=(str(collection["id"]), collection["name"]) if collection else None,
recommendation_external_ids=[str(m["id"]) for m in recommendations],
)
@dataclass
class NormalizedPerson:
external_id: str
name: str
biography: str | None
birthday: date | None
deathday: date | None
birthplace: str | None
gender: str | None
known_for_department: str | None
popularity: float | None
aliases: list[str]
artwork: list[NormalizedArtwork]
external_ids: dict[str, str]
def _gender_label(tmdb_gender: int | None) -> str | None:
return {1: "female", 2: "male", 3: "non_binary"}.get(tmdb_gender or 0)
def _video_url(video: dict) -> str:
site = (video.get("site") or "").lower()
key = video.get("key", "")
if site == "youtube":
return f"https://www.youtube.com/watch?v={key}"
if site == "vimeo":
return f"https://vimeo.com/{key}"
return key
def normalize_person(raw: dict) -> NormalizedPerson:
images = (raw.get("images") or {}).get("profiles") or []
ext_ids = raw.get("external_ids") or {}
external_ids: dict[str, str] = {}
if raw.get("imdb_id"):
external_ids["imdb"] = raw["imdb_id"]
elif ext_ids.get("imdb_id"):
external_ids["imdb"] = ext_ids["imdb_id"]
if ext_ids.get("wikidata_id"):
external_ids["wikidata"] = ext_ids["wikidata_id"]
artwork = [
NormalizedArtwork(
"profile",
_image_url_required(img["file_path"]),
img["file_path"],
img.get("width"),
img.get("height"),
img.get("aspect_ratio"),
img.get("iso_639_1"),
img.get("vote_average"),
)
for img in images
]
if raw.get("profile_path") and not any(a.provider_asset_id == raw["profile_path"] for a in artwork):
artwork.insert(
0,
NormalizedArtwork(
"profile",
_image_url_required(raw["profile_path"]),
raw["profile_path"],
None,
None,
None,
None,
),
)
also_known_as = raw.get("also_known_as") or []
return NormalizedPerson(
external_id=str(raw["id"]),
name=raw.get("name", ""),
biography=raw.get("biography") or None,
birthday=_parse_date(raw.get("birthday")),
deathday=_parse_date(raw.get("deathday")),
birthplace=raw.get("place_of_birth") or None,
gender=_gender_label(raw.get("gender")),
known_for_department=raw.get("known_for_department"),
popularity=raw.get("popularity"),
aliases=list(also_known_as),
artwork=artwork,
external_ids=external_ids,
)
+147
View File
@@ -0,0 +1,147 @@
"""Assembles Movie API responses from repository data."""
from __future__ import annotations
import uuid
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.ids import aurora_id, parse_aurora_id
from app.models.enums import ArtworkType
from app.repositories import movie_repository, polymorphic
from app.schemas.common import CollectionBriefOut, CompanyBriefOut, GenreOut, KeywordOut
from app.schemas.movie import (
MovieCreditsOut,
MovieDetailOut,
MovieImagesOut,
MovieSummaryOut,
MovieVideosOut,
)
from app.services.common import primary_artwork_path, split_credits, to_images_out, to_video_out
class MovieNotFoundError(Exception):
pass
async def _resolve_movie_id(movie_aurora_id: str) -> uuid.UUID:
try:
return uuid.UUID(parse_aurora_id(movie_aurora_id, expected_type="movie"))
except ValueError as exc:
raise MovieNotFoundError(str(exc)) from exc
async def get_movie_detail(session: AsyncSession, movie_aurora_id: str) -> MovieDetailOut:
movie_id = await _resolve_movie_id(movie_aurora_id)
movie = await movie_repository.get_movie_by_id(session, movie_id)
if movie is None:
raise MovieNotFoundError(movie_aurora_id)
genres = await movie_repository.get_movie_genres(session, movie_id)
keywords = await movie_repository.get_movie_keywords(session, movie_id)
companies = await movie_repository.get_movie_companies(session, movie_id)
credits_ = await polymorphic.get_credits(session, "movie", movie_id)
artwork = await polymorphic.get_artwork(session, "movie", movie_id)
videos = await polymorphic.get_videos(session, "movie", movie_id)
external_ids = await polymorphic.get_external_ids(session, "movie", movie_id)
cast, crew = split_credits(credits_)
return MovieDetailOut(
id=aurora_id("movie", str(movie.id)),
title=movie.title,
original_title=movie.original_title,
overview=movie.overview,
tagline=movie.tagline,
status=movie.status.value,
original_language=movie.original_language_code,
release_date=movie.primary_release_date,
runtime=movie.runtime_minutes,
budget=movie.budget,
revenue=movie.revenue,
popularity=movie.popularity,
vote_average=movie.vote_average,
vote_count=movie.vote_count,
adult=movie.adult,
genres=[GenreOut(id=aurora_id("genre", str(g.id)), name=g.name) for g in genres],
keywords=[KeywordOut(id=aurora_id("keyword", str(k.id)), name=k.name) for k in keywords],
cast=cast,
crew=crew,
companies=[
CompanyBriefOut(
id=aurora_id("company", str(company.id)),
name=company.name,
company_type=company.company_type.value,
logo_path=None,
)
for company, _role in companies
],
collection=(
CollectionBriefOut(
id=aurora_id("collection", str(movie.collection.id)), name=movie.collection.name
)
if movie.collection
else None
),
images=to_images_out(artwork),
videos=[to_video_out(v) for v in videos],
external_ids=external_ids,
created_at=movie.created_at,
updated_at=movie.updated_at,
)
async def get_movie_credits(session: AsyncSession, movie_aurora_id: str) -> MovieCreditsOut:
movie_id = await _resolve_movie_id(movie_aurora_id)
movie = await movie_repository.get_movie_by_id(session, movie_id)
if movie is None:
raise MovieNotFoundError(movie_aurora_id)
credits_ = await polymorphic.get_credits(session, "movie", movie_id)
cast, crew = split_credits(credits_)
return MovieCreditsOut(id=aurora_id("movie", str(movie.id)), cast=cast, crew=crew)
async def get_movie_images(session: AsyncSession, movie_aurora_id: str) -> MovieImagesOut:
movie_id = await _resolve_movie_id(movie_aurora_id)
movie = await movie_repository.get_movie_by_id(session, movie_id)
if movie is None:
raise MovieNotFoundError(movie_aurora_id)
artwork = await polymorphic.get_artwork(session, "movie", movie_id)
return MovieImagesOut(id=aurora_id("movie", str(movie.id)), images=to_images_out(artwork))
async def get_movie_videos(session: AsyncSession, movie_aurora_id: str) -> MovieVideosOut:
movie_id = await _resolve_movie_id(movie_aurora_id)
movie = await movie_repository.get_movie_by_id(session, movie_id)
if movie is None:
raise MovieNotFoundError(movie_aurora_id)
videos = await polymorphic.get_videos(session, "movie", movie_id)
return MovieVideosOut(id=aurora_id("movie", str(movie.id)), videos=[to_video_out(v) for v in videos])
async def build_movie_summary(session: AsyncSession, movie_id: uuid.UUID) -> MovieSummaryOut | None:
movie = await movie_repository.get_movie_by_id(session, movie_id)
if movie is None:
return None
posters = await polymorphic.get_artwork(session, "movie", movie_id, ArtworkType.POSTER)
return MovieSummaryOut(
id=aurora_id("movie", str(movie.id)),
title=movie.title,
original_title=movie.original_title,
overview=movie.overview,
release_date=movie.primary_release_date,
poster_path=primary_artwork_path(posters, ArtworkType.POSTER),
popularity=movie.popularity,
vote_average=movie.vote_average,
)
async def build_movie_summaries(
session: AsyncSession, movie_ids: list[uuid.UUID]
) -> list[MovieSummaryOut]:
out: list[MovieSummaryOut] = []
for movie_id in movie_ids:
summary = await build_movie_summary(session, movie_id)
if summary is not None:
out.append(summary)
return out
+127
View File
@@ -0,0 +1,127 @@
"""Assembles Person API responses from repository data."""
from __future__ import annotations
import uuid
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.ids import aurora_id, parse_aurora_id
from app.models.enums import ArtworkType
from app.repositories import movie_repository, person_repository, polymorphic
from app.schemas.person import (
FilmographyCreditOut,
PersonCreditsOut,
PersonDetailOut,
PersonSummaryOut,
)
from app.services.common import primary_artwork_path, to_artwork_out
class PersonNotFoundError(Exception):
pass
def _resolve_person_id(aid: str) -> uuid.UUID:
try:
return uuid.UUID(parse_aurora_id(aid, expected_type="person"))
except ValueError as exc:
raise PersonNotFoundError(str(exc)) from exc
async def get_person_detail(session: AsyncSession, person_aurora_id: str) -> PersonDetailOut:
person_id = _resolve_person_id(person_aurora_id)
person = await person_repository.get_person_by_id(session, person_id)
if person is None:
raise PersonNotFoundError(person_aurora_id)
profiles = await polymorphic.get_artwork(session, "person", person_id, ArtworkType.PROFILE)
external_ids = await polymorphic.get_external_ids(session, "person", person_id)
return PersonDetailOut(
id=aurora_id("person", str(person.id)),
name=person.name,
aliases=list(person.aliases or []),
biography=person.biography,
birthday=person.birthday,
deathday=person.deathday,
birthplace=person.birthplace,
gender=person.gender,
known_for_department=person.known_for_department,
popularity=person.popularity,
profile_images=[to_artwork_out(a) for a in profiles],
external_ids=external_ids,
created_at=person.created_at,
updated_at=person.updated_at,
)
async def get_person_credits(session: AsyncSession, person_aurora_id: str) -> PersonCreditsOut:
person_id = _resolve_person_id(person_aurora_id)
person = await person_repository.get_person_by_id(session, person_id)
if person is None:
raise PersonNotFoundError(person_aurora_id)
credits_ = await person_repository.get_person_credits(session, person_id)
movie_ids = [c.entity_id for c in credits_ if c.entity_type == "movie"]
movies = {m.id: m for m in await movie_repository.get_movies_by_ids(session, movie_ids)}
cast: list[FilmographyCreditOut] = []
crew: list[FilmographyCreditOut] = []
for credit in credits_:
if credit.entity_type != "movie":
# Series/episode filmography follows the same shape; movies are
# implemented first per the Core 0.1 milestone (section 34).
continue
movie = movies.get(credit.entity_id)
if movie is None:
continue
posters = await polymorphic.get_artwork(
session, "movie", movie.id, ArtworkType.POSTER
)
item = FilmographyCreditOut(
id=aurora_id("credit", str(credit.id)),
media_id=aurora_id("movie", str(movie.id)),
media_type="movie",
title=movie.title,
department=credit.department,
job=credit.job,
character=credit.character,
release_date=movie.primary_release_date,
poster_path=primary_artwork_path(posters, ArtworkType.POSTER),
)
if credit.department == "Acting":
cast.append(item)
else:
crew.append(item)
return PersonCreditsOut(id=aurora_id("person", str(person.id)), cast=cast, crew=crew)
async def build_person_summary(
session: AsyncSession, person_id: uuid.UUID
) -> PersonSummaryOut | None:
person = await person_repository.get_person_by_id(session, person_id)
if person is None:
return None
profiles = await polymorphic.get_artwork(session, "person", person_id, ArtworkType.PROFILE)
return PersonSummaryOut(
id=aurora_id("person", str(person.id)),
name=person.name,
known_for_department=person.known_for_department,
profile_path=primary_artwork_path(profiles, ArtworkType.PROFILE),
popularity=person.popularity,
)
async def build_person_summaries(
session: AsyncSession, person_ids: list[uuid.UUID]
) -> list[PersonSummaryOut]:
out: list[PersonSummaryOut] = []
for person_id in person_ids:
summary = await build_person_summary(session, person_id)
if summary is not None:
out.append(summary)
return out
View File
+40
View File
@@ -0,0 +1,40 @@
"""Search provider abstraction (section 16).
API clients and the rest of the service layer talk to `SearchProvider`, not
to PostgreSQL directly, so the underlying engine can be swapped later (e.g.
for an `OpenSearchProvider`) without touching callers.
"""
from __future__ import annotations
import uuid
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from sqlalchemy.ext.asyncio import AsyncSession
@dataclass
class SearchMatch:
entity_id: uuid.UUID
score: float
@dataclass
class SearchResultIDs:
movies: list[SearchMatch] = field(default_factory=list)
series: list[SearchMatch] = field(default_factory=list)
people: list[SearchMatch] = field(default_factory=list)
collections: list[SearchMatch] = field(default_factory=list)
companies: list[SearchMatch] = field(default_factory=list)
class SearchProvider(ABC):
"""Returns ranked Aurora entity IDs matching a query. Never returns
fully-hydrated entities — that's the SearchService's job, so the
provider stays a thin, swappable ranking layer."""
@abstractmethod
async def search(
self, session: AsyncSession, query: str, *, limit_per_type: int = 10
) -> SearchResultIDs: ...
+121
View File
@@ -0,0 +1,121 @@
"""PostgreSQL-backed search: full-text search ranking blended with trigram
similarity for typo tolerance, over primary titles/names and alternate
titles. Requires the `pg_trgm` extension and the functional GIN indexes
created in the initial migration.
"""
from __future__ import annotations
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.search.base import SearchMatch, SearchProvider, SearchResultIDs
_MOVIE_SQL = text(
"""
WITH scored AS (
SELECT m.id AS entity_id,
GREATEST(
ts_rank_cd(to_tsvector('simple', coalesce(m.title, '') || ' ' || coalesce(m.original_title, '')), plainto_tsquery('simple', :q)),
similarity(m.title, :q)
) AS score
FROM movies m
WHERE to_tsvector('simple', coalesce(m.title, '') || ' ' || coalesce(m.original_title, '')) @@ plainto_tsquery('simple', :q)
OR m.title % :q
UNION ALL
SELECT mat.movie_id AS entity_id, similarity(mat.title, :q) AS score
FROM movie_alternate_titles mat
WHERE mat.title % :q
)
SELECT entity_id, MAX(score) AS score
FROM scored
GROUP BY entity_id
ORDER BY score DESC
LIMIT :limit
"""
)
_SERIES_SQL = text(
"""
WITH scored AS (
SELECT s.id AS entity_id,
GREATEST(
ts_rank_cd(to_tsvector('simple', coalesce(s.title, '') || ' ' || coalesce(s.original_title, '')), plainto_tsquery('simple', :q)),
similarity(s.title, :q)
) AS score
FROM series s
WHERE to_tsvector('simple', coalesce(s.title, '') || ' ' || coalesce(s.original_title, '')) @@ plainto_tsquery('simple', :q)
OR s.title % :q
UNION ALL
SELECT sat.series_id AS entity_id, similarity(sat.title, :q) AS score
FROM series_alternate_titles sat
WHERE sat.title % :q
)
SELECT entity_id, MAX(score) AS score
FROM scored
GROUP BY entity_id
ORDER BY score DESC
LIMIT :limit
"""
)
_PEOPLE_SQL = text(
"""
SELECT p.id AS entity_id,
GREATEST(
ts_rank_cd(to_tsvector('simple', p.name), plainto_tsquery('simple', :q)),
similarity(p.name, :q)
) AS score
FROM people p
WHERE to_tsvector('simple', p.name) @@ plainto_tsquery('simple', :q)
OR p.name % :q
ORDER BY score DESC
LIMIT :limit
"""
)
_COLLECTIONS_SQL = text(
"""
SELECT c.id AS entity_id, similarity(c.name, :q) AS score
FROM collections c
WHERE c.name % :q OR to_tsvector('simple', c.name) @@ plainto_tsquery('simple', :q)
ORDER BY score DESC
LIMIT :limit
"""
)
_COMPANIES_SQL = text(
"""
SELECT co.id AS entity_id, similarity(co.name, :q) AS score
FROM companies co
WHERE co.name % :q OR to_tsvector('simple', co.name) @@ plainto_tsquery('simple', :q)
ORDER BY score DESC
LIMIT :limit
"""
)
class PostgreSQLSearchProvider(SearchProvider):
async def search(
self, session: AsyncSession, query: str, *, limit_per_type: int = 10
) -> SearchResultIDs:
params = {"q": query, "limit": limit_per_type}
movies = await session.execute(_MOVIE_SQL, params)
series = await session.execute(_SERIES_SQL, params)
people = await session.execute(_PEOPLE_SQL, params)
collections = await session.execute(_COLLECTIONS_SQL, params)
companies = await session.execute(_COMPANIES_SQL, params)
def to_matches(rows) -> list[SearchMatch]:
return [
SearchMatch(entity_id=row.entity_id, score=float(row.score or 0.0))
for row in rows
]
return SearchResultIDs(
movies=to_matches(movies),
series=to_matches(series),
people=to_matches(people),
collections=to_matches(collections),
companies=to_matches(companies),
)
+49
View File
@@ -0,0 +1,49 @@
"""Search service: runs a `SearchProvider` and hydrates matches into full
response summaries via the entity services. API clients never see the
underlying search engine (section 16)."""
from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession
from app.schemas.search import SearchResultsOut
from app.services.collection_service import build_collection_summaries
from app.services.company_service import build_company_summaries
from app.services.movie_service import build_movie_summaries
from app.services.person_service import build_person_summaries
from app.services.search.base import SearchProvider
from app.services.tv_service import build_series_summaries
class SearchService:
def __init__(self, provider: SearchProvider) -> None:
self._provider = provider
async def search(
self, session: AsyncSession, query: str, *, limit_per_type: int = 10
) -> SearchResultsOut:
query = query.strip()
if not query:
return SearchResultsOut(query=query)
results = await self._provider.search(session, query, limit_per_type=limit_per_type)
movies = await build_movie_summaries(session, [m.entity_id for m in results.movies])
series = await build_series_summaries(session, [s.entity_id for s in results.series])
people = await build_person_summaries(session, [p.entity_id for p in results.people])
collections = await build_collection_summaries(
session, [c.entity_id for c in results.collections]
)
companies = await build_company_summaries(
session, [c.entity_id for c in results.companies]
)
return SearchResultsOut(
query=query,
movies=movies,
series=series,
people=people,
collections=collections,
companies=companies,
total_results=len(movies) + len(series) + len(people) + len(collections) + len(companies),
)
+229
View File
@@ -0,0 +1,229 @@
"""Assembles Series/Season/Episode API responses from repository data."""
from __future__ import annotations
import uuid
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.ids import aurora_id, parse_aurora_id
from app.models.enums import ArtworkType, CompanyType
from app.repositories import polymorphic, tv_repository
from app.schemas.common import CompanyBriefOut, GenreOut, KeywordOut
from app.schemas.tv import (
EpisodeBriefOut,
EpisodeDetailOut,
SeasonBriefOut,
SeasonDetailOut,
SeriesDetailOut,
SeriesSeasonsOut,
SeriesSummaryOut,
)
from app.services.common import primary_artwork_path, split_credits, to_images_out, to_video_out
class SeriesNotFoundError(Exception):
pass
class SeasonNotFoundError(Exception):
pass
class EpisodeNotFoundError(Exception):
pass
def _resolve_id(aid: str, expected_type: str, error_cls: type[Exception]) -> uuid.UUID:
try:
return uuid.UUID(parse_aurora_id(aid, expected_type=expected_type))
except ValueError as exc:
raise error_cls(str(exc)) from exc
async def _season_brief(session: AsyncSession, season) -> SeasonBriefOut:
posters = await polymorphic.get_artwork(session, "season", season.id, ArtworkType.POSTER)
return SeasonBriefOut(
id=aurora_id("season", str(season.id)),
season_number=season.season_number,
title=season.title,
episode_count=season.episode_count,
air_date=season.air_date,
poster_path=primary_artwork_path(posters, ArtworkType.POSTER),
)
async def get_series_detail(session: AsyncSession, series_aurora_id: str) -> SeriesDetailOut:
series_id = _resolve_id(series_aurora_id, "series", SeriesNotFoundError)
series = await tv_repository.get_series_by_id(session, series_id)
if series is None:
raise SeriesNotFoundError(series_aurora_id)
genres = await tv_repository.get_series_genres(session, series_id)
keywords = await tv_repository.get_series_keywords(session, series_id)
networks = await tv_repository.get_series_companies(session, series_id, CompanyType.NETWORK)
companies = await tv_repository.get_series_companies(
session, series_id, CompanyType.PRODUCTION_COMPANY
)
seasons = await tv_repository.get_seasons_for_series(session, series_id)
credits_ = await polymorphic.get_credits(session, "series", series_id)
artwork = await polymorphic.get_artwork(session, "series", series_id)
videos = await polymorphic.get_videos(session, "series", series_id)
external_ids = await polymorphic.get_external_ids(session, "series", series_id)
cast, crew = split_credits([c for c in credits_ if c.department != "Creator"])
_, creators = split_credits([c for c in credits_ if c.department == "Creator"])
season_briefs = [await _season_brief(session, s) for s in seasons]
return SeriesDetailOut(
id=aurora_id("series", str(series.id)),
title=series.title,
original_title=series.original_title,
overview=series.overview,
tagline=series.tagline,
status=series.status.value,
original_language=series.original_language_code,
first_air_date=series.first_air_date,
last_air_date=series.last_air_date,
number_of_seasons=series.number_of_seasons,
number_of_episodes=series.number_of_episodes,
popularity=series.popularity,
vote_average=series.vote_average,
vote_count=series.vote_count,
genres=[GenreOut(id=aurora_id("genre", str(g.id)), name=g.name) for g in genres],
keywords=[KeywordOut(id=aurora_id("keyword", str(k.id)), name=k.name) for k in keywords],
creators=creators,
cast=cast,
crew=crew,
networks=[
CompanyBriefOut(
id=aurora_id("company", str(c.id)), name=c.name, company_type=c.company_type.value
)
for c, _role in networks
],
companies=[
CompanyBriefOut(
id=aurora_id("company", str(c.id)), name=c.name, company_type=c.company_type.value
)
for c, _role in companies
],
seasons=season_briefs,
images=to_images_out(artwork),
videos=[to_video_out(v) for v in videos],
external_ids=external_ids,
created_at=series.created_at,
updated_at=series.updated_at,
)
async def get_series_seasons(session: AsyncSession, series_aurora_id: str) -> SeriesSeasonsOut:
series_id = _resolve_id(series_aurora_id, "series", SeriesNotFoundError)
series = await tv_repository.get_series_by_id(session, series_id)
if series is None:
raise SeriesNotFoundError(series_aurora_id)
seasons = await tv_repository.get_seasons_for_series(session, series_id)
briefs = [await _season_brief(session, s) for s in seasons]
return SeriesSeasonsOut(id=aurora_id("series", str(series.id)), seasons=briefs)
async def get_season_detail(session: AsyncSession, season_aurora_id: str) -> SeasonDetailOut:
season_id = _resolve_id(season_aurora_id, "season", SeasonNotFoundError)
season = await tv_repository.get_season_by_id(session, season_id)
if season is None:
raise SeasonNotFoundError(season_aurora_id)
episodes = await tv_repository.get_episodes_for_season(session, season_id)
artwork = await polymorphic.get_artwork(session, "season", season_id)
external_ids = await polymorphic.get_external_ids(session, "season", season_id)
episode_briefs: list[EpisodeBriefOut] = []
for ep in episodes:
stills = await polymorphic.get_artwork(session, "episode", ep.id, ArtworkType.STILL)
episode_briefs.append(
EpisodeBriefOut(
id=aurora_id("episode", str(ep.id)),
episode_number=ep.episode_number,
title=ep.title,
air_date=ep.air_date,
still_path=primary_artwork_path(stills, ArtworkType.STILL),
)
)
return SeasonDetailOut(
id=aurora_id("season", str(season.id)),
series_id=aurora_id("series", str(season.series_id)),
season_number=season.season_number,
title=season.title,
overview=season.overview,
air_date=season.air_date,
episode_count=season.episode_count,
episodes=episode_briefs,
images=to_images_out(artwork),
external_ids=external_ids,
created_at=season.created_at,
updated_at=season.updated_at,
)
async def get_episode_detail(session: AsyncSession, episode_aurora_id: str) -> EpisodeDetailOut:
episode_id = _resolve_id(episode_aurora_id, "episode", EpisodeNotFoundError)
episode = await tv_repository.get_episode_by_id(session, episode_id)
if episode is None:
raise EpisodeNotFoundError(episode_aurora_id)
credits_ = await polymorphic.get_credits(session, "episode", episode_id)
artwork = await polymorphic.get_artwork(session, "episode", episode_id)
external_ids = await polymorphic.get_external_ids(session, "episode", episode_id)
main_cast, crew = split_credits([c for c in credits_ if not c.is_guest])
guest_cast, _ = split_credits([c for c in credits_ if c.is_guest])
return EpisodeDetailOut(
id=aurora_id("episode", str(episode.id)),
series_id=aurora_id("series", str(episode.series_id)),
season_id=aurora_id("season", str(episode.season_id)),
season_number=episode.season_number,
episode_number=episode.episode_number,
title=episode.title,
overview=episode.overview,
air_date=episode.air_date,
runtime=episode.runtime_minutes,
cast=main_cast,
guest_stars=guest_cast,
crew=crew,
images=to_images_out(artwork),
external_ids=external_ids,
created_at=episode.created_at,
updated_at=episode.updated_at,
)
async def build_series_summary(
session: AsyncSession, series_id: uuid.UUID
) -> SeriesSummaryOut | None:
series = await tv_repository.get_series_by_id(session, series_id)
if series is None:
return None
posters = await polymorphic.get_artwork(session, "series", series_id, ArtworkType.POSTER)
return SeriesSummaryOut(
id=aurora_id("series", str(series.id)),
title=series.title,
original_title=series.original_title,
overview=series.overview,
first_air_date=series.first_air_date,
poster_path=primary_artwork_path(posters, ArtworkType.POSTER),
popularity=series.popularity,
vote_average=series.vote_average,
)
async def build_series_summaries(
session: AsyncSession, series_ids: list[uuid.UUID]
) -> list[SeriesSummaryOut]:
out: list[SeriesSummaryOut] = []
for series_id in series_ids:
summary = await build_series_summary(session, series_id)
if summary is not None:
out.append(summary)
return out
View File
+22
View File
@@ -0,0 +1,22 @@
"""arq task functions. Thin wrappers around the ingestion runner so retry/
concurrency/observability behavior lives in arq's worker loop, not here.
"""
from __future__ import annotations
from typing import Any
from app.core.logging import get_logger
from app.services.ingestion.runner import run_movie_ingestion, run_person_ingestion
logger = get_logger(__name__)
async def ingest_movie_task(ctx: dict[str, Any], external_id: str) -> str:
movie_id = await run_movie_ingestion(external_id)
return str(movie_id)
async def ingest_person_task(ctx: dict[str, Any], external_id: str) -> str:
person_id = await run_person_ingestion(external_id)
return str(person_id)
+37
View File
@@ -0,0 +1,37 @@
"""arq worker process entrypoint.
Run with: `arq app.workers.worker.WorkerSettings`
Workers scale independently of the API (section 1) — run as many worker
processes/containers as needed; arq's Redis-backed queue coordinates them so
each job is picked up by exactly one worker.
"""
from __future__ import annotations
from arq.connections import RedisSettings
from app.core.config import get_settings
from app.core.logging import configure_logging, get_logger
from app.workers.tasks import ingest_movie_task, ingest_person_task
logger = get_logger(__name__)
async def startup(ctx: dict) -> None:
configure_logging()
logger.info("worker_started")
async def shutdown(ctx: dict) -> None:
logger.info("worker_stopped")
class WorkerSettings:
functions = [ingest_movie_task, ingest_person_task]
on_startup = startup
on_shutdown = shutdown
redis_settings = RedisSettings.from_dsn(get_settings().REDIS_URL)
max_jobs = get_settings().WORKER_CONCURRENCY
job_timeout = 300
max_tries = 5
+137
View File
@@ -0,0 +1,137 @@
# Production-oriented topology (section 1/28): Traefik -> N stateless API
# replicas -> PgBouncer -> PostgreSQL, plus Redis and independently-scaled
# workers. Requires a pre-existing external Traefik network named
# `traefik-public` and a populated `.env` (never commit real secrets).
name: aurora-core
networks:
traefik-public:
external: true
aurora-internal:
driver: bridge
services:
postgres:
image: postgres:17-alpine
restart: unless-stopped
env_file: .env
environment:
POSTGRES_USER: ${POSTGRES_USER:-aurora}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB:-aurora_core}
volumes:
- aurora_postgres_data:/var/lib/postgresql/data
networks: [aurora-internal]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-aurora}"]
interval: 5s
timeout: 5s
retries: 10
deploy:
resources:
limits: { cpus: "2", memory: 2G }
pgbouncer:
image: edoburu/pgbouncer:latest
restart: unless-stopped
environment:
DATABASE_URL: postgres://${POSTGRES_USER:-aurora}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-aurora_core}
POOL_MODE: transaction
MAX_CLIENT_CONN: "1000"
DEFAULT_POOL_SIZE: "25"
depends_on:
postgres:
condition: service_healthy
networks: [aurora-internal]
healthcheck:
test: ["CMD", "pg_isready", "-h", "localhost", "-p", "5432"]
interval: 10s
timeout: 5s
retries: 10
redis:
image: redis:7-alpine
restart: unless-stopped
command: ["redis-server", "--maxmemory", "512mb", "--maxmemory-policy", "allkeys-lru"]
volumes:
- aurora_redis_data:/data
networks: [aurora-internal]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 10
migrate:
build:
context: .
dockerfile: docker/api.Dockerfile
env_file: .env
environment:
DATABASE_URL_SYNC: postgresql+psycopg2://${POSTGRES_USER:-aurora}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-aurora_core}
depends_on:
postgres:
condition: service_healthy
networks: [aurora-internal]
entrypoint: ["alembic"]
command: ["upgrade", "head"]
api:
build:
context: .
dockerfile: docker/api.Dockerfile
restart: unless-stopped
env_file: .env
environment:
AURORA_ENV: production
DATABASE_BEHIND_TRANSACTION_POOLER: "true"
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-aurora}:${POSTGRES_PASSWORD}@pgbouncer:5432/${POSTGRES_DB:-aurora_core}
REDIS_URL: redis://redis:6379/0
depends_on:
pgbouncer:
condition: service_healthy
redis:
condition: service_healthy
migrate:
condition: service_completed_successfully
networks: [traefik-public, aurora-internal]
deploy:
replicas: 3
resources:
limits: { cpus: "1", memory: 1G }
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-public"
- "traefik.http.routers.aurora-core-api.rule=Host(`${AURORA_API_HOST:-api.aurora.local}`)"
- "traefik.http.routers.aurora-core-api.entrypoints=websecure"
- "traefik.http.routers.aurora-core-api.tls.certresolver=letsencrypt"
- "traefik.http.services.aurora-core-api.loadbalancer.server.port=8000"
- "traefik.http.services.aurora-core-api.loadbalancer.healthcheck.path=/health"
worker:
build:
context: .
dockerfile: docker/worker.Dockerfile
restart: unless-stopped
env_file: .env
environment:
AURORA_ENV: production
DATABASE_BEHIND_TRANSACTION_POOLER: "true"
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-aurora}:${POSTGRES_PASSWORD}@pgbouncer:5432/${POSTGRES_DB:-aurora_core}
REDIS_URL: redis://redis:6379/0
depends_on:
pgbouncer:
condition: service_healthy
redis:
condition: service_healthy
migrate:
condition: service_completed_successfully
networks: [aurora-internal]
deploy:
replicas: 2
resources:
limits: { cpus: "1", memory: 1G }
volumes:
aurora_postgres_data:
aurora_redis_data:
+92
View File
@@ -0,0 +1,92 @@
# Local development environment. For a production-shaped topology
# (PgBouncer, multiple API replicas, Traefik) see docker-compose.prod.yml.
name: aurora-core
services:
postgres:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_USER: aurora
POSTGRES_PASSWORD: aurora
POSTGRES_DB: aurora_core
ports:
- "5432:5432"
volumes:
- aurora_postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U aurora -d aurora_core"]
interval: 5s
timeout: 5s
retries: 10
redis:
image: redis:7-alpine
restart: unless-stopped
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 10
migrate:
build:
context: .
dockerfile: docker/api.Dockerfile
env_file: .env
environment:
DATABASE_URL_SYNC: postgresql+psycopg2://aurora:aurora@postgres:5432/aurora_core
depends_on:
postgres:
condition: service_healthy
entrypoint: ["alembic"]
command: ["upgrade", "head"]
api:
build:
context: .
dockerfile: docker/api.Dockerfile
restart: unless-stopped
env_file: .env
environment:
DATABASE_URL: postgresql+asyncpg://aurora:aurora@postgres:5432/aurora_core
DATABASE_URL_SYNC: postgresql+psycopg2://aurora:aurora@postgres:5432/aurora_core
REDIS_URL: redis://redis:6379/0
ports:
- "8000:8000"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
migrate:
condition: service_completed_successfully
command:
["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
volumes:
- ./app:/app/app
worker:
build:
context: .
dockerfile: docker/worker.Dockerfile
restart: unless-stopped
env_file: .env
environment:
DATABASE_URL: postgresql+asyncpg://aurora:aurora@postgres:5432/aurora_core
DATABASE_URL_SYNC: postgresql+psycopg2://aurora:aurora@postgres:5432/aurora_core
REDIS_URL: redis://redis:6379/0
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
migrate:
condition: service_completed_successfully
volumes:
- ./app:/app/app
volumes:
aurora_postgres_data:
+37
View File
@@ -0,0 +1,37 @@
# syntax=docker/dockerfile:1
FROM python:3.12-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl libpq5 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
FROM base AS builder
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY pyproject.toml ./
COPY app ./app
RUN pip install --no-cache-dir .
FROM base AS runtime
RUN useradd --create-home --uid 1000 aurora
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin
COPY app ./app
COPY migrations ./migrations
COPY alembic.ini ./alembic.ini
USER aurora
EXPOSE 8000
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=5 \
CMD curl -fsS http://localhost:8000/health || exit 1
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers"]
+30
View File
@@ -0,0 +1,30 @@
# syntax=docker/dockerfile:1
FROM python:3.12-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
RUN apt-get update \
&& apt-get install -y --no-install-recommends libpq5 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
FROM base AS builder
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY pyproject.toml ./
COPY app ./app
RUN pip install --no-cache-dir .
FROM base AS runtime
RUN useradd --create-home --uid 1000 aurora
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin
COPY app ./app
USER aurora
CMD ["arq", "app.workers.worker.WorkerSettings"]
+143
View File
@@ -0,0 +1,143 @@
# Aurora Core — API
Base path: `/api/v1`. Interactive OpenAPI docs are available at `/docs` in
any environment where `AURORA_API_DOCS_ENABLED=true` (default; set to
`false` to hide docs in production — section 32).
All responses are JSON, built from Pydantic response schemas
(`app/schemas/`), never from serialized ORM objects (section 18). Errors use
a consistent envelope:
```json
{ "error": { "code": "movie_not_found", "message": "No such movie: aur_movie_..." } }
```
## Health
| Method | Path | Notes |
|---|---|---|
| GET | `/health` and `/api/v1/health` | Liveness only. |
| GET | `/ready` and `/api/v1/ready` | Checks PostgreSQL (always) and Redis (if `REDIS_REQUIRED=true`). |
## Search
| Method | Path | Notes |
|---|---|---|
| GET | `/api/v1/search?q=...&limit=10` | Searches movies, series, people, collections, companies. `limit` (1-50) caps results **per type**. |
```json
GET /api/v1/search?q=the%20matrix
{
"query": "the matrix",
"movies": [ { "id": "aur_movie_...", "type": "movie", "title": "The Matrix", "release_date": "1999-03-30", "poster_path": "https://..." } ],
"series": [],
"people": [],
"collections": [ { "id": "aur_collection_...", "name": "The Matrix Collection" } ],
"companies": [],
"total_results": 2
}
```
## Movies
| Method | Path |
|---|---|
| GET | `/api/v1/movies/{aurora_id}` |
| GET | `/api/v1/movies/{aurora_id}/credits` |
| GET | `/api/v1/movies/{aurora_id}/images` |
| GET | `/api/v1/movies/{aurora_id}/videos` |
`GET /api/v1/movies/{aurora_id}` (cached, see `docs/architecture.md#caching`):
```json
{
"id": "aur_movie_01m1q7p1c...",
"type": "movie",
"title": "The Matrix",
"original_title": "The Matrix",
"overview": "...",
"tagline": "Welcome to the Real World",
"status": "released",
"original_language": "en",
"release_date": "1999-03-30",
"runtime": 136,
"budget": 63000000,
"revenue": 463517383,
"popularity": 45.5,
"vote_average": 8.2,
"vote_count": 24000,
"adult": false,
"genres": [{ "id": "aur_genre_...", "name": "Action" }],
"keywords": [{ "id": "aur_keyword_...", "name": "dystopia" }],
"cast": [{ "id": "aur_credit_...", "person": { "id": "aur_person_...", "name": "Keanu Reeves", "profile_path": null }, "character": "Neo", "billing_order": 0 }],
"crew": [{ "id": "aur_credit_...", "person": { "id": "aur_person_...", "name": "Lana Wachowski" }, "department": "Directing", "job": "Director" }],
"companies": [{ "id": "aur_company_...", "name": "Village Roadshow Pictures", "company_type": "production_company" }],
"collection": { "id": "aur_collection_...", "name": "The Matrix Collection" },
"images": { "posters": [...], "backdrops": [...], "logos": [...] },
"videos": [{ "id": "aur_vid_...", "type": "trailer", "site": "YouTube", "url": "https://www.youtube.com/watch?v=...", "is_official": true }],
"external_ids": { "tmdb": "603", "imdb": "tt0133093" },
"created_at": "...",
"updated_at": "..."
}
```
## Series / Seasons / Episodes
| Method | Path |
|---|---|
| GET | `/api/v1/series/{aurora_id}` |
| GET | `/api/v1/series/{aurora_id}/seasons` |
| GET | `/api/v1/seasons/{aurora_id}` |
| GET | `/api/v1/episodes/{aurora_id}` |
Series detail includes `networks` and `companies` as separate lists (a TV
network and a production company are both `Company` rows distinguished by
`company_type` — see `docs/database.md`), plus `creators` (series-level
"Creator" credits) alongside `cast`/`crew`.
## People
| Method | Path |
|---|---|
| GET | `/api/v1/people/{aurora_id}` |
| GET | `/api/v1/people/{aurora_id}/credits` (movie filmography in 0.1; series/episode credits are stored but not yet surfaced here — see Known limitations) |
## Collections / Companies
| Method | Path |
|---|---|
| GET | `/api/v1/collections/{aurora_id}` |
| GET | `/api/v1/companies/{aurora_id}` |
## Administrative (section 17: "separately protected")
Require header `X-API-Key: <one of AURORA_ADMIN_API_KEYS>`. Never exposed to
public clients — mount behind a separate Traefik router/ACL in production if
you want network-level isolation too.
| Method | Path | Body | Notes |
|---|---|---|---|
| POST | `/api/v1/admin/ingest/movie` | `{"tmdb_id": "603"}` | Enqueues an arq job; returns `{"job_id": "...", "status": "enqueued"}` immediately (section 15: ingestion never runs inline in a request). |
| POST | `/api/v1/admin/ingest/person` | `{"tmdb_id": "6384"}` | Same, for a person profile. |
| GET | `/api/v1/admin/ingest/jobs/{job_id}` | — | Poll job status: `pending` → `running` → `succeeded`/`failed`/`retrying`. |
## Errors
| Status | When |
|---|---|
| 404 | Entity not found, or the ID is well-formed but the wrong entity type (`aur_person_...` passed to `/movies/{id}`) |
| 422 | Request validation failure (e.g. missing/invalid query params) |
| 429 | Rate limit exceeded (`Retry-After` header set) |
| 401 | Missing/invalid `X-API-Key` on an admin endpoint |
| 503 | Admin API called with `AURORA_ADMIN_API_KEYS` unset, or the job queue is unavailable |
## Known limitations
- No pagination on list-shaped responses yet beyond search's `limit`
(movie/series lists like recommendations return everything). Cursor-based
pagination using the Aurora ID's inherent creation-order sort (see
`docs/database.md#aurora-ids-vs-primary-keys`) is the natural next step.
- `/people/{id}/credits` only returns movie credits today; series/episode
credits exist in the `credits` table but the response assembly for them
is not yet wired up (`app/services/person_service.py`).
+154
View File
@@ -0,0 +1,154 @@
# Aurora Core — Architecture
Aurora Core is a production-grade **modular monolith** (section 1/9 of
`docs/aurora-core-spec.md`) — one deployable API codebase with clear internal
module boundaries, not a set of microservices. It is the canonical metadata
layer for the Aurora ecosystem; Plex, Overseerr, Radarr, Sonarr, and future
Aurora applications are consumers of it, never dependencies of it.
```
Internet
|
Traefik
|
Aurora Core API replicas (stateless)
|
+--- PostgreSQL (authoritative)
|
+--- PgBouncer (production connection pooling)
|
+--- Redis (cache / rate limit / job queue — disposable)
|
+--- Background Workers (arq, scale independently)
|
+--- Metadata ingestion
+--- Search index maintenance (implicit: Postgres FTS/trigram indexes)
```
## Module boundaries (`app/`)
| Module | Responsibility |
|---|---|
| `app/core/` | Config, DB engine, Redis client, structured logging, security/rate-limiting, job queue connection. No business logic. |
| `app/models/` | SQLAlchemy 2.0 ORM models — the schema. See `docs/database.md`. |
| `app/schemas/` | Pydantic **response** models. API responses are always built from these, never from serialized ORM objects directly (section 18). |
| `app/repositories/` | Raw data access (SQLAlchemy queries) per aggregate, plus `polymorphic.py` for the shared credit/artwork/video/external-id tables. No response shaping. |
| `app/services/` | Business logic: assembling API responses from repositories (`movie_service.py`, ...), caching (`cache.py`), search (`search/`), and the ingestion pipeline (`metadata/`, `ingestion/`). |
| `app/providers/` | Provider adapters (`MetadataProvider` ABC + `TMDbProvider`). Fetch **raw** data only — never write to the database. |
| `app/api/v1/` | FastAPI routers. Thin: parse request, call a service, translate service exceptions to HTTP errors. |
| `app/workers/` | arq worker process + task functions, thin wrappers around `app/services/ingestion/runner.py`. |
Data flows in one direction: `providers` → `services/metadata` (normalize) →
`services/metadata/matcher` (match) → `repositories` (persist) →
`services/*_service` (assemble response) → `schemas` (validate/serialize) →
`api/v1` (HTTP). Nothing skips a layer; in particular, provider adapters
never touch the database, and routers never touch SQLAlchemy models
directly.
## Provider abstraction & ingestion pipeline (sections 13–15)
```
Provider.get_movie(external_id) # raw dict, provider-shaped
-> normalize_movie() # -> NormalizedMovie, provider-agnostic
-> match_entity() # exact external-ID match, or NEW
-> ingest_movie() # upsert Movie + satellite tables
-> upsert_provider_record() # provenance: raw payload + match confidence
```
`MetadataProvider` (`app/providers/base.py`) is the only thing that knows
TMDb-specific request shapes. A second provider (IMDb dataset export,
Wikidata, ...) means writing a new adapter that returns the same raw shape
TMDb's endpoints return, or a new `normalize_*` sibling if its shape differs
— nothing in `app/services/ingestion/` or downstream needs to change.
**Matching policy is deliberately conservative** (section 14): only an exact
external-ID match reuses an existing Aurora entity. Everything else creates
a new entity. Aurora never guesses via fuzzy title matching that two
providers' records are "the same movie" — that risk (wrongly merging two
different films) is worse than the cost of a duplicate until a human-
reviewable matching workflow exists (tracked in `docs/database.md#known-limitations`).
Ingestion always runs through workers, never inline in an HTTP request
(section 15). The admin API (`POST /api/v1/admin/ingest/movie`) only
enqueues an arq job and returns immediately; `app/workers/tasks.py` picks it
up and calls `app/services/ingestion/runner.py`, which tracks the job's
lifecycle in `ingestion_jobs` (idempotent via a unique `idempotency_key` —
re-triggering ingestion for the same provider + external ID reuses the same
job record rather than double-processing).
## Search (section 16)
`app/services/search/base.py:SearchProvider` is the abstraction; API
handlers and `SearchService` only depend on it, never on SQL directly.
`PostgreSQLSearchProvider` blends `ts_rank_cd` (full-text) with `pg_trgm`
`similarity()` (typo tolerance) across primary titles/names and alternate
titles. Swapping in `OpenSearchProvider` later means implementing the same
`SearchProvider.search()` contract — no caller changes.
## Caching (section 21)
`app/services/cache.py` wraps Redis with fail-open semantics: every read/
write function catches Redis errors and behaves as a cache miss / no-op
rather than raising, so Redis being down degrades performance, not
correctness. `app/api/deps.py:cached_response()` is the read-through helper
used by the movie/person detail endpoints (the read-heaviest, per section
21). Cache keys are versioned (`aurora:v1:...`) so a response-shape change
(bumping `CACHE_VERSION`) doesn't require a manual flush.
Cache invalidation on write: 0.1 has no write API yet (ingestion writes
directly to Postgres via workers, not through the cached read endpoints), so
there is nothing to invalidate today. The moment a write endpoint exists,
it must call `app/services/cache.py:cache_delete` for the affected keys —
tracked as a Known limitation.
## Security (section 22)
- CORS restricted to `CORS_ORIGINS` (comma-separated env var), not `*`.
- Every response carries `X-Content-Type-Options`, `X-Frame-Options`,
`Referrer-Policy` (see `app/main.py` middleware).
- Request bodies over 2 MiB are rejected before parsing.
- Redis-backed fixed-window rate limiting (`app/core/security.py`), fails
open if Redis is unreachable (same reasoning as caching).
- Admin/provider endpoints (`/api/v1/admin/*`) require `X-API-Key` matching
`AURORA_ADMIN_API_KEYS`; public read endpoints require no auth but are
rate-limited.
- No provider credentials are hard-coded; `TMDB_API_KEY` is read from the
environment and the TMDb adapter raises a clear
`ProviderNotConfiguredError` (not a fake success) if it's missing.
- Secrets never committed — `.env.example` holds placeholders only.
Not yet implemented (tracked): encryption-at-rest for provider credentials
beyond "not committed to Git" (there's currently exactly one credential,
`TMDB_API_KEY`, sourced from the environment — a secrets manager integration
is the natural next step once there are more); audit logging for admin
actions beyond structured request logs.
## Observability (section 23)
Structured logging via `structlog`, JSON in non-development environments.
Every request gets a correlation ID (`X-Request-ID`, generated if the client
doesn't send one) bound into the log context for that request's lifetime
(`app/core/logging.py`, `app/main.py:request_context_middleware`).
`GET /metrics` exposes Prometheus counters/histograms (HTTP request count,
latency by route) — compatible with Prometheus/Grafana; nothing in local
development requires the observability stack to be running.
## Health checks (section 24)
`GET /health` — liveness only (process is up). `GET /ready` — checks
PostgreSQL connectivity (always required) and Redis (required only if
`REDIS_REQUIRED=true`, since Redis is disposable — see `docs/database.md`).
Both are mounted twice: unprefixed (`/health`, `/ready`, for
Docker/Traefik health checks) and under `/api/v1` (per section 17's endpoint
list).
## Statelessness & horizontal scaling (section 1/2)
The API holds no in-process state across requests — no server-side
sessions, no in-memory caches keyed by request. Every dependency
(`get_db`, `get_redis`, `get_queue`) is request-scoped and backed by a
shared pool. This is what makes `docker-compose.prod.yml`'s `api: replicas:
3` behind Traefik correct without sticky sessions. Workers
(`docker-compose.prod.yml`'s `worker: replicas: 2`) scale independently of
the API — arq's Redis-backed queue ensures each job is claimed by exactly
one worker.
File diff suppressed because it is too large Load Diff
+165
View File
@@ -0,0 +1,165 @@
# Aurora Core — Database
PostgreSQL is authoritative (principle 4). Nothing important lives only in
Redis. See [erd.md](erd.md) for the full entity-relationship diagram.
## Aurora IDs vs. primary keys
Every table's primary key is a UUIDv7 (`app/models/base.py:UUIDPrimaryKeyMixin`),
generated in the application, not the database. UUIDv7 is time-ordered, so it
stays btree-friendly (unlike UUIDv4) and can double as a rough creation-order
cursor.
The public "Aurora ID" strings clients see (`aur_movie_01m1q7p1c...`) are a
lossless, sortable Crockford-base32 encoding of that same UUID, produced by
`app/core/ids.py:aurora_id()`. Encoding (not a second stored column) keeps
the public ID and the primary key from ever drifting apart. See section 3 of
`docs/aurora-core-spec.md` for why Aurora never uses a provider's ID (TMDb,
IMDb, ...) as a primary key.
## Reference data
- `languages` (ISO 639-1), `countries` (ISO 3166-1 alpha-2), `certifications`
(country + rating system + rating, e.g. US/MPA/PG-13) — populated
incrementally as ingestion encounters new codes (`get_or_create_*` in
`app/repositories/reference_repository.py`), not preloaded. A curated
seed list is a reasonable follow-up (see Known limitations).
- `genres`, `keywords` — deduplicated by a slugified name. Two providers'
"Sci-Fi" and "Science Fiction" are **not** currently merged; that requires
a curated synonym table, deliberately deferred (section 33 lists dedup
*foundations*, not a finished thesaurus).
## Movies / TV
`movies` and `series` hold the record itself; per-locale and per-relationship
data lives in satellite tables (`movie_alternate_titles`,
`movie_spoken_languages`, `movie_production_countries`, `release_dates`,
`movie_recommendations`, and the `series_*` equivalents) rather than JSON
blobs, per section 26. `seasons` and `episodes` form a strict
`series -> season -> episode` hierarchy; `episodes.series_id` is denormalized
(in addition to `season_id`) purely so episode queries don't need to hop
through `seasons` to filter/join by series.
`movies.status` / `series.status` are native Postgres enums, values `.value`
of `app/models/enums.py:MovieStatus`/`SeriesStatus` — see "Enum storage"
below.
## Credits
`credits` is the flexible cast/crew system from section 7: one row per
(person, media entity, department/job/character), not per-movie text blobs.
`department == "Acting"` rows carry `character`/`billing_order`; everything
else carries `job`. Series-level "Creator" credits and episode
`is_guest` (guest stars) reuse the same table rather than adding new ones.
## Companies / Collections
A single `companies` table serves production companies, studios, networks,
and distributors, distinguished by `company_type` (section 8) — a TV network
and a movie studio have the same shape (name, logo, country, parent
company), so a separate `Network` entity would just duplicate columns.
`collections` (franchises) are first-class, with their own artwork and
`collection_relations` for shared-universe links between collections
(section 9).
## Artwork / Videos
Never a `poster_url` column (section 10/11). `artwork` and `videos` are
their own tables carrying provider, provider-native asset ID, dimensions,
and (for artwork) `attribution`/`license` fields — Aurora doesn't assume
externally-sourced images can be freely redistributed. Aurora never
downloads video media; `videos.url` is a reference to the provider's
hosted copy (YouTube, Vimeo, ...).
## Polymorphic associations
`credits`, `artwork`, `videos`, `external_ids`, and `release_dates` attach to
more than one entity type via an `(entity_type, entity_id)` pair instead of a
typed foreign key, because a single column can't reference "movies.id or
series.id or people.id depending on a sibling column" in Postgres. The
tradeoff: **the schema does not enforce that `entity_id` actually points at
a real row of `entity_type`.** That integrity is enforced by the
repository/service layer (`app/repositories/polymorphic.py`,
`app/services/ingestion/*`), which is the only code path allowed to write
these tables. If Core grows enough that this becomes a real risk, the
standard fix is per-type junction tables (`movie_credits`, `series_credits`,
...) at the cost of losing the single shared query path — deliberately not
done for 0.1.
## External IDs & provenance
`external_ids` is the generalized external-identifier system (section 12):
`(provider, entity_type, external_id)` is unique (one Aurora entity per
provider-native ID), and `(entity_type, entity_id, provider)` is unique (one
mapping per provider per entity).
`provider_records` is the full provenance ledger (section 14): one row per
`(provider, entity_type, external_id)` holding the raw payload
(`raw_data` JSONB), `match_confidence`, and which `ingestion_job` produced
it. Entity-level provenance is coarse — `movies.source_provider_id` /
`series.source_provider_id` point at "the provider that was authoritative at
creation time" for quick display; full field-level provenance (which
provider supplied *this specific* field) is not tracked in 0.1. See Known
limitations.
`ingestion_jobs` tracks async work with a unique `idempotency_key` (e.g.
`tmdb:movie:603`) so re-triggering the same sync doesn't double-enqueue —
see `app/services/ingestion/runner.py`.
## Enum storage
Python enums in `app/models/enums.py` are `enum.StrEnum` (member value ==
member name's meaning, e.g. `MovieStatus.RELEASED == "released"`).
SQLAlchemy's `Enum` type stores `.name` (`"RELEASED"`) by default, which
would silently diverge from the lowercase strings the API and ingestion code
use everywhere. `app/models/base.py:_enum_column()` overrides this via
`values_callable` so the database, the ORM, and the API all agree on the
same lowercase strings.
## Migrations
Alembic (section 27); the application **never** creates tables at startup —
see `app/core/database.py`. The initial migration
(`migrations/versions/0001_initial_schema.py`) is hand-written rather than
autogenerated (no throwaway Postgres was available in the environment this
was built in — see the root README's Known Limitations), but was written
directly from the same models this document describes and validated with
`alembic history`; treat a first `alembic upgrade head` against a scratch
database as the real acceptance test, and autogenerate future migrations
against it (`alembic revision --autogenerate`) rather than the empty
database this one was written from.
```bash
alembic upgrade head # apply all migrations
alembic revision --autogenerate -m "add xyz" # generate the next one
alembic downgrade -1 # roll back one revision
```
Integration/API tests build schema directly from `Base.metadata.create_all`
(see `tests/conftest.py`) for speed and to catch model drift quickly — this
is a testing shortcut, not how the running application ever gets its schema.
## Search indexes
`pg_trgm` (trigram similarity, for typo-tolerant matching) plus functional
GIN indexes over `to_tsvector('simple', title || ' ' || original_title)` on
`movies`/`series`, `name` on `people`/`collections`/`companies`, and trigram
indexes on the alternate-title tables. See
`app/services/search/postgresql_provider.py` and section 16 of the spec.
## Known limitations (tracked, not hidden)
- No curated language/country seed data — rows are created on first
encounter during ingestion, so `english_name`/`name` are sometimes just
the code uppercased until backfilled.
- No fuzzy/candidate entity matching — only exact external-ID matches are
auto-linked (section 14: never silently merge ambiguous matches). A
review queue for AMBIGUOUS matches is the natural next step once Core has
an admin UI to act on it.
- Field-level provenance (which provider supplied *this* value) is not
tracked, only entity-level (`source_provider_id`) and per-provider raw
payloads (`provider_records.raw_data`).
- Satellite associations (credits, artwork, videos, genre/keyword/company
links) are replaced wholesale on each re-sync rather than diffed, so their
Aurora IDs are not stable across re-ingestion — see
`app/services/ingestion/movie_ingestion.py`.
+201
View File
@@ -0,0 +1,201 @@
# Aurora Core — Entity-Relationship Diagram
This is the Core 0.1 schema (36 tables, see [database.md](database.md) for
column-level detail). Association/junction tables are drawn as relationships
directly where Mermaid supports it; a few many-to-many tables with their own
attributes (`movie_companies.role`, `credits.*`) are drawn as entities.
```mermaid
erDiagram
MOVIE ||--o{ MOVIE_ALTERNATE_TITLE : has
MOVIE ||--o{ MOVIE_SPOKEN_LANGUAGE : has
MOVIE ||--o{ MOVIE_PRODUCTION_COUNTRY : has
MOVIE ||--o{ RELEASE_DATE : has
MOVIE ||--o{ MOVIE_RECOMMENDATION : recommends
MOVIE }o--o{ GENRE : "movie_genres"
MOVIE }o--o{ KEYWORD : "movie_keywords"
MOVIE }o--o{ COMPANY : "movie_companies"
MOVIE }o--|| COLLECTION : "belongs to"
MOVIE ||--o{ CREDIT : "cast/crew"
MOVIE ||--o{ ARTWORK : "images"
MOVIE ||--o{ VIDEO : "trailers"
MOVIE ||--o{ EXTERNAL_ID : "provider refs"
SERIES ||--o{ SEASON : has
SEASON ||--o{ EPISODE : has
SERIES ||--o{ SERIES_ALTERNATE_TITLE : has
SERIES ||--o{ SERIES_SPOKEN_LANGUAGE : has
SERIES ||--o{ SERIES_PRODUCTION_COUNTRY : has
SERIES }o--o{ GENRE : "series_genres"
SERIES }o--o{ KEYWORD : "series_keywords"
SERIES }o--o{ COMPANY : "series_companies (network/studio)"
SERIES ||--o{ CREDIT : "cast/crew/creators"
SERIES ||--o{ ARTWORK : "images"
SERIES ||--o{ VIDEO : "trailers"
SERIES ||--o{ EXTERNAL_ID : "provider refs"
EPISODE ||--o{ CREDIT : "cast/guest stars/crew"
EPISODE ||--o{ ARTWORK : "stills"
EPISODE ||--o{ EXTERNAL_ID : "provider refs"
SEASON ||--o{ ARTWORK : "posters"
SEASON ||--o{ EXTERNAL_ID : "provider refs"
COLLECTION ||--o{ MOVIE : "collection_movies"
COLLECTION ||--o{ SERIES : "collection_series"
COLLECTION ||--o{ COLLECTION : "collection_relations"
COLLECTION ||--o{ ARTWORK : "images"
COMPANY ||--o{ COMPANY : "parent_company"
COMPANY ||--o{ ARTWORK : "logos"
COMPANY ||--o{ EXTERNAL_ID : "provider refs"
COMPANY }o--o| COUNTRY : "based in"
PERSON ||--o{ CREDIT : "credited on"
PERSON ||--o{ ARTWORK : "profile images"
PERSON ||--o{ EXTERNAL_ID : "provider refs"
PROVIDER ||--o{ PROVIDER_RECORD : "provenance"
PROVIDER ||--o{ INGESTION_JOB : "runs"
PROVIDER ||--o{ EXTERNAL_ID : "issues"
PROVIDER ||--o{ ARTWORK : "sourced"
PROVIDER ||--o{ VIDEO : "sourced"
PROVIDER ||--o{ CREDIT : "sourced"
INGESTION_JOB ||--o{ PROVIDER_RECORD : produces
COUNTRY ||--o{ CERTIFICATION : defines
RELEASE_DATE }o--o| CERTIFICATION : rated
MOVIE {
uuid id PK
string title
string sort_title
enum status
date primary_release_date
int runtime_minutes
uuid collection_id FK
uuid source_provider_id FK
}
SERIES {
uuid id PK
string title
enum status
date first_air_date
int number_of_seasons
}
SEASON {
uuid id PK
uuid series_id FK
int season_number
}
EPISODE {
uuid id PK
uuid season_id FK
uuid series_id FK
int episode_number
}
PERSON {
uuid id PK
string name
date birthday
string known_for_department
}
CREDIT {
uuid id PK
uuid person_id FK
string entity_type
uuid entity_id
string department
string job
string character
}
COMPANY {
uuid id PK
string name
enum company_type
uuid parent_company_id FK
}
COLLECTION {
uuid id PK
string name
string slug
}
GENRE {
uuid id PK
string name
}
KEYWORD {
uuid id PK
string name
}
ARTWORK {
uuid id PK
string entity_type
uuid entity_id
enum artwork_type
uuid provider_id FK
string original_url
}
VIDEO {
uuid id PK
string entity_type
uuid entity_id
enum video_type
uuid provider_id FK
string url
}
EXTERNAL_ID {
uuid id PK
string entity_type
uuid entity_id
uuid provider_id FK
string external_id
}
PROVIDER {
uuid id PK
string slug
string name
}
PROVIDER_RECORD {
uuid id PK
uuid provider_id FK
string entity_type
string external_id
uuid aurora_entity_id
enum match_confidence
jsonb raw_data
}
INGESTION_JOB {
uuid id PK
uuid provider_id FK
string job_type
enum status
string idempotency_key
}
COUNTRY {
string code PK
string name
}
CERTIFICATION {
uuid id PK
string country_code FK
string system
string rating
}
RELEASE_DATE {
uuid id PK
uuid entity_id
string country_code FK
date release_date
uuid certification_id FK
}
```
## Notes on the polymorphic tables
`CREDIT`, `ARTWORK`, `VIDEO`, `EXTERNAL_ID`, and `RELEASE_DATE` attach to more
than one entity type (movie, series, season, episode, person, company,
collection) via an `(entity_type, entity_id)` pair rather than a typed
foreign key. Postgres can't enforce referential integrity across a
polymorphic pair with a single `FOREIGN KEY`, so integrity there is enforced
by the repository/service layer (`app/repositories/polymorphic.py`), not the
schema. See [database.md](database.md#polymorphic-associations) for the
tradeoff.
+59
View File
@@ -0,0 +1,59 @@
from __future__ import annotations
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from app.core.config import get_settings
from app.models import Base # noqa: F401 (imports every model onto Base.metadata)
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
# The application uses an async (asyncpg) URL for normal operation, but
# Alembic migrations run synchronously (psycopg2) — see DATABASE_URL_SYNC in
# app/core/config.py.
config.set_main_option("sqlalchemy.url", get_settings().DATABASE_URL_SYNC)
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
compare_server_default=True,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+27
View File
@@ -0,0 +1,27 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from __future__ import annotations
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
+813
View File
@@ -0,0 +1,813 @@
"""initial schema
Revision ID: 0001_initial_schema
Revises:
Create Date: 2026-09-04
Creates every Aurora Core 0.1 table (section 26/33 of the build spec):
reference data (languages/countries/certifications), providers and
provenance, genres/keywords, companies, collections, people, movies and
their satellite tables, series/seasons/episodes and their satellite tables,
and the polymorphic credit/artwork/video/external-id tables. Also enables
`pg_trgm` and creates the functional GIN indexes PostgreSQLSearchProvider
relies on (section 16).
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0001_initial_schema"
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _timestamps() -> list[sa.Column]:
return [
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
]
MOVIE_STATUS = postgresql.ENUM(
"rumored",
"planned",
"in_production",
"post_production",
"released",
"canceled",
name="moviestatus_enum",
)
SERIES_STATUS = postgresql.ENUM(
"planned",
"pilot",
"in_production",
"returning_series",
"ended",
"canceled",
name="seriesstatus_enum",
)
ARTWORK_TYPE = postgresql.ENUM(
"poster", "backdrop", "logo", "profile", "still", "banner", "thumbnail",
name="artworktype_enum",
)
VIDEO_TYPE = postgresql.ENUM(
"trailer", "teaser", "clip", "featurette", "behind_the_scenes", "interview", "other",
name="videotype_enum",
)
COMPANY_TYPE = postgresql.ENUM(
"production_company", "network", "studio", "distributor", name="companytype_enum"
)
INGESTION_JOB_STATUS = postgresql.ENUM(
"pending", "running", "succeeded", "failed", "retrying", name="ingestionjobstatus_enum"
)
MATCH_CONFIDENCE = postgresql.ENUM(
"exact", "high", "medium", "low", "ambiguous", "new", name="matchconfidence_enum"
)
def upgrade() -> None:
bind = op.get_bind()
op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm")
for enum_type in (
MOVIE_STATUS,
SERIES_STATUS,
ARTWORK_TYPE,
VIDEO_TYPE,
COMPANY_TYPE,
INGESTION_JOB_STATUS,
MATCH_CONFIDENCE,
):
enum_type.create(bind, checkfirst=True)
# --- Reference data -----------------------------------------------
op.create_table(
"languages",
sa.Column("code", sa.String(10), primary_key=True),
sa.Column("english_name", sa.String(200), nullable=False),
sa.Column("native_name", sa.String(200), nullable=True),
)
op.create_table(
"countries",
sa.Column("code", sa.String(2), primary_key=True),
sa.Column("name", sa.String(200), nullable=False),
)
op.create_table(
"certifications",
sa.Column("id", sa.Uuid(), primary_key=True),
*_timestamps(),
sa.Column("country_code", sa.String(2), sa.ForeignKey("countries.code"), nullable=False),
sa.Column("system", sa.String(50), nullable=False),
sa.Column("rating", sa.String(20), nullable=False),
sa.Column("meaning", sa.Text(), nullable=True),
sa.UniqueConstraint("country_code", "system", "rating", name="uq_certification_scope"),
)
# --- Providers & provenance -----------------------------------------
op.create_table(
"providers",
sa.Column("id", sa.Uuid(), primary_key=True),
*_timestamps(),
sa.Column("slug", sa.String(50), nullable=False, unique=True),
sa.Column("name", sa.String(200), nullable=False),
sa.Column("base_url", sa.String(500), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column(
"config", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default="{}"
),
)
op.create_table(
"ingestion_jobs",
sa.Column("id", sa.Uuid(), primary_key=True),
*_timestamps(),
sa.Column(
"provider_id",
sa.Uuid(),
sa.ForeignKey("providers.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("job_type", sa.String(50), nullable=False),
sa.Column("entity_type", sa.String(30), nullable=True),
sa.Column("external_id", sa.String(200), nullable=True),
sa.Column("idempotency_key", sa.String(300), nullable=False),
sa.Column("status", INGESTION_JOB_STATUS, nullable=False, server_default="pending"),
sa.Column("attempts", sa.Integer(), nullable=False, server_default="0"),
sa.Column("max_attempts", sa.Integer(), nullable=False, server_default="5"),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("error", sa.Text(), nullable=True),
sa.Column("result_aurora_id", sa.Uuid(), nullable=True),
sa.UniqueConstraint("idempotency_key", name="uq_ingestion_job_idempotency"),
)
op.create_table(
"provider_records",
sa.Column("id", sa.Uuid(), primary_key=True),
*_timestamps(),
sa.Column(
"provider_id",
sa.Uuid(),
sa.ForeignKey("providers.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("entity_type", sa.String(30), nullable=False),
sa.Column("external_id", sa.String(200), nullable=False),
sa.Column("aurora_entity_id", sa.Uuid(), nullable=True),
sa.Column(
"raw_data", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default="{}"
),
sa.Column("source_version", sa.String(100), nullable=True),
sa.Column("match_confidence", MATCH_CONFIDENCE, nullable=False, server_default="new"),
sa.Column("match_notes", sa.Text(), nullable=True),
sa.Column(
"ingestion_job_id",
sa.Uuid(),
sa.ForeignKey("ingestion_jobs.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column(
"last_synced_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.UniqueConstraint(
"provider_id", "entity_type", "external_id", name="uq_provider_record_source"
),
)
# --- Genres / keywords ----------------------------------------------
op.create_table(
"genres",
sa.Column("id", sa.Uuid(), primary_key=True),
sa.Column("name", sa.String(100), nullable=False, unique=True),
sa.Column("slug", sa.String(100), nullable=False, unique=True),
)
op.create_table(
"keywords",
sa.Column("id", sa.Uuid(), primary_key=True),
sa.Column("name", sa.String(150), nullable=False, unique=True),
sa.Column("slug", sa.String(150), nullable=False, unique=True),
)
# --- Companies --------------------------------------------------------
op.create_table(
"companies",
sa.Column("id", sa.Uuid(), primary_key=True),
*_timestamps(),
sa.Column("name", sa.String(300), nullable=False),
sa.Column(
"company_type", COMPANY_TYPE, nullable=False, server_default="production_company"
),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("country_code", sa.String(2), sa.ForeignKey("countries.code"), nullable=True),
sa.Column("headquarters", sa.String(300), nullable=True),
sa.Column("homepage", sa.String(500), nullable=True),
sa.Column(
"parent_company_id",
sa.Uuid(),
sa.ForeignKey("companies.id", ondelete="SET NULL"),
nullable=True,
),
)
op.create_index("ix_companies_name", "companies", ["name"])
# --- Collections --------------------------------------------------------
op.create_table(
"collections",
sa.Column("id", sa.Uuid(), primary_key=True),
*_timestamps(),
sa.Column("name", sa.String(300), nullable=False),
sa.Column("overview", sa.Text(), nullable=True),
sa.Column("slug", sa.String(350), nullable=False, unique=True),
)
op.create_index("ix_collections_name", "collections", ["name"])
# --- People -----------------------------------------------------------
op.create_table(
"people",
sa.Column("id", sa.Uuid(), primary_key=True),
*_timestamps(),
sa.Column("name", sa.String(300), nullable=False),
sa.Column(
"aliases", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default="[]"
),
sa.Column("biography", sa.Text(), nullable=True),
sa.Column("birthday", sa.Date(), nullable=True),
sa.Column("deathday", sa.Date(), nullable=True),
sa.Column("birthplace", sa.String(300), nullable=True),
sa.Column("gender", sa.String(30), nullable=True),
sa.Column("known_for_department", sa.String(100), nullable=True),
sa.Column("popularity", sa.Float(), nullable=True),
)
op.create_index("ix_people_name", "people", ["name"])
# --- Movies -------------------------------------------------------------
op.create_table(
"movies",
sa.Column("id", sa.Uuid(), primary_key=True),
*_timestamps(),
sa.Column("title", sa.String(500), nullable=False),
sa.Column("original_title", sa.String(500), nullable=True),
sa.Column("sort_title", sa.String(500), nullable=False),
sa.Column("overview", sa.Text(), nullable=True),
sa.Column("tagline", sa.Text(), nullable=True),
sa.Column("status", MOVIE_STATUS, nullable=False, server_default="released"),
sa.Column(
"original_language_code", sa.String(10), sa.ForeignKey("languages.code"), nullable=True
),
sa.Column("primary_release_date", sa.Date(), nullable=True),
sa.Column("runtime_minutes", sa.Integer(), nullable=True),
sa.Column("budget", sa.BigInteger(), nullable=True),
sa.Column("revenue", sa.BigInteger(), nullable=True),
sa.Column("popularity", sa.Float(), nullable=True),
sa.Column("vote_average", sa.Float(), nullable=True),
sa.Column("vote_count", sa.Integer(), nullable=True),
sa.Column("adult", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column(
"collection_id",
sa.Uuid(),
sa.ForeignKey("collections.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column(
"source_provider_id",
sa.Uuid(),
sa.ForeignKey("providers.id", ondelete="SET NULL"),
nullable=True,
),
)
op.create_index("ix_movies_title", "movies", ["title"])
op.create_index("ix_movies_sort_title", "movies", ["sort_title"])
op.create_index("ix_movies_primary_release_date", "movies", ["primary_release_date"])
op.create_table(
"movie_alternate_titles",
sa.Column("id", sa.Uuid(), primary_key=True),
sa.Column(
"movie_id", sa.Uuid(), sa.ForeignKey("movies.id", ondelete="CASCADE"), nullable=False
),
sa.Column("title", sa.String(500), nullable=False),
sa.Column("country_code", sa.String(2), sa.ForeignKey("countries.code"), nullable=True),
sa.Column("type", sa.String(50), nullable=True),
)
op.create_index(
"ix_movie_alternate_titles_movie_id", "movie_alternate_titles", ["movie_id"]
)
op.create_table(
"movie_spoken_languages",
sa.Column(
"movie_id",
sa.Uuid(),
sa.ForeignKey("movies.id", ondelete="CASCADE"),
primary_key=True,
),
sa.Column(
"language_code",
sa.String(10),
sa.ForeignKey("languages.code"),
primary_key=True,
),
)
op.create_table(
"movie_production_countries",
sa.Column(
"movie_id",
sa.Uuid(),
sa.ForeignKey("movies.id", ondelete="CASCADE"),
primary_key=True,
),
sa.Column(
"country_code",
sa.String(2),
sa.ForeignKey("countries.code"),
primary_key=True,
),
)
op.create_table(
"release_dates",
sa.Column("id", sa.Uuid(), primary_key=True),
sa.Column("entity_type", sa.String(30), nullable=False, server_default="movie"),
sa.Column("entity_id", sa.Uuid(), nullable=False),
sa.Column("country_code", sa.String(2), sa.ForeignKey("countries.code"), nullable=False),
sa.Column("release_date", sa.Date(), nullable=False),
sa.Column("release_type", sa.String(50), nullable=True),
sa.Column(
"certification_id",
sa.Uuid(),
sa.ForeignKey("certifications.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("note", sa.Text(), nullable=True),
)
op.create_index("ix_release_dates_entity_id", "release_dates", ["entity_id"])
op.create_table(
"movie_recommendations",
sa.Column(
"movie_id",
sa.Uuid(),
sa.ForeignKey("movies.id", ondelete="CASCADE"),
primary_key=True,
),
sa.Column(
"recommended_movie_id",
sa.Uuid(),
sa.ForeignKey("movies.id", ondelete="CASCADE"),
primary_key=True,
),
sa.Column("rank", sa.Integer(), nullable=True),
sa.Column("source", sa.String(50), nullable=True),
)
op.create_table(
"movie_genres",
sa.Column(
"movie_id", sa.Uuid(), sa.ForeignKey("movies.id", ondelete="CASCADE"), primary_key=True
),
sa.Column(
"genre_id", sa.Uuid(), sa.ForeignKey("genres.id", ondelete="CASCADE"), primary_key=True
),
)
op.create_table(
"movie_keywords",
sa.Column(
"movie_id", sa.Uuid(), sa.ForeignKey("movies.id", ondelete="CASCADE"), primary_key=True
),
sa.Column(
"keyword_id",
sa.Uuid(),
sa.ForeignKey("keywords.id", ondelete="CASCADE"),
primary_key=True,
),
)
op.create_table(
"movie_companies",
sa.Column(
"movie_id", sa.Uuid(), sa.ForeignKey("movies.id", ondelete="CASCADE"), primary_key=True
),
sa.Column(
"company_id",
sa.Uuid(),
sa.ForeignKey("companies.id", ondelete="CASCADE"),
primary_key=True,
),
sa.Column("role", sa.String(50), nullable=True),
)
# --- Series / Seasons / Episodes ---------------------------------------
op.create_table(
"series",
sa.Column("id", sa.Uuid(), primary_key=True),
*_timestamps(),
sa.Column("title", sa.String(500), nullable=False),
sa.Column("original_title", sa.String(500), nullable=True),
sa.Column("sort_title", sa.String(500), nullable=False),
sa.Column("overview", sa.Text(), nullable=True),
sa.Column("tagline", sa.Text(), nullable=True),
sa.Column("status", SERIES_STATUS, nullable=False, server_default="returning_series"),
sa.Column(
"original_language_code", sa.String(10), sa.ForeignKey("languages.code"), nullable=True
),
sa.Column("first_air_date", sa.Date(), nullable=True),
sa.Column("last_air_date", sa.Date(), nullable=True),
sa.Column("number_of_seasons", sa.Integer(), nullable=True),
sa.Column("number_of_episodes", sa.Integer(), nullable=True),
sa.Column("popularity", sa.Float(), nullable=True),
sa.Column("vote_average", sa.Float(), nullable=True),
sa.Column("vote_count", sa.Integer(), nullable=True),
sa.Column(
"source_provider_id",
sa.Uuid(),
sa.ForeignKey("providers.id", ondelete="SET NULL"),
nullable=True,
),
)
op.create_index("ix_series_title", "series", ["title"])
op.create_index("ix_series_sort_title", "series", ["sort_title"])
op.create_index("ix_series_first_air_date", "series", ["first_air_date"])
op.create_table(
"series_alternate_titles",
sa.Column("id", sa.Uuid(), primary_key=True),
sa.Column(
"series_id", sa.Uuid(), sa.ForeignKey("series.id", ondelete="CASCADE"), nullable=False
),
sa.Column("title", sa.String(500), nullable=False),
sa.Column("country_code", sa.String(2), sa.ForeignKey("countries.code"), nullable=True),
sa.Column("type", sa.String(50), nullable=True),
)
op.create_index(
"ix_series_alternate_titles_series_id", "series_alternate_titles", ["series_id"]
)
op.create_table(
"series_spoken_languages",
sa.Column(
"series_id",
sa.Uuid(),
sa.ForeignKey("series.id", ondelete="CASCADE"),
primary_key=True,
),
sa.Column(
"language_code",
sa.String(10),
sa.ForeignKey("languages.code"),
primary_key=True,
),
)
op.create_table(
"series_production_countries",
sa.Column(
"series_id",
sa.Uuid(),
sa.ForeignKey("series.id", ondelete="CASCADE"),
primary_key=True,
),
sa.Column(
"country_code",
sa.String(2),
sa.ForeignKey("countries.code"),
primary_key=True,
),
)
op.create_table(
"seasons",
sa.Column("id", sa.Uuid(), primary_key=True),
*_timestamps(),
sa.Column(
"series_id", sa.Uuid(), sa.ForeignKey("series.id", ondelete="CASCADE"), nullable=False
),
sa.Column("season_number", sa.Integer(), nullable=False),
sa.Column("title", sa.String(500), nullable=True),
sa.Column("overview", sa.Text(), nullable=True),
sa.Column("air_date", sa.Date(), nullable=True),
sa.Column("episode_count", sa.Integer(), nullable=True),
sa.UniqueConstraint("series_id", "season_number", name="uq_season_number"),
)
op.create_index("ix_seasons_series_id", "seasons", ["series_id"])
op.create_table(
"episodes",
sa.Column("id", sa.Uuid(), primary_key=True),
*_timestamps(),
sa.Column(
"season_id", sa.Uuid(), sa.ForeignKey("seasons.id", ondelete="CASCADE"), nullable=False
),
sa.Column(
"series_id", sa.Uuid(), sa.ForeignKey("series.id", ondelete="CASCADE"), nullable=False
),
sa.Column("season_number", sa.Integer(), nullable=False),
sa.Column("episode_number", sa.Integer(), nullable=False),
sa.Column("title", sa.String(500), nullable=True),
sa.Column("overview", sa.Text(), nullable=True),
sa.Column("air_date", sa.Date(), nullable=True),
sa.Column("runtime_minutes", sa.Integer(), nullable=True),
sa.UniqueConstraint("season_id", "episode_number", name="uq_episode_number"),
)
op.create_index("ix_episodes_season_id", "episodes", ["season_id"])
op.create_index("ix_episodes_series_id", "episodes", ["series_id"])
op.create_table(
"series_genres",
sa.Column(
"series_id", sa.Uuid(), sa.ForeignKey("series.id", ondelete="CASCADE"), primary_key=True
),
sa.Column(
"genre_id", sa.Uuid(), sa.ForeignKey("genres.id", ondelete="CASCADE"), primary_key=True
),
)
op.create_table(
"series_keywords",
sa.Column(
"series_id", sa.Uuid(), sa.ForeignKey("series.id", ondelete="CASCADE"), primary_key=True
),
sa.Column(
"keyword_id",
sa.Uuid(),
sa.ForeignKey("keywords.id", ondelete="CASCADE"),
primary_key=True,
),
)
op.create_table(
"series_companies",
sa.Column(
"series_id", sa.Uuid(), sa.ForeignKey("series.id", ondelete="CASCADE"), primary_key=True
),
sa.Column(
"company_id",
sa.Uuid(),
sa.ForeignKey("companies.id", ondelete="CASCADE"),
primary_key=True,
),
sa.Column("role", sa.String(50), nullable=True),
)
# --- Collection memberships ---------------------------------------------
op.create_table(
"collection_movies",
sa.Column(
"collection_id",
sa.Uuid(),
sa.ForeignKey("collections.id", ondelete="CASCADE"),
primary_key=True,
),
sa.Column(
"movie_id", sa.Uuid(), sa.ForeignKey("movies.id", ondelete="CASCADE"), primary_key=True
),
sa.Column("sort_order", sa.Integer(), nullable=True),
)
op.create_table(
"collection_series",
sa.Column(
"collection_id",
sa.Uuid(),
sa.ForeignKey("collections.id", ondelete="CASCADE"),
primary_key=True,
),
sa.Column(
"series_id", sa.Uuid(), sa.ForeignKey("series.id", ondelete="CASCADE"), primary_key=True
),
sa.Column("sort_order", sa.Integer(), nullable=True),
)
op.create_table(
"collection_relations",
sa.Column(
"collection_id",
sa.Uuid(),
sa.ForeignKey("collections.id", ondelete="CASCADE"),
primary_key=True,
),
sa.Column(
"related_collection_id",
sa.Uuid(),
sa.ForeignKey("collections.id", ondelete="CASCADE"),
primary_key=True,
),
sa.Column("relation_type", sa.String(50), nullable=False, server_default="related"),
)
# --- Polymorphic satellite tables ---------------------------------------
op.create_table(
"credits",
sa.Column("id", sa.Uuid(), primary_key=True),
*_timestamps(),
sa.Column(
"person_id", sa.Uuid(), sa.ForeignKey("people.id", ondelete="CASCADE"), nullable=False
),
sa.Column("entity_type", sa.String(30), nullable=False),
sa.Column("entity_id", sa.Uuid(), nullable=False),
sa.Column("department", sa.String(100), nullable=False),
sa.Column("job", sa.String(150), nullable=True),
sa.Column("character", sa.String(300), nullable=True),
sa.Column("billing_order", sa.Integer(), nullable=True),
sa.Column("is_guest", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("episode_count", sa.Integer(), nullable=True),
sa.Column(
"provider_id",
sa.Uuid(),
sa.ForeignKey("providers.id", ondelete="SET NULL"),
nullable=True,
),
)
op.create_index("ix_credits_entity", "credits", ["entity_type", "entity_id"])
op.create_index("ix_credits_person_id", "credits", ["person_id"])
op.create_table(
"artwork",
sa.Column("id", sa.Uuid(), primary_key=True),
*_timestamps(),
sa.Column("entity_type", sa.String(30), nullable=False),
sa.Column("entity_id", sa.Uuid(), nullable=False),
sa.Column("artwork_type", ARTWORK_TYPE, nullable=False),
sa.Column("language_code", sa.String(10), sa.ForeignKey("languages.code"), nullable=True),
sa.Column("width", sa.Integer(), nullable=True),
sa.Column("height", sa.Integer(), nullable=True),
sa.Column("aspect_ratio", sa.Float(), nullable=True),
sa.Column("vote_average", sa.Float(), nullable=True),
sa.Column("is_primary", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column(
"provider_id",
sa.Uuid(),
sa.ForeignKey("providers.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("provider_asset_id", sa.String(200), nullable=True),
sa.Column("original_url", sa.String(1000), nullable=False),
sa.Column("local_path", sa.String(1000), nullable=True),
sa.Column("checksum", sa.String(128), nullable=True),
sa.Column("attribution", sa.Text(), nullable=True),
sa.Column("license", sa.Text(), nullable=True),
)
op.create_index(
"ix_artwork_entity", "artwork", ["entity_type", "entity_id", "artwork_type"]
)
op.create_table(
"videos",
sa.Column("id", sa.Uuid(), primary_key=True),
*_timestamps(),
sa.Column("entity_type", sa.String(30), nullable=False),
sa.Column("entity_id", sa.Uuid(), nullable=False),
sa.Column("video_type", VIDEO_TYPE, nullable=False),
sa.Column(
"provider_id",
sa.Uuid(),
sa.ForeignKey("providers.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("provider_video_id", sa.String(200), nullable=False),
sa.Column("site", sa.String(100), nullable=True),
sa.Column("url", sa.String(1000), nullable=False),
sa.Column("language_code", sa.String(10), sa.ForeignKey("languages.code"), nullable=True),
sa.Column("country_code", sa.String(2), sa.ForeignKey("countries.code"), nullable=True),
sa.Column("is_official", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("published_at", sa.Date(), nullable=True),
sa.UniqueConstraint(
"provider_id", "provider_video_id", name="uq_video_provider_source"
),
)
op.create_index("ix_videos_entity", "videos", ["entity_type", "entity_id"])
op.create_table(
"external_ids",
sa.Column("id", sa.Uuid(), primary_key=True),
*_timestamps(),
sa.Column("entity_type", sa.String(30), nullable=False),
sa.Column("entity_id", sa.Uuid(), nullable=False),
sa.Column(
"provider_id",
sa.Uuid(),
sa.ForeignKey("providers.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("external_id", sa.String(200), nullable=False),
sa.Column("url", sa.String(500), nullable=True),
sa.UniqueConstraint(
"provider_id", "entity_type", "external_id", name="uq_external_id_source"
),
sa.UniqueConstraint(
"entity_type",
"entity_id",
"provider_id",
name="uq_external_id_entity_provider",
),
)
# --- Search indexes (section 16) ----------------------------------------
op.execute(
"CREATE INDEX ix_movies_title_trgm ON movies USING gin (title gin_trgm_ops)"
)
op.execute(
"CREATE INDEX ix_movies_title_fts ON movies "
"USING gin (to_tsvector('simple', coalesce(title, '') || ' ' || coalesce(original_title, '')))"
)
op.execute(
"CREATE INDEX ix_movie_alt_titles_trgm ON movie_alternate_titles "
"USING gin (title gin_trgm_ops)"
)
op.execute(
"CREATE INDEX ix_series_title_trgm ON series USING gin (title gin_trgm_ops)"
)
op.execute(
"CREATE INDEX ix_series_title_fts ON series "
"USING gin (to_tsvector('simple', coalesce(title, '') || ' ' || coalesce(original_title, '')))"
)
op.execute(
"CREATE INDEX ix_series_alt_titles_trgm ON series_alternate_titles "
"USING gin (title gin_trgm_ops)"
)
op.execute("CREATE INDEX ix_people_name_trgm ON people USING gin (name gin_trgm_ops)")
op.execute(
"CREATE INDEX ix_people_name_fts ON people USING gin (to_tsvector('simple', name))"
)
op.execute(
"CREATE INDEX ix_collections_name_trgm ON collections USING gin (name gin_trgm_ops)"
)
op.execute(
"CREATE INDEX ix_companies_name_trgm ON companies USING gin (name gin_trgm_ops)"
)
def downgrade() -> None:
op.drop_table("external_ids")
op.drop_table("videos")
op.drop_table("artwork")
op.drop_table("credits")
op.drop_table("collection_relations")
op.drop_table("collection_series")
op.drop_table("collection_movies")
op.drop_table("series_companies")
op.drop_table("series_keywords")
op.drop_table("series_genres")
op.drop_table("episodes")
op.drop_table("seasons")
op.drop_table("series_production_countries")
op.drop_table("series_spoken_languages")
op.drop_table("series_alternate_titles")
op.drop_table("series")
op.drop_table("movie_companies")
op.drop_table("movie_keywords")
op.drop_table("movie_genres")
op.drop_table("movie_recommendations")
op.drop_table("release_dates")
op.drop_table("movie_production_countries")
op.drop_table("movie_spoken_languages")
op.drop_table("movie_alternate_titles")
op.drop_table("movies")
op.drop_table("people")
op.drop_table("collections")
op.drop_table("companies")
op.drop_table("keywords")
op.drop_table("genres")
op.drop_table("provider_records")
op.drop_table("ingestion_jobs")
op.drop_table("providers")
op.drop_table("certifications")
op.drop_table("countries")
op.drop_table("languages")
bind = op.get_bind()
for enum_type in (
MATCH_CONFIDENCE,
INGESTION_JOB_STATUS,
COMPANY_TYPE,
VIDEO_TYPE,
ARTWORK_TYPE,
SERIES_STATUS,
MOVIE_STATUS,
):
enum_type.drop(bind, checkfirst=True)
op.execute("DROP EXTENSION IF EXISTS pg_trgm")
+75
View File
@@ -0,0 +1,75 @@
[project]
name = "aurora-core"
version = "0.1.0"
description = "Aurora Core — production-grade media metadata, discovery, and intelligence backend for the Aurora ecosystem."
readme = "README.md"
requires-python = ">=3.12"
license = { text = "Proprietary" }
authors = [{ name = "Aurora Dragon Studio" }]
dependencies = [
"fastapi>=0.115.0",
"uvicorn[standard]>=0.30.0",
"sqlalchemy>=2.0.35",
"alembic>=1.13.2",
"asyncpg>=0.29.0",
"psycopg2-binary>=2.9.9",
"pydantic>=2.9.0",
"pydantic-settings>=2.5.0",
"redis>=5.0.8",
"arq>=0.26.0",
"httpx>=0.27.0",
"structlog>=24.4.0",
"orjson>=3.10.0",
"uuid6>=2024.7.10",
"python-dotenv>=1.0.1",
"tenacity>=9.0.0",
"prometheus-client>=0.20.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.3.0",
"pytest-asyncio>=0.24.0",
"pytest-cov>=5.0.0",
"ruff>=0.6.9",
"mypy>=1.11.2",
"aiosqlite>=0.20.0",
"faker>=28.4.1",
"types-redis",
]
load = [
"locust>=2.31.0",
]
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
include = ["app*"]
[tool.ruff]
line-length = 100
target-version = "py312"
exclude = ["migrations"]
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM", "N"]
# E501: line length handled by formatter, not worth failing CI over.
# B027: MetadataProvider.aclose() is an intentional optional-override hook
# (concrete no-op default), not an accidentally-unimplemented abstract method.
# B008: FastAPI's `Depends(...)` dependency-injection idiom is a call in an
# argument default by design; this is the standard FastAPI pattern.
ignore = ["E501", "B027", "B008"]
[tool.mypy]
python_version = "3.12"
plugins = ["pydantic.mypy"]
ignore_missing_imports = true
disallow_untyped_defs = false
exclude = ["migrations", "tests"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]

Some files were not shown because too many files have changed in this diff Show More