Spaces:
Running
Running
| from __future__ import annotations | |
| import re | |
| from functools import lru_cache | |
| from pathlib import Path | |
| from urllib.parse import urlparse | |
| from pydantic import Field, SecretStr, field_validator, model_validator | |
| from pydantic_settings import BaseSettings, SettingsConfigDict | |
| DEFAULT_TEMPLATE_DIR = Path(__file__).resolve().parents[1] / "templates" / "categories" | |
| class Settings(BaseSettings): | |
| """Runtime configuration loaded from environment variables.""" | |
| model_config = SettingsConfigDict( | |
| env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore" | |
| ) | |
| app_name: str = "MediaRouter" | |
| app_version: str = "1.0.0" | |
| app_environment: str = "development" | |
| host: str = "0.0.0.0" | |
| port: int = 7860 | |
| cors_allowed_origins: str = "" | |
| temp_dir: Path = Path("./temp") | |
| output_dir: Path = Path("./outputs") | |
| template_dir: Path = DEFAULT_TEMPLATE_DIR | |
| max_upload_size: int = Field(default=1_073_741_824, ge=1_048_576) | |
| max_duration_seconds: float = Field(default=21_600.0, gt=0) | |
| max_resolution_pixels: int = Field(default=33_177_600, ge=1) | |
| whisper_model: str = "small" | |
| cleanup_minutes: int = Field(default=60, ge=1) | |
| cleanup_interval_seconds: int = Field(default=60, ge=5) | |
| max_workers: int = Field(default=2, ge=1, le=32) | |
| log_level: str = "INFO" | |
| download_timeout_seconds: float = Field(default=300.0, gt=0) | |
| allow_private_urls: bool = False | |
| base_url: str = "" | |
| ffmpeg_binary: str = "ffmpeg" | |
| ffprobe_binary: str = "ffprobe" | |
| auth_enabled: bool = True | |
| database_url: str = "sqlite+aiosqlite:///./data/mediarouter.db" | |
| # Security/tenant schema creation is automatic only for SQLite local | |
| # development. PostgreSQL deployments must apply SQL migrations explicitly. | |
| security_auto_migrate: bool = False | |
| security_database_role: str = "" | |
| security_enforce_rls: bool = True | |
| auth_role_scopes: dict[str, list[str]] = Field(default_factory=dict) | |
| auth_bootstrap_key_hash: str = "" | |
| auth_bootstrap_key_prefix: str = "" | |
| auth_bootstrap_key_name: str = "Bootstrap Administrator" | |
| auth_bootstrap_environment: str = "live" | |
| auth_last_used_update_seconds: int = Field(default=60, ge=0, le=3600) | |
| auth_default_requests_per_minute: int = Field(default=100, ge=1, le=1_000_000) | |
| auth_default_concurrent_jobs: int = Field(default=10, ge=1, le=10_000) | |
| auth_default_uploads_per_hour: int = Field(default=20, ge=1, le=1_000_000) | |
| auth_default_processing_bytes_per_day: int = Field(default=107_374_182_400, ge=1_048_576) | |
| auth_trust_proxy_headers: bool = True | |
| mcp_stdio_api_key: SecretStr | None = None | |
| # Social Automation foundation. The existing database remains the default | |
| # local store; production Supabase/Postgres deployments should set a | |
| # dedicated async SQLAlchemy URL and apply the SQL migration out-of-band. | |
| social_enabled: bool = True | |
| social_database_url: str = "" | |
| # API requests use SOCIAL_DATABASE_URL with a non-BYPASSRLS role. Workers | |
| # use a separate, backend-only connection with the trusted role below. | |
| social_worker_database_url: str = "" | |
| social_tenant_database_role: str = "" | |
| social_worker_database_role: str = "" | |
| social_enforce_rls: bool = True | |
| social_auto_migrate: bool = False | |
| social_worker_enabled: bool = True | |
| social_scheduler_interval_seconds: int = Field(default=30, ge=5, le=3600) | |
| social_job_stale_after_seconds: int = Field(default=900, ge=60, le=86_400) | |
| social_publish_retry_limit: int = Field(default=5, ge=0, le=20) | |
| social_oauth_requests_per_hour: int = Field(default=30, ge=1, le=100_000) | |
| social_publish_requests_per_minute: int = Field(default=30, ge=1, le=100_000) | |
| social_schedule_requests_per_minute: int = Field(default=60, ge=1, le=100_000) | |
| social_analytics_requests_per_minute: int = Field(default=120, ge=1, le=100_000) | |
| social_oauth_encryption_key: SecretStr | None = None | |
| supabase_url: str = "" | |
| supabase_service_role_key: SecretStr | None = None | |
| supabase_vault_enabled: bool = False | |
| google_client_id: str = "" | |
| google_client_secret: SecretStr | None = None | |
| youtube_upload_chunk_bytes: int = Field(default=8 * 1024 * 1024, ge=256 * 1024) | |
| youtube_max_concurrent_uploads: int = Field(default=2, ge=1, le=32) | |
| youtube_request_timeout_seconds: float = Field(default=60.0, gt=0, le=600) | |
| youtube_processing_poll_seconds: int = Field(default=30, ge=5, le=3600) | |
| meta_client_id: str = "" | |
| meta_client_secret: SecretStr | None = None | |
| # META_APP_* is the public configuration contract. META_CLIENT_* remains | |
| # supported for deployments created during the social foundation phase. | |
| meta_app_id: str = "" | |
| meta_app_secret: SecretStr | None = None | |
| meta_graph_api_version: str = "v25.0" | |
| tiktok_client_key: str = "" | |
| tiktok_client_secret: SecretStr | None = None | |
| # Exact, backend-owned OAuth callback registered in TikTok Login Kit. | |
| # This is intentionally separate from the secret and is never a frontend | |
| # configuration value. | |
| tiktok_redirect_uri: str = "" | |
| # Direct Post requires TikTok Content Posting approval and an audited app. | |
| # Keep it fail-closed until an operator has confirmed that access. | |
| tiktok_direct_post_enabled: bool = False | |
| tiktok_upload_chunk_bytes: int = Field(default=10_000_000, ge=5_000_000, le=64_000_000) | |
| tiktok_request_timeout_seconds: float = Field(default=60.0, gt=0, le=600) | |
| tiktok_processing_poll_seconds: int = Field(default=30, ge=5, le=3600) | |
| linkedin_client_id: str = "" | |
| linkedin_client_secret: SecretStr | None = None | |
| # Exact backend callback registered in the LinkedIn Developer Portal. | |
| linkedin_redirect_uri: str = "" | |
| # Community Management posting is approval-gated. Keep it disabled until | |
| # the operator has confirmed the application products and write scopes in | |
| # LinkedIn Developer Portal. | |
| linkedin_publishing_enabled: bool = False | |
| linkedin_request_timeout_seconds: float = Field(default=60.0, gt=0, le=600) | |
| linkedin_media_processing_poll_seconds: int = Field(default=5, ge=1, le=300) | |
| linkedin_media_processing_timeout_seconds: int = Field(default=600, ge=30, le=3600) | |
| x_client_id: str = "" | |
| x_client_secret: SecretStr | None = None | |
| # Exact OAuth 2.0 callback registered for the confidential X Web App. | |
| x_redirect_uri: str = "" | |
| # X does not expose a dependable startup entitlement probe. Keep posting | |
| # fail-closed until an operator confirms the project has current write and | |
| # media access in the X Developer Console. | |
| x_publishing_enabled: bool = False | |
| x_upload_chunk_bytes: int = Field(default=5 * 1024 * 1024, ge=1_048_576, le=64 * 1024 * 1024) | |
| x_request_timeout_seconds: float = Field(default=60.0, gt=0, le=600) | |
| x_media_processing_poll_seconds: int = Field(default=5, ge=1, le=300) | |
| x_media_processing_timeout_seconds: int = Field(default=300, ge=30, le=3600) | |
| telegram_bot_token: SecretStr | None = None | |
| whatsapp_client_id: str = "" | |
| whatsapp_client_secret: SecretStr | None = None | |
| social_oauth_redirect_base_url: str = "" | |
| # Provider-neutral generation runtime. Providers remain optional and their | |
| # worker endpoints/tokens stay server-side only. | |
| generation_enabled: bool = True | |
| generation_job_retry_limit: int = Field(default=3, ge=0, le=20) | |
| # Shared remote-generation worker transport defaults. These do not enable | |
| # a provider and intentionally contain no worker URL or credentials. | |
| ai_worker_connect_timeout_seconds: float = Field(default=10.0, gt=0, le=300) | |
| ai_worker_request_timeout_seconds: float = Field(default=60.0, gt=0, le=3600) | |
| ai_worker_read_timeout_seconds: float = Field(default=300.0, gt=0, le=7200) | |
| ai_worker_max_retries: int = Field(default=3, ge=0, le=10) | |
| ai_worker_retry_backoff_seconds: float = Field(default=0.5, ge=0, le=60) | |
| # WAN is optional. These values remain backend-only and are deliberately | |
| # not validated at Settings construction time: a bad optional worker | |
| # configuration must leave WAN unavailable without preventing unrelated | |
| # MediaRouter services from starting. | |
| wan_space_url: str = "" | |
| wan_space_token: SecretStr | None = None | |
| # Optional authenticated FLUX.2 Klein worker. Invalid configuration keeps | |
| # FLUX unavailable without affecting startup or other providers. | |
| flux_space_url: str = "" | |
| flux_space_token: SecretStr | None = None | |
| generation_worker_enabled: bool = True | |
| generation_worker_interval_seconds: float = Field(default=5.0, ge=0.5, le=3600) | |
| generation_worker_poll_backoff_seconds: float = Field(default=2.0, ge=0.5, le=300) | |
| generation_worker_batch_size: int = Field(default=8, ge=1, le=100) | |
| generation_job_stale_after_seconds: int = Field(default=900, ge=60, le=86_400) | |
| # Content Studio persistence/render safety limits. Rendering is optional | |
| # infrastructure; disabling its worker never prevents API startup. | |
| editor_state_max_bytes: int = Field(default=1_048_576, ge=16_384, le=16_777_216) | |
| render_worker_enabled: bool = True | |
| render_worker_interval_seconds: float = Field(default=2.0, ge=0.5, le=3600) | |
| render_job_stale_after_seconds: int = Field(default=900, ge=60, le=86_400) | |
| render_job_timeout_seconds: int = Field(default=7200, ge=60, le=86_400) | |
| render_job_retry_limit: int = Field(default=2, ge=0, le=10) | |
| render_max_active_jobs_per_project: int = Field(default=1, ge=1, le=10) | |
| render_max_tracks: int = Field(default=32, ge=1, le=256) | |
| render_max_clips: int = Field(default=500, ge=1, le=10_000) | |
| render_max_duration_seconds: int = Field(default=3600, ge=1, le=21_600) | |
| render_max_input_bytes: int = Field(default=4_294_967_296, ge=1_048_576) | |
| def validate_whisper_model(cls, value: str) -> str: | |
| allowed = {"tiny", "base", "small", "medium", "large-v3"} | |
| if value not in allowed: | |
| raise ValueError(f"WHISPER_MODEL must be one of: {', '.join(sorted(allowed))}") | |
| return value | |
| def normalize_log_level(cls, value: str) -> str: | |
| normalized = value.upper() | |
| allowed = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"} | |
| if normalized not in allowed: | |
| raise ValueError(f"LOG_LEVEL must be one of: {', '.join(sorted(allowed))}") | |
| return normalized | |
| def normalize_app_environment(cls, value: str) -> str: | |
| normalized = value.strip().lower() | |
| if normalized not in {"development", "test", "production"}: | |
| raise ValueError("APP_ENVIRONMENT must be development, test, or production") | |
| return normalized | |
| def allowed_cors_origins(self) -> tuple[str, ...]: | |
| """Return validated, normalized origins for Starlette CORS middleware.""" | |
| origins: list[str] = [] | |
| for configured in self.cors_allowed_origins.split(","): | |
| origin = configured.strip().rstrip("/") | |
| if not origin: | |
| continue | |
| parsed = urlparse(origin) | |
| if ( | |
| "*" in origin | |
| or parsed.scheme not in {"http", "https"} | |
| or not parsed.netloc | |
| or parsed.username | |
| or parsed.password | |
| or parsed.path | |
| or parsed.params | |
| or parsed.query | |
| or parsed.fragment | |
| ): | |
| raise ValueError( | |
| "CORS_ALLOWED_ORIGINS must contain comma-separated HTTP(S) origins" | |
| ) | |
| if origin not in origins: | |
| origins.append(origin) | |
| return tuple(origins) | |
| def validate_production_contract(self) -> Settings: | |
| """Fail clearly when the Docker production boundary is unsafe. | |
| Local development and tests retain the established SQLite defaults. | |
| The production Dockerfile sets ``APP_ENVIRONMENT=production``, making | |
| external PostgreSQL, RLS, explicit migrations, authentication, and an | |
| exact frontend CORS origin mandatory at process import/startup. | |
| """ | |
| origins = self.allowed_cors_origins | |
| if self.app_environment != "production": | |
| return self | |
| errors: list[str] = [] | |
| if not self._is_external_postgres(self.database_url): | |
| errors.append("DATABASE_URL must use an external PostgreSQL database in production") | |
| if self.security_auto_migrate: | |
| errors.append("SECURITY_AUTO_MIGRATE must be false in production") | |
| if not self.security_enforce_rls: | |
| errors.append("SECURITY_ENFORCE_RLS must be true in production") | |
| if not self.security_database_role.strip(): | |
| errors.append("SECURITY_DATABASE_ROLE is required in production") | |
| if not self.auth_enabled: | |
| errors.append("AUTH_ENABLED must be true in production") | |
| if not origins: | |
| errors.append("CORS_ALLOWED_ORIGINS must include the HTTPS Vercel frontend origin") | |
| elif any(urlparse(origin).scheme != "https" for origin in origins): | |
| errors.append("CORS_ALLOWED_ORIGINS must use HTTPS in production") | |
| if self.social_auto_migrate: | |
| errors.append("SOCIAL_AUTO_MIGRATE must be false in production") | |
| if self.social_enabled: | |
| if not self.social_database_url.strip() or not self._is_external_postgres( | |
| self.social_database_url | |
| ): | |
| errors.append( | |
| "SOCIAL_DATABASE_URL must use an explicit external PostgreSQL tenant connection" | |
| ) | |
| if not self.social_tenant_database_role.strip(): | |
| errors.append("SOCIAL_TENANT_DATABASE_ROLE is required when social is enabled") | |
| if not self._is_external_postgres(self.social_worker_database_url): | |
| errors.append( | |
| "SOCIAL_WORKER_DATABASE_URL must use an external PostgreSQL worker connection" | |
| ) | |
| if not self.social_worker_database_role.strip(): | |
| errors.append("SOCIAL_WORKER_DATABASE_ROLE is required when social is enabled") | |
| if not self.social_enforce_rls: | |
| errors.append("SOCIAL_ENFORCE_RLS must be true when social is enabled") | |
| if errors: | |
| raise ValueError("Invalid production configuration: " + "; ".join(errors)) | |
| return self | |
| def _is_external_postgres(value: str) -> bool: | |
| configured = value.strip() | |
| if not configured: | |
| return False | |
| parsed = urlparse(configured) | |
| if parsed.scheme not in {"postgres", "postgresql", "postgresql+asyncpg"}: | |
| return False | |
| hostname = (parsed.hostname or "").lower() | |
| return bool(hostname and hostname not in {"localhost", "127.0.0.1", "::1"}) | |
| def ensure_directories(self) -> None: | |
| self.temp_dir.mkdir(parents=True, exist_ok=True) | |
| self.output_dir.mkdir(parents=True, exist_ok=True) | |
| sqlite_prefixes = ("sqlite+aiosqlite:///", "sqlite:///") | |
| for url in {self.database_url, self.resolved_social_database_url}: | |
| for prefix in sqlite_prefixes: | |
| if url.startswith(prefix): | |
| database_path = url.removeprefix(prefix) | |
| if database_path and database_path != ":memory:": | |
| Path(database_path).expanduser().resolve().parent.mkdir( | |
| parents=True, exist_ok=True | |
| ) | |
| break | |
| def resolved_social_database_url(self) -> str: | |
| """Use an explicit social database when configured, otherwise local DB.""" | |
| return self.social_database_url.strip() or self.database_url | |
| def resolved_meta_app_id(self) -> str: | |
| return self.meta_app_id.strip() or self.meta_client_id.strip() | |
| def resolved_meta_app_secret(self) -> SecretStr | None: | |
| return self.meta_app_secret or self.meta_client_secret | |
| def validate_auth_environment(cls, value: str) -> str: | |
| normalized = value.strip().lower() | |
| if normalized not in {"live", "test"}: | |
| raise ValueError("AUTH_BOOTSTRAP_ENVIRONMENT must be live or test") | |
| return normalized | |
| def validate_youtube_chunk_size(cls, value: int) -> int: | |
| # Google resumable uploads require every non-final chunk to be aligned | |
| # to 256 KiB. Keeping the constraint at configuration time avoids a | |
| # late failure after an upload session was already created. | |
| if value % (256 * 1024): | |
| raise ValueError("YOUTUBE_UPLOAD_CHUNK_BYTES must be a multiple of 262144") | |
| return value | |
| def validate_meta_graph_api_version(cls, value: str) -> str: | |
| normalized = value.strip() | |
| if not re.fullmatch(r"v[0-9]+\.[0-9]+", normalized): | |
| raise ValueError("META_GRAPH_API_VERSION must use the form vNN.N") | |
| return normalized | |
| def validate_tiktok_redirect_uri(cls, value: str) -> str: | |
| normalized = value.strip() | |
| if not normalized: | |
| return "" | |
| parsed = urlparse(normalized) | |
| local_hosts = {"localhost", "127.0.0.1", "::1"} | |
| if ( | |
| not parsed.netloc | |
| or parsed.username | |
| or parsed.password | |
| or parsed.query | |
| or parsed.fragment | |
| or parsed.path != "/v1/social/accounts/tiktok/callback" | |
| or (parsed.scheme != "https" and parsed.hostname not in local_hosts) | |
| ): | |
| raise ValueError( | |
| "TIKTOK_REDIRECT_URI must be the HTTPS MediaRouter TikTok callback URI" | |
| ) | |
| return normalized | |
| def validate_x_redirect_uri(cls, value: str) -> str: | |
| normalized = value.strip() | |
| if not normalized: | |
| return "" | |
| parsed = urlparse(normalized) | |
| local_hosts = {"localhost", "127.0.0.1", "::1"} | |
| if ( | |
| not parsed.netloc | |
| or parsed.username | |
| or parsed.password | |
| or parsed.query | |
| or parsed.fragment | |
| or parsed.path != "/v1/social/accounts/x/callback" | |
| or not ( | |
| parsed.scheme == "https" | |
| or (parsed.scheme == "http" and parsed.hostname in local_hosts) | |
| ) | |
| ): | |
| raise ValueError("X_REDIRECT_URI must be the HTTPS MediaRouter X callback URI") | |
| return normalized | |
| def validate_linkedin_redirect_uri(cls, value: str) -> str: | |
| normalized = value.strip() | |
| if not normalized: | |
| return "" | |
| parsed = urlparse(normalized) | |
| local_hosts = {"localhost", "127.0.0.1", "::1"} | |
| if ( | |
| not parsed.netloc | |
| or parsed.username | |
| or parsed.password | |
| or parsed.query | |
| or parsed.fragment | |
| or parsed.path != "/v1/social/accounts/linkedin/callback" | |
| or not ( | |
| parsed.scheme == "https" | |
| or (parsed.scheme == "http" and parsed.hostname in local_hosts) | |
| ) | |
| ): | |
| raise ValueError( | |
| "LINKEDIN_REDIRECT_URI must be the HTTPS MediaRouter LinkedIn callback URI" | |
| ) | |
| return normalized | |
| def get_settings() -> Settings: | |
| settings = Settings() | |
| settings.ensure_directories() | |
| return settings | |