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

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

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
NixReaper
2026-09-04 17:34:03 -05:00
co-authored by Claude Sonnet 5
commit c20df1c503
111 changed files with 10041 additions and 0 deletions
View File
+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()]