docker.videokonverter/app/config.py
data ea5a81cd17 v2.4.0: Video-Player, Import-Zuordnung, Loeschen, Audio-Fix
- Video-Player mit ffmpeg-Transcoding (EAC3/DTS/AC3 -> AAC)
- Play-Buttons in allen Ansichten (Serien, Filme, Ordner)
- Delete-Buttons fuer einzelne Videos (DB + Datei)
- Import: Nicht-erkannte Dateien per Modal zuordnen/ueberspringen
- Import: Start blockiert wenn ungeloeste Items vorhanden
- Audio channelmap Fix: 5.1(side) -> 5.1 fuer libopus
- ENV-Variablen: VK_* Prefix (VK_DB_HOST, VK_MODE etc.)
- WebSocket: Server-Log Push statt HTTP-Polling
- Ordner-Loeschen Fix im Filebrowser
- Import: Duplikat-Erkennung bei erneutem Scan

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 17:35:37 +01:00

318 lines
11 KiB
Python

"""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()
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"""
presets_file = self._cfg_path / "presets.yaml"
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:
logging.warning("Keine presets.yaml gefunden - verwende leere Presets")
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", {})