2 Commits

Author SHA1 Message Date
Hitonabi 1aa51ca943 UI-Feinschliff: Settings-Tabs funktionieren, Details-Knopf tut etwas
Ampel / ampel (push) Successful in 33s
- Settings: Tabs waren reine Dekoration (kein onClick, isActive fest) —
  jetzt echte Tab-Navigation (Ripping/Verzeichnisse/APIs/Benachrichtigungen)
  + OMDb-Key-Feld im APIs-Tab
- Laufwerk-Details: aufklappbares Panel (Pfad/Modell/Seriennummer/Disc)
  mit "Rippen starten" (POST /jobs) und "Auswerfen" (Eject-Endpoint)
- Placebos raus: 4 tote Schnellaktionen, doppelte Geraete-Sektion mit
  zweitem totem Details-Knopf, toter Abmelden-Knopf

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:30:09 +02:00
Hitonabi 5528e0f652 Metadaten-Fallback OMDb + Pre-Scan repariert + Eject-Endpoint
- clients/omdb.py: OMDb als zweite Quelle (Fallback-Kette: TMDB exakt ->
  OMDb -> bester TMDB-Vorschlag mit niedriger Confidence -> unknown)
- Pre-Scan-Reparatur: Titel kam nie an — makemkvcon existiert nur im
  Worker, isosize war nirgends installiert (fiel still auf "DVD" zurueck).
  Jetzt: ISO-9660-Volume-Label direkt vom Medium + Label-Normalisierung
  (PULP_FICTION -> Pulp Fiction), Disc-Typ ueber zentrale detection.py
- POST /devices/{name}/eject (CDROMEJECT-ioctl) mit Job-Schutz (409 wenn
  auf dem Laufwerk gerade gerippt wird)
- OMDB_API_KEY in compose/.env.example; .env.example komplett ehrlich
  dokumentiert (JWT-Pflicht, MakeMKV-Beta-Key-Rhythmus)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:30:09 +02:00
