- 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>
318 lines
11 KiB
Python
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", {})
|