"""Konfigurationsmanagement - Singleton fuer Settings und Presets Alle wichtigen Settings koennen per Umgebungsvariable ueberschrieben werden. ENV-Variablen haben IMMER Vorrang vor settings.yaml. Mapping (VK_ Prefix): Datenbank: VK_DB_HOST, VK_DB_PORT, VK_DB_USER, VK_DB_PASSWORD, VK_DB_NAME Encoding: VK_MODE (cpu/gpu/auto), VK_GPU_DEVICE, VK_MAX_JOBS, VK_DEFAULT_PRESET Server: VK_PORT, VK_HOST, VK_EXTERNAL_URL Library: VK_TVDB_API_KEY, VK_TVDB_LANGUAGE, VK_LIBRARY_ENABLED (true/false) Dateien: VK_TARGET_CONTAINER (webm/mkv/mp4) Logging: VK_LOG_LEVEL (DEBUG/INFO/WARNING/ERROR) """ import os import logging import yaml from pathlib import Path from typing import Optional from logging.handlers import TimedRotatingFileHandler, RotatingFileHandler # Mapping: ENV-Variable -> (settings-pfad, typ) # Pfad als Tuple: ("section", "key") _ENV_MAP: dict[str, tuple[tuple[str, str], type]] = { "VK_DB_HOST": (("database", "host"), str), "VK_DB_PORT": (("database", "port"), int), "VK_DB_USER": (("database", "user"), str), "VK_DB_PASSWORD": (("database", "password"), str), "VK_DB_NAME": (("database", "database"), str), "VK_MODE": (("encoding", "mode"), str), "VK_GPU_DEVICE": (("encoding", "gpu_device"), str), "VK_MAX_JOBS": (("encoding", "max_parallel_jobs"), int), "VK_DEFAULT_PRESET": (("encoding", "default_preset"), str), "VK_PORT": (("server", "port"), int), "VK_HOST": (("server", "host"), str), "VK_EXTERNAL_URL": (("server", "external_url"), str), "VK_TVDB_API_KEY": (("library", "tvdb_api_key"), str), "VK_TVDB_LANGUAGE": (("library", "tvdb_language"), str), "VK_LIBRARY_ENABLED": (("library", "enabled"), bool), "VK_TARGET_CONTAINER": (("files", "target_container"), str), "VK_LOG_LEVEL": (("logging", "level"), str), } # Rueckwaertskompatibilitaet _ENV_ALIASES: dict[str, str] = { "VIDEO_KONVERTER_MODE": "VK_MODE", } # Default-Settings wenn keine settings.yaml existiert _DEFAULT_SETTINGS: dict = { "database": { "host": "localhost", "port": 3306, "user": "video", "password": "", "database": "video_converter", }, "encoding": { "mode": "cpu", "default_preset": "cpu_av1", "gpu_device": "/dev/dri/renderD128", "gpu_driver": "iHD", "max_parallel_jobs": 1, }, "server": { "host": "0.0.0.0", "port": 8080, "external_url": "", "use_https": False, "websocket_path": "/ws", }, "files": { "delete_source": False, "recursive_scan": True, "scan_extensions": [".mkv", ".mp4", ".avi", ".wmv", ".vob", ".ts", ".m4v", ".flv", ".mov"], "target_container": "webm", "target_folder": "same", }, "audio": { "bitrate_map": {2: "128k", 6: "320k", 8: "450k"}, "default_bitrate": "192k", "default_codec": "libopus", "keep_channels": True, "languages": ["ger", "eng", "und"], }, "subtitle": { "codec_blacklist": ["hdmv_pgs_subtitle", "dvd_subtitle", "dvb_subtitle"], "languages": ["ger", "eng"], }, "library": { "enabled": True, "import_default_mode": "copy", "import_naming_pattern": "{series} - S{season:02d}E{episode:02d} - {title}.{ext}", "import_season_pattern": "Season {season:02d}", "scan_interval_hours": 0, "tvdb_api_key": "", "tvdb_language": "deu", "tvdb_pin": "", }, "cleanup": { "enabled": False, "delete_extensions": [".avi", ".wmv", ".vob", ".nfo", ".txt", ".jpg", ".png", ".srt", ".sub", ".idx"], "keep_extensions": [".srt"], "exclude_patterns": ["readme*", "*.md"], }, "logging": { "level": "INFO", "file": "server.log", "rotation": "time", "backup_count": 7, "max_size_mb": 10, }, "statistics": { "cleanup_days": 365, "max_entries": 5000, }, } class Config: """Laedt und verwaltet settings.yaml und presets.yaml. ENV-Variablen (VK_*) ueberschreiben YAML-Werte.""" _instance: Optional['Config'] = None def __new__(cls) -> 'Config': if cls._instance is None: cls._instance = super().__new__(cls) cls._instance._initialized = False return cls._instance def __init__(self) -> None: if self._initialized: return self._initialized = True self._base_path = Path(__file__).parent self._cfg_path = self._base_path / "cfg" self._log_path = self._base_path.parent / "logs" self._data_path = self._base_path.parent / "data" # Verzeichnisse sicherstellen self._cfg_path.mkdir(parents=True, exist_ok=True) self._log_path.mkdir(parents=True, exist_ok=True) self._data_path.mkdir(parents=True, exist_ok=True) self.settings: dict = {} self.presets: dict = {} self._load_settings() self._load_presets() self._apply_env_overrides() @property def log_file_path(self) -> str: """Pfad zur aktiven Log-Datei""" log_file = self.settings.get("logging", {}).get("file", "server.log") return str(self._log_path / log_file) def _load_settings(self) -> None: """Laedt settings.yaml oder erzeugt Defaults""" import copy settings_file = self._cfg_path / "settings.yaml" if settings_file.exists(): try: with open(settings_file, "r", encoding="utf-8") as f: self.settings = yaml.safe_load(f) or {} logging.info(f"Settings geladen: {settings_file}") except Exception as e: logging.error(f"Settings lesen fehlgeschlagen: {e}") self.settings = copy.deepcopy(_DEFAULT_SETTINGS) else: # Keine settings.yaml -> Defaults verwenden und speichern logging.info("Keine settings.yaml gefunden - erzeuge Defaults") self.settings = copy.deepcopy(_DEFAULT_SETTINGS) self._save_yaml(settings_file, self.settings) def _load_presets(self) -> None: """Laedt presets.yaml. Falls nicht vorhanden, aus cfg_defaults kopieren.""" presets_file = self._cfg_path / "presets.yaml" if not presets_file.exists(): # Versuche Default-Presets aus cfg_defaults zu kopieren defaults_file = Path(__file__).parent.parent / "cfg_defaults" / "presets.yaml" if defaults_file.exists(): import shutil shutil.copy2(defaults_file, presets_file) logging.info(f"Default-Presets kopiert: {defaults_file} -> {presets_file}") else: logging.warning("Keine presets.yaml und keine Defaults gefunden") if presets_file.exists(): try: with open(presets_file, "r", encoding="utf-8") as f: self.presets = yaml.safe_load(f) or {} logging.info(f"Presets geladen: {presets_file}") except Exception as e: logging.error(f"Presets lesen fehlgeschlagen: {e}") self.presets = {} else: self.presets = {} def _apply_env_overrides(self) -> None: """Umgebungsvariablen (VK_*) ueberschreiben Settings. Unterstuetzt auch alte Variablennamen per Alias-Mapping.""" applied = [] # Aliase aufloesen (z.B. VIDEO_KONVERTER_MODE -> VK_MODE) for old_name, new_name in _ENV_ALIASES.items(): if old_name in os.environ and new_name not in os.environ: os.environ[new_name] = os.environ[old_name] for env_key, ((section, key), val_type) in _ENV_MAP.items(): raw = os.environ.get(env_key) if raw is None: continue # Typ-Konvertierung try: if val_type is bool: value = raw.lower() in ("true", "1", "yes", "on") elif val_type is int: value = int(raw) else: value = raw except (ValueError, TypeError): logging.warning(f"ENV {env_key}={raw!r} - ungueliger Wert, uebersprungen") continue self.settings.setdefault(section, {})[key] = value applied.append(f"{env_key}={value}") if applied: logging.info(f"ENV-Overrides angewendet: {', '.join(applied)}") @staticmethod def _save_yaml(path: Path, data: dict) -> None: """Schreibt dict als YAML in Datei""" try: with open(path, "w", encoding="utf-8") as f: yaml.dump(data, f, default_flow_style=False, indent=2, allow_unicode=True) logging.info(f"YAML gespeichert: {path}") except Exception as e: logging.error(f"YAML speichern fehlgeschlagen ({path}): {e}") def save_settings(self) -> None: """Schreibt aktuelle Settings zurueck in settings.yaml""" self._save_yaml(self._cfg_path / "settings.yaml", self.settings) def save_presets(self) -> None: """Schreibt Presets zurueck in presets.yaml""" self._save_yaml(self._cfg_path / "presets.yaml", self.presets) def setup_logging(self) -> None: """Konfiguriert Logging mit Rotation""" log_cfg = self.settings.get("logging", {}) log_level = log_cfg.get("level", "INFO") log_file = log_cfg.get("file", "server.log") log_mode = log_cfg.get("rotation", "time") backup_count = log_cfg.get("backup_count", 7) log_path = self._log_path / log_file handlers = [logging.StreamHandler()] if log_mode == "time": file_handler = TimedRotatingFileHandler( str(log_path), when="midnight", interval=1, backupCount=backup_count, encoding="utf-8" ) else: max_bytes = log_cfg.get("max_size_mb", 10) * 1024 * 1024 file_handler = RotatingFileHandler( str(log_path), maxBytes=max_bytes, backupCount=backup_count, encoding="utf-8" ) handlers.append(file_handler) # force=True weil Config.__init__ logging aufruft bevor setup_logging() logging.basicConfig( level=getattr(logging, log_level, logging.INFO), format="%(asctime)s - %(levelname)s - %(message)s", handlers=handlers, force=True, ) # --- Properties fuer haeufig benoetigte Werte --- @property def encoding_mode(self) -> str: return self.settings.get("encoding", {}).get("mode", "cpu") @property def gpu_device(self) -> str: return self.settings.get("encoding", {}).get("gpu_device", "/dev/dri/renderD128") @property def max_parallel_jobs(self) -> int: return self.settings.get("encoding", {}).get("max_parallel_jobs", 1) @property def target_container(self) -> str: return self.settings.get("files", {}).get("target_container", "webm") @property def default_preset_name(self) -> str: return self.settings.get("encoding", {}).get("default_preset", "cpu_av1") @property def default_preset(self) -> dict: name = self.default_preset_name return self.presets.get(name, {}) @property def data_path(self) -> Path: return self._data_path @property def audio_config(self) -> dict: return self.settings.get("audio", {}) @property def subtitle_config(self) -> dict: return self.settings.get("subtitle", {}) @property def files_config(self) -> dict: return self.settings.get("files", {}) @property def cleanup_config(self) -> dict: return self.settings.get("cleanup", {}) @property def server_config(self) -> dict: return self.settings.get("server", {})