13 changed files with 393 additions and 179 deletions
+21 -3
View File
@@ -1,4 +1,22 @@
POSTGRES_PASSWORD=rippy123
SECRET_KEY=change-me-in-production
# Rippy-Umgebung — nach .env kopieren und Werte eintragen.
# Die .env liegt NUR auf der VM (gitignored), nie im Repo.
# PostgreSQL (intern; Compose nutzt aktuell rippy/rippy — Härtung folgt)
POSTGRES_PASSWORD=rippy
# JWT-Signierschlüssel — PFLICHT, API startet sonst nicht.
# Erzeugen: openssl rand -hex 32
JWT_SECRET_KEY=
# Metadaten-APIs
# TMDB (Pflicht für Metadaten-Lookup): kostenlos auf themoviedb.org
TMDB_API_KEY=
THEtvdb_API_KEY=
# TVDb (optional, Serien-Fallback)
THETVDB_API_KEY=
# OMDb (optional, Fallback-Quelle): kostenloser Key auf omdbapi.com/apikey.aspx
OMDB_API_KEY=
# MakeMKV-Beta-Key (optional): DVDs gehen ohne, Blu-ray läuft 30 Tage im
# Testmodus. Aktueller Key: Forum-Thread "MakeMKV is free while in beta".
# Wechselt etwa monatlich — bei Blu-ray-Fehlern zuerst hier schauen.
MAKEMKV_APP_KEY=
+1
View File
@@ -15,6 +15,7 @@ services:
- REDIS_URL=redis://redis:6379/0
- TMDB_API_KEY=${TMDB_API_KEY}
- THETVDB_API_KEY=${THETVDB_API_KEY}
- OMDB_API_KEY=${OMDB_API_KEY}
- JWT_SECRET_KEY=${JWT_SECRET_KEY}
- LOG_LEVEL=INFO
healthcheck:
+76
View File
@@ -0,0 +1,76 @@
"""OMDb API Client — Fallback-Quelle für die Metadaten-Erkennung.
Kommt zum Zug, wenn TMDB nichts (Sicheres) findet. Kostenloser Key von
https://www.omdbapi.com/apikey.aspx (1000 Anfragen/Tag reichen fürs Heimlab).
"""
from typing import Dict, Optional
import requests
from cache import get, set as cache_set
from config import settings
OMDB_BASE_URL = "https://www.omdbapi.com/"
def parse_runtime_minutes(runtime: str) -> int:
"""OMDb liefert '142 min' als Text → Minuten als Zahl (pure Funktion, testbar)."""
if not runtime:
return 0
teil = runtime.strip().split(" ")[0]
return int(teil) if teil.isdigit() else 0
def parse_year(year: str) -> Optional[int]:
"""OMDb-Jahr kann '1999' oder '20052012' (Serie) sein → erstes Jahr."""
if not year:
return None
erste = year.strip()[:4]
return int(erste) if erste.isdigit() else None
class OMDbClient:
def __init__(self):
self.api_key = settings.omdb_api_key
self.session = requests.Session()
def lookup(self, title: str, year: Optional[int] = None) -> Optional[Dict]:
"""Sucht per Titel (t=), liefert normalisierte Metadaten oder None."""
if not self.api_key:
return None
cache_key = f"omdb:{title.lower()}:{year or ''}"
cached = get(cache_key)
if cached:
return cached
params = {"apikey": self.api_key, "t": title, "r": "json"}
if year:
params["y"] = str(year)
try:
response = self.session.get(OMDB_BASE_URL, params=params, timeout=10)
response.raise_for_status()
data = response.json()
except Exception as e:
print(f"OMDb API Error: {e}")
return None
if data.get("Response") != "True":
return None
ergebnis = {
"type": "tv" if data.get("Type") == "series" else "movie",
"id": data.get("imdbID", ""),
"title": data.get("Title", title),
"year": parse_year(data.get("Year", "")),
"overview": data.get("Plot", ""),
"poster_path": data.get("Poster", "") if data.get("Poster") != "N/A" else "",
"backdrop_path": "",
"runtime": parse_runtime_minutes(data.get("Runtime", "")),
"genres": [g.strip() for g in data.get("Genre", "").split(",") if g.strip()],
"source": "omdb",
}
cache_set(cache_key, ergebnis)
return ergebnis
+1
View File
@@ -11,6 +11,7 @@ class Settings(BaseSettings):
# API Keys
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)")
omdb_api_key: Optional[str] = Field(default=None, description="OMDb API Key (Fallback-Quelle, optional)")
musicbrainz_user: Optional[str] = Field(default=None, description="MusicBrainz User (Optional)")
# Cache
+12
View File
@@ -96,6 +96,18 @@ def list_jobs(limit: int = 100) -> list:
return [dict(z) for z in zeilen]
def has_active_job(device: str) -> bool:
"""True, wenn auf dem Gerät ein Job läuft oder wartet (Eject-Schutz)."""
with engine.connect() as conn:
zeile = conn.execute(
select(jobs.c.id)
.where(jobs.c.device == device)
.where(jobs.c.status.in_(("pending", "running")))
.limit(1)
).first()
return zeile is not None
def add_log(level: str, source: str, message: str) -> None:
with engine.begin() as conn:
conn.execute(
+13
View File
@@ -8,6 +8,7 @@ direkt vom Kernel und funktioniert überall.
import glob
import os
from fcntl import ioctl
from detection import (
CDS_DISC_OK,
@@ -17,6 +18,18 @@ from detection import (
drive_status,
)
# include/uapi/linux/cdrom.h
CDROMEJECT = 0x5309
def eject(device_path: str) -> None:
"""Wirft die Disc aus (CDROMEJECT-ioctl). Wirft OSError bei Fehlern."""
fd = os.open(device_path, os.O_RDONLY | os.O_NONBLOCK)
try:
ioctl(fd, CDROMEJECT, 0)
finally:
os.close(fd)
def list_optical_devices() -> list:
"""Alle optischen Laufwerke, die der Container sieht (devices: in compose)."""
+18
View File
@@ -209,6 +209,24 @@ async def create_job(request: JobCreateRequest):
return {"id": job_id, "status": "pending", "device": device_path}
@app.post("/devices/{name}/eject")
async def eject_device(name: str):
"""Wirft die Disc aus. Verweigert, wenn auf dem Gerät gerade ein Job läuft."""
device_path = f"/dev/{name}"
if device_path not in device_discovery.list_optical_devices():
raise HTTPException(status_code=404, detail=f"Laufwerk {device_path} nicht gefunden")
if await asyncio.to_thread(db.has_active_job, device_path):
raise HTTPException(
status_code=409, detail="Auf diesem Laufwerk läuft gerade ein Job"
)
try:
await asyncio.to_thread(device_discovery.eject, device_path)
except OSError as e:
raise HTTPException(status_code=500, detail=f"Auswurf fehlgeschlagen: {e}")
await asyncio.to_thread(db.add_log, "info", "api", f"Disc ausgeworfen: {device_path}")
return {"status": "ejected", "device": device_path}
@app.get("/logs")
async def get_logs(limit: int = 200):
"""Echte Ereignisse aus der Datenbank (Watcher, API, Worker)."""
+98 -40
View File
@@ -5,16 +5,51 @@ WIEDERHERGESTELLT 22.07.2026: Beim SoC-Refactoring wurde die echte Implementieru
lieferte seitdem immer „Unknown Disc". Dies ist die echte Logik, bereinigt.
"""
import shutil
import subprocess
from typing import Dict, List
from typing import Dict, List, Optional
import detection
from clients.tmdb import TMDBClient
from clients.musicbrainz import MusicBrainzClient
from clients.omdb import OMDbClient
from clients.thetvdb import TheTVDBClient
from cache import get, set as cache_set
from cache.keys import generate_prescan_key
def normalize_disc_label(label: str) -> str:
"""Disc-Labels wie 'PULP_FICTION_DE''Pulp Fiction De' (pure Funktion).
Volume-Labels sind fast immer GROSS_MIT_UNTERSTRICHEN — so findet keine
Metadaten-API etwas. Unterstriche zu Leerzeichen, Titel-Schreibung.
"""
if not label:
return ""
bereinigt = " ".join(label.replace("_", " ").replace(".", " ").split())
if bereinigt.isupper():
bereinigt = bereinigt.title()
return bereinigt
def read_iso_volume_label(device_path: str) -> Optional[str]:
"""Liest das Volume-Label aus dem ISO-9660 Primary Volume Descriptor.
Sektor 16 (Offset 32768), Bytes 40-71 = Volume Identifier. Funktioniert für
DVDs und die meisten Blu-rays (UDF-Bridge) — ganz ohne Zusatzwerkzeuge.
"""
try:
with open(device_path, "rb") as f:
f.seek(32768)
pvd = f.read(2048)
if len(pvd) < 72 or pvd[1:6] != b"CD001":
return None
label = pvd[40:72].decode("ascii", errors="replace").strip()
return label or None
except OSError:
return None
class PreScanResult:
def __init__(
self,
@@ -48,6 +83,7 @@ class PreScan:
self.tmdb = TMDBClient()
self.musicbrainz = MusicBrainzClient()
self.thetvdb = TheTVDBClient()
self.omdb = OMDbClient()
def scan(self, device_path: str) -> PreScanResult:
"""Führe Pre-Scan durch."""
@@ -59,25 +95,13 @@ class PreScan:
return self._scan_video(device_path, toc)
def _detect_disc_type(self, device_path: str) -> str:
"""Erkenne Disc-Typ (CD/DVD/Blu-ray) über die Medien-Größe."""
try:
result = subprocess.run(
["isosize", "-x", device_path],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
size = int(result.stdout.strip())
if size < 700 * 1024 * 1024:
return "CD"
elif size < 15 * 1024 * 1024 * 1024:
return "DVD"
else:
return "Blu-ray"
except Exception:
pass
return "DVD"
"""Erkenne Disc-Typ über die zentrale ioctl-Erkennung (detection.py).
Vorher lief hier `isosize` — das Werkzeug war im Container nicht
installiert, die Erkennung fiel still immer auf "DVD" zurück.
"""
typ = detection.detect_disc_type(device_path)
return {"cd": "CD", "dvd": "DVD", "bluray": "Blu-ray"}.get(typ, "DVD")
def _read_toc(self, device_path: str, disc_type: str) -> Dict:
"""Lese TOC (Table of Contents)."""
@@ -105,25 +129,30 @@ class PreScan:
"duration": 0
})
else:
# makemkvcon liest nur den TOC — KEIN Ripping
result = subprocess.run(
["makemkvcon", "--minlength=300", "--progress=off", "info", device_path],
capture_output=True,
text=True,
timeout=30
)
for line in result.stdout.split('\n'):
if line.startswith('DRV:'):
parts = line.split(',')
if len(parts) >= 4:
toc["title"] = parts[3].strip().strip('"')
elif line.startswith('TINFO:'):
parts = line.split(',')
if len(parts) >= 5:
toc["tracks"].append({
"title": parts[4].strip().strip('"') if len(parts) > 4 else "Title",
"duration": int(parts[2]) if len(parts) > 2 else 0
})
# Titel-Quelle 1: ISO-Volume-Label direkt vom Medium — läuft
# überall. (makemkvcon gibt es NUR im Worker-Container; der
# alte Aufruf hier scheiterte in der API still und der Titel
# blieb ewig "Unknown Title".)
label = read_iso_volume_label(device_path)
if label:
toc["title"] = normalize_disc_label(label)
# Titel-Quelle 2 (optional, falls makemkvcon doch da ist):
if shutil.which("makemkvcon"):
result = subprocess.run(
["makemkvcon", "-r", "--minlength=300", "info", f"dev:{device_path}"],
capture_output=True,
text=True,
timeout=120
)
for line in result.stdout.split('\n'):
if line.startswith('TINFO:'):
parts = line.split(',')
if len(parts) >= 5:
toc["tracks"].append({
"title": parts[4].strip().strip('"') if len(parts) > 4 else "Title",
"duration": int(parts[2]) if len(parts) > 2 else 0
})
except Exception as e:
print(f"Pre-Scan TOC Error: {e}")
return toc
@@ -188,7 +217,7 @@ class PreScan:
if cached:
return PreScanResult(**cached)
title = toc["title"] or "Unknown Title"
title = normalize_disc_label(toc["title"] or "") or "Unknown Title"
confidence = 0.0
metadata = {}
matched = False
@@ -235,6 +264,35 @@ class PreScan:
matched = True
break
# Fallback 1: OMDb (eigene Datenbasis — findet oft, was TMDB nicht
# exakt trifft; braucht OMDB_API_KEY, sonst überspringt es sich selbst)
if not matched:
omdb_treffer = self.omdb.lookup(title)
if omdb_treffer:
confidence = 0.8
metadata = omdb_treffer
title = omdb_treffer.get("title", title)
matched = True
# Fallback 2: bester TMDB-Vorschlag ohne exakten Treffer — als
# VORSCHLAG gekennzeichnet (niedrige Confidence, Nutzer korrigiert)
if not matched and movies:
vorschlag = self.tmdb.get_movie_details(movies[0]["id"])
if vorschlag:
confidence = 0.6
metadata = {
"type": "movie",
"id": movies[0]["id"],
"title": vorschlag.get("title", title),
"year": int(vorschlag.get("release_date", "0")[:4]) if vorschlag.get("release_date") else None,
"overview": vorschlag.get("overview", ""),
"poster_path": vorschlag.get("poster_path", ""),
"backdrop_path": vorschlag.get("backdrop_path", ""),
"runtime": vorschlag.get("runtime", 0),
"genres": [g["name"] for g in vorschlag.get("genres", [])]
}
matched = True
if not matched:
confidence = 0.3
metadata = {
+30
View File
@@ -0,0 +1,30 @@
"""Tests für die puren Pre-Scan-/OMDb-Helfer (Label-Normalisierung, Parsing)."""
from clients.omdb import parse_runtime_minutes, parse_year
from prescan.prescan import normalize_disc_label
def test_label_normalisierung():
# Disc-Labels sind fast immer GROSS_MIT_UNTERSTRICHEN
assert normalize_disc_label("PULP_FICTION") == "Pulp Fiction"
assert normalize_disc_label("DIE_HARD_4.0_DE") == "Die Hard 4 0 De"
assert normalize_disc_label(" doppel raum ") == "doppel raum"
assert normalize_disc_label("") == ""
def test_label_mit_gemischter_schreibung_bleibt():
# Wer schon sauber benannt hat, wird nicht plattgewalzt
assert normalize_disc_label("Inception") == "Inception"
def test_omdb_runtime_parsing():
assert parse_runtime_minutes("142 min") == 142
assert parse_runtime_minutes("N/A") == 0
assert parse_runtime_minutes("") == 0
def test_omdb_jahr_parsing():
assert parse_year("1999") == 1999
assert parse_year("20052012") == 2005 # Serien-Zeitspanne
assert parse_year("N/A") is None
assert parse_year("") is None
+3 -7
View File
@@ -1,5 +1,5 @@
import { useState } from 'react'
import { LayoutDashboard, Disc, Settings, LogOut, Sun, Moon, Terminal } from 'lucide-react'
import { LayoutDashboard, Disc, Settings, Sun, Moon, Terminal } from 'lucide-react'
import { useDarkMode } from './context/ThemeContext'
import Dashboard from './pages/Dashboard'
import MetadataPreview from './pages/MetadataPreview'
@@ -61,12 +61,8 @@ function App() {
))}
</nav>
<div className="p-4 border-t border-slate-800 dark:border-slate-700">
<button className="w-full flex items-center gap-3 px-3 py-2 rounded-lg transition-colors text-slate-400 hover:text-white hover:bg-slate-800 dark:text-slate-400 dark:hover:text-white dark:hover:bg-slate-700">
<LogOut size={20} />
<span className="text-sm">Abmelden</span>
</button>
</div>
{/* Abmelden-Knopf entfernt (23.07.): es gibt (noch) keinen
Login-Flow im UI — ein toter Knopf gaukelt nur einen vor. */}
</aside>
{/* Main Content */}
+78 -2
View File
@@ -16,6 +16,9 @@ interface Device {
export default function DeviceDiscovery() {
const [devices, setDevices] = useState<Device[]>([])
const [loading, setLoading] = useState(true)
const [expandedId, setExpandedId] = useState<string | null>(null)
const [actionFeedback, setActionFeedback] = useState<string | null>(null)
const [actionBusy, setActionBusy] = useState(false)
const { theme } = useDarkMode()
const refreshDevices = async () => {
@@ -29,6 +32,33 @@ export default function DeviceDiscovery() {
}
}
const startRip = async (device: Device) => {
setActionBusy(true)
setActionFeedback(null)
try {
const response = await api.post('/jobs', { device_path: device.path })
setActionFeedback(`✓ Job angelegt (${response.data.id.slice(0, 8)}…) — Fortschritt im Dashboard`)
} catch (error: any) {
setActionFeedback(`${error?.response?.data?.detail || 'Job konnte nicht angelegt werden'}`)
} finally {
setActionBusy(false)
}
}
const ejectDisc = async (device: Device) => {
setActionBusy(true)
setActionFeedback(null)
try {
await api.post(`/devices/${device.id}/eject`)
setActionFeedback('✓ Disc ausgeworfen')
refreshDevices()
} catch (error: any) {
setActionFeedback(`${error?.response?.data?.detail || 'Auswurf fehlgeschlagen'}`)
} finally {
setActionBusy(false)
}
}
useEffect(() => {
refreshDevices()
const interval = setInterval(refreshDevices, 5000)
@@ -146,10 +176,56 @@ export default function DeviceDiscovery() {
</div>
</div>
</div>
<button className={`text-sm font-medium transition-colors ${theme === 'dark' ? 'text-indigo-400 hover:text-indigo-300' : 'text-indigo-600 hover:text-indigo-700'}`}>
Details
<button
onClick={() => {
setExpandedId(expandedId === device.id ? null : device.id)
setActionFeedback(null)
}}
className={`text-sm font-medium transition-colors ${theme === 'dark' ? 'text-indigo-400 hover:text-indigo-300' : 'text-indigo-600 hover:text-indigo-700'}`}
>
{expandedId === device.id ? 'Schließen' : 'Details'}
</button>
</div>
{expandedId === device.id && (
<div className={`mt-4 pt-4 border-t space-y-3 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'}`}>
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm">
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>Gerätepfad</span>
<span className={`font-mono ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>{device.path}</span>
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>Modell</span>
<span className={theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}>{device.model || 'unbekannt'}</span>
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>Seriennummer</span>
<span className={`font-mono text-xs ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>{device.serial || '—'}</span>
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>Disc</span>
<span className={theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}>
{device.status === 'ready' ? `eingelegt (${device.type.toUpperCase()})` : 'keine'}
</span>
</div>
<div className="flex items-center gap-3 pt-1">
<button
onClick={() => startRip(device)}
disabled={actionBusy || device.status !== 'ready'}
className="px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
Rippen starten
</button>
<button
onClick={() => ejectDisc(device)}
disabled={actionBusy || device.status !== 'ready'}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${theme === 'dark' ? 'bg-slate-700 text-slate-200 hover:bg-slate-600' : 'bg-slate-200 text-slate-700 hover:bg-slate-300'}`}
>
Auswerfen
</button>
</div>
{actionFeedback && (
<p className={`text-sm ${actionFeedback.startsWith('✓') ? 'text-emerald-500' : 'text-rose-500'}`}>
{actionFeedback}
</p>
)}
</div>
)}
</div>
))}
</div>
+5 -111
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { Clock, Activity, HardDrive, BarChart, AlertCircle, CheckCircle, Disc, Play, Pause, SkipForward } from 'lucide-react'
import { Clock, Activity, AlertCircle, CheckCircle, Disc } from 'lucide-react'
import { api } from '../lib/api'
import { useDarkMode } from '../context/ThemeContext'
import DeviceDiscovery from '../components/DeviceDiscovery'
@@ -16,14 +16,6 @@ interface Job {
title?: string
}
interface Device {
id: string
name: string
type: 'cd' | 'dvd' | 'bluray'
path: string
status: 'empty' | 'ready' | 'ripping'
}
async function fetchJobs(): Promise<Job[]> {
try {
const response = await api.get('/jobs')
@@ -33,15 +25,6 @@ async function fetchJobs(): Promise<Job[]> {
}
}
async function fetchDevices(): Promise<Device[]> {
try {
const response = await api.get('/devices')
return response.data
} catch {
return []
}
}
function StatusBadge({ status }: { status: string }) {
const { theme } = useDarkMode()
const styles = theme === 'dark'
@@ -136,19 +119,13 @@ function ProgressBar({ progress }: { progress: number }) {
export default function Dashboard() {
const [jobs, setJobs] = useState<Job[]>([])
const [devices, setDevices] = useState<Device[]>([])
const [loading, setLoading] = useState(true)
const { theme } = useDarkMode()
useEffect(() => {
const loadData = async () => {
try {
const [jobsData, devicesData] = await Promise.all([
fetchJobs(),
fetchDevices()
])
setJobs(jobsData)
setDevices(devicesData)
setJobs(await fetchJobs())
} finally {
setLoading(false)
}
@@ -220,93 +197,10 @@ export default function Dashboard() {
/>
</div>
{/* Quick Actions */}
<div className={`rounded-xl shadow-sm border p-6 transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
<h2 className={`text-lg font-semibold mb-4 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Schnellaktionen</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 transition-colors duration-300">
<button className="flex flex-col items-center justify-center gap-3 p-4 rounded-lg border transition-all group hover:border-indigo-500 transition-colors duration-300">
<div className={`p-3 rounded-full transition-colors ${theme === 'dark' ? 'bg-indigo-900/30 text-indigo-400' : 'bg-indigo-100 text-indigo-600'} group-hover:bg-indigo-200`}>
<Disc size={24} />
</div>
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>Neuer Job</span>
</button>
<button className="flex flex-col items-center justify-center gap-3 p-4 rounded-lg border transition-all group hover:border-emerald-500 transition-colors duration-300">
<div className={`p-3 rounded-full transition-colors ${theme === 'dark' ? 'bg-emerald-900/30 text-emerald-400' : 'bg-emerald-100 text-emerald-600'} group-hover:bg-emerald-200`}>
<Play size={24} />
</div>
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>Alle starten</span>
</button>
<button className="flex flex-col items-center justify-center gap-3 p-4 rounded-lg border transition-all group hover:border-amber-500 transition-colors duration-300">
<div className={`p-3 rounded-full transition-colors ${theme === 'dark' ? 'bg-amber-900/30 text-amber-400' : 'bg-amber-100 text-amber-600'} group-hover:bg-amber-200`}>
<Pause size={24} />
</div>
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>Pausieren</span>
</button>
<button className="flex flex-col items-center justify-center gap-3 p-4 rounded-lg border transition-all group hover:border-rose-500 transition-colors duration-300">
<div className={`p-3 rounded-full transition-colors ${theme === 'dark' ? 'bg-rose-900/30 text-rose-400' : 'bg-rose-100 text-rose-600'} group-hover:bg-rose-200`}>
<SkipForward size={24} />
</div>
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>Überspringen</span>
</button>
</div>
</div>
{/* Devices */}
<div className={`rounded-xl shadow-sm border overflow-hidden transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
<div className={`px-6 py-4 border-b transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'}`}>
<h2 className={`text-lg font-semibold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Geräte</h2>
</div>
<div className="p-6 transition-colors duration-300">
{devices.length === 0 ? (
<div className="text-center py-12 transition-colors duration-300">
<div className={`mx-auto w-16 h-16 rounded-full flex items-center justify-center mb-4 ${theme === 'dark' ? 'bg-slate-700' : 'bg-slate-100'}`}>
<HardDrive className={theme === 'dark' ? 'text-slate-400' : 'text-slate-400'} size={32} />
</div>
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Keine Geräte gefunden</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 transition-colors duration-300">
{devices.map(device => (
<div key={device.id} className={`rounded-lg p-4 border transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-900 border-slate-700' : 'bg-slate-50 border-slate-200'}`}>
<div className="flex items-start justify-between mb-3 transition-colors duration-300">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg transition-colors duration-300 ${
device.type === 'cd' ? theme === 'dark' ? 'bg-blue-900/30 text-blue-400' : 'bg-blue-100 text-blue-600' :
device.type === 'dvd' ? theme === 'dark' ? 'bg-purple-900/30 text-purple-400' : 'bg-purple-100 text-purple-600' :
theme === 'dark' ? 'bg-pink-900/30 text-pink-400' : 'bg-pink-100 text-pink-600'
}`}>
<Disc size={20} />
</div>
<div>
<h3 className={`font-semibold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>{device.name}</h3>
<p className={`text-xs font-mono ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>{device.path}</p>
</div>
</div>
<TypeBadge type={device.type} />
</div>
<div className="flex items-center justify-between mt-4 transition-colors duration-300">
<span className={`px-2.5 py-1 rounded-md text-xs font-medium transition-colors duration-300 ${
device.status === 'empty' ? theme === 'dark' ? 'bg-slate-700 text-slate-300' : 'bg-slate-100 text-slate-600' :
device.status === 'ready' ? theme === 'dark' ? 'bg-emerald-900/30 text-emerald-400' : 'bg-emerald-100 text-emerald-700' :
theme === 'dark' ? 'bg-blue-900/30 text-blue-400' : 'bg-blue-100 text-blue-700'
}`}>
{device.status === 'empty' ? 'Leer' :
device.status === 'ready' ? 'Bereit' :
'Rippt'}
</span>
<button className={`text-sm font-medium transition-colors duration-300 ${theme === 'dark' ? 'text-indigo-400 hover:text-indigo-300' : 'text-indigo-600 hover:text-indigo-700'}`}>
Details
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* Recent Jobs */}
{/* Schnellaktionen + doppelte Geräte-Sektion sind raus (23.07.): alle
vier Knöpfe und der zweite Details-Button waren funktionslos
die echte Geräte-Steuerung lebt in DeviceDiscovery oben. */}
<div className={`rounded-xl shadow-sm border overflow-hidden transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
<div className={`px-6 py-4 border-b transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'} flex items-center justify-between`}>
<h2 className={`text-lg font-semibold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Neueste Jobs</h2>
+37 -16
View File
@@ -1,11 +1,12 @@
import { useState, useEffect } from 'react'
import { Settings, Save, Disc, Cpu, Database, Globe, AlertCircle, CheckCircle } from 'lucide-react'
import { Save, Disc, Database, Globe, AlertCircle, CheckCircle } from 'lucide-react'
import { api } from '../lib/api'
import { useDarkMode } from '../context/ThemeContext'
interface SettingsState {
tmdbApiKey: string
tvdbApiKey: string
omdbApiKey: string
outputDir: string
movieDir: string
seriesDir: string
@@ -19,6 +20,7 @@ interface SettingsState {
const defaultSettings: SettingsState = {
tmdbApiKey: '',
tvdbApiKey: '',
omdbApiKey: '',
outputDir: '/app/media',
movieDir: 'movies',
seriesDir: 'series',
@@ -29,8 +31,11 @@ const defaultSettings: SettingsState = {
notificationWebhook: '',
}
type SettingsTab = 'ripping' | 'verzeichnisse' | 'apis' | 'benachrichtigungen'
export default function SettingsPage() {
const [settings, setSettings] = useState<SettingsState>(defaultSettings)
const [activeTab, setActiveTab] = useState<SettingsTab>('ripping')
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
@@ -90,19 +95,18 @@ export default function SettingsPage() {
)}
<div className={`rounded-xl shadow-sm overflow-hidden transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border border-slate-700' : 'bg-white border border-slate-200'}`}>
{/* Tabs */}
{/* Tabs — echte Navigation (vorher reine Dekoration ohne onClick) */}
<div className={`flex transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'} border-b`}>
<TabButton icon={Disc} label="Ripping" isActive={true} darkMode={theme === 'dark'} />
<TabButton icon={Cpu} label="Verarbeitung" isActive={false} darkMode={theme === 'dark'} />
<TabButton icon={Database} label="Datenbank" isActive={false} darkMode={theme === 'dark'} />
<TabButton icon={Globe} label="APIs" isActive={false} darkMode={theme === 'dark'} />
<TabButton icon={AlertCircle} label="Benachrichtigungen" isActive={false} darkMode={theme === 'dark'} />
<TabButton icon={Disc} label="Ripping" isActive={activeTab === 'ripping'} onClick={() => setActiveTab('ripping')} darkMode={theme === 'dark'} />
<TabButton icon={Database} label="Verzeichnisse" isActive={activeTab === 'verzeichnisse'} onClick={() => setActiveTab('verzeichnisse')} darkMode={theme === 'dark'} />
<TabButton icon={Globe} label="APIs" isActive={activeTab === 'apis'} onClick={() => setActiveTab('apis')} darkMode={theme === 'dark'} />
<TabButton icon={AlertCircle} label="Benachrichtigungen" isActive={activeTab === 'benachrichtigungen'} onClick={() => setActiveTab('benachrichtigungen')} darkMode={theme === 'dark'} />
</div>
{/* Content */}
<div className="p-6 space-y-8 transition-colors duration-300">
{/* Ripping Settings */}
<section>
{activeTab === 'ripping' && <section>
<h2 className={`text-lg font-semibold mb-4 flex items-center gap-2 transition-colors duration-300 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
<Disc size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} />
Ripping-Einstellungen
@@ -160,10 +164,10 @@ export default function SettingsPage() {
/>
</div>
</div>
</section>
</section>}
{/* Output Directories */}
<section>
{activeTab === 'verzeichnisse' && <section>
<h2 className={`text-lg font-semibold mb-4 flex items-center gap-2 transition-colors duration-300 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
<Database size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} />
Ausgabeverzeichnisse
@@ -223,10 +227,10 @@ export default function SettingsPage() {
/>
</div>
</div>
</section>
</section>}
{/* API Keys */}
<section>
{activeTab === 'apis' && <section>
<h2 className={`text-lg font-semibold mb-4 flex items-center gap-2 transition-colors duration-300 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
<Globe size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} />
API-Schlüssel
@@ -261,11 +265,27 @@ export default function SettingsPage() {
placeholder="Dein TVDb API Key"
/>
</div>
<div className="space-y-2 transition-colors duration-300">
<label className={`block text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
OMDb API Key (Fallback)
</label>
<input
type="password"
value={settings.omdbApiKey}
onChange={(e) => handleChange('omdbApiKey', e.target.value)}
className={`w-full px-3 py-2 ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'} border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all`}
placeholder="Dein OMDb API Key"
/>
<p className={`text-xs ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
Zweite Quelle, wenn TMDB nichts findet kostenloser Key auf <a href="https://www.omdbapi.com/apikey.aspx" target="_blank" rel="noopener noreferrer" className={`${theme === 'dark' ? 'text-indigo-400 hover:underline' : 'text-indigo-600 hover:underline'}`}>omdbapi.com</a>
</p>
</div>
</div>
</section>
</section>}
{/* Webhook */}
<section>
{activeTab === 'benachrichtigungen' && <section>
<h2 className={`text-lg font-semibold mb-4 flex items-center gap-2 transition-colors duration-300 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
<AlertCircle size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} />
Webhook-Benachrichtigungen
@@ -286,7 +306,7 @@ export default function SettingsPage() {
Optional: URL für Benachrichtigungen bei Job-Ende (Discord, Slack, etc.)
</p>
</div>
</section>
</section>}
{/* Save Button */}
<div className={`flex items-center justify-end gap-4 pt-6 transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'} border-t`}>
@@ -314,10 +334,11 @@ 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, onClick, darkMode }: { icon: any, label: string, isActive: boolean, onClick: () => void, darkMode: boolean }) {
const { theme } = useDarkMode()
return (
<button
onClick={onClick}
className={`px-6 py-3 text-sm font-medium transition-colors border-b-2 ${
isActive
? `${darkMode ? 'border-indigo-500 text-indigo-400' : 'border-indigo-600 text-indigo-600'}`