import os, sys from dotenv import load_dotenv from product_env import getenv_product # Populate os.environ from a repo-root .env before Config reads it. This is # idempotent: init_manager.py (operator boot) and conftest.py (tests) also call # load_dotenv(), and python-dotenv defaults to override=False, so an already-set # process env var always wins. Without this, importing Config off a bare # ``python -c "from oneirodex import create_app"`` (no .env pre-loaded) ran on # defaults only and _load_secret_key() would raise. load_dotenv() def keep_psycopg2_for_plain_postgresql_urls() -> None: """Keep ``postgresql://`` URLs on psycopg2. SQLAlchemy 2.1 made psycopg (v3) the driver for a URL without ``+driver``. The app, its tests, Alembic and every operator's ``DATABASE_URL`` use the plain form with psycopg2 installed, so without this each of them failed with ``No module named 'psycopg'``. Explicit ``postgresql+psycopg://`` URLs are untouched. Harmless on SQLAlchemy 2.0, where this is the default. """ from sqlalchemy.dialects import registry registry.register('postgresql', 'sqlalchemy.dialects.postgresql.psycopg2', 'PGDialect_psycopg2') keep_psycopg2_for_plain_postgresql_urls() def _env_bool(name: str, default: bool = False) -> bool: """Parse a boolean env var. Returns ``default`` when the var is unset; otherwise true iff the value is one of ``1 / true / yes / on`` (case-insensitive, surrounding whitespace ignored). Replaces the ~40 near-identical getenv/lower/compare parses that used to fill this file. Each flag keeps its previous effective default: a former ``'true'`` default becomes ``_env_bool('NAME', True)``. """ raw = os.getenv(name) if raw is None: return default return raw.strip().lower() in {'1', 'true', 'yes', 'on'} def _env_int(name: str, default: int = 0) -> int: """Parse a non-negative integer env var; ``default`` when unset or invalid. For limits where 0 means "the built-in default": a typo such as ``256MB`` used to raise out of ``import config`` without naming the variable, so the app did not start. It now logs which variable was ignored and carries on. """ raw = (os.getenv(name) or '').strip() if not raw: return default try: value = int(raw) except ValueError: value = -1 if value < 0: import logging logging.getLogger(__name__).warning( '%s=%r is not a whole number of bytes; using the default', name, raw ) return default return value def _load_secret_key(): """Load SECRET_KEY from env. Fail loudly if unset outside of test runs.""" key = os.getenv('SECRET_KEY') if key and key != 'put_your_own_secret_string_here_32617432': return key # Allow pytest runs to proceed with a generated key; never silently used in prod. if 'pytest' in sys.modules or 'PYTEST_CURRENT_TEST' in os.environ: import secrets return secrets.token_urlsafe(64) raise RuntimeError( "SECRET_KEY environment variable is not set (or still has the example value). " "Generate a strong random value (e.g. `python -c 'import secrets; print(secrets.token_urlsafe(64))'`) " "and set it in your .env file before starting the application." ) def _parse_library_roots(raw): """Parse ONEIRODEX_LIBRARY_ROOTS at import time so app.config carries the list. The parser is imported lazily to keep config.py free of package imports at module scope; a failure here must leave the app booting with no extra roots rather than not booting at all. """ try: from oneirodex.utils.library_roots import parse_library_roots except Exception: # pragma: no cover - defensive, keeps boot resilient return [] return parse_library_roots(raw) class Config(object): # Set Database connection string here or in your .env file, when using docker set the hostname to 'db' SQLALCHEMY_DATABASE_URI = os.getenv('DATABASE_URL', 'postgresql://postgres:postgres@localhost:5432/oneirodex') # Host path to on-disk game files (Compose mounts to /storage in the container) DATA_FOLDER_GAMES = os.getenv('DATA_FOLDER_GAMES', r'Z:\gamez') # OS-specific base folder paths if os.name == 'nt': # Windows BASE_FOLDER_WINDOWS = os.getenv('BASE_FOLDER_WINDOWS', 'Z:\\') else: # POSIX (Linux, Unix, MacOS, etc.) BASE_FOLDER_POSIX = os.getenv('BASE_FOLDER_POSIX', '/storage') # Extra scan locations beyond the single base folder above: NAS shares, a # second internal disk, additional Docker binds. Entries are separated by # "|" and may carry a display label: # ONEIRODEX_LIBRARY_ROOTS=NAS ROMs=/mnt/nas/roms|Archive=/mnt/archive/games # Mounting the share stays the operator's job — this only tells Oneirodex # which mounts are libraries. See docs/runbooks/remote-scan-locations.md. LIBRARY_ROOTS = _parse_library_roots(getenv_product('LIBRARY_ROOTS')) # YOU CAN LEAVE ALL THESE SETTINGS AT DEFAULT: UPLOAD_FOLDER = os.path.join(os.path.dirname(__file__), 'oneirodex/static/library') SECRET_KEY = _load_secret_key() IMAGE_SAVE_PATH = os.path.join(os.path.dirname(__file__), 'oneirodex/static/library/images') IGDB_API_ENDPOINT = os.getenv('IGDB_API_ENDPOINT', 'https://api.igdb.com/v4/games') SQLALCHEMY_TRACK_MODIFICATIONS = False # Session/remember-me cookie hardening. Defaults are safe for HTTPS deployments; # Set both cookie Secure flags false only when deliberately using direct HTTP. SESSION_COOKIE_SECURE = _env_bool('SESSION_COOKIE_SECURE', True) SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax') REMEMBER_COOKIE_SECURE = _env_bool('REMEMBER_COOKIE_SECURE', True) REMEMBER_COOKIE_HTTPONLY = True REMEMBER_COOKIE_SAMESITE = os.getenv('REMEMBER_COOKIE_SAMESITE', 'Lax') # HTTP security response headers — see oneirodex/utils/security_headers.py. # CSP enforces by default. Classic onclick= is gone; WebRetro WASM is a # native /static/* document with baseline headers only (no CSP), so Flask # pages do not need 'unsafe-eval'. Set CSP_ENFORCE=false to report-only. CSP_ENABLED = _env_bool('CSP_ENABLED', True) CSP_ENFORCE = _env_bool('CSP_ENFORCE', True) # Only sent when SESSION_COOKIE_SECURE is on — HSTS on a LAN box reached by # IP over plain HTTP is a lockout, not a hardening. HSTS_SECONDS = int(os.getenv('HSTS_SECONDS', '31536000') or '31536000') # Firmware upload ceiling. utils/emulator_bios.py already reads this from # config and documents the override, but nothing ever populated it, so the # env var silently did nothing and the 64MB default always won. EMULATOR_BIOS_MAX_BYTES = _env_int('EMULATOR_BIOS_MAX_BYTES') # Total .cht storage across every game (0 = the built-in 256 MB). Each game # is separately capped at 200 files of 1 MB; this is the bound on the sum. CHEAT_STORAGE_MAX_BYTES = _env_int('CHEAT_STORAGE_MAX_BYTES') # Total retained chat-attachment storage. Zero/unset keeps the bounded # 1 GiB default; operators can raise the cap to match their library volume. CHAT_ATTACHMENT_STORAGE_MAX_BYTES = ( _env_int('CHAT_ATTACHMENT_STORAGE_MAX_BYTES') or (1024 * 1024 * 1024) ) # Maximum regular files retained across all chat channels and users. # Zero/unset keeps the bounded 10,000-file default. CHAT_ATTACHMENT_STORAGE_MAX_FILES = ( max(1, _env_int('CHAT_ATTACHMENT_STORAGE_MAX_FILES') or 10_000) ) # Global request-body ceiling. Every upload route already has its own, # tighter limit (firmware at 64MB is the largest); without this one, none of # them applied until after Werkzeug had buffered the whole body. Default # keeps headroom over the firmware cap so raising that alone cannot wedge # uploads behind a lower global limit. _bios_mb = (EMULATOR_BIOS_MAX_BYTES or (64 * 1024 * 1024)) // (1024 * 1024) MAX_UPLOAD_MB = int( os.getenv('MAX_UPLOAD_MB', '') or max(128, _bios_mb + 16) ) MAX_CONTENT_LENGTH = MAX_UPLOAD_MB * 1024 * 1024 # Zipstream configuration for streaming ZIP downloads ZIPSTREAM_CHUNK_SIZE = int(os.getenv('ZIPSTREAM_CHUNK_SIZE', 65536)) # 64KB chunks for memory efficiency ZIPSTREAM_COMPRESSION_LEVEL = int(os.getenv('ZIPSTREAM_COMPRESSION_LEVEL', 0)) # ZIP_STORED for compatibility ZIPSTREAM_ENABLE_ZIP64 = _env_bool('ZIPSTREAM_ENABLE_ZIP64', True) # Support large games # Development mode - forces theme files to be recopied on startup (helpful for theme development) DEV_MODE = _env_bool('DEV_MODE', False) # Reverse proxy / HTTPS termination — number of trusted proxy hops (0 = disabled). # Set to the forwarded hop count when behind a proxy; forwarded values are # honored only when that peer also matches TRUSTED_PROXY_CIDRS below. TRUSTED_PROXIES = int(os.getenv('TRUSTED_PROXIES', '0') or '0') # The hop count does not authenticate a proxy; only matching peer addresses # in this comma-separated IP/CIDR list may supply forwarded headers. TRUSTED_PROXY_CIDRS = os.getenv('TRUSTED_PROXY_CIDRS', '') # Hosts allowed to become the origin of an emailed link (password reset, # invite). Empty = trust private/LAN hosts only, which is what a household # install reached by IP needs. Set it when Oneirodex is published on a public # hostname. Comma separated, port optional: "games.example.com,10.0.0.5:5006". # # Deliberately NOT named TRUSTED_HOSTS: Werkzeug (pinned werkzeug==3.1.8 in # requirements.txt) reads that key itself and rejects any unlisted Host with a # 400 before a route runs. It also expects a list — handing it this comma # string would iterate per character and lock the install out entirely. # tests/test_trusted_host.py caught exactly that. If you bump Werkzeug, re-read # its TRUSTED_HOSTS handling before assuming this separation still holds. # Operators who want that stricter app-wide enforcement set Flask's # TRUSTED_HOSTS to a real list; this key only governs email links. TRUSTED_LINK_HOSTS = os.getenv('TRUSTED_LINK_HOSTS', '') # Product modules default ON — disable via env, setup wizard, or Admin → Features. # Auth (OIDC) stays off by default elsewhere. Destructive auto-apply stays gated. ENABLE_ARR_MODULE = _env_bool('ENABLE_ARR_MODULE', True) # Emulator save-state sync (WebRetro / companion) ENABLE_EMULATOR_SAVE_SYNC = _env_bool('ENABLE_EMULATOR_SAVE_SYNC', True) ENCRYPT_EMULATOR_SAVES = _env_bool('ENCRYPT_EMULATOR_SAVES', False) # Optional private BIOS/firmware dir (operator upload or host volume). Never vendor blobs. # When unset, bios_root() falls back to static/library/bios. EMULATOR_BIOS_PATH = os.getenv('EMULATOR_BIOS_PATH') or None # Ollama AI assist (suggestions on; silent rename stays off) ENABLE_AI_ASSIST = _env_bool('ENABLE_AI_ASSIST', True) ENABLE_AI_AUTO_APPLY = _env_bool('ENABLE_AI_AUTO_APPLY', False) # Generated cover art (FEAT-D3). Off by default: this is the only feature # that talks to an endpoint outside the process, so it stays opt-in and # self-hosted-first. Engine speaks the A1111 API (AUTOMATIC1111 / SD.Next / # Forge all implement it). # FEAT-D1: check version / updates / DLC after a library scan. Opt-in — # each check is store HTTP traffic, so a scan must not start doing it # without being asked. SCAN_CHECK_FRESHNESS = _env_bool('SCAN_CHECK_FRESHNESS', False) SCAN_FRESHNESS_LIMIT = int(os.getenv('SCAN_FRESHNESS_LIMIT', '50')) ENABLE_AI_ARTWORK = _env_bool('ENABLE_AI_ARTWORK', False) AI_ARTWORK_URL = os.getenv('AI_ARTWORK_URL', '') AI_ARTWORK_ENGINE = os.getenv('AI_ARTWORK_ENGINE', 'a1111') OLLAMA_BASE_URL = os.getenv('OLLAMA_BASE_URL', 'http://127.0.0.1:11434') OLLAMA_MODEL = os.getenv('OLLAMA_MODEL', 'llama3.2') # UIR-1 two-bar chrome. Default flipped to **true** (W27-A3): the migration # this flag was waiting on is finished. All eleven member pages carry the # branch, and the shell stopped being optional when TopNav.jsx was deleted # and SideRail/TopBar became the only chrome App.jsx renders. # # Left off, the flag produced the exact half-migrated layout it existed to # prevent — the new bar naming the page, and every page still drawing its # own title card underneath it. # # Still not an admin Features toggle, for the original reason: it is a # build-out flag, and a switch in the admin UI that only half-changes the # layout would be its own lie. ENABLE_NEW_CHROME = _env_bool('ENABLE_NEW_CHROME', True) # Theme fonts. Empty FONT_PATH means the default under static/library/fonts, # which is what a Compose deploy wants (it already persists that volume). FONT_PATH = os.getenv('FONT_PATH', '') FONT_MAX_BYTES = int(os.getenv('FONT_MAX_BYTES', str(8 * 1024 * 1024))) # Install the built-in OFL faces on first boot rather than leaving them to a # script nobody knows to run — the picker offered five fonts and shipped # none. Runs in the background and never blocks or fails startup. Turn off # for air-gapped installs and use scripts/fetch-fonts.py --out instead. FETCH_FONTS_ON_BOOT = _env_bool('FETCH_FONTS_ON_BOOT', True) # WebRetro cores are provisioned, not vendored — they carry GPL and # non-commercial terms that make shipping them in the tree the wrong call. # Off means browser play stays dark until an operator runs # scripts/fetch-webretro-cores.sh (which also takes --from-dir for # air-gapped installs). Boot says so in the log rather than leaving it to be # discovered as "browser play is broken". FETCH_WEBRETRO_CORES_ON_BOOT = _env_bool('FETCH_WEBRETRO_CORES_ON_BOOT', True) # Point at a local firmware collection to have it imported on boot. Existing # files are never replaced, so this tops up what is missing and is safe to # leave set. Unset means no import. BIOS_IMPORT_SOURCE = os.getenv('BIOS_IMPORT_SOURCE', '') or None # Hardlink helpers on; filesystem apply remains a safety lock ENABLE_HARDLINK_HELPERS = _env_bool('ENABLE_HARDLINK_HELPERS', True) ALLOW_HARDLINK_APPLY = _env_bool('ALLOW_HARDLINK_APPLY', False) ENABLE_ARR_HARDLINK_PIPELINE = _env_bool('ENABLE_ARR_HARDLINK_PIPELINE', True) # Remote path mapping. The download client usually runs in its own # container and reports paths from *its* mounts, which do not exist here. # Format: "remote=>local" pairs joined by "|", e.g. # /downloads=>/storage/downloads|/data/torrents=>/mnt/user/torrents # Empty (the default) means client and app share a filesystem view. ARR_REMOTE_PATH_MAP = os.getenv('ARR_REMOTE_PATH_MAP', '') # Mobile / Quest-browser VR catalog ENABLE_VR_BROWSE = _env_bool('ENABLE_VR_BROWSE', True) # BYO debrid + single-player assists ENABLE_DEBRID = _env_bool('ENABLE_DEBRID', True) ENABLE_GAME_ASSISTS = _env_bool('ENABLE_GAME_ASSISTS', True) REAL_DEBRID_TOKEN = os.getenv('REAL_DEBRID_TOKEN', '') ALLDEBRID_API_KEY = os.getenv('ALLDEBRID_API_KEY', '') PREMIUMIZE_API_KEY = os.getenv('PREMIUMIZE_API_KEY', '') TORBOX_API_KEY = os.getenv('TORBOX_API_KEY', '') # Browser play / mods / patches — on by default; degrade gracefully if tools missing ENABLE_PCDOS_BROWSER = _env_bool('ENABLE_PCDOS_BROWSER', True) ENABLE_MOD_TRACKING = _env_bool('ENABLE_MOD_TRACKING', True) ENABLE_ROM_PATCH_APPLY = _env_bool('ENABLE_ROM_PATCH_APPLY', True) # DAT unique-hash: open zip/7z/rar and hash inner dump when outer archive hash misses. # Default ON; set DAT_HASH_INNER_ARCHIVE=0 to skip (scan stays basename/outer-hash only). DAT_HASH_INNER_ARCHIVE = _env_bool('DAT_HASH_INNER_ARCHIVE', True) FLIPS_PATH = os.getenv('FLIPS_PATH', '') ENABLE_PATCH_CATALOG = _env_bool('ENABLE_PATCH_CATALOG', True) PATCH_CATALOG_PATH = os.getenv('PATCH_CATALOG_PATH', '') ENABLE_ROM_AI_TRANSLATE = _env_bool('ENABLE_ROM_AI_TRANSLATE', True) RETROARCH_AI_SERVICE_URL = os.getenv('RETROARCH_AI_SERVICE_URL', '') ENABLE_RUFFLE = _env_bool('ENABLE_RUFFLE', True) ENABLE_ACTIVITY_FEED = _env_bool('ENABLE_ACTIVITY_FEED', True) # Household voice (LiveKit) — flag on; UI degrades until LIVEKIT_* configured ENABLE_LIVEKIT = _env_bool('ENABLE_LIVEKIT', True) # BYO Sunshine / Wolf remote play (Moonlight) — off by default; operator-owned GPU host ENABLE_REMOTE_PLAY = _env_bool('ENABLE_REMOTE_PLAY', False) SUNSHINE_BASE_URL = os.getenv('SUNSHINE_BASE_URL', '') WOLF_BASE_URL = os.getenv('WOLF_BASE_URL', '') REMOTE_PLAY_PROVIDER = os.getenv('REMOTE_PLAY_PROVIDER', 'sunshine') REMOTE_PLAY_TOKEN = os.getenv('REMOTE_PLAY_TOKEN', '') REMOTE_PLAY_TOKEN_HINT = os.getenv('REMOTE_PLAY_TOKEN_HINT', '') REMOTE_PLAY_PIN_HINT = os.getenv('REMOTE_PLAY_PIN_HINT', '') REMOTE_PLAY_APP_HINT = os.getenv('REMOTE_PLAY_APP_HINT', '') REMOTE_PLAY_HOST_LABEL = os.getenv('REMOTE_PLAY_HOST_LABEL', '') # Archive / library malware scan (ClamAV when available + filename heuristics) ENABLE_MALWARE_SCAN = _env_bool('ENABLE_MALWARE_SCAN', True) CLAMAV_HOST = os.getenv('CLAMAV_HOST', '127.0.0.1') CLAMAV_PORT = int(os.getenv('CLAMAV_PORT', '3310') or '3310') CLAMAV_SOCKET = os.getenv('CLAMAV_SOCKET', '') MALWARE_SCAN_BLOCK_ON_HIT = _env_bool('MALWARE_SCAN_BLOCK_ON_HIT', True) # Wave 18 — free games feed (News + notifications) ENABLE_FREE_GAMES = _env_bool('ENABLE_FREE_GAMES', True) FREE_GAMES_POLL_HOURS = float(os.getenv('FREE_GAMES_POLL_HOURS', '3') or '3') # Look a giveaway's cover up on IGDB when the store has no portrait URL of # its own (GOG, itch, Humble, IndieGala, Epic via GamerPower). Results are # memoized for a month, so this is roughly one call per new title. Off # leaves those tiles on the aggregator's wide banner. ENABLE_FREE_GAMES_COVER_LOOKUP = _env_bool('ENABLE_FREE_GAMES_COVER_LOOKUP', True) # Discover's on-box recommender. Off means the rebuild daemon never starts; # Discover keeps working and the rows that depend on a profile stay empty. ENABLE_DISCOVER_ML = _env_bool('ENABLE_DISCOVER_ML', True) DISCOVER_ML_REBUILD_HOURS = float( os.getenv('DISCOVER_ML_REBUILD_HOURS', '24') or '24' ) # Batched email digest (mentions / DMs / free games) — needs admin SMTP ENABLE_EMAIL_DIGEST = _env_bool('ENABLE_EMAIL_DIGEST', True) EMAIL_DIGEST_INTERVAL_HOURS = float(os.getenv('EMAIL_DIGEST_INTERVAL_HOURS', '24') or '24') # Auth rate limit (in-process; single-container default) ENABLE_LOGIN_RATE_LIMIT = _env_bool('ENABLE_LOGIN_RATE_LIMIT', True) LOGIN_RATE_LIMIT_ATTEMPTS = int(os.getenv('LOGIN_RATE_LIMIT_ATTEMPTS', '10') or '10') LOGIN_RATE_LIMIT_WINDOW_SECONDS = float(os.getenv('LOGIN_RATE_LIMIT_WINDOW_SECONDS', '300') or '300') # BYO challenge / captcha solver sidecar (FlareSolverr-compatible TRAWL) — opt-in only. ENABLE_CHALLENGE_SOLVER = _env_bool('ENABLE_CHALLENGE_SOLVER', False) CHALLENGE_SOLVER_URL = os.getenv('CHALLENGE_SOLVER_URL', '') CHALLENGE_SOLVER_PROVIDER = os.getenv('CHALLENGE_SOLVER_PROVIDER', 'flaresolverr_compat') CHALLENGE_SOLVER_TIMEOUT_MS = int(os.getenv('CHALLENGE_SOLVER_TIMEOUT_MS', '60000') or '60000') CHALLENGE_SOLVER_MAX_TIER = int(os.getenv('CHALLENGE_SOLVER_MAX_TIER', '5') or '5') CHALLENGE_TOKEN_API_URL = os.getenv('CHALLENGE_TOKEN_API_URL', '') CHALLENGE_TOKEN_API_KEY = os.getenv('CHALLENGE_TOKEN_API_KEY', '') # Ambient lighting bridge (Hyperion.ng / Home Assistant) — opt-in only. ENABLE_AMBIENT_LIGHTING = _env_bool('ENABLE_AMBIENT_LIGHTING', False) LIGHTING_PROVIDER = os.getenv('LIGHTING_PROVIDER', 'off') HYPERION_URL = os.getenv('HYPERION_URL', '') HYPERION_TOKEN = os.getenv('HYPERION_TOKEN', '') HYPERION_PRIORITY = int(os.getenv('HYPERION_PRIORITY', '50') or '50') AMBIENT_ACCENT_COLOR = os.getenv('AMBIENT_ACCENT_COLOR', '255,128,32') HA_URL = os.getenv('HA_URL', os.getenv('HOME_ASSISTANT_URL', '')) HA_TOKEN = os.getenv('HA_TOKEN', os.getenv('HOME_ASSISTANT_TOKEN', '')) HA_LIGHT_ENTITIES = os.getenv('HA_LIGHT_ENTITIES', '') HA_PLAY_SCENE = os.getenv('HA_PLAY_SCENE', '') HA_STOP_SCENE = os.getenv('HA_STOP_SCENE', '') # Homelab SSRF policy — *arr / Ollama / connector URLs may target RFC1918 hosts. # Cloud metadata (169.254.169.254) stays blocked. Default on for Unraid/NAS installs. ALLOW_PRIVATE_LAN_URLS = _env_bool('ALLOW_PRIVATE_LAN_URLS', True) # When true (default), OIDC JIT updates never overwrite an existing user's role. OIDC_LOCK_ROLES = _env_bool('OIDC_LOCK_ROLES', True) # Link an existing account by email even when the provider does not mark # the address verified. Only for a provider whose admins set users' emails. OIDC_TRUST_PROVIDER_EMAIL = _env_bool('OIDC_TRUST_PROVIDER_EMAIL', False) # AGPL §13: a user interacting with this over a network must be offered the # Corresponding Source. README states the obligation; this is what actually # discharges it in the running app. # # Configurable precisely because §13 is about *this* deployment: if you # modify Oneirodex and run it for others, you owe them **your** source, not # upstream's. Point this at your fork before you deploy a modified build. ONEIRODEX_SOURCE_URL = getenv_product( 'SOURCE_URL', 'https://github.com/cephyrixzyth/oneirodex' ) # In-app support → GitHub Issues (optional; tickets still save without a token) SUPPORT_GITHUB_TOKEN = os.getenv('SUPPORT_GITHUB_TOKEN', '') SUPPORT_GITHUB_REPO = os.getenv('SUPPORT_GITHUB_REPO', 'cephyrixzyth/oneirodex') # Optional *arr connector defaults (overridden by Admin → Arr config) PROWLARR_URL = os.getenv('PROWLARR_URL', '') PROWLARR_API_KEY = os.getenv('PROWLARR_API_KEY', '') JACKETT_URL = os.getenv('JACKETT_URL', '') JACKETT_API_KEY = os.getenv('JACKETT_API_KEY', '') QBITTORRENT_URL = os.getenv('QBITTORRENT_URL', '') QBITTORRENT_USERNAME = os.getenv('QBITTORRENT_USERNAME', 'admin') QBITTORRENT_PASSWORD = os.getenv('QBITTORRENT_PASSWORD', '') TRANSMISSION_URL = os.getenv('TRANSMISSION_URL', '') TRANSMISSION_USERNAME = os.getenv('TRANSMISSION_USERNAME', '') TRANSMISSION_PASSWORD = os.getenv('TRANSMISSION_PASSWORD', '') DELUGE_URL = os.getenv('DELUGE_URL', '') DELUGE_PASSWORD = os.getenv('DELUGE_PASSWORD', '') SABNZBD_URL = os.getenv('SABNZBD_URL', '') SABNZBD_API_KEY = os.getenv('SABNZBD_API_KEY', '') class ProdConfig(Config): """Explicit production profile — what create_app() selects outside pytest. Config already holds production-safe values; this names the profile so the selection in create_app() is symmetric and any future prod-only override has an obvious home. """ DEBUG = False TESTING = False class TestConfig(Config): """Test profile. create_app() selects this automatically under pytest. Carries what conftest.py's ``app`` fixture and oneirodex/__init__.py's pytest branch used to set on the instance *after* construction (wave A2.6): * ``TESTING`` / CSRF off so the test client skips token plumbing. * ``SERVER_NAME`` / ``APPLICATION_ROOT`` / ``PREFERRED_URL_SCHEME`` so ``url_for(..., _external=True)`` works outside a request context — Flask raises "Unable to build URLs outside an active request without SERVER_NAME" otherwise. A real ``SERVER_NAME`` in ``Config`` would start rejecting requests whose Host header does not match, which is why it lives here and not there. * the test database URI, taken explicitly from ``TEST_DATABASE_URL`` (with the ``DATABASE_URL`` override conftest also sets as a fallback). """ TESTING = True DEBUG = False WTF_CSRF_ENABLED = False SERVER_NAME = 'localhost' APPLICATION_ROOT = '/' PREFERRED_URL_SCHEME = 'http' # Stable across app instances within a run — matches the value conftest # used to force on the instance. _load_secret_key() would otherwise hand # each create_app() a fresh ephemeral key. SECRET_KEY = 'test-secret-key' SQLALCHEMY_DATABASE_URI = ( os.getenv('TEST_DATABASE_URL') or os.getenv('DATABASE_URL') or Config.SQLALCHEMY_DATABASE_URI )