CI / lint (push) Canceled after 0s
CI / typecheck (push) Canceled after 0s
CI / unit-tests (push) Canceled after 0s
CI / integration-tests (push) Canceled after 0s
CI / container-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
Section 10: every image API responses reference has pointed straight at TMDb's CDN since day one -- Aurora never actually downloaded or served artwork itself, despite storing dimensions/checksum/local_path columns for exactly that. Closes the gap: - is_primary was declared on the Artwork model but never actually set anywhere -- every row defaulted to False. Now the normalizer marks the one image each provider designates as *the* image for an entity (TMDb's poster_path/backdrop_path/profile_path) as primary, distinct from the dozens of alternates also ingested, including correctly marking an existing row primary (not inserting a duplicate) when that image already appears in the alternates list. - app/services/artwork/: an ArtworkStorage interface with a LocalDiskStorage implementation (checksum-named files, single-host deployment via a Docker volume shared between api and worker -- see docs/architecture.md for the S3/R2 upgrade path), and a download pipeline with content-type validation, a size cap, and path-traversal protection. - After movie/series/person ingestion succeeds, tasks.py cascades one download_artwork_task per primary artwork id via arq's ctx['redis'] (same fan-out pattern as the season cascade) -- fire-and-forget, not tracked in ingestion_jobs, since a failed image cache is a known-safe fallback to the provider URL, not a user-facing failure. - app/main.py serves MEDIA_ROOT at MEDIA_URL_PREFIX; local_path is preferred over original_url once set (already-existing fallback logic in app/services/common.py:artwork_url, now actually exercised). Also fixes a real interaction bug this surfaced: every ingestion pipeline deleted and recreated all of an entity's Artwork rows on each re-sync, which would have silently wiped local_path/checksum on every re-ingestion, forcing a full re-download every time and defeating the point of caching locally at all. app/repositories/polymorphic.py's new sync_artwork upserts by provider_asset_id (TMDb's stable per-image path) instead, preserving downloaded copies across re-sync, and replaces three near-identical delete+recreate helpers that used to live separately in movie_ingestion.py, series_ingestion.py, and person_ingestion.py. Co-Authored-By: Claude Sonnet 5 <[email protected]>
118 lines
4.0 KiB
Python
118 lines
4.0 KiB
Python
"""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)
|
|
|
|
# --- Artwork storage (section 10) ---
|
|
# Local-disk self-hosting, deliberately simple for 0.1: fine for a
|
|
# single-host deployment where the api and worker containers share one
|
|
# volume (see docker-compose.yml). A genuinely multi-host deployment
|
|
# would need real object storage (S3/R2/...) behind the same
|
|
# ArtworkStorage interface instead — see app/services/artwork/storage.py.
|
|
MEDIA_ROOT: str = Field(default="./media")
|
|
# Root-relative URL path artwork is served under (app/main.py mounts
|
|
# MEDIA_ROOT there). Kept relative rather than a full domain so it keeps
|
|
# working whether Aurora is reached via localhost, a Traefik host, or
|
|
# (once configured) amdbs.net.
|
|
MEDIA_URL_PREFIX: str = Field(default="/media")
|
|
ARTWORK_DOWNLOAD_MAX_BYTES: int = Field(default=15 * 1024 * 1024)
|
|
|
|
@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()
|