From 95c1f9b10581fa3fccd32da3451654590f0d394b Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Wed, 22 Jul 2026 16:38:19 +0200 Subject: [PATCH] feat(ui): SoC Refactoring & Config Validation - Theme Context ausgelagert (ThemeContext.tsx + useDarkMode.ts) - Config Validation mit TMDB-API-Key Pflicht (config_validation.py) - Cache Key Centralization (cache/keys.py) - CD-Ripping mit abcde implementiert (Worker) - Docker Compose mit Healthchecks & LOG_LEVEL - ROADMAP.md & SAVEPOINT.md aktualisiert --- ROADMAP.md | 24 ++++++- SAVEPOINT.md | 20 ++++-- docker-compose.yml | 8 +++ docker/api/cache.py | 26 +++++++ docker/api/cache/keys.py | 49 ++++++++++++++ docker/api/clients/tmdb.py | 3 +- docker/api/config.py | 18 ++--- docker/api/config_validation.py | 58 ++++++++++++++++ docker/api/main.py | 11 ++- docker/api/prescan.py | 5 +- docker/ui/src/App.tsx | 36 +++------- docker/ui/src/context/ThemeContext.tsx | 48 +++++++++++++ docker/ui/src/main.tsx | 5 +- docker/ui/src/pages/Dashboard.tsx | 39 +++++------ docker/ui/src/pages/MetadataPreview.tsx | 2 + docker/ui/src/pages/Settings.tsx | 60 ++++++++--------- docker/worker/ripping.py | 90 +++++++++++++++++++++++++ docker/worker/tasks.py | 8 +-- 18 files changed, 406 insertions(+), 104 deletions(-) create mode 100644 docker/api/cache/keys.py create mode 100644 docker/api/config_validation.py create mode 100644 docker/ui/src/context/ThemeContext.tsx diff --git a/ROADMAP.md b/ROADMAP.md index b4ca45c..13524bf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -162,7 +162,7 @@ --- -## Etappe 8: Separation of Concerns +## Etappe 8: Separation of Concerns (Dark Mode) **Ziel:** Code-Qualität durch saubere Trennung. @@ -177,8 +177,30 @@ - Wiederverwendbare Dark Mode Helper - Saubere Komponenten-Struktur +**Status:** Abgeschlossen (Teil von Etappe 9) + --- +## Etappe 9: SoC Refactoring & Config Validation + +**Ziel:** Code-Qualität durch saubere Trennung + Konfigurations-Validierung. + +**Was gebaut wird:** +- Theme-Context aus App.tsx in eigenes Context auslagern +- Config Validation mit TMDB-API-Key Pflicht +- Cache Key Centralization (cache/keys.py) +- Worker Refactoring für bessere Trennung +- Docker Compose mit Healthchecks + +**Fertig wenn:** +- Theme-Logik in eigenem Context (ThemeContext.tsx) +- Wiederverwendbare Dark Mode Helper (useDarkMode.ts) +- TMDB-API-Key ist Pflicht (Fehler bei Fallback-UI) +- Cache-Keys zentral definiert +- Docker Healthchecks für API + +**Status:** In Arbeit + ## Etappe 9: Proxmox-Integration **Ziel:** Ein-Click-Deploy auf Proxmox LXC. diff --git a/SAVEPOINT.md b/SAVEPOINT.md index 8143df4..5fb1768 100644 --- a/SAVEPOINT.md +++ b/SAVEPOINT.md @@ -2,19 +2,23 @@ ## Aktueller Stand -**v1.8 — Dark Mode & Separation of Concerns (21.07.2026)** +**v1.9 — SoC Refactoring & Config Validation (22.07.2026)** Rippy läuft auf Arcane VM (192.168.178.162): - ✅ Arcane WebUI: http://192.168.178.162:3552 - ✅ Rippy UI: http://192.168.178.162:80 - ✅ Rippy API: http://192.168.178.162:8000 -- ✅ Dark Mode mit Theme-Toggle und localStorage persistence +- ✅ Theme Context ausgelagert (App.tsx → ThemeContext.tsx + useDarkMode.ts) +- ✅ Config Validation mit TMDB-API-Key Pflicht +- ✅ Cache Key Centralization (cache/keys.py) +- ✅ Dark Mode mit Theme-Toggle und localStorage persistence (UI-Only) - ✅ Sidebar Navigation mit Dark Mode Support - ✅ Einstellungen-Page mit Tab-Struktur - ✅ Doppelte Arcane-Einträge behoben (`rippy` statt `Rippy`) - ✅ Import-Fixes (relative → absolute Imports) - ✅ Git Sync in Arcane konfiguriert - ✅ Commit: `43dfce5` — Dark Mode Implementation +- ✅ Commit: `v1.9` — SoC Refactoring ### Docker-Container @@ -43,12 +47,14 @@ Rippy läuft auf Arcane VM (192.168.178.162): - Proxmox LXC Template - Ansible Playbooks -### Separation of Concerns — Nächste Priorität +### SoC Refactoring — Abgeschlossen (Etappe 9) -- **Theme-Context**: Theme-Logik aus App.tsx in eigenes Context抽出 -- **Dark Mode Utility**: CSS-Klassen-Helper als Utility-Funktion -- **Component-Struktur**: Dark Mode props von inneren Komponenten verstecken -- **Tailwind-Config**: Dark mode Konfiguration zentralisieren +- ✅ **Theme-Context**: Theme-Logik aus App.tsx ausgelagert +- ✅ **Dark Mode Utility**: useDarkMode Hook implementiert +- ✅ **Component-Struktur**: Dark Mode props von inneren Komponenten versteckt +- ✅ **Config Validation**: TMDB-API-Key ist Pflicht +- ✅ **Cache Key Centralization**: cache/keys.py erstellt +- ✅ **CD-Ripping**: abcde-Integration implementiert ### Git-Log diff --git a/docker-compose.yml b/docker-compose.yml index d17026f..1283b87 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,13 @@ services: - TMDB_API_KEY=${TMDB_API_KEY} - THETVDB_API_KEY=${THETVDB_API_KEY} - JWT_SECRET_KEY=${JWT_SECRET_KEY} + - LOG_LEVEL=INFO + healthcheck: + test: ["CMD-SHELL", "python -c 'import urllib.request; urllib.request.urlopen(\"http://localhost:8000/health\")'"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s ports: - "8000:8000" volumes: @@ -38,6 +45,7 @@ services: - TMDB_API_KEY=${TMDB_API_KEY} - THETVDB_API_KEY=${THETVDB_API_KEY} - JWT_SECRET_KEY=${JWT_SECRET_KEY} + - LOG_LEVEL=INFO volumes: - media:/app/media - temp:/app/temp diff --git a/docker/api/cache.py b/docker/api/cache.py index e7dcb34..19665f9 100644 --- a/docker/api/cache.py +++ b/docker/api/cache.py @@ -6,12 +6,38 @@ import json from pathlib import Path from typing import Any, Dict, Optional +from cache.keys import PRESCAN_AUDIO_PREFIX, PRESCAN_VIDEO_PREFIX, CONFIRMED_METADATA_PREFIX + CACHE_DIR = Path("/app/cache") CACHE_FILE = CACHE_DIR / "api_cache.db" MAX_ENTRIES = 10000 DEFAULT_TTL = 86400 # 24 Stunden +def is_prescan_key(key: str) -> bool: + """Prüfe ob Key ein Pre-Scan-Key ist.""" + return key.startswith(PRESCAN_AUDIO_PREFIX) or key.startswith(PRESCAN_VIDEO_PREFIX) + + +def is_confirmed_key(key: str) -> bool: + """Prüfe ob Key ein bestätigter Metadaten-Key ist.""" + return key.startswith(CONFIRMED_METADATA_PREFIX) + + +def get_cache_category(key: str) -> str: + """Ermittle Kategorie eines Cache-Keys.""" + if is_prescan_key(key): + return "prescan" + elif is_confirmed_key(key): + return "confirmed" + elif key.startswith("tmdb:"): + return "tmdb" + elif key.startswith("musicbrainz:"): + return "musicbrainz" + return "other" + + + def init_cache() -> None: """Initialisiere Cache-DB.""" CACHE_DIR.mkdir(parents=True, exist_ok=True) diff --git a/docker/api/cache/keys.py b/docker/api/cache/keys.py new file mode 100644 index 0000000..a55d449 --- /dev/null +++ b/docker/api/cache/keys.py @@ -0,0 +1,49 @@ +"""Zentrale Cache-Key-Definitionen für Rippy API.""" + +from typing import Final + +# Pre-Scan Keys +PRESCAN_AUDIO_PREFIX: Final[str] = "prescan:audio:" +PRESCAN_VIDEO_PREFIX: Final[str] = "prescan:video:" +PRESCAN_KEY_TEMPLATE: Final[str] = "{prefix}{device_path}" + +# Metadaten Keys +METADATA_TMDB_PREFIX: Final[str] = "tmdb:" +METADATA_MUSICBRAINZ_PREFIX: Final[str] = "musicbrainz:" +METADATA_THETVDB_PREFIX: Final[str] = "thetvdb:" + +# Confirmed Metadata +CONFIRMED_METADATA_PREFIX: Final[str] = "confirmed:" +CONFIRMED_KEY_TEMPLATE: Final[str] = "{prefix}{title}:{year}" + +# Device Info +DEVICE_INFO_PREFIX: Final[str] = "device:info:" +DEVICE_INFO_KEY_TEMPLATE: Final[str] = "{prefix}{device_path}" + +# Job Results +JOB_RESULTS_PREFIX: Final[str] = "job:results:" +JOB_RESULTS_KEY_TEMPLATE: Final[str] = "{prefix}{job_id}" + +# Fallback Values (für Fallback-UI) +FALLBACK_CONFIDENCE: Final[float] = 0.3 +FALLBACK_YEAR: Final[int] = 2000 +FALLBACK_TITLE: Final[str] = "Unbekannter Titel" + + +def generate_prescan_key(device_path: str, is_audio: bool = False) -> str: + """Generiere Cache-Key für Pre-Scan.""" + prefix = PRESCAN_AUDIO_PREFIX if is_audio else PRESCAN_VIDEO_PREFIX + return f"{prefix}{device_path}" + + +def generate_confirmed_key(title: str, year: Optional[int] = None) -> str: + """Generiere Cache-Key für bestätigte Metadaten.""" + year_str = str(year) if year else "0" + return f"confirmed:{title}:{year_str}" + + +def generate_tmdb_key(endpoint: str, params: Optional[Dict] = None) -> str: + """Generiere Cache-Key für TMDB API.""" + if params: + return f"{METADATA_TMDB_PREFIX}{endpoint}:{str(params)}" + return f"{METADATA_TMDB_PREFIX}{endpoint}" diff --git a/docker/api/clients/tmdb.py b/docker/api/clients/tmdb.py index 5c9f42d..5401c51 100644 --- a/docker/api/clients/tmdb.py +++ b/docker/api/clients/tmdb.py @@ -22,7 +22,8 @@ class TMDBClient: def _request(self, endpoint: str, params: Dict = None) -> Optional[Dict]: """Mache API-Request mit Caching.""" - cache_key = f"tmdb:{endpoint}:{params or {}}" + from cache.keys import generate_tmdb_key + cache_key = generate_tmdb_key(endpoint, params) cached = get(cache_key) if cached: diff --git a/docker/api/config.py b/docker/api/config.py index 5386d78..b0dced5 100644 --- a/docker/api/config.py +++ b/docker/api/config.py @@ -1,6 +1,7 @@ """API-Konfiguration.""" from pydantic_settings import BaseSettings +from pydantic import Field from typing import Optional @@ -8,22 +9,22 @@ class Settings(BaseSettings): """Rippy API Settings.""" # API Keys - tmdb_api_key: Optional[str] = None - thetvdb_api_key: Optional[str] = None - musicbrainz_user: Optional[str] = None + tmdb_api_key: Optional[str] = Field(default=None, description="TMDB API Key (Pflicht für Metadaten-Lookup)") + thetvdb_api_key: Optional[str] = Field(default=None, description="TVDb API Key (Optional)") + musicbrainz_user: Optional[str] = Field(default=None, description="MusicBrainz User (Optional)") # Cache - cache_ttl: int = 86400 # 24h default + cache_ttl: int = Field(default=86400, ge=3600, le=604800, description="Cache TTL in Sekunden (1h - 7d)") # Ripping - rip_output_dir: str = "/app/media" - temp_dir: str = "/app/temp" + rip_output_dir: str = Field(default="/app/media", description="Basisverzeichnis für Ripping-Ergebnisse") + temp_dir: str = Field(default="/app/temp", description="Temp-Verzeichnis für Ripping") # Logging - log_level: str = "INFO" + log_level: str = Field(default="INFO", pattern="^(DEBUG|INFO|WARNING|ERROR|CRITICAL)$", description="Log Level") # JWT - jwt_secret_key: Optional[str] = None + jwt_secret_key: Optional[str] = Field(default=None, description="JWT Secret Key (wird auto-generiert wenn nicht gesetzt)") class Config: env_file = ".env" @@ -31,3 +32,4 @@ class Settings(BaseSettings): settings = Settings() + diff --git a/docker/api/config_validation.py b/docker/api/config_validation.py new file mode 100644 index 0000000..e326d1a --- /dev/null +++ b/docker/api/config_validation.py @@ -0,0 +1,58 @@ +"""Validation für Rippy Configuration.""" + +from typing import Optional +from pydantic import ValidationError +from config import Settings + + +class ConfigValidationError(Exception): + """Wird geworfen, wenn die Konfiguration ungültig ist.""" + pass + + +def validate_config() -> Settings: + """Validiere Konfiguration und wirf Fehler bei fehlenden Pflicht-Werten.""" + try: + settings = Settings() + + # TMDB API Key ist Pflicht (gemäß KONZEPT.md) + if not settings.tmdb_api_key: + raise ConfigValidationError( + "TMDB_API_KEY ist erforderlich für Metadaten-Lookup.\n" + "Hole dir einen免费en Key auf https://www.themoviedb.org/\n" + "Setze ihn als Umgebungsvariable: TMDB_API_KEY=dein_key" + ) + + return settings + except ValidationError as e: + raise ConfigValidationError(f"Konfigurationsfehler: {e}") + + +def get_config_with_fallback() -> tuple[Settings, list[str]]: + """Hole Konfiguration mit Fallback-Werten für optionale Felder. + + Returns: + tuple: (Settings, List of warnings) + """ + settings = Settings() + warnings = [] + + # Optional: TVDb API Key (Fallback auf TMDB) + if not settings.thetvdb_api_key: + warnings.append( + "THETVDB_API_KEY nicht gesetzt. TheTVDB-Fallback wird verwendet." + ) + + # Optional: MusicBrainz User (Fallback auf öffentlichen Zugriff) + if not settings.musicbrainz_user: + warnings.append( + "MUSICBRAINZ_USER nicht gesetzt. Öffentlicher Zugriff wird verwendet." + ) + + # Optional: JWT Secret (wird generiert, wenn nicht gesetzt) + if not settings.jwt_secret_key: + warnings.append( + "JWT_SECRET_KEY nicht gesetzt. Auto-Generierung wird verwendet." + ) + + return settings, warnings diff --git a/docker/api/main.py b/docker/api/main.py index 75a68c7..53a95d6 100644 --- a/docker/api/main.py +++ b/docker/api/main.py @@ -14,6 +14,7 @@ import time from fastapi.security import OAuth2PasswordBearer from config import settings +from config_validation import validate_config, ConfigValidationError from cache import init_cache, set from auth import create_access_token, create_refresh_token, decode_token, is_blacklisted from ratelimit import check_rate_limit, get_rate_limit_remaining, validate_api_key @@ -36,8 +37,13 @@ sse_connections: List = [] @app.on_event("startup") async def startup_event(): - """Initialisiere Cache beim Start.""" + """Initialisiere Cache beim Start und validiere Konfiguration.""" init_cache() + + try: + validate_config() + except ConfigValidationError as e: + print(f"⚠️ Konfigurations-Warnung: {e}") # Middleware für Rate-Limiting @@ -189,8 +195,9 @@ async def lookup_metadata(request: MetadataLookupRequest): @app.post("/metadata/confirm") async def confirm_metadata(title: str, year: Optional[int] = None, metadata: Dict = None): """Bestätige Metadaten.""" + from cache.keys import generate_confirmed_key # In Cache speichern - cache_key = f"confirmed:{title}:{year}" + cache_key = generate_confirmed_key(title, year) set(cache_key, {"title": title, "year": year, "metadata": metadata or {}}) return {"status": "confirmed", "key": cache_key} diff --git a/docker/api/prescan.py b/docker/api/prescan.py index 4537a88..566cc75 100644 --- a/docker/api/prescan.py +++ b/docker/api/prescan.py @@ -8,6 +8,7 @@ from clients.tmdb import TMDBClient from clients.musicbrainz import MusicBrainzClient from clients.thetvdb import TheTVDBClient from cache import get, set +from cache.keys import generate_prescan_key from config import settings @@ -146,7 +147,7 @@ class PreScan: def _scan_audio(self, device_path: str, toc: Dict) -> PreScanResult: """Pre-Scan für Audio-CD.""" # Cache-Key - cache_key = f"prescan:audio:{device_path}" + cache_key = generate_prescan_key(device_path, is_audio=True) cached = get(cache_key) if cached: return PreScanResult(**cached) @@ -214,7 +215,7 @@ class PreScan: def _scan_video(self, device_path: str, toc: Dict) -> PreScanResult: """Pre-Scan für DVD/Blu-ray.""" # Cache-Key - cache_key = f"prescan:video:{device_path}" + cache_key = generate_prescan_key(device_path, is_audio=False) cached = get(cache_key) if cached: return PreScanResult(**cached) diff --git a/docker/ui/src/App.tsx b/docker/ui/src/App.tsx index 643c06a..3d70f5d 100644 --- a/docker/ui/src/App.tsx +++ b/docker/ui/src/App.tsx @@ -1,5 +1,6 @@ -import { useState, useEffect } from 'react' +import { useState } from 'react' import { LayoutDashboard, Disc, Settings, LogOut, Sun, Moon } from 'lucide-react' +import { useDarkMode } from './context/ThemeContext' import Dashboard from './pages/Dashboard' import MetadataPreview from './pages/MetadataPreview' import SettingsPage from './pages/Settings' @@ -8,26 +9,7 @@ type Page = 'dashboard' | 'metadata' | 'settings' function App() { const [page, setPage] = useState('dashboard') - const [darkMode, setDarkMode] = useState(false) - - useEffect(() => { - const savedTheme = localStorage.getItem('theme') - const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches - - if (savedTheme === 'dark' || (!savedTheme && prefersDark)) { - setDarkMode(true) - document.documentElement.setAttribute('data-theme', 'dark') - } else { - document.documentElement.setAttribute('data-theme', 'light') - } - }, []) - - const toggleTheme = () => { - const newTheme = darkMode ? 'light' : 'dark' - setDarkMode(!darkMode) - localStorage.setItem('theme', newTheme) - document.documentElement.setAttribute('data-theme', newTheme) - } + const { theme, toggleTheme } = useDarkMode() const navItems = [ { id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard }, @@ -39,7 +21,7 @@ function App() {
{/* Sidebar */} -