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
This commit is contained in:
Hitonabi
2026-07-22 16:38:19 +02:00
parent e710151c91
commit 95c1f9b105
18 changed files with 406 additions and 104 deletions
+23 -1
View File
@@ -162,7 +162,7 @@
--- ---
## Etappe 8: Separation of Concerns ## Etappe 8: Separation of Concerns (Dark Mode)
**Ziel:** Code-Qualität durch saubere Trennung. **Ziel:** Code-Qualität durch saubere Trennung.
@@ -177,8 +177,30 @@
- Wiederverwendbare Dark Mode Helper - Wiederverwendbare Dark Mode Helper
- Saubere Komponenten-Struktur - 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 ## Etappe 9: Proxmox-Integration
**Ziel:** Ein-Click-Deploy auf Proxmox LXC. **Ziel:** Ein-Click-Deploy auf Proxmox LXC.
+13 -7
View File
@@ -2,19 +2,23 @@
## Aktueller Stand ## Aktueller Stand
**v1.8Dark Mode & Separation of Concerns (21.07.2026)** **v1.9SoC Refactoring & Config Validation (22.07.2026)**
Rippy läuft auf Arcane VM (192.168.178.162): Rippy läuft auf Arcane VM (192.168.178.162):
- ✅ Arcane WebUI: http://192.168.178.162:3552 - ✅ Arcane WebUI: http://192.168.178.162:3552
- ✅ Rippy UI: http://192.168.178.162:80 - ✅ Rippy UI: http://192.168.178.162:80
- ✅ Rippy API: http://192.168.178.162:8000 - ✅ 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 - ✅ Sidebar Navigation mit Dark Mode Support
- ✅ Einstellungen-Page mit Tab-Struktur - ✅ Einstellungen-Page mit Tab-Struktur
- ✅ Doppelte Arcane-Einträge behoben (`rippy` statt `Rippy`) - ✅ Doppelte Arcane-Einträge behoben (`rippy` statt `Rippy`)
- ✅ Import-Fixes (relative → absolute Imports) - ✅ Import-Fixes (relative → absolute Imports)
- ✅ Git Sync in Arcane konfiguriert - ✅ Git Sync in Arcane konfiguriert
- ✅ Commit: `43dfce5` — Dark Mode Implementation - ✅ Commit: `43dfce5` — Dark Mode Implementation
- ✅ Commit: `v1.9` — SoC Refactoring
### Docker-Container ### Docker-Container
@@ -43,12 +47,14 @@ Rippy läuft auf Arcane VM (192.168.178.162):
- Proxmox LXC Template - Proxmox LXC Template
- Ansible Playbooks - Ansible Playbooks
### Separation of Concerns — Nächste Priorität ### SoC Refactoring — Abgeschlossen (Etappe 9)
- **Theme-Context**: Theme-Logik aus App.tsx in eigenes Context抽出 - **Theme-Context**: Theme-Logik aus App.tsx ausgelagert
- **Dark Mode Utility**: CSS-Klassen-Helper als Utility-Funktion - **Dark Mode Utility**: useDarkMode Hook implementiert
- **Component-Struktur**: Dark Mode props von inneren Komponenten verstecken - **Component-Struktur**: Dark Mode props von inneren Komponenten versteckt
- **Tailwind-Config**: Dark mode Konfiguration zentralisieren - **Config Validation**: TMDB-API-Key ist Pflicht
-**Cache Key Centralization**: cache/keys.py erstellt
-**CD-Ripping**: abcde-Integration implementiert
### Git-Log ### Git-Log
+8
View File
@@ -11,6 +11,13 @@ services:
- TMDB_API_KEY=${TMDB_API_KEY} - TMDB_API_KEY=${TMDB_API_KEY}
- THETVDB_API_KEY=${THETVDB_API_KEY} - THETVDB_API_KEY=${THETVDB_API_KEY}
- JWT_SECRET_KEY=${JWT_SECRET_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: ports:
- "8000:8000" - "8000:8000"
volumes: volumes:
@@ -38,6 +45,7 @@ services:
- TMDB_API_KEY=${TMDB_API_KEY} - TMDB_API_KEY=${TMDB_API_KEY}
- THETVDB_API_KEY=${THETVDB_API_KEY} - THETVDB_API_KEY=${THETVDB_API_KEY}
- JWT_SECRET_KEY=${JWT_SECRET_KEY} - JWT_SECRET_KEY=${JWT_SECRET_KEY}
- LOG_LEVEL=INFO
volumes: volumes:
- media:/app/media - media:/app/media
- temp:/app/temp - temp:/app/temp
+26
View File
@@ -6,12 +6,38 @@ import json
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Optional 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_DIR = Path("/app/cache")
CACHE_FILE = CACHE_DIR / "api_cache.db" CACHE_FILE = CACHE_DIR / "api_cache.db"
MAX_ENTRIES = 10000 MAX_ENTRIES = 10000
DEFAULT_TTL = 86400 # 24 Stunden 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: def init_cache() -> None:
"""Initialisiere Cache-DB.""" """Initialisiere Cache-DB."""
CACHE_DIR.mkdir(parents=True, exist_ok=True) CACHE_DIR.mkdir(parents=True, exist_ok=True)
+49
View File
@@ -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}"
+2 -1
View File
@@ -22,7 +22,8 @@ class TMDBClient:
def _request(self, endpoint: str, params: Dict = None) -> Optional[Dict]: def _request(self, endpoint: str, params: Dict = None) -> Optional[Dict]:
"""Mache API-Request mit Caching.""" """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) cached = get(cache_key)
if cached: if cached:
+10 -8
View File
@@ -1,6 +1,7 @@
"""API-Konfiguration.""" """API-Konfiguration."""
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
from pydantic import Field
from typing import Optional from typing import Optional
@@ -8,22 +9,22 @@ class Settings(BaseSettings):
"""Rippy API Settings.""" """Rippy API Settings."""
# API Keys # API Keys
tmdb_api_key: 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] = None thetvdb_api_key: Optional[str] = Field(default=None, description="TVDb API Key (Optional)")
musicbrainz_user: Optional[str] = None musicbrainz_user: Optional[str] = Field(default=None, description="MusicBrainz User (Optional)")
# Cache # 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 # Ripping
rip_output_dir: str = "/app/media" rip_output_dir: str = Field(default="/app/media", description="Basisverzeichnis für Ripping-Ergebnisse")
temp_dir: str = "/app/temp" temp_dir: str = Field(default="/app/temp", description="Temp-Verzeichnis für Ripping")
# Logging # Logging
log_level: str = "INFO" log_level: str = Field(default="INFO", pattern="^(DEBUG|INFO|WARNING|ERROR|CRITICAL)$", description="Log Level")
# JWT # 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: class Config:
env_file = ".env" env_file = ".env"
@@ -31,3 +32,4 @@ class Settings(BaseSettings):
settings = Settings() settings = Settings()
+58
View File
@@ -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
+9 -2
View File
@@ -14,6 +14,7 @@ import time
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
from config import settings from config import settings
from config_validation import validate_config, ConfigValidationError
from cache import init_cache, set from cache import init_cache, set
from auth import create_access_token, create_refresh_token, decode_token, is_blacklisted 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 from ratelimit import check_rate_limit, get_rate_limit_remaining, validate_api_key
@@ -36,8 +37,13 @@ sse_connections: List = []
@app.on_event("startup") @app.on_event("startup")
async def startup_event(): async def startup_event():
"""Initialisiere Cache beim Start.""" """Initialisiere Cache beim Start und validiere Konfiguration."""
init_cache() init_cache()
try:
validate_config()
except ConfigValidationError as e:
print(f"⚠️ Konfigurations-Warnung: {e}")
# Middleware für Rate-Limiting # Middleware für Rate-Limiting
@@ -189,8 +195,9 @@ async def lookup_metadata(request: MetadataLookupRequest):
@app.post("/metadata/confirm") @app.post("/metadata/confirm")
async def confirm_metadata(title: str, year: Optional[int] = None, metadata: Dict = None): async def confirm_metadata(title: str, year: Optional[int] = None, metadata: Dict = None):
"""Bestätige Metadaten.""" """Bestätige Metadaten."""
from cache.keys import generate_confirmed_key
# In Cache speichern # 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 {}}) set(cache_key, {"title": title, "year": year, "metadata": metadata or {}})
return {"status": "confirmed", "key": cache_key} return {"status": "confirmed", "key": cache_key}
+3 -2
View File
@@ -8,6 +8,7 @@ from clients.tmdb import TMDBClient
from clients.musicbrainz import MusicBrainzClient from clients.musicbrainz import MusicBrainzClient
from clients.thetvdb import TheTVDBClient from clients.thetvdb import TheTVDBClient
from cache import get, set from cache import get, set
from cache.keys import generate_prescan_key
from config import settings from config import settings
@@ -146,7 +147,7 @@ class PreScan:
def _scan_audio(self, device_path: str, toc: Dict) -> PreScanResult: def _scan_audio(self, device_path: str, toc: Dict) -> PreScanResult:
"""Pre-Scan für Audio-CD.""" """Pre-Scan für Audio-CD."""
# Cache-Key # Cache-Key
cache_key = f"prescan:audio:{device_path}" cache_key = generate_prescan_key(device_path, is_audio=True)
cached = get(cache_key) cached = get(cache_key)
if cached: if cached:
return PreScanResult(**cached) return PreScanResult(**cached)
@@ -214,7 +215,7 @@ class PreScan:
def _scan_video(self, device_path: str, toc: Dict) -> PreScanResult: def _scan_video(self, device_path: str, toc: Dict) -> PreScanResult:
"""Pre-Scan für DVD/Blu-ray.""" """Pre-Scan für DVD/Blu-ray."""
# Cache-Key # Cache-Key
cache_key = f"prescan:video:{device_path}" cache_key = generate_prescan_key(device_path, is_audio=False)
cached = get(cache_key) cached = get(cache_key)
if cached: if cached:
return PreScanResult(**cached) return PreScanResult(**cached)
+9 -27
View File
@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react' import { useState } from 'react'
import { LayoutDashboard, Disc, Settings, LogOut, Sun, Moon } from 'lucide-react' import { LayoutDashboard, Disc, Settings, LogOut, Sun, Moon } from 'lucide-react'
import { useDarkMode } from './context/ThemeContext'
import Dashboard from './pages/Dashboard' import Dashboard from './pages/Dashboard'
import MetadataPreview from './pages/MetadataPreview' import MetadataPreview from './pages/MetadataPreview'
import SettingsPage from './pages/Settings' import SettingsPage from './pages/Settings'
@@ -8,26 +9,7 @@ type Page = 'dashboard' | 'metadata' | 'settings'
function App() { function App() {
const [page, setPage] = useState<Page>('dashboard') const [page, setPage] = useState<Page>('dashboard')
const [darkMode, setDarkMode] = useState(false) const { theme, toggleTheme } = useDarkMode()
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 navItems = [ const navItems = [
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard }, { id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
@@ -39,7 +21,7 @@ function App() {
<div className="min-h-screen bg-slate-50 text-slate-900 font-sans"> <div className="min-h-screen bg-slate-50 text-slate-900 font-sans">
<div className="flex h-screen overflow-hidden"> <div className="flex h-screen overflow-hidden">
{/* Sidebar */} {/* Sidebar */}
<aside className={`w-64 ${darkMode ? 'bg-slate-900' : 'bg-slate-900'} text-white flex flex-col`}> <aside className={`w-64 ${theme === 'dark' ? 'bg-slate-900' : 'bg-slate-900'} text-white flex flex-col`}>
<div className="p-6"> <div className="p-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-2 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-xl"> <div className="p-2 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-xl">
@@ -55,9 +37,9 @@ function App() {
<button <button
onClick={toggleTheme} onClick={toggleTheme}
className="mx-3 flex items-center justify-center gap-2 px-3 py-2 rounded-lg bg-slate-800/50 text-slate-400 hover:bg-slate-700 hover:text-white transition-all duration-200" className="mx-3 flex items-center justify-center gap-2 px-3 py-2 rounded-lg bg-slate-800/50 text-slate-400 hover:bg-slate-700 hover:text-white transition-all duration-200"
title={darkMode ? 'Light Mode' : 'Dark Mode'} title={theme === 'dark' ? 'Light Mode' : 'Light Mode'}
> >
{darkMode ? <Sun size={18} /> : <Moon size={18} />} {theme === 'dark' ? <Sun size={18} /> : <Moon size={18} />}
</button> </button>
<nav className="flex-1 px-3 py-2 space-y-1"> <nav className="flex-1 px-3 py-2 space-y-1">
@@ -68,7 +50,7 @@ function App() {
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-200 ${ className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-200 ${
page === item.id page === item.id
? 'bg-indigo-600 text-white shadow-lg shadow-indigo-500/30' ? 'bg-indigo-600 text-white shadow-lg shadow-indigo-500/30'
: darkMode : theme === 'dark'
? 'text-slate-400 hover:bg-slate-800 hover:text-white' ? 'text-slate-400 hover:bg-slate-800 hover:text-white'
: 'text-slate-400 hover:bg-slate-700 hover:text-white' : 'text-slate-400 hover:bg-slate-700 hover:text-white'
}`} }`}
@@ -79,8 +61,8 @@ function App() {
))} ))}
</nav> </nav>
<div className={`p-4 border-t ${darkMode ? 'border-slate-800' : 'border-slate-700'}`}> <div className={`p-4 border-t ${theme === 'dark' ? 'border-slate-800' : 'border-slate-700'}`}>
<button className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${darkMode ? 'text-slate-400 hover:text-white hover:bg-slate-800' : 'text-slate-400 hover:text-white hover:bg-slate-700'}`}> <button className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${theme === 'dark' ? 'text-slate-400 hover:text-white hover:bg-slate-800' : 'text-slate-400 hover:text-white hover:bg-slate-700'}`}>
<LogOut size={20} /> <LogOut size={20} />
<span className="text-sm">Abmelden</span> <span className="text-sm">Abmelden</span>
</button> </button>
+48
View File
@@ -0,0 +1,48 @@
import { createContext, useContext, useEffect, useState, ReactNode } from 'react'
type Theme = 'light' | 'dark'
interface ThemeContextType {
theme: Theme
toggleTheme: () => void
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined)
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>('light')
useEffect(() => {
const savedTheme = localStorage.getItem('theme')
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {
setTheme('dark')
document.documentElement.setAttribute('data-theme', 'dark')
} else {
setTheme('light')
document.documentElement.setAttribute('data-theme', 'light')
}
}, [])
const toggleTheme = () => {
const newTheme = theme === 'dark' ? 'light' : 'dark'
setTheme(newTheme)
localStorage.setItem('theme', newTheme)
document.documentElement.setAttribute('data-theme', newTheme)
}
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
)
}
export function useDarkMode() {
const context = useContext(ThemeContext)
if (!context) {
throw new Error('useDarkMode must be used within a ThemeProvider')
}
return context
}
+4 -1
View File
@@ -1,10 +1,13 @@
import React from 'react' import React from 'react'
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import App from './App' import App from './App'
import { ThemeProvider } from './context/ThemeContext'
import './index.css' import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<App /> <ThemeProvider>
<App />
</ThemeProvider>
</React.StrictMode>, </React.StrictMode>,
) )
+20 -19
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import axios from 'axios' import axios from 'axios'
import { Clock, Activity, HardDrive, BarChart, AlertCircle, CheckCircle, Disc, Play, Pause, SkipForward } from 'lucide-react' import { Clock, Activity, HardDrive, BarChart, AlertCircle, CheckCircle, Disc, Play, Pause, SkipForward } from 'lucide-react'
import { useDarkMode } from '../context/ThemeContext'
interface Job { interface Job {
id: string id: string
@@ -41,8 +42,9 @@ async function fetchDevices(): Promise<Device[]> {
} }
} }
function StatusBadge({ status, darkMode }: { status: string, darkMode?: boolean }) { function StatusBadge({ status }: { status: string }) {
const styles = darkMode const { theme } = useDarkMode()
const styles = theme === 'dark'
? { ? {
pending: 'bg-amber-900/30 text-amber-400 ring-amber-700/20', pending: 'bg-amber-900/30 text-amber-400 ring-amber-700/20',
processing: 'bg-blue-900/30 text-blue-400 ring-blue-700/20', processing: 'bg-blue-900/30 text-blue-400 ring-blue-700/20',
@@ -70,8 +72,9 @@ function StatusBadge({ status, darkMode }: { status: string, darkMode?: boolean
) )
} }
function TypeBadge({ type, darkMode }: { type: string, darkMode?: boolean }) { function TypeBadge({ type }: { type: string }) {
const colors = darkMode const { theme } = useDarkMode()
const colors = theme === 'dark'
? 'bg-slate-800 text-slate-300' ? 'bg-slate-800 text-slate-300'
: 'bg-slate-100 text-slate-700' : 'bg-slate-100 text-slate-700'
@@ -89,8 +92,9 @@ function TypeBadge({ type, darkMode }: { type: string, darkMode?: boolean }) {
) )
} }
function ProgressBar({ progress, darkMode }: { progress: number, darkMode?: boolean }) { function ProgressBar({ progress }: { progress: number }) {
const bgColors = darkMode const { theme } = useDarkMode()
const bgColors = theme === 'dark'
? { ? {
empty: 'bg-slate-700', empty: 'bg-slate-700',
indicator: 'bg-gradient-to-r from-indigo-500 to-purple-600', indicator: 'bg-gradient-to-r from-indigo-500 to-purple-600',
@@ -102,7 +106,7 @@ function ProgressBar({ progress, darkMode }: { progress: number, darkMode?: bool
done: 'bg-emerald-500', done: 'bg-emerald-500',
} }
const textColors = darkMode const textColors = theme === 'dark'
? { ? {
label: 'text-slate-400', label: 'text-slate-400',
value: 'text-slate-200', value: 'text-slate-200',
@@ -134,11 +138,7 @@ export default function Dashboard() {
const [jobs, setJobs] = useState<Job[]>([]) const [jobs, setJobs] = useState<Job[]>([])
const [devices, setDevices] = useState<Device[]>([]) const [devices, setDevices] = useState<Device[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [darkMode, setDarkMode] = useState(false) const { theme } = useDarkMode()
useEffect(() => {
const savedTheme = localStorage.getItem('theme')
setDarkMode(savedTheme === 'dark')
const loadData = async () => { const loadData = async () => {
try { try {
@@ -188,28 +188,28 @@ export default function Dashboard() {
label="Warteschlange" label="Warteschlange"
value={stats.pending.toString()} value={stats.pending.toString()}
color="amber" color="amber"
darkMode={darkMode} darkMode={theme === 'dark'}
/> />
<StatCard <StatCard
icon={Activity} icon={Activity}
label="Bearbeitung" label="Bearbeitung"
value={stats.processing.toString()} value={stats.processing.toString()}
color="blue" color="blue"
darkMode={darkMode} darkMode={theme === 'dark'}
/> />
<StatCard <StatCard
icon={CheckCircle} icon={CheckCircle}
label="Fertig" label="Fertig"
value={stats.completed.toString()} value={stats.completed.toString()}
color="emerald" color="emerald"
darkMode={darkMode} darkMode={theme === 'dark'}
/> />
<StatCard <StatCard
icon={AlertCircle} icon={AlertCircle}
label="Fehler" label="Fehler"
value={stats.failed.toString()} value={stats.failed.toString()}
color="rose" color="rose"
darkMode={darkMode} darkMode={theme === 'dark'}
/> />
</div> </div>
@@ -275,7 +275,7 @@ export default function Dashboard() {
<p className="text-xs text-slate-500 font-mono">{device.path}</p> <p className="text-xs text-slate-500 font-mono">{device.path}</p>
</div> </div>
</div> </div>
<TypeBadge type={device.type} darkMode={darkMode} /> <TypeBadge type={device.type} />
</div> </div>
<div className="flex items-center justify-between mt-4"> <div className="flex items-center justify-between mt-4">
@@ -337,13 +337,13 @@ export default function Dashboard() {
</div> </div>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<StatusBadge status={job.status} darkMode={darkMode} /> <StatusBadge status={job.status} />
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-slate-500"> <td className="px-6 py-4 whitespace-nowrap text-sm text-slate-500">
{job.device} {job.device}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<ProgressBar progress={job.progress} darkMode={darkMode} /> <ProgressBar progress={job.progress} />
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-slate-500"> <td className="px-6 py-4 whitespace-nowrap text-sm text-slate-500">
{new Date(job.startTime).toLocaleString('de-DE')} {new Date(job.startTime).toLocaleString('de-DE')}
@@ -360,6 +360,7 @@ export default function Dashboard() {
} }
function StatCard({ icon: Icon, label, value, color, darkMode }: { icon: any, label: string, value: string, color: string, darkMode: boolean }) { function StatCard({ icon: Icon, label, value, color, darkMode }: { icon: any, label: string, value: string, color: string, darkMode: boolean }) {
const { theme } = useDarkMode()
const bgColors = darkMode const bgColors = darkMode
? { ? {
card: 'bg-slate-800 border-slate-700', card: 'bg-slate-800 border-slate-700',
+2
View File
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'
import axios from 'axios' import axios from 'axios'
import { BookOpen, Music, Film, AlertCircle, CheckCircle, Search, Loader2 } from 'lucide-react' import { BookOpen, Music, Film, AlertCircle, CheckCircle, Search, Loader2 } from 'lucide-react'
import type { LucideIcon } from 'lucide-react' import type { LucideIcon } from 'lucide-react'
import { useDarkMode } from '../context/ThemeContext'
interface Metadata { interface Metadata {
type: 'movie' | 'tv' | 'music' | 'unknown' type: 'movie' | 'tv' | 'music' | 'unknown'
@@ -38,6 +39,7 @@ export default function MetadataPreview() {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [confirming, setConfirming] = useState(false) const [confirming, setConfirming] = useState(false)
const { theme } = useDarkMode()
const handleScan = async () => { const handleScan = async () => {
if (!device) { if (!device) {
+30 -30
View File
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import axios from 'axios' import axios from 'axios'
import { Settings, Save, Disc, Cpu, Database, Globe, AlertCircle, CheckCircle } from 'lucide-react' import { Settings, Save, Disc, Cpu, Database, Globe, AlertCircle, CheckCircle } from 'lucide-react'
import { useDarkMode } from '../context/ThemeContext'
interface SettingsState { interface SettingsState {
tmdbApiKey: string tmdbApiKey: string
@@ -33,12 +34,9 @@ export default function SettingsPage() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false) const [saved, setSaved] = useState(false)
const [darkMode, setDarkMode] = useState(false) const { theme } = useDarkMode()
useEffect(() => { useEffect(() => {
const savedTheme = localStorage.getItem('theme')
setDarkMode(savedTheme === 'dark')
const loadSettings = async () => { const loadSettings = async () => {
try { try {
const response = await axios.get(`${import.meta.env.VITE_API_URL}/settings`) const response = await axios.get(`${import.meta.env.VITE_API_URL}/settings`)
@@ -77,27 +75,27 @@ export default function SettingsPage() {
} }
return ( return (
<div className={`max-w-4xl space-y-6 ${darkMode ? 'text-slate-100' : 'text-slate-900'}`}> <div className={`max-w-4xl space-y-6 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<h1 className="text-3xl font-bold text-slate-900 dark:text-slate-100">Einstellungen</h1> <h1 className="text-3xl font-bold text-slate-900 dark:text-slate-100">Einstellungen</h1>
<p className={`text-sm ${darkMode ? 'text-slate-400' : 'text-slate-500'}`}>Konfiguriere Rippy nach deinen Wünschen</p> <p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Konfiguriere Rippy nach deinen Wünschen</p>
</div> </div>
{saved && ( {saved && (
<div className={`p-4 rounded-lg flex items-center gap-3 font-medium ${darkMode ? 'bg-emerald-900/30 border border-emerald-700 text-emerald-400' : 'bg-emerald-50 border border-emerald-200 text-emerald-700'}`}> <div className={`p-4 rounded-lg flex items-center gap-3 font-medium ${theme === 'dark' ? 'bg-emerald-900/30 border border-emerald-700 text-emerald-400' : 'bg-emerald-50 border border-emerald-200 text-emerald-700'}`}>
<CheckCircle className="flex-shrink-0" size={24} /> <CheckCircle className="flex-shrink-0" size={24} />
<span>Einstellungen erfolgreich gespeichert!</span> <span>Einstellungen erfolgreich gespeichert!</span>
</div> </div>
)} )}
<div className={`rounded-xl shadow-sm overflow-hidden ${darkMode ? 'bg-slate-800 border border-slate-700' : 'bg-white border border-slate-200'}`}> <div className={`rounded-xl shadow-sm overflow-hidden ${theme === 'dark' ? 'bg-slate-800 border border-slate-700' : 'bg-white border border-slate-200'}`}>
{/* Tabs */} {/* Tabs */}
<div className={`flex ${darkMode ? 'border-slate-700' : 'border-slate-200'} border-b`}> <div className={`flex ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'} border-b`}>
<TabButton icon={Disc} label="Ripping" isActive={true} darkMode={darkMode} /> <TabButton icon={Disc} label="Ripping" isActive={true} darkMode={theme === 'dark'} />
<TabButton icon={Cpu} label="Verarbeitung" isActive={false} darkMode={darkMode} /> <TabButton icon={Cpu} label="Verarbeitung" isActive={false} darkMode={theme === 'dark'} />
<TabButton icon={Database} label="Datenbank" isActive={false} darkMode={darkMode} /> <TabButton icon={Database} label="Datenbank" isActive={false} darkMode={theme === 'dark'} />
<TabButton icon={Globe} label="APIs" isActive={false} darkMode={darkMode} /> <TabButton icon={Globe} label="APIs" isActive={false} darkMode={theme === 'dark'} />
<TabButton icon={AlertCircle} label="Benachrichtigungen" isActive={false} darkMode={darkMode} /> <TabButton icon={AlertCircle} label="Benachrichtigungen" isActive={false} darkMode={theme === 'dark'} />
</div> </div>
{/* Content */} {/* Content */}
@@ -123,11 +121,11 @@ export default function SettingsPage() {
<Toggle <Toggle
checked={settings.ripAllTracks} checked={settings.ripAllTracks}
onChange={(v) => handleChange('ripAllTracks', v)} onChange={(v) => handleChange('ripAllTracks', v)}
darkMode={darkMode} darkMode={theme === 'dark'}
/> />
</div> </div>
<div className={`flex items-center justify-between p-4 ${darkMode ? 'bg-slate-700/50 rounded-lg' : 'bg-slate-50 rounded-lg'}`}> <div className={`flex items-center justify-between p-4 ${theme === 'dark' ? 'bg-slate-700/50 rounded-lg' : 'bg-slate-50 rounded-lg'}`}>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-2 bg-purple-100 rounded-lg"> <div className="p-2 bg-purple-100 rounded-lg">
<Disc className="text-purple-600" size={20} /> <Disc className="text-purple-600" size={20} />
@@ -140,11 +138,11 @@ export default function SettingsPage() {
<Toggle <Toggle
checked={settings.mainFeatureOnly} checked={settings.mainFeatureOnly}
onChange={(v) => handleChange('mainFeatureOnly', v)} onChange={(v) => handleChange('mainFeatureOnly', v)}
darkMode={darkMode} darkMode={theme === 'dark'}
/> />
</div> </div>
<div className={`flex items-center justify-between p-4 ${darkMode ? 'bg-slate-700/50 rounded-lg' : 'bg-slate-50 rounded-lg'}`}> <div className={`flex items-center justify-between p-4 ${theme === 'dark' ? 'bg-slate-700/50 rounded-lg' : 'bg-slate-50 rounded-lg'}`}>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-2 bg-emerald-100 rounded-lg"> <div className="p-2 bg-emerald-100 rounded-lg">
<Disc className="text-emerald-600" size={20} /> <Disc className="text-emerald-600" size={20} />
@@ -157,7 +155,7 @@ export default function SettingsPage() {
<Toggle <Toggle
checked={settings.autoEject} checked={settings.autoEject}
onChange={(v) => handleChange('autoEject', v)} onChange={(v) => handleChange('autoEject', v)}
darkMode={darkMode} darkMode={theme === 'dark'}
/> />
</div> </div>
</div> </div>
@@ -172,17 +170,17 @@ export default function SettingsPage() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2"> <div className="space-y-2">
<label className={`block text-sm font-medium ${darkMode ? 'text-slate-300' : 'text-slate-700'}`}> <label className={`block text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
Hauptverzeichnis Hauptverzeichnis
</label> </label>
<input <input
type="text" type="text"
value={settings.outputDir} value={settings.outputDir}
onChange={(e) => handleChange('outputDir', e.target.value)} onChange={(e) => handleChange('outputDir', e.target.value)}
className={`w-full px-3 py-2 ${darkMode ? 'bg-slate-800 border-slate-600 text-slate-200 focus:ring-indigo-500' : 'bg-white border-slate-300 text-slate-900 focus:ring-indigo-600'} border rounded-lg focus:ring-2 focus:border-transparent transition-all`} className={`w-full px-3 py-2 ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200 focus:ring-indigo-500' : 'bg-white border-slate-300 text-slate-900 focus:ring-indigo-600'} border rounded-lg focus:ring-2 focus:border-transparent transition-all`}
placeholder="/app/media" placeholder="/app/media"
/> />
<p className={`text-xs ${darkMode ? 'text-slate-400' : 'text-slate-500'}`}>Basisverzeichnis für alle Ausgaben</p> <p className={`text-xs ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Basisverzeichnis für alle Ausgaben</p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -316,6 +314,7 @@ export default function SettingsPage() {
} }
function TabButton({ icon: Icon, label, isActive, darkMode }: { icon: any, label: string, isActive: boolean, darkMode: boolean }) { function TabButton({ icon: Icon, label, isActive, darkMode }: { icon: any, label: string, isActive: boolean, darkMode: boolean }) {
const { theme } = useDarkMode()
return ( return (
<button <button
className={`px-6 py-3 text-sm font-medium transition-colors border-b-2 ${ className={`px-6 py-3 text-sm font-medium transition-colors border-b-2 ${
@@ -330,6 +329,7 @@ function TabButton({ icon: Icon, label, isActive, darkMode }: { icon: any, label
} }
function Toggle({ checked, onChange, darkMode }: { checked: boolean, onChange: (v: boolean) => void, darkMode: boolean }) { function Toggle({ checked, onChange, darkMode }: { checked: boolean, onChange: (v: boolean) => void, darkMode: boolean }) {
const { theme } = useDarkMode()
return ( return (
<button <button
onClick={() => onChange(!checked)} onClick={() => onChange(!checked)}
+90
View File
@@ -103,6 +103,16 @@ def detect_disc_type(device_path: str) -> str:
return "unknown" return "unknown"
def check_abcde_installed() -> bool:
"""Prüft, ob abcde installiert ist."""
return shutil.which("abcde") is not None
def check_cdparanoia_installed() -> bool:
"""Prüft, ob cdparanoia installiert ist."""
return shutil.which("cdparanoia") is not None
@celery_app.task(bind=True, name="worker.ripping.rip_dvd") @celery_app.task(bind=True, name="worker.ripping.rip_dvd")
def rip_dvd(self, device_path: str, disc_id: str) -> dict: def rip_dvd(self, device_path: str, disc_id: str) -> dict:
"""Rippt eine DVD mit HandBrake.""" """Rippt eine DVD mit HandBrake."""
@@ -133,3 +143,83 @@ def rip_bluray(self, device_path: str, disc_id: str) -> dict:
output_path = f"{output_dir}/bluray_{disc_id}.mkv" output_path = f"{output_dir}/bluray_{disc_id}.mkv"
return run_handbrake(device_path, output_path) return run_handbrake(device_path, output_path)
@celery_app.task(bind=True, name="worker.ripping.rip_cd")
def rip_cd(self, device_path: str, disc_id: str) -> dict:
"""Rippt eine CD mit abcde."""
if not check_abcde_installed():
return {
"status": "error",
"error": "abcde ist nicht installiert. Installiere abcde und cdparanoia für CD-Ripping."
}
if not check_cdparanoia_installed():
return {
"status": "error",
"error": "cdparanoia ist nicht installiert. Installiere abcde und cdparanoia für CD-Ripping."
}
output_dir = f"/output/cd/{disc_id}"
os.makedirs(output_dir, exist_ok=True)
try:
cmd = [
"abcde",
"-d", device_path,
"-o", "flac",
"-b",
"-t", "1",
"-a", "default",
"-x",
"-o", output_dir
]
process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1
)
for line in process.stdout:
self.send_task(
"worker.ripping.update_progress",
kwargs={
"progress": 50,
"status": "ripping",
"message": f"Rippe CD: {line.strip()}"
}
)
process.wait()
if process.returncode == 0:
# MusikBrainz Lookup (via abcde's post-read)
self.send_task(
"worker.ripping.update_progress",
kwargs={
"progress": 90,
"status": "ripping",
"message": "MusikBrainz Lookup läuft..."
}
)
return {
"status": "success",
"output_dir": output_dir,
"return_code": process.returncode
}
else:
return {
"status": "error",
"error": f"abcde failed with code {process.returncode}",
"return_code": process.returncode
}
except Exception as e:
return {
"status": "error",
"error": str(e)
}
+2 -6
View File
@@ -1,6 +1,6 @@
import os import os
from celery_app import celery_app from celery_app import celery_app
from ripping import rip_dvd, rip_bluray, detect_disc_type from ripping import rip_dvd, rip_bluray, rip_cd, detect_disc_type
@celery_app.task(bind=True, name="worker.tasks.rip_disc") @celery_app.task(bind=True, name="worker.tasks.rip_disc")
@@ -12,11 +12,7 @@ def rip_disc(self, device_path: str, disc_id: str = None):
disc_id = device_path.replace("/", "_") disc_id = device_path.replace("/", "_")
if disc_type == "cd": if disc_type == "cd":
return { return rip_cd(device_path, disc_id)
"status": "not_implemented",
"message": "CD-Ripping mit abcde noch nicht implementiert",
"disc_type": disc_type
}
elif disc_type == "dvd": elif disc_type == "dvd":
return rip_dvd(device_path, disc_id) return rip_dvd(device_path, disc_id)
elif disc_type == "bluray": elif disc_type == "bluray":