import re import secrets from typing import Optional from pydantic import Field from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): """Application settings from environment variables.""" model_config = SettingsConfigDict(env_file=".env", extra="ignore") jwt_secret_key: str = Field( default_factory=lambda: secrets.token_urlsafe(32), validation_alias="JWT_SECRET_KEY", ) password_salt: str = Field( default="default-salt", validation_alias="PASSWORD_SALT" ) master_password_hash: Optional[str] = Field( default=None, validation_alias="MASTER_PASSWORD_HASH" ) # ⚠️ WARNING: DEVELOPMENT ONLY - NEVER USE IN PRODUCTION ⚠️ # This field allows setting a plaintext master password via environment # variable for development/testing purposes only. In production # deployments, use MASTER_PASSWORD_HASH instead and NEVER set this field. master_password: Optional[str] = Field( default=None, validation_alias="MASTER_PASSWORD", description=( "**DEVELOPMENT ONLY** - Plaintext master password. " "NEVER enable in production. Use MASTER_PASSWORD_HASH instead." ), ) token_expiry_hours: int = Field( default=24, validation_alias="SESSION_TIMEOUT_HOURS" ) anime_directory: str = Field( default="", validation_alias="ANIME_DIRECTORY" ) log_level: str = Field( default="INFO", validation_alias="LOG_LEVEL" ) # Additional settings from .env database_url: str = Field( default="sqlite:///./data/aniworld.db", validation_alias="DATABASE_URL" ) cors_origins: str = Field( default="http://localhost:3000", validation_alias="CORS_ORIGINS", ) api_rate_limit: int = Field( default=100, validation_alias="API_RATE_LIMIT" ) default_provider: str = Field( default="aniworld.to", validation_alias="DEFAULT_PROVIDER" ) provider_timeout: int = Field( default=30, validation_alias="PROVIDER_TIMEOUT" ) retry_attempts: int = Field( default=3, validation_alias="RETRY_ATTEMPTS" ) # NFO / TMDB Settings tmdb_api_key: Optional[str] = Field( default=None, validation_alias="TMDB_API_KEY", description="TMDB API key for scraping TV show metadata" ) nfo_auto_create: bool = Field( default=False, validation_alias="NFO_AUTO_CREATE", description="Automatically create NFO files when scanning series" ) nfo_update_on_scan: bool = Field( default=False, validation_alias="NFO_UPDATE_ON_SCAN", description="Update existing NFO files when scanning series" ) nfo_download_poster: bool = Field( default=True, validation_alias="NFO_DOWNLOAD_POSTER", description="Download poster.jpg when creating NFO" ) nfo_download_logo: bool = Field( default=True, validation_alias="NFO_DOWNLOAD_LOGO", description="Download logo.png when creating NFO" ) nfo_download_fanart: bool = Field( default=True, validation_alias="NFO_DOWNLOAD_FANART", description="Download fanart.jpg when creating NFO" ) nfo_image_size: str = Field( default="original", validation_alias="NFO_IMAGE_SIZE", description="Image size to download (original, w500, etc.)" ) nfo_prefer_fsk_rating: bool = Field( default=True, validation_alias="NFO_PREFER_FSK_RATING", description="Prefer German FSK rating over MPAA rating in NFO files" ) nfo_folder_ignore_patterns: str = Field( default="The Last of Us|Loki|Chernobyl|Star Trek Discovery|Marvel|Matrix|Fast & Furious|Jurassic|James Bond|Mission: Impossible|Bourne|Hunger Games|Die Hard|John Wick|Pacific Rim|Guardians of the Galaxy|Avengers|Batman|Superman|Wonder Woman|Spider-Man|X-Men|Fantastic Four|Terminator|Predator|Rambo|Rocky|Expendables|Tomb Raider|Jumanji|Jurassic Park|Pirates of the Caribbean|Harry Potter|Lord of the Rings|Hobbit|Game of Thrones|Westworld|Stranger Things|Breaking Bad|Better Call Saul|Sherlock|Downton Abbey|The Crown|Bridgerton|Sex Education|Normal People|Emily in Paris|The Witcher|Servant|Lucifer|Dark|Shadow and Bone|Grimm|Fairytale", validation_alias="NFO_FOLDER_IGNORE_PATTERNS", description="Regex patterns for folder names to skip during scan (pipe-separated)" ) @property def folder_ignore_patterns(self) -> list[str]: """Parse ignore patterns from comma-separated string into list. Returns: List of regex patterns to skip during folder scanning. """ if not self.nfo_folder_ignore_patterns: return [] return [ pattern.strip() for pattern in self.nfo_folder_ignore_patterns.split("|") if pattern.strip() ] def should_ignore_folder(self, folder_name: str) -> bool: """Check if folder should be ignored based on ignore patterns. Args: folder_name: Name of folder to check. Returns: True if folder matches any ignore pattern, False otherwise. """ for pattern in self.folder_ignore_patterns: if re.search(pattern, folder_name, re.IGNORECASE): return True return False @property def allowed_origins(self) -> list[str]: """Return the list of allowed CORS origins. The environment variable should contain a comma-separated list. When ``*`` is provided we fall back to a safe local development default instead of allowing every origin in production. """ raw = (self.cors_origins or "").strip() if not raw: return [] if raw == "*": return [ "http://localhost:3000", "http://localhost:8000", ] return [origin.strip() for origin in raw.split(",") if origin.strip()] @property def scan_key_overrides(self) -> dict[str, str]: """Return scan key overrides from config.json. Maps folder names to provider keys for cases where auto-generated keys from folder names are incorrect. Returns: Dict mapping folder names to provider keys. """ from src.server.services.config_service import ConfigService try: config_service = ConfigService() config = config_service.load_config() return config.scan_key_overrides or {} except Exception: return {} settings = Settings()