import pathlib from typing import Any, Literal, Self import sqlalchemy as sa from fansifter_common.constants import PROD_ENVIRONMENT, QA_ENVIRONMENT from fansifter_common.utils.functional import lazy_proxy from pydantic import Field, SecretStr, ValidationError, model_validator from pydantic_settings import BaseSettings from resonance_engine.dsp.enums import DSPClientName, DSPId from resonance_engine.dsp.types import DSPClientCredentials class Settings(BaseSettings): model_config = { "env_file": ".env", "extra": "ignore", } # App environment: str = Field("dev", validation_alias="ENVIRONMENT") debug: bool = Field(False, validation_alias="APP_DEBUG") # Service service_name: str = "ows-resonance-engine" service_version: str = "1.0.0" # Directories base_dir: pathlib.Path = pathlib.Path(__file__).resolve().parent root_dir: pathlib.Path = base_dir.parent.parent dashboard_dir: pathlib.Path = root_dir / "static" / "dashboard" # AWS aws_region_name: str = Field("us-east-1", validation_alias="AWS_REGION_NAME") # Aurora DSQL dsql_endpoint: str | None = Field(None, validation_alias="DSQL_ENDPOINT") dsql_token_expires_in: int = Field(900, validation_alias="DSQL_TOKEN_EXPIRES_IN") dsql_user: str = Field("admin", validation_alias="DSQL_USER") dsql_db_name: str = Field("postgres", validation_alias="DSQL_DB_NAME") # Database db_user: str = Field("resonance", validation_alias="DB_USER") db_password: SecretStr | None = Field(None, validation_alias="DB_PASSWORD") db_host: str = Field("localhost", validation_alias="DB_HOST") db_port: int = Field(5432, validation_alias="DB_PORT") db_name: str = Field("resonance", validation_alias="DB_NAME") db_echo: bool = Field(False, validation_alias="DB_ECHO") db_pool_size: int = Field(5, validation_alias="DB_POOL_SIZE") db_pool_max_overflow: int = Field(10, validation_alias="DB_POOL_MAX_OVERFLOW") db_pool_recycle: int = Field(900, validation_alias="DB_POOL_RECYCLE") @property def db_url(self) -> sa.URL: url = sa.URL.create( drivername="postgresql+psycopg", username=self.db_user, password=self.db_password.get_secret_value() if self.db_password else None, host=self.db_host, port=self.db_port, database=self.db_name, ) if self.dsql_endpoint: url = url.set( host=self.dsql_endpoint, username=self.dsql_user, database=self.dsql_db_name, ) return url # Redis redis_host: str = Field("localhost", validation_alias="REDIS_HOST") redis_port: int = Field(6379, validation_alias="REDIS_PORT") redis_ssl: bool = False @model_validator(mode="after") def _setup_redis_ssl(self) -> Self: if self.environment in [QA_ENVIRONMENT, PROD_ENVIRONMENT]: self.redis_ssl = True return self # Kafka kafka_bootstrap_servers: str = Field( "localhost:29092", validation_alias="KAFKA_BOOTSTRAP_SERVERS" ) kafka_use_ssl: bool = Field(False, validation_alias="KAFKA_USE_SSL") kafka_compression_type: str = Field( "zstd", validation_alias="KAFKA_COMPRESSION_TYPE" ) @model_validator(mode="after") def _kafka_use_ssl(self) -> Self: if self.environment in [QA_ENVIRONMENT, PROD_ENVIRONMENT]: self.kafka_use_ssl = True return self # Kafka topics kafka_topic_fan_profile: str = Field( "event.resonanceEngine.fanProfile", validation_alias="KAFKA_TOPIC_FAN_PROFILE", ) kafka_topic_fan_top_artists: str = Field( "event.resonanceEngine.fanTopArtists", validation_alias="KAFKA_TOPIC_FAN_TOP_ARTISTS", ) kafka_topic_fan_top_tracks: str = Field( "event.resonanceEngine.fanTopTracks", validation_alias="KAFKA_TOPIC_FAN_TOP_TRACKS", ) kafka_topic_fan_recently_played: str = Field( "event.resonanceEngine.fanRecentlyPlayed", validation_alias="KAFKA_TOPIC_FAN_RECENTLY_PLAYED", ) kafka_topic_fan_playlists: str = Field( "event.resonanceEngine.fanPlaylists", validation_alias="KAFKA_TOPIC_FAN_PLAYLISTS", ) kafka_topic_fan_saved_albums: str = Field( "event.resonanceEngine.fanSavedAlbums", validation_alias="KAFKA_TOPIC_FAN_SAVED_ALBUMS", ) kafka_topic_fan_saved_tracks: str = Field( "event.resonanceEngine.fanSavedTracks", validation_alias="KAFKA_TOPIC_FAN_SAVED_TRACKS", ) kafka_topic_fan_followed_artists: str = Field( "event.resonanceEngine.fanFollowedArtists", validation_alias="KAFKA_TOPIC_FAN_FOLLOWED_ARTISTS", ) # Encryption encryption_backend: Literal["fernet", "plain"] = Field( "fernet", validation_alias="ENCRYPTION_BACKEND" ) encryption_keys: list[SecretStr] = Field( default_factory=list, validation_alias="ENCRYPTION_KEYS" ) encryption_keys_secret_name: str = Field( "", validation_alias="ENCRYPTION_KEYS_SECRET_NAME" ) # Auth (Auth0 OAuth2) # Global switch — when false, auth is bypassed entirely (local dev / envs # without Auth0). Defaults to enabled so unset envs stay secure. auth_enabled: bool = Field(True, validation_alias="AUTH_ENABLED") auth0_domain: str = Field("", validation_alias="AUTH0_DOMAIN") auth0_client_id: str = Field("", validation_alias="AUTH0_CLIENT_ID") auth0_client_secret: SecretStr = Field( SecretStr(""), validation_alias="AUTH0_CLIENT_SECRET" ) auth0_callback_url: str = Field( "http://localhost:8000/oauth2/callback", validation_alias="AUTH0_CALLBACK_URL", ) auth0_post_login_redirect: str = Field( "/dashboard", validation_alias="AUTH0_POST_LOGIN_REDIRECT" ) auth0_logout_redirect: str = Field( "http://localhost:8000/dashboard/login", validation_alias="AUTH0_LOGOUT_REDIRECT", ) auth0_audience: str = Field("", validation_alias="AUTH0_AUDIENCE") # Session cookie session_cookie_name: str = Field("session", validation_alias="SESSION_COOKIE_NAME") session_cookie_secure: bool = Field(False, validation_alias="SESSION_COOKIE_SECURE") @model_validator(mode="after") def _secure_cookies_in_prod(self) -> Self: if self.environment in [QA_ENVIRONMENT, PROD_ENVIRONMENT]: self.session_cookie_secure = True return self # DSP dsp_stats_backend: Literal["memory", "redis"] = Field( "memory", validation_alias="DSP_STATS_BACKEND" ) @model_validator(mode="after") def _setup_dsp_stats_backend(self) -> Self: if self.environment in [QA_ENVIRONMENT, PROD_ENVIRONMENT]: self.dsp_stats_backend = "redis" return self # Fan data fandata_sink_backend: Literal["dummy", "kafka"] = Field( "dummy", validation_alias="FANDATA_SINK_BACKEND" ) @model_validator(mode="after") def _setup_fandata_sink_backend(self) -> Self: if self.environment in [QA_ENVIRONMENT, PROD_ENVIRONMENT]: self.fandata_sink_backend = "kafka" return self # Fan collection fan_collect_worker_timeout_s: int = Field( 540, validation_alias="FAN_COLLECT_WORKER_TIMEOUT_S" ) # Stop taking new fans this many seconds before the worker timeout # (budget = worker_timeout_s - margin), so the in-flight fan finishes first. fan_collect_batch_margin_s: int = Field( 120, validation_alias="FAN_COLLECT_BATCH_MARGIN_S" ) # Per-resource call refresh intervals (seconds; 0 = always refetch) fan_collect_profile_interval_s: int = Field( 604800, validation_alias="FAN_COLLECT_PROFILE_INTERVAL_S" ) fan_collect_top_artists_interval_s: int = Field( 604800, validation_alias="FAN_COLLECT_TOP_ARTISTS_INTERVAL_S" ) fan_collect_top_tracks_interval_s: int = Field( 604800, validation_alias="FAN_COLLECT_TOP_TRACKS_INTERVAL_S" ) fan_collect_recently_played_interval_s: int = Field( 86400, validation_alias="FAN_COLLECT_RECENTLY_PLAYED_INTERVAL_S" ) fan_collect_playlists_interval_s: int = Field( 86400, validation_alias="FAN_COLLECT_PLAYLISTS_INTERVAL_S" ) fan_collect_saved_albums_interval_s: int = Field( 86400, validation_alias="FAN_COLLECT_SAVED_ALBUMS_INTERVAL_S" ) fan_collect_saved_tracks_interval_s: int = Field( 86400, validation_alias="FAN_COLLECT_SAVED_TRACKS_INTERVAL_S" ) fan_collect_followed_artists_interval_s: int = Field( 86400, validation_alias="FAN_COLLECT_FOLLOWED_ARTISTS_INTERVAL_S" ) @property def fan_collect_min_interval_s(self) -> int: """Shortest resource interval — nothing is due before it elapses, so the fan-selection gate uses it: a fan collected more recently has nothing to collect and shouldn't be selected only to skip.""" return min( self.fan_collect_profile_interval_s, self.fan_collect_top_artists_interval_s, self.fan_collect_top_tracks_interval_s, self.fan_collect_recently_played_interval_s, self.fan_collect_playlists_interval_s, self.fan_collect_saved_albums_interval_s, self.fan_collect_saved_tracks_interval_s, self.fan_collect_followed_artists_interval_s, ) # Revoke after this many consecutive token-refresh failures (0 = off). fan_collect_token_error_revoke_threshold: int = Field( 20, validation_alias="FAN_COLLECT_TOKEN_ERROR_REVOKE_THRESHOLD" ) # DSP clients # Spotify spotify_songwhip_client_id: str = Field( "", validation_alias="SPOTIFY_SONGWHIP_CLIENT_ID" ) spotify_songwhip_client_secret: SecretStr = Field( SecretStr(""), validation_alias="SPOTIFY_SONGWHIP_CLIENT_SECRET" ) spotify_songwhip_client_secret_name: str = Field( "", validation_alias="SPOTIFY_SONGWHIP_CLIENT_SECRET_NAME" ) spotify_smf_sme_client_id: str = Field( "", validation_alias="SPOTIFY_SMF_SME_CLIENT_ID" ) spotify_smf_sme_client_secret: SecretStr = Field( SecretStr(""), validation_alias="SPOTIFY_SMF_SME_CLIENT_SECRET" ) spotify_smf_sme_client_secret_name: str = Field( "", validation_alias="SPOTIFY_SMF_SME_CLIENT_SECRET_NAME" ) spotify_smf_orch_client_id: str = Field( "", validation_alias="SPOTIFY_SMF_ORCH_CLIENT_ID" ) spotify_smf_orch_client_secret: SecretStr = Field( SecretStr(""), validation_alias="SPOTIFY_SMF_ORCH_CLIENT_SECRET" ) spotify_smf_orch_client_secret_name: str = Field( "", validation_alias="SPOTIFY_SMF_ORCH_CLIENT_SECRET_NAME" ) # Amazon amazon_songwhip_profile_id: str = Field( "", validation_alias="AMAZON_SONGWHIP_PROFILE_ID" ) amazon_songwhip_client_id: str = Field( "", validation_alias="AMAZON_SONGWHIP_CLIENT_ID" ) amazon_songwhip_client_secret_name: str = Field( "", validation_alias="AMAZON_SONGWHIP_CLIENT_SECRET_NAME" ) amazon_songwhip_client_secret: SecretStr = Field( SecretStr(""), validation_alias="AMAZON_SONGWHIP_CLIENT_SECRET" ) # Apple apple_songwhip_team_id: str = Field("", validation_alias="APPLE_SONGWHIP_TEAM_ID") apple_songwhip_key_id: str = Field("", validation_alias="APPLE_SONGWHIP_KEY_ID") apple_songwhip_private_key_name: str = Field( "", validation_alias="APPLE_SONGWHIP_PRIVATE_KEY_NAME" ) apple_songwhip_private_key: SecretStr = Field( SecretStr(""), validation_alias="APPLE_SONGWHIP_PRIVATE_KEY" ) @property def dsp_clients(self) -> dict[DSPClientName, DSPClientCredentials]: return { DSPClientName.spotify_songwhip: { "dsp_id": DSPId.spotify, "client_id": self.spotify_songwhip_client_id, "client_secret": self.spotify_songwhip_client_secret, "client_secret_name": self.spotify_songwhip_client_secret_name, }, DSPClientName.spotify_smf_sme: { "dsp_id": DSPId.spotify, "client_id": self.spotify_smf_sme_client_id, "client_secret": self.spotify_smf_sme_client_secret, "client_secret_name": self.spotify_smf_sme_client_secret_name, }, DSPClientName.spotify_smf_orch: { "dsp_id": DSPId.spotify, "client_id": self.spotify_smf_orch_client_id, "client_secret": self.spotify_smf_orch_client_secret, "client_secret_name": self.spotify_smf_orch_client_secret_name, }, DSPClientName.deezer_songwhip: { "dsp_id": DSPId.deezer, }, DSPClientName.amazon_songwhip: { "dsp_id": DSPId.amazon, "client_id": self.amazon_songwhip_client_id, "client_secret": self.amazon_songwhip_client_secret, "client_secret_name": self.amazon_songwhip_client_secret_name, "profile_id": self.amazon_songwhip_profile_id, }, DSPClientName.apple_songwhip: { "dsp_id": DSPId.apple, "team_id": self.apple_songwhip_team_id, "key_id": self.apple_songwhip_key_id, "private_key_name": self.apple_songwhip_private_key_name, "private_key": self.apple_songwhip_private_key, }, } # SQS queues — per-DSP fan-collect work queues sqs_fan_collect_spotify_songwhip_queue_url: str = Field( "", validation_alias="SQS_FAN_COLLECT_SPOTIFY_SONGWHIP_QUEUE_URL" ) sqs_fan_collect_spotify_smf_sme_queue_url: str = Field( "", validation_alias="SQS_FAN_COLLECT_SPOTIFY_SMF_SME_QUEUE_URL" ) sqs_fan_collect_spotify_smf_orch_queue_url: str = Field( "", validation_alias="SQS_FAN_COLLECT_SPOTIFY_SMF_ORCH_QUEUE_URL" ) sqs_fan_collect_deezer_songwhip_queue_url: str = Field( "", validation_alias="SQS_FAN_COLLECT_DEEZER_SONGWHIP_QUEUE_URL" ) sqs_fan_collect_amazon_songwhip_queue_url: str = Field( "", validation_alias="SQS_FAN_COLLECT_AMAZON_SONGWHIP_QUEUE_URL" ) sqs_fan_collect_apple_songwhip_queue_url: str = Field( "", validation_alias="SQS_FAN_COLLECT_APPLE_SONGWHIP_QUEUE_URL" ) # Fanout cron — must match the EventBridge schedule interval. fan_fanout_window_s: int = Field(300, validation_alias="FAN_FANOUT_WINDOW_S") # Close fanouts stuck before dispatch (messages_sent == 0); dispatched ones # close via close_if_all_collected / DLQ. fan_fanout_dispatch_stale_s: int = Field( 1200, validation_alias="FAN_FANOUT_DISPATCH_STALE_S" ) # Safety-net window for the dispatch lock (crash/DLQ fallback); must outlast # worst-case queue drain + SQS retry cycles. Default 2h. fan_dispatch_lock_s: int = Field(7200, validation_alias="FAN_DISPATCH_LOCK_S") # True (prod): dispatch via SQS → async worker. False: inline collect (local/smoke). fan_fanout_async_enabled: bool = Field( True, validation_alias="FAN_FANOUT_ASYNC_ENABLED" ) @model_validator(mode="after") def _enforce_fanout_async_in_prod(self) -> Self: if self.environment in [QA_ENVIRONMENT, PROD_ENVIRONMENT]: self.fan_fanout_async_enabled = True return self # Logging logging_debug: bool = Field(False, validation_alias="LOGGING_DEBUG") @property def logging_config(self) -> dict[str, Any]: return { "version": 1, "disable_existing_loggers": True, "formatters": { "json": { "()": "owslogger.logger.DDJsonFormatter", "service_name": self.service_name, "service_version": self.service_version, "env": self.environment, }, "console": { "()": "fansifter_common.logging.ConsoleFormatter", }, }, "filters": { "require_debug_true": { "()": "fansifter_common.logging.RequireDebugTrueFilter", "value": self.logging_debug, }, "require_debug_false": { "()": "fansifter_common.logging.RequireDebugFalseFilter", "value": self.logging_debug, }, }, "handlers": { "stream": { "level": "INFO", "class": "logging.StreamHandler", "formatter": "json", "filters": ["require_debug_false"], }, "rich": { "level": "DEBUG", "class": "rich.logging.RichHandler", "filters": ["require_debug_true"], "formatter": "console", }, "null": { "level": "DEBUG", "class": "logging.NullHandler", }, }, "loggers": { "resonance_engine": { "handlers": ["stream", "rich"], "level": "INFO", "propagate": False, }, "sqlalchemy.engine": { "handlers": ["stream", "rich"], "level": "WARNING", "propagate": False, }, "httpx": { "handlers": ["stream", "rich"], "level": "WARNING", "propagate": False, }, "uvicorn": { "handlers": ["stream", "rich"], "level": "INFO", "propagate": False, }, "uvicorn.access": { "handlers": ["null"], "level": "INFO", "propagate": False, }, }, } # Sentry sentry_dsn: str | None = Field(None, validation_alias="SENTRY_DSN") sentry_send_default_pii: bool = Field( False, validation_alias="SENTRY_SEND_DEFAULT_PII" ) @property def sentry_traces_sample_rate(self) -> float: """Performance trace sampling: 10% in prod, off elsewhere.""" return 0.1 if self.environment == PROD_ENVIRONMENT else 0.0 @model_validator(mode="after") def _setup_sentry_dsn(self) -> Self: if not self.sentry_dsn and self.environment in [ QA_ENVIRONMENT, PROD_ENVIRONMENT, ]: raise ValueError(f"Sentry is not configured in {self.environment}.") return self def get_settings(**defaults: Any) -> Settings: """Setup the application settings.""" try: return Settings(**defaults) except ValidationError as exc: errors = "\n".join( [ f" * {'.'.join(map(str, error['loc']))} - {error['msg']}" for error in exc.errors() ] ) raise RuntimeError(f"Failed to initialize settings:\n {errors}") from exc settings = lazy_proxy(get_settings)