config.py
Rama main · réplica de lectura del repositorio original.
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
)