Ctrl K
ComunidadGuías…

app_state.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""
app_state.py – Manages global configuration
"""

import os
import hashlib
import json
import uuid
import threading
from dotenv import load_dotenv

import app as core
import app.client as client

from runtime_paths import CONFIG_DIR, ENV_PATH

CONFIG_DIR.mkdir(parents=True, exist_ok=True)

is_configured = False
DEFAULT_USER_NAME, DEFAULT_UID = None, None
GEMINI_API_KEY = None

AI_PROVIDER = "ollama"
OLLAMA_URL = "http://localhost:11434"
OLLAMA_MODEL = "qwen2.5:7b"
OLLAMA_TIMEOUT = 120
STARRED_MODELS = []
VERBOSE_LOGGING = False
_logging_settings_lock = threading.RLock()
EXTERNAL_API_KEY = None
WEBHOOK_DEBOUNCE_SECONDS = 30

# Gatekeeping: ACCESS_KEY guards the entire API/UI (auto-generated on first run, DB-only,
# never round-tripped through .env). WEBHOOK_SECRET is a separate, opt-in secret checked
# on /api/webhook only, since Emby/Jellyfin notification plugins can't send custom headers
# and instead get it via a URL query param.
ACCESS_KEY = None
WEBHOOK_SECRET = None

CACHE_REFRESH_MINUTES = 15
SERVER_TYPE = "emby"
SERVER_ID = None

# Defined after VERBOSE_LOGGING above: get_logger() reads it to set the initial level.
from app.logger import get_logger

logger = get_logger("MixerBee.State")

def load_logging_settings() -> bool:
    """Load installation logging independently of legacy media credentials."""
    import database
    from app.logger import refresh_logger_level
    from app.log_buffer import capture

    global VERBOSE_LOGGING
    with _logging_settings_lock:
        with database.get_db_connection() as conn:
            row = conn.execute("SELECT value FROM settings WHERE key='VERBOSE_LOGGING'").fetchone()
        VERBOSE_LOGGING = str(row['value'] if row else 'false').lower() in ('true', '1', 't', 'yes')
        refresh_logger_level()
        # Capture follows the saved preference exactly, so a restart with verbose on
        # starts collecting immediately and a restart with it off collects nothing.
        capture.attach_all()
        capture.set_enabled(VERBOSE_LOGGING)
        return VERBOSE_LOGGING


def set_verbose_logging(enabled: bool) -> bool:
    """Persist before applying, so a failed save leaves runtime logging unchanged."""
    import database
    from app.logger import refresh_logger_level
    from app.log_buffer import capture

    global VERBOSE_LOGGING
    with _logging_settings_lock:
        with database.get_db_connection() as conn:
            columns = {r['name'] for r in conn.execute('PRAGMA table_info(settings)')}
            if 'updated_at' in columns:
                conn.execute(
                    "INSERT OR REPLACE INTO settings (key,value,updated_at) VALUES ('VERBOSE_LOGGING',?,CURRENT_TIMESTAMP)",
                    ('true' if enabled else 'false',))
            else:
                conn.execute(
                    "INSERT OR REPLACE INTO settings (key,value) VALUES ('VERBOSE_LOGGING',?)",
                    ('true' if enabled else 'false',))
            conn.commit()
        VERBOSE_LOGGING = enabled
        refresh_logger_level()
        # Turning verbose off drops every retained record; turning it on starts a new
        # capture generation, which invalidates any cursor a browser still holds.
        capture.attach_all()
        capture.set_enabled(enabled)
        return VERBOSE_LOGGING


def get_env_hash():
    """Calculates an MD5 hash of the .env file to detect manual changes."""
    if not ENV_PATH.exists():
        return ""
    with open(ENV_PATH, "rb") as f:
        return hashlib.md5(f.read()).hexdigest()

def sync_env_to_db():
    """
    Checks if the .env file has changed since the last run.
    If it has, we push the .env values into the database.
    """
    import database

    current_hash = get_env_hash()

    with database.get_db_connection() as conn:
        row = conn.execute("SELECT value FROM settings WHERE key = 'env_hash'").fetchone()
        stored_hash = row['value'] if row else ""

        if current_hash != stored_hash:
            logger.info("SETTINGS: .env file change detected. Syncing to database...")
            load_dotenv(ENV_PATH, override=True)

            keys_to_sync = [
                "SERVER_TYPE", "EMBY_URL", "EMBY_USER", "EMBY_PASS",
                "AI_PROVIDER", "OLLAMA_URL", "OLLAMA_MODEL", "OLLAMA_TIMEOUT", "GEMINI_API_KEY",
                "VERBOSE_LOGGING", "EXTERNAL_API_KEY"
            ]

            for k in keys_to_sync:
                val = os.environ.get(k, "")
                conn.execute(
                    "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
                    (k, val)
                )

            conn.execute("INSERT OR REPLACE INTO settings (key, value) VALUES ('env_hash', ?)", (current_hash,))
            conn.commit()

def load_settings_from_db():
    """Hydrates runtime globals from the SQLite settings table."""
    import database

    global SERVER_TYPE, AI_PROVIDER, OLLAMA_URL, OLLAMA_MODEL, OLLAMA_TIMEOUT, GEMINI_API_KEY, VERBOSE_LOGGING, STARRED_MODELS, EXTERNAL_API_KEY, ACCESS_KEY, WEBHOOK_SECRET, WEBHOOK_DEBOUNCE_SECONDS

    with database.get_db_connection() as conn:
        rows = conn.execute("SELECT key, value FROM settings").fetchall()
        settings = {row['key']: row['value'] for row in rows}

        SERVER_TYPE = settings.get("SERVER_TYPE", "emby").lower()
        # Legacy settings facade for the current single-connection UI only.
        core.EMBY_URL = client.EMBY_URL = settings.get("EMBY_URL", "").rstrip("/")
        core.EMBY_USER = settings.get("EMBY_USER")
        core.EMBY_PASS = settings.get("EMBY_PASS")

        AI_PROVIDER = settings.get("AI_PROVIDER", "ollama").lower()
        OLLAMA_URL = settings.get("OLLAMA_URL", "http://localhost:11434")
        OLLAMA_MODEL = settings.get("OLLAMA_MODEL", "qwen2.5:7b")
        GEMINI_API_KEY = settings.get("GEMINI_API_KEY")

        try:
            OLLAMA_TIMEOUT = int(settings.get("OLLAMA_TIMEOUT") or 120)
        except (TypeError, ValueError):
            OLLAMA_TIMEOUT = 120
        
        try:
            STARRED_MODELS = json.loads(settings.get("STARRED_MODELS", "[]"))
        except:
            STARRED_MODELS = []
            
        VERBOSE_LOGGING = str(settings.get("VERBOSE_LOGGING", "false")).lower() in ("true", "1", "t", "yes")
        
        EXTERNAL_API_KEY = settings.get("EXTERNAL_API_KEY")

        # ACCESS_KEY/WEBHOOK_SECRET/WEBHOOK_DEBOUNCE_SECONDS are DB-only (never written to
        # .env, never in keys_to_sync above) so a later .env edit can never wipe them out via
        # sync_env_to_db, which would otherwise write "" for any key .env doesn't mention.
        # ACCESS_KEY/WEBHOOK_SECRET are also opt-in and unset by default: MixerBee stays open
        # on the LAN, same as before this existed, until the admin deliberately sets one from
        # Settings (the UI nags but never forces this).
        ACCESS_KEY = settings.get("MIXERBEE_ACCESS_KEY") or None
        WEBHOOK_SECRET = settings.get("MIXERBEE_WEBHOOK_SECRET") or None

        try:
            WEBHOOK_DEBOUNCE_SECONDS = max(1, int(settings.get("WEBHOOK_DEBOUNCE_SECONDS") or 30))
        except (TypeError, ValueError):
            WEBHOOK_DEBOUNCE_SECONDS = 30

def load_and_authenticate() -> bool:
    """Master startup sequence: Hash check -> DB Sync -> Hydrate -> Authenticate."""
    global is_configured, DEFAULT_USER_NAME, DEFAULT_UID, SERVER_ID, OLLAMA_TIMEOUT

    try:
        sync_env_to_db()
        load_settings_from_db()

        try:
            from app.logger import refresh_logger_level
            refresh_logger_level()
        except ImportError:
            pass

        if not all([core.EMBY_URL, core.EMBY_USER, core.EMBY_PASS]):
            raise ValueError("Incomplete server configuration.")

        from app.media_client import Connection, MediaClient
        from connections import save_authenticated_connection
        candidate = MediaClient(Connection(uuid.uuid4().hex, core.EMBY_URL, SERVER_TYPE, core.EMBY_USER, core.EMBY_PASS))
        try:
            auth = candidate.authenticate()
        finally:
            candidate.session.close()
        # Seed the provider selection only when .env actually named one *and* supplied
        # its credentials. The module defaults above are fallbacks, not consent: writing
        # them in would make every legacy import look like a deliberate AI setup.
        provider = (AI_PROVIDER or "").strip().lower()
        seeded_ai = {"GEMINI_API_KEY": GEMINI_API_KEY or "", "OLLAMA_URL": OLLAMA_URL or "",
                     "OLLAMA_MODEL": OLLAMA_MODEL or "", "OLLAMA_TIMEOUT": OLLAMA_TIMEOUT,
                     "AI_PROVIDER": ""}
        if (provider == "gemini" and (GEMINI_API_KEY or "").strip()) or \
           (provider == "ollama" and (OLLAMA_URL or "").strip() and (OLLAMA_MODEL or "").strip()):
            seeded_ai["AI_PROVIDER"] = provider
        media = save_authenticated_connection(
            core.EMBY_URL, SERVER_TYPE, core.EMBY_USER, core.EMBY_PASS, auth, seeded_ai)
        SERVER_ID = media.connection.server_id

        DEFAULT_USER_NAME = core.EMBY_USER
        DEFAULT_UID = media.user_id
        is_configured = True
        return True
    except Exception as e:
        is_configured = False
        logger.warning(f"Auth failed: {e}")
        return False