Compare commits
2 Commits
96c6e1fe42
...
1aa51ca943
| Author | SHA1 | Date | |
|---|---|---|---|
| 1aa51ca943 | |||
| 5528e0f652 |
+21
-3
@@ -1,4 +1,22 @@
|
|||||||
POSTGRES_PASSWORD=rippy123
|
# Rippy-Umgebung — nach .env kopieren und Werte eintragen.
|
||||||
SECRET_KEY=change-me-in-production
|
# 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=
|
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=
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ services:
|
|||||||
- REDIS_URL=redis://redis:6379/0
|
- REDIS_URL=redis://redis:6379/0
|
||||||
- TMDB_API_KEY=${TMDB_API_KEY}
|
- TMDB_API_KEY=${TMDB_API_KEY}
|
||||||
- THETVDB_API_KEY=${THETVDB_API_KEY}
|
- THETVDB_API_KEY=${THETVDB_API_KEY}
|
||||||
|
- OMDB_API_KEY=${OMDB_API_KEY}
|
||||||
- JWT_SECRET_KEY=${JWT_SECRET_KEY}
|
- JWT_SECRET_KEY=${JWT_SECRET_KEY}
|
||||||
- LOG_LEVEL=INFO
|
- LOG_LEVEL=INFO
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -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 '2005–2012' (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
|
||||||
@@ -11,6 +11,7 @@ class Settings(BaseSettings):
|
|||||||
# API Keys
|
# API Keys
|
||||||
tmdb_api_key: Optional[str] = Field(default=None, description="TMDB API Key (Pflicht für Metadaten-Lookup)")
|
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)")
|
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)")
|
musicbrainz_user: Optional[str] = Field(default=None, description="MusicBrainz User (Optional)")
|
||||||
|
|
||||||
# Cache
|
# Cache
|
||||||
|
|||||||
@@ -96,6 +96,18 @@ def list_jobs(limit: int = 100) -> list:
|
|||||||
return [dict(z) for z in zeilen]
|
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:
|
def add_log(level: str, source: str, message: str) -> None:
|
||||||
with engine.begin() as conn:
|
with engine.begin() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ direkt vom Kernel und funktioniert überall.
|
|||||||
|
|
||||||
import glob
|
import glob
|
||||||
import os
|
import os
|
||||||
|
from fcntl import ioctl
|
||||||
|
|
||||||
from detection import (
|
from detection import (
|
||||||
CDS_DISC_OK,
|
CDS_DISC_OK,
|
||||||
@@ -17,6 +18,18 @@ from detection import (
|
|||||||
drive_status,
|
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:
|
def list_optical_devices() -> list:
|
||||||
"""Alle optischen Laufwerke, die der Container sieht (devices: in compose)."""
|
"""Alle optischen Laufwerke, die der Container sieht (devices: in compose)."""
|
||||||
|
|||||||
@@ -209,6 +209,24 @@ async def create_job(request: JobCreateRequest):
|
|||||||
return {"id": job_id, "status": "pending", "device": device_path}
|
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")
|
@app.get("/logs")
|
||||||
async def get_logs(limit: int = 200):
|
async def get_logs(limit: int = 200):
|
||||||
"""Echte Ereignisse aus der Datenbank (Watcher, API, Worker)."""
|
"""Echte Ereignisse aus der Datenbank (Watcher, API, Worker)."""
|
||||||
|
|||||||
@@ -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.
|
lieferte seitdem immer „Unknown Disc". Dies ist die echte Logik, bereinigt.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from typing import Dict, List
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
import detection
|
||||||
from clients.tmdb import TMDBClient
|
from clients.tmdb import TMDBClient
|
||||||
from clients.musicbrainz import MusicBrainzClient
|
from clients.musicbrainz import MusicBrainzClient
|
||||||
|
from clients.omdb import OMDbClient
|
||||||
from clients.thetvdb import TheTVDBClient
|
from clients.thetvdb import TheTVDBClient
|
||||||
from cache import get, set as cache_set
|
from cache import get, set as cache_set
|
||||||
from cache.keys import generate_prescan_key
|
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:
|
class PreScanResult:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -48,6 +83,7 @@ class PreScan:
|
|||||||
self.tmdb = TMDBClient()
|
self.tmdb = TMDBClient()
|
||||||
self.musicbrainz = MusicBrainzClient()
|
self.musicbrainz = MusicBrainzClient()
|
||||||
self.thetvdb = TheTVDBClient()
|
self.thetvdb = TheTVDBClient()
|
||||||
|
self.omdb = OMDbClient()
|
||||||
|
|
||||||
def scan(self, device_path: str) -> PreScanResult:
|
def scan(self, device_path: str) -> PreScanResult:
|
||||||
"""Führe Pre-Scan durch."""
|
"""Führe Pre-Scan durch."""
|
||||||
@@ -59,25 +95,13 @@ class PreScan:
|
|||||||
return self._scan_video(device_path, toc)
|
return self._scan_video(device_path, toc)
|
||||||
|
|
||||||
def _detect_disc_type(self, device_path: str) -> str:
|
def _detect_disc_type(self, device_path: str) -> str:
|
||||||
"""Erkenne Disc-Typ (CD/DVD/Blu-ray) über die Medien-Größe."""
|
"""Erkenne Disc-Typ über die zentrale ioctl-Erkennung (detection.py).
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
Vorher lief hier `isosize` — das Werkzeug war im Container nicht
|
||||||
["isosize", "-x", device_path],
|
installiert, die Erkennung fiel still immer auf "DVD" zurück.
|
||||||
capture_output=True,
|
"""
|
||||||
text=True,
|
typ = detection.detect_disc_type(device_path)
|
||||||
timeout=5
|
return {"cd": "CD", "dvd": "DVD", "bluray": "Blu-ray"}.get(typ, "DVD")
|
||||||
)
|
|
||||||
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"
|
|
||||||
|
|
||||||
def _read_toc(self, device_path: str, disc_type: str) -> Dict:
|
def _read_toc(self, device_path: str, disc_type: str) -> Dict:
|
||||||
"""Lese TOC (Table of Contents)."""
|
"""Lese TOC (Table of Contents)."""
|
||||||
@@ -105,25 +129,30 @@ class PreScan:
|
|||||||
"duration": 0
|
"duration": 0
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
# makemkvcon liest nur den TOC — KEIN Ripping
|
# Titel-Quelle 1: ISO-Volume-Label direkt vom Medium — läuft
|
||||||
result = subprocess.run(
|
# überall. (makemkvcon gibt es NUR im Worker-Container; der
|
||||||
["makemkvcon", "--minlength=300", "--progress=off", "info", device_path],
|
# alte Aufruf hier scheiterte in der API still und der Titel
|
||||||
capture_output=True,
|
# blieb ewig "Unknown Title".)
|
||||||
text=True,
|
label = read_iso_volume_label(device_path)
|
||||||
timeout=30
|
if label:
|
||||||
)
|
toc["title"] = normalize_disc_label(label)
|
||||||
for line in result.stdout.split('\n'):
|
|
||||||
if line.startswith('DRV:'):
|
# Titel-Quelle 2 (optional, falls makemkvcon doch da ist):
|
||||||
parts = line.split(',')
|
if shutil.which("makemkvcon"):
|
||||||
if len(parts) >= 4:
|
result = subprocess.run(
|
||||||
toc["title"] = parts[3].strip().strip('"')
|
["makemkvcon", "-r", "--minlength=300", "info", f"dev:{device_path}"],
|
||||||
elif line.startswith('TINFO:'):
|
capture_output=True,
|
||||||
parts = line.split(',')
|
text=True,
|
||||||
if len(parts) >= 5:
|
timeout=120
|
||||||
toc["tracks"].append({
|
)
|
||||||
"title": parts[4].strip().strip('"') if len(parts) > 4 else "Title",
|
for line in result.stdout.split('\n'):
|
||||||
"duration": int(parts[2]) if len(parts) > 2 else 0
|
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:
|
except Exception as e:
|
||||||
print(f"Pre-Scan TOC Error: {e}")
|
print(f"Pre-Scan TOC Error: {e}")
|
||||||
return toc
|
return toc
|
||||||
@@ -188,7 +217,7 @@ class PreScan:
|
|||||||
if cached:
|
if cached:
|
||||||
return PreScanResult(**cached)
|
return PreScanResult(**cached)
|
||||||
|
|
||||||
title = toc["title"] or "Unknown Title"
|
title = normalize_disc_label(toc["title"] or "") or "Unknown Title"
|
||||||
confidence = 0.0
|
confidence = 0.0
|
||||||
metadata = {}
|
metadata = {}
|
||||||
matched = False
|
matched = False
|
||||||
@@ -235,6 +264,35 @@ class PreScan:
|
|||||||
matched = True
|
matched = True
|
||||||
break
|
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:
|
if not matched:
|
||||||
confidence = 0.3
|
confidence = 0.3
|
||||||
metadata = {
|
metadata = {
|
||||||
|
|||||||
@@ -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("2005–2012") == 2005 # Serien-Zeitspanne
|
||||||
|
assert parse_year("N/A") is None
|
||||||
|
assert parse_year("") is None
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react'
|
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 { useDarkMode } from './context/ThemeContext'
|
||||||
import Dashboard from './pages/Dashboard'
|
import Dashboard from './pages/Dashboard'
|
||||||
import MetadataPreview from './pages/MetadataPreview'
|
import MetadataPreview from './pages/MetadataPreview'
|
||||||
@@ -61,12 +61,8 @@ function App() {
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="p-4 border-t border-slate-800 dark:border-slate-700">
|
{/* Abmelden-Knopf entfernt (23.07.): es gibt (noch) keinen
|
||||||
<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">
|
Login-Flow im UI — ein toter Knopf gaukelt nur einen vor. */}
|
||||||
<LogOut size={20} />
|
|
||||||
<span className="text-sm">Abmelden</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ interface Device {
|
|||||||
export default function DeviceDiscovery() {
|
export default function DeviceDiscovery() {
|
||||||
const [devices, setDevices] = useState<Device[]>([])
|
const [devices, setDevices] = useState<Device[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
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 { theme } = useDarkMode()
|
||||||
|
|
||||||
const refreshDevices = async () => {
|
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(() => {
|
useEffect(() => {
|
||||||
refreshDevices()
|
refreshDevices()
|
||||||
const interval = setInterval(refreshDevices, 5000)
|
const interval = setInterval(refreshDevices, 5000)
|
||||||
@@ -146,10 +176,56 @@ export default function DeviceDiscovery() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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'}`}>
|
<button
|
||||||
Details
|
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>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react'
|
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 { api } from '../lib/api'
|
||||||
import { useDarkMode } from '../context/ThemeContext'
|
import { useDarkMode } from '../context/ThemeContext'
|
||||||
import DeviceDiscovery from '../components/DeviceDiscovery'
|
import DeviceDiscovery from '../components/DeviceDiscovery'
|
||||||
@@ -16,14 +16,6 @@ interface Job {
|
|||||||
title?: string
|
title?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Device {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
type: 'cd' | 'dvd' | 'bluray'
|
|
||||||
path: string
|
|
||||||
status: 'empty' | 'ready' | 'ripping'
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchJobs(): Promise<Job[]> {
|
async function fetchJobs(): Promise<Job[]> {
|
||||||
try {
|
try {
|
||||||
const response = await api.get('/jobs')
|
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 }) {
|
function StatusBadge({ status }: { status: string }) {
|
||||||
const { theme } = useDarkMode()
|
const { theme } = useDarkMode()
|
||||||
const styles = theme === 'dark'
|
const styles = theme === 'dark'
|
||||||
@@ -136,19 +119,13 @@ function ProgressBar({ progress }: { progress: number }) {
|
|||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const [jobs, setJobs] = useState<Job[]>([])
|
const [jobs, setJobs] = useState<Job[]>([])
|
||||||
const [devices, setDevices] = useState<Device[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const { theme } = useDarkMode()
|
const { theme } = useDarkMode()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
const [jobsData, devicesData] = await Promise.all([
|
setJobs(await fetchJobs())
|
||||||
fetchJobs(),
|
|
||||||
fetchDevices()
|
|
||||||
])
|
|
||||||
setJobs(jobsData)
|
|
||||||
setDevices(devicesData)
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -220,93 +197,10 @@ export default function Dashboard() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
{/* 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={`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`}>
|
<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>
|
<h2 className={`text-lg font-semibold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Neueste Jobs</h2>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useState, useEffect } from 'react'
|
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 { api } from '../lib/api'
|
||||||
import { useDarkMode } from '../context/ThemeContext'
|
import { useDarkMode } from '../context/ThemeContext'
|
||||||
|
|
||||||
interface SettingsState {
|
interface SettingsState {
|
||||||
tmdbApiKey: string
|
tmdbApiKey: string
|
||||||
tvdbApiKey: string
|
tvdbApiKey: string
|
||||||
|
omdbApiKey: string
|
||||||
outputDir: string
|
outputDir: string
|
||||||
movieDir: string
|
movieDir: string
|
||||||
seriesDir: string
|
seriesDir: string
|
||||||
@@ -19,6 +20,7 @@ interface SettingsState {
|
|||||||
const defaultSettings: SettingsState = {
|
const defaultSettings: SettingsState = {
|
||||||
tmdbApiKey: '',
|
tmdbApiKey: '',
|
||||||
tvdbApiKey: '',
|
tvdbApiKey: '',
|
||||||
|
omdbApiKey: '',
|
||||||
outputDir: '/app/media',
|
outputDir: '/app/media',
|
||||||
movieDir: 'movies',
|
movieDir: 'movies',
|
||||||
seriesDir: 'series',
|
seriesDir: 'series',
|
||||||
@@ -29,8 +31,11 @@ const defaultSettings: SettingsState = {
|
|||||||
notificationWebhook: '',
|
notificationWebhook: '',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SettingsTab = 'ripping' | 'verzeichnisse' | 'apis' | 'benachrichtigungen'
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const [settings, setSettings] = useState<SettingsState>(defaultSettings)
|
const [settings, setSettings] = useState<SettingsState>(defaultSettings)
|
||||||
|
const [activeTab, setActiveTab] = useState<SettingsTab>('ripping')
|
||||||
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)
|
||||||
@@ -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'}`}>
|
<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`}>
|
<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={Disc} label="Ripping" isActive={activeTab === 'ripping'} onClick={() => setActiveTab('ripping')} darkMode={theme === 'dark'} />
|
||||||
<TabButton icon={Cpu} label="Verarbeitung" isActive={false} darkMode={theme === 'dark'} />
|
<TabButton icon={Database} label="Verzeichnisse" isActive={activeTab === 'verzeichnisse'} onClick={() => setActiveTab('verzeichnisse')} darkMode={theme === 'dark'} />
|
||||||
<TabButton icon={Database} label="Datenbank" isActive={false} darkMode={theme === 'dark'} />
|
<TabButton icon={Globe} label="APIs" isActive={activeTab === 'apis'} onClick={() => setActiveTab('apis')} darkMode={theme === 'dark'} />
|
||||||
<TabButton icon={Globe} label="APIs" isActive={false} darkMode={theme === 'dark'} />
|
<TabButton icon={AlertCircle} label="Benachrichtigungen" isActive={activeTab === 'benachrichtigungen'} onClick={() => setActiveTab('benachrichtigungen')} darkMode={theme === 'dark'} />
|
||||||
<TabButton icon={AlertCircle} label="Benachrichtigungen" isActive={false} darkMode={theme === 'dark'} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="p-6 space-y-8 transition-colors duration-300">
|
<div className="p-6 space-y-8 transition-colors duration-300">
|
||||||
{/* Ripping Settings */}
|
{/* 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'}`}>
|
<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'} />
|
<Disc size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} />
|
||||||
Ripping-Einstellungen
|
Ripping-Einstellungen
|
||||||
@@ -160,10 +164,10 @@ export default function SettingsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>}
|
||||||
|
|
||||||
{/* Output Directories */}
|
{/* 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'}`}>
|
<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'} />
|
<Database size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} />
|
||||||
Ausgabeverzeichnisse
|
Ausgabeverzeichnisse
|
||||||
@@ -223,10 +227,10 @@ export default function SettingsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>}
|
||||||
|
|
||||||
{/* API Keys */}
|
{/* 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'}`}>
|
<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'} />
|
<Globe size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} />
|
||||||
API-Schlüssel
|
API-Schlüssel
|
||||||
@@ -261,11 +265,27 @@ export default function SettingsPage() {
|
|||||||
placeholder="Dein TVDb API Key"
|
placeholder="Dein TVDb API Key"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</section>
|
</section>}
|
||||||
|
|
||||||
{/* Webhook */}
|
{/* 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'}`}>
|
<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'} />
|
<AlertCircle size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} />
|
||||||
Webhook-Benachrichtigungen
|
Webhook-Benachrichtigungen
|
||||||
@@ -286,7 +306,7 @@ export default function SettingsPage() {
|
|||||||
Optional: URL für Benachrichtigungen bei Job-Ende (Discord, Slack, etc.)
|
Optional: URL für Benachrichtigungen bei Job-Ende (Discord, Slack, etc.)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>}
|
||||||
|
|
||||||
{/* Save Button */}
|
{/* 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`}>
|
<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()
|
const { theme } = useDarkMode()
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
onClick={onClick}
|
||||||
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 ${
|
||||||
isActive
|
isActive
|
||||||
? `${darkMode ? 'border-indigo-500 text-indigo-400' : 'border-indigo-600 text-indigo-600'}`
|
? `${darkMode ? 'border-indigo-500 text-indigo-400' : 'border-indigo-600 text-indigo-600'}`
|
||||||
|
|||||||
Reference in New Issue
Block a user