Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 46a8f50c34 | |||
| 3f47b504c2 | |||
| 71c618073e | |||
| 20cdfedb85 | |||
| b1d9ece9b2 | |||
| b337525bed | |||
| 4fb29d16cf | |||
| 57171b4c88 | |||
| 3aef344b95 | |||
| dc1ceda0c2 | |||
| 2de0899ac6 | |||
| 47d07d6168 | |||
| 4ba02047db | |||
| fe1387b1ec | |||
| 67a4a76afa | |||
| b130eeb967 | |||
| bf80f9dfd4 | |||
| e0864828c2 | |||
| 60abf42359 | |||
| e18315814c | |||
| 9b19b74d39 | |||
| 9bc8ad11b2 | |||
| 0f20d9d8f4 | |||
| f0a8455fc2 | |||
| 3643f74eb1 | |||
| b8162753a9 | |||
| bd00d4c728 | |||
| 90b708302e | |||
| 7004d798d6 |
@@ -1,3 +1,5 @@
|
|||||||
# Shell-Skripte brauchen LF — mit CRLF scheitert der Interpreter im Container
|
# Shell-Skripte brauchen LF — mit CRLF scheitert der Interpreter im Container
|
||||||
# ("/bin/sh^M: bad interpreter"). Wir entwickeln auf Windows, gebaut wird auf Linux.
|
# ("/bin/sh^M: bad interpreter"). Wir entwickeln auf Windows, gebaut wird auf Linux.
|
||||||
*.sh text eol=lf
|
*.sh text eol=lf
|
||||||
|
*.exe binary
|
||||||
|
*.ico binary
|
||||||
|
|||||||
@@ -72,13 +72,12 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
exit $rot
|
exit $rot
|
||||||
|
|
||||||
- name: Grün-Gate — grünen Stand auf 'stable' befördern (Arcane deployt von dort)
|
- name: Ergebnis
|
||||||
if: success()
|
if: success()
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
if [ "${GITHUB_REF_NAME:-}" = "main" ]; then
|
# Single Source of Truth: 'main' (24.07.2026, Commander-Entscheid —
|
||||||
git push origin +HEAD:refs/heads/stable
|
# der stable-Zwischenbranch ist weg). Die Ampel PRÜFT nur noch und
|
||||||
echo "✅ main → stable befördert. Arcane-GitSync zieht 'stable' und deployt."
|
# befördert nichts; deployt wird direkt aus 'main' (git pull auf der
|
||||||
else
|
# VM bzw. ./deploy.sh). Rot heißt weiterhin: nicht deployen.
|
||||||
echo "Kein main-Push — keine Beförderung."
|
echo "✅ AMPEL GRÜN. Deploy-Quelle ist 'main' — von dort deployen."
|
||||||
fi
|
|
||||||
|
|||||||
@@ -17,10 +17,12 @@ abschwächen). Keine Tests = rot = nicht fertig. Der Commander liest die Ampel,
|
|||||||
gestrichenes Muss-Feature → erst den Commander fragen, NIE still ersetzen.
|
gestrichenes Muss-Feature → erst den Commander fragen, NIE still ersetzen.
|
||||||
(Vorgefallen: Muss-Feature „MakeMKV lossless" wurde still durch lossy HandBrake ersetzt.)
|
(Vorgefallen: Muss-Feature „MakeMKV lossless" wurde still durch lossy HandBrake ersetzt.)
|
||||||
|
|
||||||
**C. Deployment läuft AUTOMATISCH über das Grün-Gate.** Ampel grün auf `main` →
|
**C. Single Source of Truth ist `main` — es gibt nur diesen einen Branch**
|
||||||
CI befördert den Stand auf Branch `stable` → Arcane-GitSync zieht `stable` und
|
(seit 24.07.2026; der `stable`-Zwischenbranch ist abgeschafft). Die CI-Ampel
|
||||||
deployt. Du deployst also durch PUSHEN, nicht durch Kommandos. `./deploy.sh` ist
|
läuft bei jedem Push und PRÜFT nur (Ruff/pytest/Vite-Build) — sie befördert
|
||||||
NUR der Notfall-Hebel (Arcane down o. ä.); nie freihändig per SSH auf der VM bauen.
|
nichts mehr. Deployt wird direkt aus `main`: auf der VM `git pull` bzw.
|
||||||
|
`./deploy.sh` (verifiziere vorher, dass die Ampel für den Commit GRÜN ist —
|
||||||
|
rot heißt: nicht deployen). Nie freihändig per SSH auf der VM bauen.
|
||||||
(Vorgefallen: Doppel-Anlage `Rippy` + `rippy` auf der VM durch Freihand-Deploys.)
|
(Vorgefallen: Doppel-Anlage `Rippy` + `rippy` auf der VM durch Freihand-Deploys.)
|
||||||
|
|
||||||
**D. Externe Schnittstellen NIE aus dem Kopf.** Vor Nutzung fremder CLI-Flags oder
|
**D. Externe Schnittstellen NIE aus dem Kopf.** Vor Nutzung fremder CLI-Flags oder
|
||||||
|
|||||||
@@ -177,7 +177,8 @@ Entwicklungs-Infrastruktur DIESER Installation, nicht Teil von Rippy.
|
|||||||
|
|
||||||
## Entwicklung
|
## Entwicklung
|
||||||
|
|
||||||
CI („Ampel") läuft bei jedem Push: Ruff, pytest, Vite-Build. Grün auf
|
CI („Ampel") läuft bei jedem Push: Ruff, pytest, Vite-Build. Es gibt genau
|
||||||
`main` wird automatisch auf `stable` befördert — deploye von `stable`.
|
einen Branch, `main` (Single Source of Truth) — die Ampel prüft nur,
|
||||||
|
deployt wird direkt aus `main`. Rot heißt: nicht deployen.
|
||||||
Regeln für Beiträge: [AGENTS.md](AGENTS.md) · Konzept: [KONZEPT.md](KONZEPT.md) ·
|
Regeln für Beiträge: [AGENTS.md](AGENTS.md) · Konzept: [KONZEPT.md](KONZEPT.md) ·
|
||||||
Fahrplan: [ROADMAP.md](ROADMAP.md)
|
Fahrplan: [ROADMAP.md](ROADMAP.md)
|
||||||
|
|||||||
+89
-1
@@ -1,6 +1,94 @@
|
|||||||
# SAVEPOINT — Rippy
|
# SAVEPOINT — Rippy
|
||||||
|
|
||||||
## Aktueller Stand: v3.5 — Track-Tabelle, nativer Windows-Worker, Anleitungs-Tab (24.07.2026, Claude)
|
## Aktueller Stand: v3.9 — Windows-Installer als echte .exe (Icon, kein Konsolenfenster) (24.07.2026)
|
||||||
|
|
||||||
|
- **RippyWorkerSetup.exe** ersetzt den .vbs/.bat-Weg (Commander-Einwand:
|
||||||
|
.vbs ist abgekündigt + wird als gefährlich geflaggt). install-gui.ps1 via
|
||||||
|
ps2exe kompiliert: eingebettetes Rippy-Disc-Icon, KEIN Konsolenfenster
|
||||||
|
(-noConsole), WinForms (-STA). Vorgebaut auf Windows
|
||||||
|
(deploy/worker-windows/build-exe.ps1) + committet — Windows-.exe geht
|
||||||
|
nicht von Linux (Rippy-Host). API: GET /worker-setup/windows-exe.
|
||||||
|
UI (Worker → Windows): „Installer herunterladen (.exe)" als Haupt-Weg.
|
||||||
|
**E2E bewiesen**: von der VM geladen → Fenster öffnet, conhost-Zähler
|
||||||
|
unverändert (kein Konsolenfenster), Icon eingebettet.
|
||||||
|
- **Wichtig — friert nichts ein**: Die .exe holt HandBrake zur Laufzeit
|
||||||
|
(GitHub latest) und den Worker-Code live von Rippy. Nur bei Änderung der
|
||||||
|
GUI (install-gui.ps1) neu bauen (build-exe.ps1), NICHT bei Tool-Updates.
|
||||||
|
- **Update-Story dokumentiert** (Commander-Frage): MakeMKV → .env-Bump +
|
||||||
|
Rebuild (Update-Check zeigt es); HandBrake Docker = Debian-stabil,
|
||||||
|
native Worker = latest beim Installieren.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vorheriger Stand: v3.8 — Grafischer Windows-Installer + Versionslage klar (24.07.2026)
|
||||||
|
|
||||||
|
- **Grafischer Windows-Installer** (statt CLI): install-gui.ps1 (WinForms,
|
||||||
|
ASCII-only) — Fenster mit Feldern (Rippy-IP, Worker-Name, Autostart) +
|
||||||
|
Install-Knopf mit Live-Log + „Worker starten". Gleiche Schritte wie der
|
||||||
|
CLI-Installer (Worker-Code, HandBrake latest, venv, Tray/Start/Uninstall).
|
||||||
|
UI (Worker → Windows): Knopf „Grafischen Installer herunterladen (.bat)" —
|
||||||
|
die .bat wird CLIENT-seitig mit der eingetragenen LAN-IP erzeugt,
|
||||||
|
Doppelklick lädt+startet die GUI von Rippy (GET /worker-setup/windows-gui).
|
||||||
|
CLI-Befehl bleibt als Profi-Alternative. Bewiesen: GUI-Fenster öffnet
|
||||||
|
sauber auf dem Commander-PC (Titel „Rippy Encoding-Worker - Installation").
|
||||||
|
- **Werkzeug-Versionslage exakt dargestellt** (Commander-Wunsch): System-Tab
|
||||||
|
erklärt jetzt klar — MakeMKV aktualisierbar (wichtig wegen Schlüssel-DB),
|
||||||
|
HandBrake im Docker-Worker bewusst Debian-stabil (1.6.1, KEIN Alarm mehr),
|
||||||
|
native Worker holen die neueste. Update-Check zeigt MakeMKV mit „✓ aktuell"
|
||||||
|
bzw. Update-Befehl, HandBrake neutral als Info.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vorheriger Stand: v3.7 — Drei Praxis-Bugs (Mounts, HandBrake, Encoder-Wahl) (24.07.2026)
|
||||||
|
|
||||||
|
Alle drei live auf der VM verifiziert:
|
||||||
|
- **Speicher-Mounts robust**: Ein toter CIFS-Mount (NAS weg/Rebuild) war
|
||||||
|
mounted:false, verschwand aus „Verfügbare Ziele" und ließ sich nicht neu
|
||||||
|
anlegen („Name existiert"). Jetzt: `reachable`-Status (bounded `timeout 3
|
||||||
|
ls` — Endpoint lädt in 0,014 s statt 10 s), `umount -l`-Fallback,
|
||||||
|
`reparieren()` (lazy abhängen + frisch mounten), Re-Add repariert statt
|
||||||
|
409, POST /storage-mounts/{name}/repair, eigene „Netzwerk-Mounts"-Liste im
|
||||||
|
UI mit Status + Reparieren/Entfernen. Bewiesen: Reparatur des `rippy`-
|
||||||
|
Mounts → mounted:true, writable:true.
|
||||||
|
- **HandBrake-Versionen konsistent**: Windows-Installer zieht dynamisch die
|
||||||
|
neueste (GitHub latest) — bewiesen: HandBrake 1.11.2 installiert (statt
|
||||||
|
fest 1.9.2). Update-UI erklärt: Docker = Debian-stabil (bewusst älter),
|
||||||
|
Windows = neueste. Nebenbei ein latenter Installer-Bug gefixt: PowerShell
|
||||||
|
5.1 liest .ps1 als ANSI — ein Gedankenstrich zerschoss das Skript →
|
||||||
|
install.ps1 ist jetzt ASCII-only.
|
||||||
|
- **Encoder-/Worker-Wahl beim Rip**: Celery `worker_direct=True`, gezieltes
|
||||||
|
Routing (transcode_queue) mit sicherem Fallback auf die geteilte Queue.
|
||||||
|
Bewiesen mit ping_worker: Task landet exakt beim gewählten Node. Dropdown
|
||||||
|
im Rip-Dialog ab 2 Online-Workern. Node-Zuordnung disambiguiert bei
|
||||||
|
mehreren Workern pro Host. Nebenfund gefixt: DeviceDiscovery leitete die
|
||||||
|
Titel-Auswahl (titles) gar nicht weiter.
|
||||||
|
⚠️ Gezieltes Routing braucht AKTUELLEN Worker-Code (worker_direct) — ein
|
||||||
|
vor dieser Version installierter Remote-Worker pingt zwar, konsumiert aber
|
||||||
|
seine Direkt-Queue nicht → neu installieren.
|
||||||
|
- **Voller Transcode-E2E weiterhin durch die UHD-Key-Lage blockiert** (keine
|
||||||
|
entschlüsselbare Disc) — die Routing-Mechanik ist per ping_worker bewiesen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vorheriger Stand: v3.6 — Design 2.0 gelandet + Ein-Branch-Umstellung (24.07.2026)
|
||||||
|
|
||||||
|
- **Design 2.0 ist in `main`** (von Gemini umgesetzt): UI von 428
|
||||||
|
`theme === 'dark'`-Ternaries auf Tailwind-`dark:` + UI-Primitives
|
||||||
|
(`components/ui/`, `lib/design.ts`) umgebaut, „Cinematic Cinema OS"-Look.
|
||||||
|
Ampel grün (49 Tests, Ruff, Vite-Build), Live auf der VM.
|
||||||
|
- **Nur noch EIN Branch: `main`** (Commander-Entscheid). Der
|
||||||
|
`stable`-Zwischenbranch samt Grün-Gate ist abgeschafft — er war
|
||||||
|
vestigial, weil die VM ohnehin aus `main` deployt. Konsequenzen:
|
||||||
|
- ci.yml: Beförderungs-Schritt raus, die Ampel PRÜFT nur noch.
|
||||||
|
- deploy.sh: nutzt `main` statt `stable`.
|
||||||
|
- AGENTS §C / README / DESIGN-Briefing entsprechend umgeschrieben.
|
||||||
|
- Gelöschte Branches: `stable`, `design-2.0` (gemergt), `kernumbau-2026-07-23` (alt).
|
||||||
|
- **Deploy-Weg ab jetzt:** push → Ampel grün prüfen → auf der VM
|
||||||
|
`git pull` (in ~/projects/rippy) bzw. `./deploy.sh`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vorheriger Stand: v3.5 — Track-Tabelle, nativer Windows-Worker, Anleitungs-Tab (24.07.2026, Claude)
|
||||||
|
|
||||||
- **Volle Titel-Auswahl vor dem Rip**: „Disc scannen" im Rip-Dialog →
|
- **Volle Titel-Auswahl vor dem Rip**: „Disc scannen" im Rip-Dialog →
|
||||||
Worker-Task scan_tracks (makemkvcon info; Ergebnis via settings-Tabelle
|
Worker-Task scan_tracks (makemkvcon info; Ergebnis via settings-Tabelle
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# NOTFALL-Deploy für Rippy (Normalweg = Grün-Gate: push → Ampel grün → CI
|
# Deploy für Rippy — deployt den aktuellen 'main'-Stand auf die VM.
|
||||||
# befördert auf 'stable' → Arcane-GitSync deployt).
|
# Single Source of Truth ist seit 24.07.2026 'main' (kein stable-Branch mehr);
|
||||||
# Dieses Skript nur nutzen, wenn der Automat klemmt (Arcane down o. ä.).
|
# die Ampel prüft nur noch, befördert aber nichts. Vor dem Deploy also selbst
|
||||||
|
# sicherstellen, dass die Ampel für den zu deployenden Commit GRÜN ist.
|
||||||
#
|
#
|
||||||
# Nutzt einen EIGENEN Klon (~/notfall-rippy, Branch stable) und fasst Arcanes
|
# Nutzt einen EIGENEN Klon (~/notfall-rippy) und fasst Arcanes
|
||||||
# Projektverzeichnis (~/projects/rippy) NIE an — 22.07. gelernt: ein git-Klon
|
# Projektverzeichnis (~/projects/rippy) NIE an — 22.07. gelernt: ein git-Klon
|
||||||
# im Arcane-Verzeichnis kollidiert mit dessen Promote (read-only .git-Objekte).
|
# im Arcane-Verzeichnis kollidiert mit dessen Sync (read-only .git-Objekte).
|
||||||
# Die .env gehört Arcane und wird nur GELESEN (für compose-Interpolation).
|
# Die .env gehört Arcane und wird nur GELESEN (für compose-Interpolation).
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -15,11 +16,11 @@ DIENST="${1:-}" # optional: nur einen Dienst neu bauen, z.B. ./deploy.sh api
|
|||||||
ssh "$VM" 'bash -s' <<REMOTE
|
ssh "$VM" 'bash -s' <<REMOTE
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
if [ ! -d ~/notfall-rippy/.git ]; then
|
if [ ! -d ~/notfall-rippy/.git ]; then
|
||||||
git clone --branch stable https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/rippy.git ~/notfall-rippy
|
git clone --branch main https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/rippy.git ~/notfall-rippy
|
||||||
fi
|
fi
|
||||||
cd ~/notfall-rippy
|
cd ~/notfall-rippy
|
||||||
git fetch origin stable
|
git fetch origin main
|
||||||
git reset --hard origin/stable
|
git reset --hard origin/main
|
||||||
cp ~/projects/rippy/.env .env 2>/dev/null || echo "WARNUNG: keine .env in Arcanes Projektverzeichnis"
|
cp ~/projects/rippy/.env .env 2>/dev/null || echo "WARNUNG: keine .env in Arcanes Projektverzeichnis"
|
||||||
docker compose -p rippy up -d --build $DIENST
|
docker compose -p rippy up -d --build $DIENST
|
||||||
docker compose -p rippy ps --format 'table {{.Name}}\t{{.Status}}'
|
docker compose -p rippy ps --format 'table {{.Name}}\t{{.Status}}'
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,59 @@
|
|||||||
|
# Baut rippy.ico + RippyWorkerSetup.exe aus install-gui.ps1 (ps2exe).
|
||||||
|
#
|
||||||
|
# WANN NEU BAUEN: nur wenn sich install-gui.ps1 (die Installer-Oberflaeche)
|
||||||
|
# aendert. NICHT bei MakeMKV-/HandBrake-Updates — die .exe holt HandBrake zur
|
||||||
|
# Laufzeit (GitHub latest) und den Worker-Code live von Rippy, friert also
|
||||||
|
# keine Versionen ein.
|
||||||
|
# AUSFUEHREN AUF WINDOWS (eine Windows-.exe kann nicht von Linux gebaut werden):
|
||||||
|
# powershell -ExecutionPolicy Bypass -File build-exe.ps1
|
||||||
|
# Danach RippyWorkerSetup.exe committen (die API serviert sie).
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
Add-Type -AssemblyName System.Drawing
|
||||||
|
Set-Location $PSScriptRoot
|
||||||
|
|
||||||
|
$guiSrc = Join-Path $PSScriptRoot "install-gui.ps1"
|
||||||
|
|
||||||
|
# 1. Rippy-Disc-Icon 256x256 zeichnen -> PNG -> ICO-Container
|
||||||
|
$bmp = New-Object System.Drawing.Bitmap 256, 256
|
||||||
|
$g = [System.Drawing.Graphics]::FromImage($bmp)
|
||||||
|
$g.SmoothingMode = "AntiAlias"
|
||||||
|
$g.Clear([System.Drawing.Color]::Transparent)
|
||||||
|
$g.FillEllipse((New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(99, 102, 241))), 8, 8, 240, 240)
|
||||||
|
$g.FillEllipse((New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(147, 51, 234))), 40, 40, 176, 176)
|
||||||
|
$g.FillEllipse([System.Drawing.Brushes]::White, 104, 104, 48, 48)
|
||||||
|
$ms = New-Object System.IO.MemoryStream
|
||||||
|
$bmp.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
|
||||||
|
$png = $ms.ToArray()
|
||||||
|
|
||||||
|
$icoStream = New-Object System.IO.MemoryStream
|
||||||
|
$bw = New-Object System.IO.BinaryWriter $icoStream
|
||||||
|
$bw.Write([UInt16]0); $bw.Write([UInt16]1); $bw.Write([UInt16]1) # ICONDIR
|
||||||
|
$bw.Write([Byte]0); $bw.Write([Byte]0); $bw.Write([Byte]0); $bw.Write([Byte]0) # 256x256, colors, res
|
||||||
|
$bw.Write([UInt16]1); $bw.Write([UInt16]32) # planes, bpp
|
||||||
|
$bw.Write([UInt32]$png.Length); $bw.Write([UInt32]22) # size, offset
|
||||||
|
$bw.Write($png); $bw.Flush()
|
||||||
|
[System.IO.File]::WriteAllBytes("$PSScriptRoot\rippy.ico", $icoStream.ToArray())
|
||||||
|
Write-Host "rippy.ico erzeugt ($($png.Length) Bytes PNG)"
|
||||||
|
|
||||||
|
# 2. ps2exe sicherstellen — TLS 1.2 ist der Fix fuer den NuGet-Bootstrap-Fehler
|
||||||
|
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor 3072
|
||||||
|
if (-not (Get-Module -ListAvailable ps2exe)) {
|
||||||
|
Write-Host "Bootstrappe NuGet + PSGallery ..."
|
||||||
|
try { Get-PackageProvider -Name NuGet -ErrorAction Stop | Out-Null }
|
||||||
|
catch { Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Scope CurrentUser | Out-Null }
|
||||||
|
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted -ErrorAction SilentlyContinue
|
||||||
|
Write-Host "Installiere ps2exe ..."
|
||||||
|
Install-Module ps2exe -Scope CurrentUser -Force -AllowClobber
|
||||||
|
}
|
||||||
|
Import-Module ps2exe
|
||||||
|
|
||||||
|
# 3. Kompilieren (WinForms -> noConsole + STA, Icon eingebettet)
|
||||||
|
Invoke-ps2exe -inputFile $guiSrc -outputFile "$PSScriptRoot\RippyWorkerSetup.exe" `
|
||||||
|
-iconFile "$PSScriptRoot\rippy.ico" -noConsole -STA `
|
||||||
|
-title "Rippy Worker Setup" -product "Rippy" -company "Rippy" -version "1.0.0.0"
|
||||||
|
|
||||||
|
if (Test-Path "$PSScriptRoot\RippyWorkerSetup.exe") {
|
||||||
|
Write-Host "OK: RippyWorkerSetup.exe = $((Get-Item "$PSScriptRoot\RippyWorkerSetup.exe").Length) Bytes"
|
||||||
|
} else {
|
||||||
|
Write-Host "FEHLER: keine .exe erzeugt"
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
# Rippy Windows-Worker - GRAFISCHER Installer (WinForms, keine externe Runtime).
|
||||||
|
# Wird ueber rippy-worker-setup.bat gestartet (Doppelklick). Braucht nur
|
||||||
|
# Python 3.10+ auf der Maschine - kein Docker, kein git.
|
||||||
|
#
|
||||||
|
# ASCII-only (PowerShell 5.1 liest .ps1 als ANSI - Sonderzeichen zerschiessen
|
||||||
|
# das Skript).
|
||||||
|
|
||||||
|
param(
|
||||||
|
[string]$RippyHost = "",
|
||||||
|
[string]$WorkerName = $env:COMPUTERNAME.ToLower()
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
Add-Type -AssemblyName System.Windows.Forms
|
||||||
|
Add-Type -AssemblyName System.Drawing
|
||||||
|
|
||||||
|
$InstallDir = Join-Path $env:USERPROFILE "rippy-worker"
|
||||||
|
|
||||||
|
# --- Fenster ---------------------------------------------------------------
|
||||||
|
$form = New-Object System.Windows.Forms.Form
|
||||||
|
$form.Text = "Rippy Encoding-Worker - Installation"
|
||||||
|
$form.Size = New-Object System.Drawing.Size(560, 520)
|
||||||
|
$form.StartPosition = "CenterScreen"
|
||||||
|
$form.FormBorderStyle = "FixedDialog"
|
||||||
|
$form.MaximizeBox = $false
|
||||||
|
$form.BackColor = [System.Drawing.Color]::FromArgb(248, 250, 252)
|
||||||
|
$form.Font = New-Object System.Drawing.Font("Segoe UI", 9)
|
||||||
|
|
||||||
|
# Fenster-Icon: Rippy-Disc zur Laufzeit zeichnen (Indigo-Kreis + weisses Loch)
|
||||||
|
# - kein externes .ico noetig. Erscheint in Titelleiste und Taskleiste.
|
||||||
|
try {
|
||||||
|
$ibmp = New-Object System.Drawing.Bitmap 32, 32
|
||||||
|
$ig = [System.Drawing.Graphics]::FromImage($ibmp)
|
||||||
|
$ig.SmoothingMode = "AntiAlias"
|
||||||
|
$ig.FillEllipse((New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(79, 70, 229))), 1, 1, 30, 30)
|
||||||
|
$ig.FillEllipse([System.Drawing.Brushes]::White, 12, 12, 8, 8)
|
||||||
|
$form.Icon = [System.Drawing.Icon]::FromHandle($ibmp.GetHicon())
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
$titel = New-Object System.Windows.Forms.Label
|
||||||
|
$titel.Text = "Rippy Encoding-Worker einrichten"
|
||||||
|
$titel.Font = New-Object System.Drawing.Font("Segoe UI", 14, [System.Drawing.FontStyle]::Bold)
|
||||||
|
$titel.ForeColor = [System.Drawing.Color]::FromArgb(79, 70, 229)
|
||||||
|
$titel.Location = New-Object System.Drawing.Point(24, 20)
|
||||||
|
$titel.Size = New-Object System.Drawing.Size(500, 30)
|
||||||
|
$form.Controls.Add($titel)
|
||||||
|
|
||||||
|
$info = New-Object System.Windows.Forms.Label
|
||||||
|
$info.Text = "Diese Maschine uebernimmt die Video-Kompression fuer Rippy. Gerippt wird weiter auf der Rippy-Hauptmaschine."
|
||||||
|
$info.Location = New-Object System.Drawing.Point(24, 52)
|
||||||
|
$info.Size = New-Object System.Drawing.Size(500, 34)
|
||||||
|
$info.ForeColor = [System.Drawing.Color]::FromArgb(100, 116, 139)
|
||||||
|
$form.Controls.Add($info)
|
||||||
|
|
||||||
|
# Rippy-Adresse
|
||||||
|
$lblHost = New-Object System.Windows.Forms.Label
|
||||||
|
$lblHost.Text = "LAN-IP der Rippy-Maschine (Docker-Host) - NICHT dieser PC:"
|
||||||
|
$lblHost.Location = New-Object System.Drawing.Point(24, 96)
|
||||||
|
$lblHost.Size = New-Object System.Drawing.Size(500, 18)
|
||||||
|
$form.Controls.Add($lblHost)
|
||||||
|
|
||||||
|
$txtHost = New-Object System.Windows.Forms.TextBox
|
||||||
|
$txtHost.Location = New-Object System.Drawing.Point(24, 116)
|
||||||
|
$txtHost.Size = New-Object System.Drawing.Size(300, 26)
|
||||||
|
$txtHost.Text = $RippyHost
|
||||||
|
$txtHost.Font = New-Object System.Drawing.Font("Consolas", 10)
|
||||||
|
$form.Controls.Add($txtHost)
|
||||||
|
|
||||||
|
# Worker-Name
|
||||||
|
$lblName = New-Object System.Windows.Forms.Label
|
||||||
|
$lblName.Text = "Name dieses Workers (frei waehlbar):"
|
||||||
|
$lblName.Location = New-Object System.Drawing.Point(24, 152)
|
||||||
|
$lblName.Size = New-Object System.Drawing.Size(500, 18)
|
||||||
|
$form.Controls.Add($lblName)
|
||||||
|
|
||||||
|
$txtName = New-Object System.Windows.Forms.TextBox
|
||||||
|
$txtName.Location = New-Object System.Drawing.Point(24, 172)
|
||||||
|
$txtName.Size = New-Object System.Drawing.Size(300, 26)
|
||||||
|
$txtName.Text = $WorkerName
|
||||||
|
$form.Controls.Add($txtName)
|
||||||
|
|
||||||
|
# Autostart
|
||||||
|
$chkAuto = New-Object System.Windows.Forms.CheckBox
|
||||||
|
$chkAuto.Text = "Beim Anmelden automatisch starten (Tray-Symbol)"
|
||||||
|
$chkAuto.Location = New-Object System.Drawing.Point(24, 206)
|
||||||
|
$chkAuto.Size = New-Object System.Drawing.Size(400, 22)
|
||||||
|
$chkAuto.Checked = $true
|
||||||
|
$form.Controls.Add($chkAuto)
|
||||||
|
|
||||||
|
# Log-Bereich
|
||||||
|
$log = New-Object System.Windows.Forms.TextBox
|
||||||
|
$log.Location = New-Object System.Drawing.Point(24, 240)
|
||||||
|
$log.Size = New-Object System.Drawing.Size(500, 170)
|
||||||
|
$log.Multiline = $true
|
||||||
|
$log.ReadOnly = $true
|
||||||
|
$log.ScrollBars = "Vertical"
|
||||||
|
$log.BackColor = [System.Drawing.Color]::FromArgb(15, 23, 42)
|
||||||
|
$log.ForeColor = [System.Drawing.Color]::FromArgb(226, 232, 240)
|
||||||
|
$log.Font = New-Object System.Drawing.Font("Consolas", 8.5)
|
||||||
|
$form.Controls.Add($log)
|
||||||
|
|
||||||
|
# Knoepfe
|
||||||
|
$btnInstall = New-Object System.Windows.Forms.Button
|
||||||
|
$btnInstall.Text = "Installieren"
|
||||||
|
$btnInstall.Location = New-Object System.Drawing.Point(24, 424)
|
||||||
|
$btnInstall.Size = New-Object System.Drawing.Size(150, 40)
|
||||||
|
$btnInstall.BackColor = [System.Drawing.Color]::FromArgb(79, 70, 229)
|
||||||
|
$btnInstall.ForeColor = [System.Drawing.Color]::White
|
||||||
|
$btnInstall.FlatStyle = "Flat"
|
||||||
|
$btnInstall.Font = New-Object System.Drawing.Font("Segoe UI", 10, [System.Drawing.FontStyle]::Bold)
|
||||||
|
$form.Controls.Add($btnInstall)
|
||||||
|
|
||||||
|
$btnStart = New-Object System.Windows.Forms.Button
|
||||||
|
$btnStart.Text = "Worker starten"
|
||||||
|
$btnStart.Location = New-Object System.Drawing.Point(190, 424)
|
||||||
|
$btnStart.Size = New-Object System.Drawing.Size(150, 40)
|
||||||
|
$btnStart.BackColor = [System.Drawing.Color]::FromArgb(16, 185, 129)
|
||||||
|
$btnStart.ForeColor = [System.Drawing.Color]::White
|
||||||
|
$btnStart.FlatStyle = "Flat"
|
||||||
|
$btnStart.Font = New-Object System.Drawing.Font("Segoe UI", 10, [System.Drawing.FontStyle]::Bold)
|
||||||
|
$btnStart.Visible = $false
|
||||||
|
$form.Controls.Add($btnStart)
|
||||||
|
|
||||||
|
function Log($text) {
|
||||||
|
$log.AppendText($text + "`r`n")
|
||||||
|
[System.Windows.Forms.Application]::DoEvents()
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Installations-Logik ---------------------------------------------------
|
||||||
|
function Do-Install {
|
||||||
|
$rHost = $txtHost.Text.Trim()
|
||||||
|
$wName = $txtName.Text.Trim()
|
||||||
|
if (-not $rHost) { [System.Windows.Forms.MessageBox]::Show("Bitte die LAN-IP der Rippy-Maschine eintragen.", "Fehlt"); return }
|
||||||
|
if (-not $wName) { $wName = $env:COMPUTERNAME.ToLower() }
|
||||||
|
|
||||||
|
$btnInstall.Enabled = $false
|
||||||
|
$log.Clear()
|
||||||
|
|
||||||
|
try {
|
||||||
|
# 1. Python finden
|
||||||
|
$python = $null
|
||||||
|
foreach ($k in @("py -3", "python")) {
|
||||||
|
try {
|
||||||
|
$v = Invoke-Expression "$k --version" 2>&1
|
||||||
|
if ("$v" -match "Python 3\.(\d+)" -and [int]$Matches[1] -ge 10) { $python = $k; break }
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
if (-not $python) {
|
||||||
|
Log "FEHLER: Python 3.10+ nicht gefunden."
|
||||||
|
Log "Installieren mit: winget install Python.Python.3.12"
|
||||||
|
Log "Danach diesen Installer erneut starten."
|
||||||
|
$btnInstall.Enabled = $true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Log "Python gefunden: $python"
|
||||||
|
|
||||||
|
# 2. Ordner + Worker-Code
|
||||||
|
New-Item -ItemType Directory -Force $InstallDir | Out-Null
|
||||||
|
Set-Location $InstallDir
|
||||||
|
$HandBrakeVersion = "1.11.2"
|
||||||
|
try {
|
||||||
|
$rel = Invoke-RestMethod "https://api.github.com/repos/HandBrake/HandBrake/releases/latest" -TimeoutSec 15 -Headers @{ "User-Agent" = "rippy-installer" }
|
||||||
|
if ($rel.tag_name) { $HandBrakeVersion = ([string]$rel.tag_name).TrimStart("v") }
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
Log "Lade Worker-Code von $rHost ..."
|
||||||
|
Invoke-WebRequest "http://$rHost/api/worker-setup/paket" -OutFile worker.zip -UseBasicParsing
|
||||||
|
Expand-Archive worker.zip -DestinationPath . -Force
|
||||||
|
Remove-Item worker.zip
|
||||||
|
|
||||||
|
# 3. venv + Abhaengigkeiten
|
||||||
|
if (-not (Test-Path "venv")) { Log "Lege Python-Umgebung an ..."; Invoke-Expression "$python -m venv venv" }
|
||||||
|
Log "Installiere Python-Abhaengigkeiten (kann 1-2 min dauern) ..."
|
||||||
|
& .\venv\Scripts\python.exe -m pip install --quiet --upgrade pip
|
||||||
|
& .\venv\Scripts\pip.exe install --quiet -r requirements.txt
|
||||||
|
& .\venv\Scripts\pip.exe install --quiet pystray pillow
|
||||||
|
Log "Abhaengigkeiten installiert."
|
||||||
|
|
||||||
|
# 4. HandBrakeCLI
|
||||||
|
if (-not (Test-Path ".\HandBrakeCLI.exe") -and -not (Get-Command HandBrakeCLI.exe -ErrorAction SilentlyContinue)) {
|
||||||
|
Log "Lade HandBrakeCLI $HandBrakeVersion (offizielles Release) ..."
|
||||||
|
$url = "https://github.com/HandBrake/HandBrake/releases/download/$HandBrakeVersion/HandBrakeCLI-$HandBrakeVersion-win-x86_64.zip"
|
||||||
|
Invoke-WebRequest $url -OutFile hb.zip -UseBasicParsing
|
||||||
|
Expand-Archive hb.zip -DestinationPath . -Force
|
||||||
|
Remove-Item hb.zip
|
||||||
|
}
|
||||||
|
Log "HandBrake bereit."
|
||||||
|
|
||||||
|
# 5. Start-Skripte + Deinstaller erzeugen
|
||||||
|
$trayBat = "@echo off`r`ncd /d `"%~dp0`"`r`nset REDIS_URL=redis://${rHost}:6379/0`r`nset DATABASE_URL=postgresql://rippy:rippy@${rHost}:5432/rippy`r`nset API_URL=http://${rHost}:8000`r`nset WORKER_NAME=$wName`r`nset RIPPY_TRAY_HOST=$rHost`r`nset PATH=%~dp0;%PATH%`r`nstart `"`" venv\Scripts\pythonw.exe tray.py"
|
||||||
|
Set-Content -Path "start-tray.bat" -Value $trayBat -Encoding ASCII
|
||||||
|
|
||||||
|
$workBat = "@echo off`r`ncd /d `"%~dp0`"`r`nset REDIS_URL=redis://${rHost}:6379/0`r`nset DATABASE_URL=postgresql://rippy:rippy@${rHost}:5432/rippy`r`nset API_URL=http://${rHost}:8000`r`nset WORKER_NAME=$wName`r`nset PATH=%~dp0;%PATH%`r`nvenv\Scripts\celery.exe -A celery_app worker --loglevel=info -Q transcode --pool=solo -n ${wName}@%%h"
|
||||||
|
Set-Content -Path "start-worker.bat" -Value $workBat -Encoding ASCII
|
||||||
|
|
||||||
|
$uninstall = @"
|
||||||
|
param([switch]`$Force)
|
||||||
|
if (-not `$Force) { if ([System.Windows.Forms.MessageBox]::Show("Rippy-Worker '$wName' deinstallieren?","Rippy",4) -ne "Yes") { exit } }
|
||||||
|
Add-Type -AssemblyName System.Windows.Forms
|
||||||
|
Get-CimInstance Win32_Process | Where-Object { `$_.ExecutablePath -like "`$PSScriptRoot*" } | ForEach-Object { Stop-Process -Id `$_.ProcessId -Force -ErrorAction SilentlyContinue }
|
||||||
|
Start-Sleep 2
|
||||||
|
schtasks /delete /tn "RippyWorker" /f 2>`$null | Out-Null
|
||||||
|
try { Invoke-RestMethod -Method Delete "http://$rHost/api/workers/$wName" -TimeoutSec 5 | Out-Null } catch {}
|
||||||
|
Set-Location (Split-Path `$PSScriptRoot -Parent)
|
||||||
|
Remove-Item -Recurse -Force `$PSScriptRoot
|
||||||
|
[System.Windows.Forms.MessageBox]::Show("Rippy-Worker deinstalliert.","Rippy")
|
||||||
|
"@
|
||||||
|
Set-Content -Path "uninstall.ps1" -Value $uninstall -Encoding ASCII
|
||||||
|
|
||||||
|
# 6. Autostart
|
||||||
|
if ($chkAuto.Checked) {
|
||||||
|
schtasks /create /f /tn "RippyWorker" /tr "`"$InstallDir\start-tray.bat`"" /sc onlogon | Out-Null
|
||||||
|
Log "Autostart eingerichtet (Start bei Anmeldung)."
|
||||||
|
}
|
||||||
|
|
||||||
|
Log ""
|
||||||
|
Log "FERTIG! Ordner: $InstallDir"
|
||||||
|
Log "Klick 'Worker starten' - er erscheint dann in Rippy unter Einstellungen -> Worker."
|
||||||
|
$btnStart.Visible = $true
|
||||||
|
$btnInstall.Text = "Neu installieren"
|
||||||
|
$btnInstall.Enabled = $true
|
||||||
|
} catch {
|
||||||
|
Log ""
|
||||||
|
Log "FEHLER: $_"
|
||||||
|
Log "Tipp: Ist die IP korrekt und die Rippy-Maschine im Heimnetz erreichbar?"
|
||||||
|
$btnInstall.Enabled = $true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$btnInstall.Add_Click({ Do-Install })
|
||||||
|
$btnStart.Add_Click({
|
||||||
|
Start-Process -FilePath (Join-Path $InstallDir "start-tray.bat") -WindowStyle Hidden
|
||||||
|
[System.Windows.Forms.MessageBox]::Show("Worker gestartet - Symbol erscheint unten rechts neben der Uhr.", "Rippy")
|
||||||
|
$form.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
[void]$form.ShowDialog()
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
# Rippy: Nativer Windows-Transcode-Worker — Installation OHNE Docker.
|
# Rippy: Nativer Windows-Transcode-Worker - Installation OHNE Docker.
|
||||||
#
|
#
|
||||||
# Was das Skript tut (alles in einen Unterordner "rippy-worker"):
|
# Was das Skript tut (alles in einen Unterordner "rippy-worker"):
|
||||||
# 1. Prueft Python (3.10+), legt ein venv an, installiert die Abhaengigkeiten
|
# 1. Prueft Python (3.10+), legt ein venv an, installiert die Abhaengigkeiten
|
||||||
# 2. Laedt den Worker-Code direkt von deiner Rippy-Instanz (/api/worker-setup/paket)
|
# 2. Laedt den Worker-Code direkt von deiner Rippy-Instanz (/api/worker-setup/paket)
|
||||||
# 3. Laedt HandBrakeCLI (gepinnte Version, offizielles GitHub-Release),
|
# 3. Laedt HandBrakeCLI (gepinnte Version, offizielles GitHub-Release),
|
||||||
# falls nicht schon im PATH oder im Ordner vorhanden
|
# falls nicht schon im PATH oder im Ordner vorhanden
|
||||||
# 4. Erzeugt start-worker.bat — Doppelklick startet den Worker
|
# 4. Erzeugt start-worker.bat - Doppelklick startet den Worker
|
||||||
#
|
#
|
||||||
# Der Worker bedient NUR die Kompressions-Queue (transcode) — gerippt wird
|
# Der Worker bedient NUR die Kompressions-Queue (transcode) - gerippt wird
|
||||||
# weiterhin auf der Rippy-Hauptmaschine (dort haengt das Laufwerk).
|
# weiterhin auf der Rippy-Hauptmaschine (dort haengt das Laufwerk).
|
||||||
#
|
#
|
||||||
# Aufruf (PowerShell):
|
# Aufruf (PowerShell):
|
||||||
@@ -22,7 +22,17 @@ param(
|
|||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
$HandBrakeVersion = "1.9.2"
|
# HandBrake-Version: die NEUESTE offizielle (GitHub-Release) - so passt der
|
||||||
|
# Windows-Worker zu dem, was der Update-Check im UI meldet (frueher war hier
|
||||||
|
# eine feste 1.9.2 hardcodiert, was verwirrte). Faellt auf eine bekannte
|
||||||
|
# Version zurueck, falls die GitHub-API gerade nicht erreichbar ist.
|
||||||
|
$HandBrakeVersion = "1.11.2"
|
||||||
|
try {
|
||||||
|
$rel = Invoke-RestMethod "https://api.github.com/repos/HandBrake/HandBrake/releases/latest" `
|
||||||
|
-TimeoutSec 15 -Headers @{ "User-Agent" = "rippy-installer" }
|
||||||
|
if ($rel.tag_name) { $HandBrakeVersion = ([string]$rel.tag_name).TrimStart("v") }
|
||||||
|
Write-Host "Neueste HandBrake-Version: $HandBrakeVersion"
|
||||||
|
} catch { Write-Host "GitHub-API nicht erreichbar - nutze HandBrake $HandBrakeVersion" }
|
||||||
$HandBrakeUrl = "https://github.com/HandBrake/HandBrake/releases/download/$HandBrakeVersion/HandBrakeCLI-$HandBrakeVersion-win-x86_64.zip"
|
$HandBrakeUrl = "https://github.com/HandBrake/HandBrake/releases/download/$HandBrakeVersion/HandBrakeCLI-$HandBrakeVersion-win-x86_64.zip"
|
||||||
|
|
||||||
Write-Host "== Rippy Windows-Worker Installation ==" -ForegroundColor Cyan
|
Write-Host "== Rippy Windows-Worker Installation ==" -ForegroundColor Cyan
|
||||||
@@ -82,7 +92,7 @@ Write-Host "HandBrakeCLI bereit."
|
|||||||
# 5. Start-Skript erzeugen
|
# 5. Start-Skript erzeugen
|
||||||
$bat = @"
|
$bat = @"
|
||||||
@echo off
|
@echo off
|
||||||
rem Rippy Windows-Transcode-Worker — verbindet sich mit $RippyHost
|
rem Rippy Windows-Transcode-Worker - verbindet sich mit $RippyHost
|
||||||
cd /d "%~dp0"
|
cd /d "%~dp0"
|
||||||
set REDIS_URL=redis://${RippyHost}:6379/0
|
set REDIS_URL=redis://${RippyHost}:6379/0
|
||||||
set DATABASE_URL=postgresql://rippy:rippy@${RippyHost}:5432/rippy
|
set DATABASE_URL=postgresql://rippy:rippy@${RippyHost}:5432/rippy
|
||||||
@@ -96,7 +106,7 @@ Set-Content -Path "start-worker.bat" -Value $bat -Encoding ASCII
|
|||||||
# 6. Tray-Start (empfohlen): Symbol neben der Uhr, kein Konsolenfenster
|
# 6. Tray-Start (empfohlen): Symbol neben der Uhr, kein Konsolenfenster
|
||||||
$trayBat = @"
|
$trayBat = @"
|
||||||
@echo off
|
@echo off
|
||||||
rem Rippy-Worker mit Tray-Symbol — verbindet sich mit $RippyHost
|
rem Rippy-Worker mit Tray-Symbol - verbindet sich mit $RippyHost
|
||||||
cd /d "%~dp0"
|
cd /d "%~dp0"
|
||||||
set REDIS_URL=redis://${RippyHost}:6379/0
|
set REDIS_URL=redis://${RippyHost}:6379/0
|
||||||
set DATABASE_URL=postgresql://rippy:rippy@${RippyHost}:5432/rippy
|
set DATABASE_URL=postgresql://rippy:rippy@${RippyHost}:5432/rippy
|
||||||
@@ -109,7 +119,7 @@ start "" venv\Scripts\pythonw.exe tray.py
|
|||||||
Set-Content -Path "start-tray.bat" -Value $trayBat -Encoding ASCII
|
Set-Content -Path "start-tray.bat" -Value $trayBat -Encoding ASCII
|
||||||
Write-Host "start-worker.bat + start-tray.bat erzeugt."
|
Write-Host "start-worker.bat + start-tray.bat erzeugt."
|
||||||
|
|
||||||
# 7. Deinstaller — MUSS-Kriterium: rueckstandsfrei entfernbar
|
# 7. Deinstaller - MUSS-Kriterium: rueckstandsfrei entfernbar
|
||||||
$uninstall = @"
|
$uninstall = @"
|
||||||
# Rippy-Worker DEINSTALLIEREN: stoppt Prozesse, entfernt Autostart,
|
# Rippy-Worker DEINSTALLIEREN: stoppt Prozesse, entfernt Autostart,
|
||||||
# meldet den Worker in Rippy ab und loescht diesen Ordner komplett.
|
# meldet den Worker in Rippy ab und loescht diesen Ordner komplett.
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
@@ -21,6 +21,10 @@ COPY docker/api/ .
|
|||||||
# native Worker aus (GET /worker-setup/windows bzw. /worker-setup/paket) —
|
# native Worker aus (GET /worker-setup/windows bzw. /worker-setup/paket) —
|
||||||
# die Zielmaschine braucht weder git noch Docker.
|
# die Zielmaschine braucht weder git noch Docker.
|
||||||
COPY docker/worker/*.py docker/worker/requirements.txt worker_dist/
|
COPY docker/worker/*.py docker/worker/requirements.txt worker_dist/
|
||||||
COPY deploy/worker-windows/install.ps1 worker_dist/
|
COPY deploy/worker-windows/install.ps1 deploy/worker-windows/install-gui.ps1 worker_dist/
|
||||||
|
# Vorgebaute .exe (Rippy-Icon, kein Konsolenfenster) — auf Windows via
|
||||||
|
# deploy/worker-windows/build-exe.ps1 erzeugt (Windows-.exe geht nicht von
|
||||||
|
# Linux). Nur bei GUI-Aenderungen neu bauen, nicht bei Tool-Updates.
|
||||||
|
COPY deploy/worker-windows/RippyWorkerSetup.exe worker_dist/
|
||||||
|
|
||||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--app-dir", "/app"]
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--app-dir", "/app"]
|
||||||
|
|||||||
@@ -7,12 +7,33 @@ kein POST /jobs, kein udev-Daemon. Dieser Client schließt die Lücke.
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from celery import Celery
|
from celery import Celery
|
||||||
|
from celery.utils import worker_direct
|
||||||
|
|
||||||
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
||||||
|
|
||||||
celery_client = Celery("rippy_api", broker=REDIS_URL, backend=REDIS_URL)
|
celery_client = Celery("rippy_api", broker=REDIS_URL, backend=REDIS_URL)
|
||||||
|
|
||||||
|
|
||||||
|
def transcode_queue(node: str = None):
|
||||||
|
"""Ziel-Queue für die Kompression: der GEWÄHLTE Worker (worker_direct)
|
||||||
|
wenn er gerade online ist, sonst die geteilte transcode-Queue.
|
||||||
|
|
||||||
|
So kann ein Job gezielt einen Encoder ansprechen — fällt der Worker aber
|
||||||
|
weg, bleibt die Kompression nicht in einer toten Queue hängen, sondern
|
||||||
|
landet bei irgendeinem freien Worker.
|
||||||
|
"""
|
||||||
|
if not node:
|
||||||
|
return "transcode"
|
||||||
|
try:
|
||||||
|
antworten = celery_client.control.ping(timeout=1.0) or []
|
||||||
|
online = {k for antwort in antworten for k in antwort.keys()}
|
||||||
|
if node in online:
|
||||||
|
return worker_direct(node)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return "transcode"
|
||||||
|
|
||||||
|
|
||||||
def start_rip(device_path: str, job_id: str, target_dir: str = None):
|
def start_rip(device_path: str, job_id: str, target_dir: str = None):
|
||||||
"""Schickt den Rip-Task an den Worker (Task-Name aus worker/tasks.py)."""
|
"""Schickt den Rip-Task an den Worker (Task-Name aus worker/tasks.py)."""
|
||||||
return celery_client.send_task(
|
return celery_client.send_task(
|
||||||
|
|||||||
+141
-28
@@ -12,6 +12,7 @@ import uuid
|
|||||||
|
|
||||||
import db
|
import db
|
||||||
import devices as device_discovery
|
import devices as device_discovery
|
||||||
|
import makemkv_key
|
||||||
import mounts as mount_verwaltung
|
import mounts as mount_verwaltung
|
||||||
import notify
|
import notify
|
||||||
from celery_client import celery_client, start_rip
|
from celery_client import celery_client, start_rip
|
||||||
@@ -46,13 +47,22 @@ async def startup_event():
|
|||||||
except ConfigValidationError as e:
|
except ConfigValidationError as e:
|
||||||
print(f"⚠️ Konfigurations-Warnung: {e}")
|
print(f"⚠️ Konfigurations-Warnung: {e}")
|
||||||
|
|
||||||
# Gespeicherte Netzwerk-Speicherziele wiederherstellen
|
# Gespeicherte Netzwerk-Speicherziele wiederherstellen — NICHT-BLOCKIEREND.
|
||||||
|
# Ein zickiger/langsamer Netz-Mount (der CIFS-Schreibtest in mounten() kann im
|
||||||
|
# Kernel haengen, wait_for_response) darf den API-Start NIE blockieren. Vorfall
|
||||||
|
# 24.07.: ~5 min "Waiting for application startup", kein Endpoint bedient, bis der
|
||||||
|
# soft-Mount per Timeout abbrach. Darum im Hintergrund: die Mounts stellen sich
|
||||||
|
# her, sobald der Server antwortet, ohne die API auszubremsen.
|
||||||
def remount():
|
def remount():
|
||||||
for meldung in mount_verwaltung.alle_remounten():
|
for meldung in mount_verwaltung.alle_remounten():
|
||||||
db.add_log("info", "mounts", meldung)
|
db.add_log("info", "mounts", meldung)
|
||||||
await asyncio.to_thread(remount)
|
asyncio.create_task(asyncio.to_thread(remount))
|
||||||
|
|
||||||
asyncio.create_task(disc_watcher())
|
asyncio.create_task(disc_watcher())
|
||||||
|
# MakeMKV-Beta-Key automatisch aktuell halten (wechselt ~monatlich, laeuft zum
|
||||||
|
# Monatsende ab) — sonst blockt Blu-ray-Ripping irgendwann still. Taeglicher
|
||||||
|
# Forum-Abgleich; wirkt ohne Rebuild ab dem naechsten Rip.
|
||||||
|
asyncio.create_task(makemkv_key.refresh_loop())
|
||||||
|
|
||||||
|
|
||||||
# Auto-Pre-Scan-Ergebnisse je Laufwerk: das Dashboard zeigt damit sofort,
|
# Auto-Pre-Scan-Ergebnisse je Laufwerk: das Dashboard zeigt damit sofort,
|
||||||
@@ -338,6 +348,7 @@ class JobCreateRequest(BaseModel):
|
|||||||
season: Optional[int] = None
|
season: Optional[int] = None
|
||||||
main_feature_only: Optional[bool] = None # pro Rip; None = Setting gilt
|
main_feature_only: Optional[bool] = None # pro Rip; None = Setting gilt
|
||||||
titles: Optional[List[int]] = None # exakte Titel-Auswahl (Track-Tabelle)
|
titles: Optional[List[int]] = None # exakte Titel-Auswahl (Track-Tabelle)
|
||||||
|
transcode_node: Optional[str] = None # gewählter Encoder-Worker (Celery-Node)
|
||||||
|
|
||||||
|
|
||||||
MEDIA_ROOT = "/app/media"
|
MEDIA_ROOT = "/app/media"
|
||||||
@@ -395,6 +406,8 @@ async def create_job(request: JobCreateRequest):
|
|||||||
titel_liste = sorted({int(t) for t in request.titles if int(t) >= 0})[:200]
|
titel_liste = sorted({int(t) for t in request.titles if int(t) >= 0})[:200]
|
||||||
if titel_liste:
|
if titel_liste:
|
||||||
meta_dict["titles"] = titel_liste
|
meta_dict["titles"] = titel_liste
|
||||||
|
if request.transcode_node:
|
||||||
|
meta_dict["transcode_node"] = request.transcode_node
|
||||||
meta_json = json.dumps(meta_dict) if meta_dict else None
|
meta_json = json.dumps(meta_dict) if meta_dict else None
|
||||||
|
|
||||||
job_id = str(uuid.uuid4())
|
job_id = str(uuid.uuid4())
|
||||||
@@ -511,7 +524,15 @@ async def storage_targets():
|
|||||||
return ziele
|
return ziele
|
||||||
for name in eintraege:
|
for name in eintraege:
|
||||||
pfad = os.path.join(MEDIA_ROOT, name)
|
pfad = os.path.join(MEDIA_ROOT, name)
|
||||||
if not os.path.isdir(pfad):
|
try:
|
||||||
|
ist_mount = os.path.ismount(pfad)
|
||||||
|
except OSError:
|
||||||
|
# Toter CIFS-Mount (NAS weg) — als Ziel unbrauchbar, aber NICHT
|
||||||
|
# den ganzen Endpoint sprengen (Befund 24.07.: Eintrag flog raus,
|
||||||
|
# ließ sich aber nicht mehr neu anlegen). Überspringen ist ok,
|
||||||
|
# die Mount-Verwaltung (/storage-mounts) zeigt ihn zum Reparieren.
|
||||||
|
continue
|
||||||
|
if not ist_mount and not os.path.isdir(pfad):
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
nutzung = shutil.disk_usage(pfad)
|
nutzung = shutil.disk_usage(pfad)
|
||||||
@@ -521,7 +542,7 @@ async def storage_targets():
|
|||||||
ziele.append({
|
ziele.append({
|
||||||
"name": name,
|
"name": name,
|
||||||
"path": pfad,
|
"path": pfad,
|
||||||
"is_mount": os.path.ismount(pfad),
|
"is_mount": ist_mount,
|
||||||
"free_gb": frei_gb,
|
"free_gb": frei_gb,
|
||||||
})
|
})
|
||||||
return ziele
|
return ziele
|
||||||
@@ -599,10 +620,16 @@ async def retry_transcode(job_id: str):
|
|||||||
basis = job.get("target_dir") or f"{MEDIA_ROOT}/{job.get('disc_type') or 'bluray'}"
|
basis = job.get("target_dir") or f"{MEDIA_ROOT}/{job.get('disc_type') or 'bluray'}"
|
||||||
final_dir = job.get("output_path") or f"{basis}/{job_id}"
|
final_dir = job.get("output_path") or f"{basis}/{job_id}"
|
||||||
|
|
||||||
|
# An den (beim Rip gewählten) Encoder-Worker routen, sonst geteilte Queue
|
||||||
|
try:
|
||||||
|
meta = json.loads(job.get("meta") or "{}")
|
||||||
|
except ValueError:
|
||||||
|
meta = {}
|
||||||
|
from celery_client import transcode_queue
|
||||||
celery_client.send_task(
|
celery_client.send_task(
|
||||||
"worker.tasks.transcode_files",
|
"worker.tasks.transcode_files",
|
||||||
args=[job_id, raw_dir, final_dir],
|
args=[job_id, raw_dir, final_dir],
|
||||||
queue="transcode",
|
queue=transcode_queue(meta.get("transcode_node")),
|
||||||
)
|
)
|
||||||
await asyncio.to_thread(db.update_job, job_id, status="transcoding", progress=0, error=None)
|
await asyncio.to_thread(db.update_job, job_id, status="transcoding", progress=0, error=None)
|
||||||
await asyncio.to_thread(db.add_log, "info", "api", f"Job {job_id}: Kompression neu eingereiht")
|
await asyncio.to_thread(db.add_log, "info", "api", f"Job {job_id}: Kompression neu eingereiht")
|
||||||
@@ -639,18 +666,23 @@ async def capabilities():
|
|||||||
zeilen = db.list_workers()
|
zeilen = db.list_workers()
|
||||||
try:
|
try:
|
||||||
antworten = celery_client.control.ping(timeout=1.0) or []
|
antworten = celery_client.control.ping(timeout=1.0) or []
|
||||||
online_namen = {
|
ping_knoten = [k for antwort in antworten for k in antwort.keys()]
|
||||||
knoten.split("@", 1)[-1]
|
|
||||||
for antwort in antworten
|
|
||||||
for knoten in antwort.keys()
|
|
||||||
}
|
|
||||||
except Exception:
|
except Exception:
|
||||||
online_namen = set()
|
ping_knoten = []
|
||||||
for zeile in zeilen:
|
for zeile in zeilen:
|
||||||
# Celery-Ping meldet den HOSTNAME des Knotens — bei gesetztem
|
# Celery-Knotenname = <name>@<hostname>. Der Ping liefert ihn voll;
|
||||||
# WORKER_NAME (Anzeigename) steckt der echte Hostname in info.
|
# gematcht wird über den Hostname (der Docker-Worker heißt celery@…,
|
||||||
|
# der WORKER_NAME steckt nur in info). Laufen MEHRERE Worker auf
|
||||||
|
# demselben Host (Befund 24.07.), disambiguiert der Name-Teil —
|
||||||
|
# sonst bekäme der falsche Worker den Node. `node` ist der
|
||||||
|
# Routing-Ziel-Knoten für die gezielte Encoder-Wahl.
|
||||||
hostname = (zeile.get("info") or {}).get("hostname") or zeile["name"]
|
hostname = (zeile.get("info") or {}).get("hostname") or zeile["name"]
|
||||||
zeile["online"] = hostname in online_namen
|
kandidaten = [k for k in ping_knoten if k.split("@", 1)[-1] == hostname]
|
||||||
|
node = next(
|
||||||
|
(k for k in kandidaten if k.split("@", 1)[0] == zeile["name"]), None
|
||||||
|
) or (kandidaten[0] if kandidaten else None)
|
||||||
|
zeile["online"] = node is not None
|
||||||
|
zeile["node"] = node
|
||||||
return zeilen
|
return zeilen
|
||||||
|
|
||||||
return {"workers": await asyncio.to_thread(sammle)}
|
return {"workers": await asyncio.to_thread(sammle)}
|
||||||
@@ -806,39 +838,67 @@ class MountRequest(BaseModel):
|
|||||||
|
|
||||||
@app.get("/storage-mounts")
|
@app.get("/storage-mounts")
|
||||||
async def get_storage_mounts():
|
async def get_storage_mounts():
|
||||||
"""Konfigurierte Netzwerk-Speicherziele inkl. Live-Mount-Status."""
|
"""Konfigurierte Netzwerk-Speicherziele inkl. Live-Status.
|
||||||
eintraege = await asyncio.to_thread(db.list_mounts)
|
|
||||||
return [
|
`mounted` = liegt ein Mount an; `reachable` = ist er auch WIRKLICH nutzbar
|
||||||
{
|
(toter CIFS-Mount nach NAS-Ausfall: mounted=true, reachable=false → das UI
|
||||||
|
bietet dann „Reparieren" statt den Eintrag verschwinden zu lassen)."""
|
||||||
|
def sammle():
|
||||||
|
eintraege = db.list_mounts()
|
||||||
|
ergebnis = []
|
||||||
|
for e in eintraege:
|
||||||
|
gemountet = mount_verwaltung.ist_gemountet(e["name"])
|
||||||
|
ergebnis.append({
|
||||||
"name": e["name"],
|
"name": e["name"],
|
||||||
"type": e["typ"],
|
"type": e["typ"],
|
||||||
"source": e["quelle"],
|
"source": e["quelle"],
|
||||||
"mounted": mount_verwaltung.ist_gemountet(e["name"]),
|
"mounted": gemountet,
|
||||||
|
"reachable": mount_verwaltung.ist_erreichbar(e["name"]) if gemountet else False,
|
||||||
"has_credentials": bool(e.get("username")),
|
"has_credentials": bool(e.get("username")),
|
||||||
}
|
})
|
||||||
for e in eintraege
|
return ergebnis
|
||||||
]
|
|
||||||
|
return await asyncio.to_thread(sammle)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/storage-mounts", status_code=201)
|
@app.post("/storage-mounts", status_code=201)
|
||||||
async def create_storage_mount(request: MountRequest):
|
async def create_storage_mount(request: MountRequest):
|
||||||
"""Hängt ein NFS/SMB-Ziel ein und speichert es für den nächsten Start."""
|
"""Hängt ein NFS/SMB-Ziel ein und speichert es für den nächsten Start.
|
||||||
|
|
||||||
|
Existiert der Name schon UND ist erreichbar → 409. Existiert er, ist aber
|
||||||
|
TOT (NAS war weg) → wird mit den neuen Angaben frisch repariert statt
|
||||||
|
stur „Name bereits vergeben" zu melden (Befund 24.07.: man saß sonst fest)."""
|
||||||
if not mount_verwaltung.validiere_name(request.name):
|
if not mount_verwaltung.validiere_name(request.name):
|
||||||
raise HTTPException(status_code=422, detail="Name: nur a-z, 0-9, Bindestrich (2-31 Zeichen)")
|
raise HTTPException(status_code=422, detail="Name: nur a-z, 0-9, Bindestrich (2-31 Zeichen)")
|
||||||
if request.type not in ("nfs", "cifs"):
|
if request.type not in ("nfs", "cifs"):
|
||||||
raise HTTPException(status_code=422, detail="Typ muss nfs oder cifs sein")
|
raise HTTPException(status_code=422, detail="Typ muss nfs oder cifs sein")
|
||||||
if any(e["name"] == request.name for e in await asyncio.to_thread(db.list_mounts)):
|
|
||||||
raise HTTPException(status_code=409, detail="Name bereits vergeben")
|
vorhanden = any(e["name"] == request.name for e in await asyncio.to_thread(db.list_mounts))
|
||||||
|
if vorhanden:
|
||||||
|
gemountet = await asyncio.to_thread(mount_verwaltung.ist_gemountet, request.name)
|
||||||
|
erreichbar = gemountet and await asyncio.to_thread(mount_verwaltung.ist_erreichbar, request.name)
|
||||||
|
if erreichbar:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail="Name bereits vergeben und aktiv — erst entfernen, dann neu anlegen.",
|
||||||
|
)
|
||||||
|
# Toter/veralteter Eintrag → reparieren (lazy abhängen + frisch mounten)
|
||||||
|
aktion = mount_verwaltung.reparieren
|
||||||
|
else:
|
||||||
|
aktion = mount_verwaltung.mounten
|
||||||
|
|
||||||
try:
|
try:
|
||||||
schreibbar = await asyncio.to_thread(
|
schreibbar = await asyncio.to_thread(
|
||||||
mount_verwaltung.mounten,
|
aktion,
|
||||||
request.name, request.type, request.source,
|
request.name, request.type, request.source,
|
||||||
request.options or "", request.username or "", request.password or "",
|
request.options or "", request.username or "", request.password or "",
|
||||||
)
|
)
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
# Bei Reparatur die (evtl. neuen) Zugangsdaten in der DB aktualisieren
|
||||||
|
if vorhanden:
|
||||||
|
await asyncio.to_thread(db.delete_mount, request.name)
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
db.save_mount,
|
db.save_mount,
|
||||||
request.name, request.type, request.source,
|
request.name, request.type, request.source,
|
||||||
@@ -847,10 +907,37 @@ async def create_storage_mount(request: MountRequest):
|
|||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
db.add_log,
|
db.add_log,
|
||||||
"success" if schreibbar else "warning", "mounts",
|
"success" if schreibbar else "warning", "mounts",
|
||||||
f"Speicherziel '{request.name}' ({request.type}) eingehängt: {request.source}"
|
f"Speicherziel '{request.name}' ({request.type}) "
|
||||||
|
+ ("repariert" if vorhanden else "eingehängt") + f": {request.source}"
|
||||||
+ ("" if schreibbar else " — ACHTUNG: NUR LESBAR (Schreibtest fehlgeschlagen)"),
|
+ ("" if schreibbar else " — ACHTUNG: NUR LESBAR (Schreibtest fehlgeschlagen)"),
|
||||||
)
|
)
|
||||||
return {"name": request.name, "mounted": True, "writable": schreibbar}
|
return {"name": request.name, "mounted": True, "writable": schreibbar, "repaired": vorhanden}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/storage-mounts/{name}/repair")
|
||||||
|
async def repair_storage_mount(name: str):
|
||||||
|
"""Toten/veralteten Mount mit den GESPEICHERTEN Zugangsdaten neu verbinden
|
||||||
|
(Reparieren-Knopf im UI) — ohne dass der Nutzer alles neu eintippt."""
|
||||||
|
eintrag = next(
|
||||||
|
(e for e in await asyncio.to_thread(db.list_mounts) if e["name"] == name), None
|
||||||
|
)
|
||||||
|
if not eintrag:
|
||||||
|
raise HTTPException(status_code=404, detail="Speicherziel nicht gefunden")
|
||||||
|
try:
|
||||||
|
schreibbar = await asyncio.to_thread(
|
||||||
|
mount_verwaltung.reparieren,
|
||||||
|
eintrag["name"], eintrag["typ"], eintrag["quelle"],
|
||||||
|
eintrag.get("optionen") or "", eintrag.get("username") or "",
|
||||||
|
eintrag.get("passwort") or "",
|
||||||
|
)
|
||||||
|
except RuntimeError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
await asyncio.to_thread(
|
||||||
|
db.add_log, "success" if schreibbar else "warning", "mounts",
|
||||||
|
f"Speicherziel '{name}' neu verbunden"
|
||||||
|
+ ("" if schreibbar else " — NUR LESBAR"),
|
||||||
|
)
|
||||||
|
return {"name": name, "mounted": True, "writable": schreibbar}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/storage-mounts/shares")
|
@app.get("/storage-mounts/shares")
|
||||||
@@ -1012,6 +1099,32 @@ async def worker_setup_windows():
|
|||||||
return FileResponse(pfad, media_type="text/plain", filename="install-rippy-worker.ps1")
|
return FileResponse(pfad, media_type="text/plain", filename="install-rippy-worker.ps1")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/worker-setup/windows-gui")
|
||||||
|
async def worker_setup_windows_gui():
|
||||||
|
"""Grafischer Windows-Installer (WinForms, PowerShell-Quelle) — Rückfall
|
||||||
|
für Fortgeschrittene; der Normalweg ist die .exe unten."""
|
||||||
|
pfad = "worker_dist/install-gui.ps1"
|
||||||
|
if not os.path.isfile(pfad):
|
||||||
|
raise HTTPException(status_code=404, detail="GUI-Installer nicht im Image — API neu bauen")
|
||||||
|
return FileResponse(pfad, media_type="text/plain", filename="rippy-worker-gui.ps1")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/worker-setup/windows-exe")
|
||||||
|
async def worker_setup_windows_exe():
|
||||||
|
"""Fertige Windows-Installer-.exe (Rippy-Icon, kein Konsolenfenster).
|
||||||
|
|
||||||
|
Vorgebaut auf Windows (deploy/worker-windows/build-exe.ps1) — eine
|
||||||
|
Windows-.exe lässt sich nicht auf Linux bauen. Generisch: die GUI fragt
|
||||||
|
die Rippy-Adresse selbst ab, HandBrake + Worker-Code kommen zur Laufzeit."""
|
||||||
|
pfad = "worker_dist/RippyWorkerSetup.exe"
|
||||||
|
if not os.path.isfile(pfad):
|
||||||
|
raise HTTPException(status_code=404, detail="Installer-.exe nicht im Image — API neu bauen")
|
||||||
|
return FileResponse(
|
||||||
|
pfad, media_type="application/vnd.microsoft.portable-executable",
|
||||||
|
filename="RippyWorkerSetup.exe",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/worker-setup/paket")
|
@app.get("/worker-setup/paket")
|
||||||
async def worker_setup_paket():
|
async def worker_setup_paket():
|
||||||
"""Worker-Quellcode als Zip — der Windows-Installer lädt ihn von hier.
|
"""Worker-Quellcode als Zip — der Windows-Installer lädt ihn von hier.
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""Automatische Erneuerung des MakeMKV-Beta-Keys.
|
||||||
|
|
||||||
|
Der kostenlose MakeMKV-Beta-Key (Forum-Thread "MakeMKV is free while in beta",
|
||||||
|
t=1053) wechselt etwa monatlich und laeuft zum Monatsende ab. Ohne gueltigen Key
|
||||||
|
faellt Blu-ray/UHD-Ripping in den 30-Tage-Testmodus; DVD-Ripping bleibt frei.
|
||||||
|
Bisher musste der Key von Hand nachgetragen werden (UI: Einstellungen -> System).
|
||||||
|
|
||||||
|
Dieses Modul holt den aktuellen Key vom Forum und schreibt ihn in die Settings
|
||||||
|
(makemkvAppKey). Der Worker liest den Key VOR jedem Rip aus den Settings
|
||||||
|
(docker/worker/tasks.py) und schreibt settings.conf -> die Erneuerung wirkt ohne
|
||||||
|
Rebuild/Neustart, ab dem naechsten Rip.
|
||||||
|
|
||||||
|
Wichtig: Das ist die kostenlose, oeffentliche Beta-LIZENZ der Software selbst —
|
||||||
|
NICHT das Entschluesseln oder Verteilen von Disc-Schluesseln. Den AACS-Schluessel
|
||||||
|
zieht MakeMKV via LibreDrive ohnehin selbst aus dem Laufwerk.
|
||||||
|
|
||||||
|
Quelle/Format dokumentiert (AGENTS Regel D — nicht geraten):
|
||||||
|
- Forum-Thread: https://forum.makemkv.com/forum/viewtopic.php?t=1053
|
||||||
|
- Key-Format: "T-" + langer String aus [A-Za-z0-9_] (typ. ~66 Zeichen; die exakte
|
||||||
|
Laenge variiert je Key, darum kein fixes Limit, sondern mind. 50, greedy).
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import re
|
||||||
|
|
||||||
|
FORUM_URL = "https://forum.makemkv.com/forum/viewtopic.php?t=1053"
|
||||||
|
KEY_RE = re.compile(r"T-[A-Za-z0-9_]{50,}")
|
||||||
|
_HEADERS = {"User-Agent": "rippy-makemkv-key-refresh/1.0"}
|
||||||
|
|
||||||
|
|
||||||
|
def extract_key(html: str) -> str | None:
|
||||||
|
"""Zieht den Beta-Key ("T-...") aus dem Forum-HTML. Pure Funktion -> testbar."""
|
||||||
|
treffer = KEY_RE.search(html or "")
|
||||||
|
return treffer.group(0) if treffer else None
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_current_key(timeout: int = 15) -> str | None:
|
||||||
|
"""Holt den aktuellen Beta-Key vom MakeMKV-Forum. None bei Fehler/kein Treffer."""
|
||||||
|
import requests # lazy: haelt extract_key + den Test dependency-frei
|
||||||
|
antwort = requests.get(FORUM_URL, headers=_HEADERS, timeout=timeout)
|
||||||
|
antwort.raise_for_status()
|
||||||
|
return extract_key(antwort.text)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_key(key: str) -> bool:
|
||||||
|
"""Merge den Key in die Settings. save_settings ueberschreibt das GANZE JSON,
|
||||||
|
darum erst lesen, dann setzen. Rueckgabe True, wenn sich der Key geaendert hat."""
|
||||||
|
import db
|
||||||
|
einstellungen = db.get_settings()
|
||||||
|
if (einstellungen.get("makemkvAppKey") or "").strip() == key:
|
||||||
|
return False
|
||||||
|
einstellungen["makemkvAppKey"] = key
|
||||||
|
db.save_settings(einstellungen)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def refresh_once() -> bool:
|
||||||
|
"""Einmal holen + anwenden. Best-effort, loggt in die App-Logs. True = geaendert."""
|
||||||
|
import db
|
||||||
|
key = await asyncio.to_thread(fetch_current_key)
|
||||||
|
if not key:
|
||||||
|
db.add_log("warning", "makemkv-key",
|
||||||
|
"Aktueller Beta-Key nicht im Forum gefunden (Format geaendert?).")
|
||||||
|
return False
|
||||||
|
geaendert = await asyncio.to_thread(_apply_key, key)
|
||||||
|
if geaendert:
|
||||||
|
db.add_log("success", "makemkv-key",
|
||||||
|
f"MakeMKV-Beta-Key automatisch erneuert (…{key[-6:]}).")
|
||||||
|
return geaendert
|
||||||
|
|
||||||
|
|
||||||
|
async def refresh_loop(intervall_stunden: int = 24, start_verzoegerung_s: int = 60) -> None:
|
||||||
|
"""Periodische Erneuerung (Default: taeglich, damit ein Monatswechsel nie verpasst
|
||||||
|
wird). Fehler werden geloggt, aber nie geworfen -> die API laeuft weiter."""
|
||||||
|
import db
|
||||||
|
await asyncio.sleep(start_verzoegerung_s)
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await refresh_once()
|
||||||
|
except Exception as exc:
|
||||||
|
db.add_log("warning", "makemkv-key", f"Key-Refresh fehlgeschlagen: {exc!r}")
|
||||||
|
await asyncio.sleep(max(3600, intervall_stunden * 3600))
|
||||||
+62
-4
@@ -30,7 +30,31 @@ def validiere_name(name: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def ist_gemountet(name: str) -> bool:
|
def ist_gemountet(name: str) -> bool:
|
||||||
|
"""Ist unter /app/media/<name> etwas gemountet? Ein TOTER CIFS-Mount
|
||||||
|
(NAS weg) lässt os.path.ismount mit OSError fliegen — das zählt weiter
|
||||||
|
als 'gemountet' (nur eben kaputt), damit die Reparatur greift."""
|
||||||
|
try:
|
||||||
return os.path.ismount(_mountpoint(name))
|
return os.path.ismount(_mountpoint(name))
|
||||||
|
except OSError:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def ist_erreichbar(name: str) -> bool:
|
||||||
|
"""Kann auf das Ziel WIRKLICH zugegriffen werden?
|
||||||
|
|
||||||
|
Ein toter CIFS-Mount (Host weg, stale) ist zwar 'gemountet', aber jeder
|
||||||
|
Zugriff scheitert. Der Zugriff wird HART auf 3 s begrenzt (`timeout 3 ls`)
|
||||||
|
— ein direktes os.listdir blockierte sonst bis zum CIFS-Timeout (~10 s,
|
||||||
|
Befund 24.07.: die Speicherziele-Seite lud dadurch träge).
|
||||||
|
"""
|
||||||
|
ziel = _mountpoint(name)
|
||||||
|
try:
|
||||||
|
ergebnis = subprocess.run(
|
||||||
|
["timeout", "3", "ls", ziel], capture_output=True, timeout=5
|
||||||
|
)
|
||||||
|
return ergebnis.returncode == 0
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def schreibtest(pfad: str) -> bool:
|
def schreibtest(pfad: str) -> bool:
|
||||||
@@ -121,9 +145,17 @@ def mounten(name: str, typ: str, quelle: str, optionen: str = "",
|
|||||||
Mount-Fehlern.
|
Mount-Fehlern.
|
||||||
"""
|
"""
|
||||||
ziel = _mountpoint(name)
|
ziel = _mountpoint(name)
|
||||||
|
# exist_ok fängt „Ordner da" NICHT, wenn ein toter Mount os.path.isdir
|
||||||
|
# täuschen lässt (FileExistsError, Befund 24.07.) — deshalb tolerant.
|
||||||
|
try:
|
||||||
os.makedirs(ziel, exist_ok=True)
|
os.makedirs(ziel, exist_ok=True)
|
||||||
|
except FileExistsError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
if os.path.ismount(ziel):
|
if os.path.ismount(ziel):
|
||||||
return schreibtest(ziel)
|
return schreibtest(ziel)
|
||||||
|
except OSError:
|
||||||
|
pass # toter Mount → als „nicht gemountet" behandeln und neu mounten
|
||||||
|
|
||||||
creds_datei = None
|
creds_datei = None
|
||||||
try:
|
try:
|
||||||
@@ -169,22 +201,48 @@ def mounten(name: str, typ: str, quelle: str, optionen: str = "",
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _lazy_umount(ziel: str) -> None:
|
||||||
|
"""`umount -l`: hängt auch einen TOTEN/beschäftigten Mount ab (detach now,
|
||||||
|
cleanup later). Ohne das ließ sich ein Mount zu einem weggefallenen NAS
|
||||||
|
gar nicht mehr entfernen (Befund 24.07.)."""
|
||||||
|
subprocess.run(["umount", "-l", ziel], capture_output=True, text=True, timeout=30)
|
||||||
|
|
||||||
|
|
||||||
def aushaengen(name: str) -> None:
|
def aushaengen(name: str) -> None:
|
||||||
ziel = _mountpoint(name)
|
ziel = _mountpoint(name)
|
||||||
if os.path.ismount(ziel):
|
try:
|
||||||
|
noch_mount = os.path.ismount(ziel)
|
||||||
|
except OSError:
|
||||||
|
noch_mount = True # stale
|
||||||
|
if noch_mount:
|
||||||
ergebnis = subprocess.run(
|
ergebnis = subprocess.run(
|
||||||
["umount", ziel], capture_output=True, text=True, timeout=30
|
["umount", ziel], capture_output=True, text=True, timeout=30
|
||||||
)
|
)
|
||||||
if ergebnis.returncode != 0:
|
if ergebnis.returncode != 0:
|
||||||
raise RuntimeError(
|
# Toter/beschäftigter Mount → lazy detach (klappt immer)
|
||||||
f"umount schlug fehl: {(ergebnis.stderr or '').strip()[:300]}"
|
_lazy_umount(ziel)
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
os.rmdir(ziel)
|
os.rmdir(ziel)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass # nicht leer oder weg — egal
|
pass # nicht leer oder weg — egal
|
||||||
|
|
||||||
|
|
||||||
|
def reparieren(name: str, typ: str, quelle: str, optionen: str = "",
|
||||||
|
username: str = "", passwort: str = "") -> bool:
|
||||||
|
"""Toten/veralteten Mount frisch neu verbinden: lazy abhängen, neu mounten.
|
||||||
|
|
||||||
|
Nötig, wenn ein NAS-Mount stale geworden ist (Rebuild, NAS-Schlaf) — das
|
||||||
|
normale mounten() würde am „ist schon Mountpoint" hängenbleiben.
|
||||||
|
"""
|
||||||
|
ziel = _mountpoint(name)
|
||||||
|
try:
|
||||||
|
if os.path.ismount(ziel):
|
||||||
|
_lazy_umount(ziel)
|
||||||
|
except OSError:
|
||||||
|
_lazy_umount(ziel)
|
||||||
|
return mounten(name, typ, quelle, optionen, username, passwort)
|
||||||
|
|
||||||
|
|
||||||
def alle_remounten() -> list:
|
def alle_remounten() -> list:
|
||||||
"""Beim API-Start: alle gespeicherten Mounts wiederherstellen."""
|
"""Beim API-Start: alle gespeicherten Mounts wiederherstellen."""
|
||||||
meldungen = []
|
meldungen = []
|
||||||
|
|||||||
@@ -42,3 +42,18 @@ def test_worker_task_name_passt_zum_celery_client():
|
|||||||
|
|
||||||
quelle = inspect.getsource(celery_client.start_rip)
|
quelle = inspect.getsource(celery_client.start_rip)
|
||||||
assert '"worker.tasks.rip_disc"' in quelle
|
assert '"worker.tasks.rip_disc"' in quelle
|
||||||
|
|
||||||
|
|
||||||
|
def test_remount_blockiert_den_api_start_nicht():
|
||||||
|
"""Regression (Vorfall 24.07.): ein haengender Netz-Mount (CIFS-Schreibtest kann
|
||||||
|
im Kernel haengen, wait_for_response) darf den API-Start NICHT blockieren. remount
|
||||||
|
muss als Hintergrund-Task laufen (create_task), nicht direkt awaited werden."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
import main
|
||||||
|
|
||||||
|
quelle = inspect.getsource(main.startup_event)
|
||||||
|
assert "create_task(asyncio.to_thread(remount))" in quelle, \
|
||||||
|
"remount muss als Hintergrund-Task laufen (nicht blockierend)"
|
||||||
|
assert "await asyncio.to_thread(remount)" not in quelle, \
|
||||||
|
"remount darf nicht mehr direkt awaited werden (blockiert sonst den Start)"
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""Tests fuer den MakeMKV-Beta-Key-Parser (dependency-frei, nur extract_key)."""
|
||||||
|
from makemkv_key import extract_key
|
||||||
|
|
||||||
|
# Beispiel-Key im echten Format (T- + 64 Zeichen [A-Za-z0-9_]).
|
||||||
|
_KEY = "T-BSaJ6gwgMx4eIggWkVYXiVP_6zehm7WAO9dEydvzOHFHoZ6YQ82BL5cGpYDxvyRWnS"
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_key_findet_key_im_html():
|
||||||
|
html = f'<div class="content"><pre><code>{_KEY}</code></pre></div>'
|
||||||
|
assert extract_key(html) == _KEY
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_key_ohne_key_gibt_none():
|
||||||
|
assert extract_key("<html>kein Schluessel hier</html>") is None
|
||||||
|
assert extract_key("") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_key_zu_kurz_kein_treffer():
|
||||||
|
# Format ist streng T- + genau 64 Zeichen — zu kurz faellt raus.
|
||||||
|
assert extract_key("T-zukurz123") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_key_liefert_ganzen_key():
|
||||||
|
# Regression: die Regex darf den Key NICHT abschneiden (Beta-Keys sind laenger
|
||||||
|
# als die im Forum genannten 64 Zeichen — hier der volle Key zwischen Text).
|
||||||
|
treffer = extract_key(f"Vorher-Text {_KEY} Nachher-Text")
|
||||||
|
assert treffer == _KEY
|
||||||
|
assert treffer.startswith("T-")
|
||||||
+49
-76
@@ -1,101 +1,74 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState } from 'react'
|
||||||
import { LayoutDashboard, Disc, Settings, Sun, Moon, Terminal, BookOpen } from 'lucide-react'
|
import { LayoutDashboard, BookOpen, FileText, Settings as SettingsIcon } from 'lucide-react'
|
||||||
import { api } from './lib/api'
|
|
||||||
import { useDarkMode } from './context/ThemeContext'
|
|
||||||
import Dashboard from './pages/Dashboard'
|
import Dashboard from './pages/Dashboard'
|
||||||
import AnleitungPage from './pages/Anleitung'
|
|
||||||
import SettingsPage from './pages/Settings'
|
import SettingsPage from './pages/Settings'
|
||||||
import LogsPage from './pages/Logs'
|
import LogsPage from './pages/Logs'
|
||||||
import FirstRunWizard from './components/FirstRunWizard'
|
import AnleitungPage from './pages/Anleitung'
|
||||||
|
|
||||||
// Metadaten-Seite entfernt (24.07., Abnahme durch den Commander): die
|
type Page = 'dashboard' | 'anleitung' | 'logs' | 'settings'
|
||||||
// Korrektur läuft komplett über das „Nicht korrekt?"-Popup am Dashboard.
|
|
||||||
type Page = 'dashboard' | 'anleitung' | 'settings' | 'logs'
|
|
||||||
|
|
||||||
function App() {
|
export default function App() {
|
||||||
const [page, setPage] = useState<Page>('dashboard')
|
const [currentPage, setCurrentPage] = useState<Page>('dashboard')
|
||||||
const [setupDone, setSetupDone] = useState<boolean | null>(null)
|
|
||||||
const { theme, toggleTheme } = useDarkMode()
|
|
||||||
|
|
||||||
useEffect(() => {
|
const navItems: { id: Page; label: string; icon: any }[] = [
|
||||||
api.get('/setup')
|
|
||||||
.then(r => setSetupDone(!!r.data.done))
|
|
||||||
.catch(() => setSetupDone(true)) // API nicht erreichbar → kein Wizard-Zwang
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
if (setupDone === false) {
|
|
||||||
return <FirstRunWizard onDone={() => setSetupDone(true)} />
|
|
||||||
}
|
|
||||||
|
|
||||||
const navItems = [
|
|
||||||
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||||
{ id: 'anleitung', label: 'Anleitung', icon: BookOpen },
|
{ id: 'anleitung', label: 'Anleitung', icon: BookOpen },
|
||||||
{ id: 'logs', label: 'Logs', icon: Terminal },
|
{ id: 'logs', label: 'Logs', icon: FileText },
|
||||||
{ id: 'settings', label: 'Einstellungen', icon: Settings },
|
{ id: 'settings', label: 'Einstellungen', icon: SettingsIcon },
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white text-slate-900 dark:bg-slate-900 dark:text-slate-100 font-sans transition-colors duration-300">
|
<div className="min-h-screen bg-[#080b11] text-slate-100 font-sans selection:bg-amber-500/30 selection:text-amber-300">
|
||||||
<div className="flex h-screen overflow-hidden">
|
{/* Top Header Navigation Bar */}
|
||||||
{/* Sidebar */}
|
<header className="sticky top-0 z-50 bg-[#0c101d]/95 backdrop-blur-2xl border-b border-amber-500/20 shadow-xl shadow-black/50">
|
||||||
<aside className="w-64 bg-slate-900 text-slate-100 flex flex-col">
|
<div className="max-w-[1400px] mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between gap-4">
|
||||||
<div className="p-6">
|
|
||||||
<div className="flex items-center gap-3">
|
{/* Logo */}
|
||||||
<div className="p-2 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-xl">
|
<div className="flex items-center gap-3 cursor-pointer" onClick={() => setCurrentPage('dashboard')}>
|
||||||
<Disc size={28} className="text-white" />
|
<span className="text-2xl font-black tracking-wider bg-gradient-to-r from-amber-400 via-amber-300 to-amber-500 bg-clip-text text-transparent drop-shadow-[0_0_12px_rgba(245,158,11,0.4)] font-mono">
|
||||||
</div>
|
RIPPY
|
||||||
<div>
|
</span>
|
||||||
<h1 className="text-xl font-bold tracking-tight">Rippy</h1>
|
<span className="px-2.5 py-0.5 rounded text-[10px] font-bold uppercase tracking-widest bg-amber-500/15 text-amber-400 border border-amber-500/30">
|
||||||
<p className="text-xs text-slate-400 dark:text-slate-500">Automated Ripping</p>
|
CINEMA OS
|
||||||
</div>
|
</span>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
{/* Central Nav Tabs */}
|
||||||
onClick={toggleTheme}
|
<nav className="flex items-center gap-1.5 bg-[#121827] p-1.5 rounded-2xl border border-slate-800 shadow-inner">
|
||||||
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"
|
{navItems.map((item) => {
|
||||||
title={theme === 'dark' ? 'Light Mode' : 'Light Mode'}
|
const Icon = item.icon
|
||||||
>
|
const isActive = currentPage === item.id
|
||||||
{theme === 'dark' ? <Sun size={18} /> : <Moon size={18} />}
|
return (
|
||||||
</button>
|
|
||||||
|
|
||||||
<nav className="flex-1 px-3 py-2 space-y-1">
|
|
||||||
{navItems.map((item) => (
|
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={() => setPage(item.id as Page)}
|
onClick={() => setCurrentPage(item.id)}
|
||||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-200 ${
|
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-semibold transition-all duration-200 ${
|
||||||
page === item.id
|
isActive
|
||||||
? 'bg-indigo-600 text-white shadow-lg shadow-indigo-500/30'
|
? 'bg-amber-500 text-slate-950 shadow-md shadow-amber-500/20 font-bold scale-[1.02]'
|
||||||
: 'text-slate-400 hover:bg-slate-800 hover:text-white'
|
: 'text-slate-400 hover:text-slate-100 hover:bg-slate-800/60'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<item.icon size={20} />
|
<Icon size={15} className={isActive ? 'text-slate-950' : 'text-slate-400'} />
|
||||||
<span className="font-medium">{item.label}</span>
|
<span>{item.label}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Abmelden-Knopf entfernt (23.07.): es gibt (noch) keinen
|
<div className="flex items-center gap-2 text-xs font-mono text-emerald-400 bg-emerald-500/10 px-3 py-1.5 rounded-xl border border-emerald-500/20">
|
||||||
Login-Flow im UI — ein toter Knopf gaukelt nur einen vor. */}
|
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
|
||||||
|
<span>ONLINE</span>
|
||||||
<footer className="px-4 py-4 text-center text-xs text-slate-500 border-t border-slate-800">
|
|
||||||
Created with <span className="text-rose-400">❤️</span> by LucyAI, Claude and KrBrZ
|
|
||||||
</footer>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
<main className="flex-1 overflow-auto bg-slate-50 dark:bg-slate-950">
|
|
||||||
<div className="max-w-7xl mx-auto p-6">
|
|
||||||
{page === 'dashboard' && <Dashboard />}
|
|
||||||
{page === 'anleitung' && <AnleitungPage />}
|
|
||||||
{page === 'logs' && <LogsPage />}
|
|
||||||
{page === 'settings' && <SettingsPage />}
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Main Content Viewport */}
|
||||||
|
<main className="max-w-[1400px] mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
{currentPage === 'dashboard' && <Dashboard />}
|
||||||
|
{currentPage === 'anleitung' && <AnleitungPage />}
|
||||||
|
{currentPage === 'logs' && <LogsPage />}
|
||||||
|
{currentPage === 'settings' && <SettingsPage />}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { AlertTriangle, CheckCircle2, HelpCircle } from 'lucide-react'
|
||||||
import { useDarkMode } from '../context/ThemeContext'
|
import { Modal } from './ui/Modal'
|
||||||
|
import { Button } from './ui/Button'
|
||||||
|
|
||||||
interface ConfirmDialogProps {
|
interface ConfirmDialogProps {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
@@ -22,62 +23,51 @@ export default function ConfirmDialog({
|
|||||||
cancelText = 'Abbrechen',
|
cancelText = 'Abbrechen',
|
||||||
type = 'info',
|
type = 'info',
|
||||||
}: ConfirmDialogProps) {
|
}: ConfirmDialogProps) {
|
||||||
const { theme } = useDarkMode()
|
|
||||||
|
|
||||||
const getIcon = () => {
|
const getIcon = () => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'warning': return <span className="text-4xl">⚠️</span>
|
case 'warning':
|
||||||
case 'success': return <span className="text-4xl">✅</span>
|
return <AlertTriangle className="text-amber-500 w-8 h-8 flex-shrink-0" />
|
||||||
default: return <span className="text-4xl">🤔</span>
|
case 'success':
|
||||||
|
return <CheckCircle2 className="text-emerald-500 w-8 h-8 flex-shrink-0" />
|
||||||
|
default:
|
||||||
|
return <HelpCircle className="text-indigo-500 w-8 h-8 flex-shrink-0" />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getConfirmColor = () => {
|
const getConfirmVariant = () => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'warning': return 'bg-amber-600 hover:bg-amber-700 shadow-lg shadow-amber-500/30'
|
case 'warning':
|
||||||
case 'success': return 'bg-emerald-600 hover:bg-emerald-700 shadow-lg shadow-emerald-500/30'
|
return 'amber'
|
||||||
default: return 'bg-indigo-600 hover:bg-indigo-700 shadow-lg shadow-indigo-500/30'
|
case 'success':
|
||||||
|
return 'primary'
|
||||||
|
default:
|
||||||
|
return 'primary'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isOpen) return null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm transition-all">
|
<Modal isOpen={isOpen} onClose={onClose} maxWidth="md">
|
||||||
<div className={`w-full max-w-md rounded-xl shadow-2xl transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800' : 'bg-white'}`}>
|
<div className="flex items-start gap-4">
|
||||||
{/* Header */}
|
|
||||||
<div className={`px-6 py-4 border-b transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'}`}>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{getIcon()}
|
{getIcon()}
|
||||||
<h2 className={`text-xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
<div className="space-y-2 flex-1">
|
||||||
|
<h2 className="text-lg font-bold text-slate-900 dark:text-slate-100">
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Body */}
|
|
||||||
<div className="px-6 py-6">
|
|
||||||
<p className={`text-sm leading-relaxed ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
|
||||||
{message}
|
{message}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
<div className="mt-6 flex justify-end gap-3 border-t border-slate-200 dark:border-slate-800 pt-4">
|
||||||
<div className={`px-6 py-4 border-t transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'} flex justify-end gap-3`}>
|
<Button variant="secondary" onClick={onClose}>
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${theme === 'dark' ? 'text-slate-400 hover:bg-slate-700' : 'text-slate-600 hover:bg-slate-100'}`}
|
|
||||||
>
|
|
||||||
{cancelText}
|
{cancelText}
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button variant={getConfirmVariant()} onClick={onConfirm}>
|
||||||
onClick={onConfirm}
|
|
||||||
className={`px-4 py-2 rounded-lg text-sm font-medium text-white transition-colors ${getConfirmColor()}`}
|
|
||||||
>
|
|
||||||
{confirmText}
|
{confirmText}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Modal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { HardDrive, Disc, AlertCircle, CheckCircle, RefreshCw } from 'lucide-react'
|
import { HardDrive, Disc, AlertCircle, CheckCircle, RefreshCw } from 'lucide-react'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { useDarkMode } from '../context/ThemeContext'
|
|
||||||
import { useToast } from '../context/ToastContext'
|
import { useToast } from '../context/ToastContext'
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from './ui/Card'
|
||||||
|
import { Button } from './ui/Button'
|
||||||
|
import { TypeBadge } from './ui/Badge'
|
||||||
import RipTargetModal, { RipOptionen } from './RipTargetModal'
|
import RipTargetModal, { RipOptionen } from './RipTargetModal'
|
||||||
import MetadataKorrektur from './MetadataKorrektur'
|
import MetadataKorrektur from './MetadataKorrektur'
|
||||||
|
|
||||||
@@ -35,13 +37,22 @@ interface Device {
|
|||||||
disc?: DiscInfo
|
disc?: DiscInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
// TMDB liefert relative Poster-Pfade, OMDb absolute URLs — beides abdecken
|
|
||||||
function posterUrl(disc?: DiscInfo): string | null {
|
function posterUrl(disc?: DiscInfo): string | null {
|
||||||
const p = disc?.metadata?.poster_path
|
const p = disc?.metadata?.poster_path
|
||||||
if (!p) return null
|
if (!p) return null
|
||||||
return p.startsWith('http') ? p : `https://image.tmdb.org/t/p/w342${p}`
|
return p.startsWith('http') ? p : `https://image.tmdb.org/t/p/w342${p}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function typLabel(type: string): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'cd': return 'Audio CD'
|
||||||
|
case 'dvd': return 'DVD'
|
||||||
|
case 'bluray': return 'Blu-ray'
|
||||||
|
case 'uhd': return '4K UHD'
|
||||||
|
default: return 'Unbekannt'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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)
|
||||||
@@ -51,12 +62,9 @@ export default function DeviceDiscovery() {
|
|||||||
const [actionBusy, setActionBusy] = useState(false)
|
const [actionBusy, setActionBusy] = useState(false)
|
||||||
const [modalDevice, setModalDevice] = useState<Device | null>(null)
|
const [modalDevice, setModalDevice] = useState<Device | null>(null)
|
||||||
const [korrekturDevice, setKorrekturDevice] = useState<Device | null>(null)
|
const [korrekturDevice, setKorrekturDevice] = useState<Device | null>(null)
|
||||||
const { theme } = useDarkMode()
|
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
|
|
||||||
const refreshDevices = async (manuell = false) => {
|
const refreshDevices = async (manuell = false) => {
|
||||||
// manuell = Klick auf den Refresh-Knopf: dreht das Icon und quittiert
|
|
||||||
// per Toast — vorher gab der Knopf KEIN sichtbares Feedback.
|
|
||||||
if (manuell) setRefreshing(true)
|
if (manuell) setRefreshing(true)
|
||||||
try {
|
try {
|
||||||
const response = await api.get('/devices')
|
const response = await api.get('/devices')
|
||||||
@@ -80,6 +88,8 @@ export default function DeviceDiscovery() {
|
|||||||
...(targetDir ? { target_dir: targetDir } : {}),
|
...(targetDir ? { target_dir: targetDir } : {}),
|
||||||
...(optionen?.series ? { series: optionen.series, season: optionen.season } : {}),
|
...(optionen?.series ? { series: optionen.series, season: optionen.season } : {}),
|
||||||
...(optionen?.mainFeatureOnly !== undefined ? { main_feature_only: optionen.mainFeatureOnly } : {}),
|
...(optionen?.mainFeatureOnly !== undefined ? { main_feature_only: optionen.mainFeatureOnly } : {}),
|
||||||
|
...(optionen?.titles && optionen.titles.length ? { titles: optionen.titles } : {}),
|
||||||
|
...(optionen?.transcodeNode ? { transcode_node: optionen.transcodeNode } : {}),
|
||||||
})
|
})
|
||||||
setActionFeedback(`✓ Job angelegt (${response.data.id.slice(0, 8)}…) — Fortschritt im Dashboard`)
|
setActionFeedback(`✓ Job angelegt (${response.data.id.slice(0, 8)}…) — Fortschritt im Dashboard`)
|
||||||
toast('success', 'Rip gestartet — Fortschritt unten bei „Neueste Jobs"')
|
toast('success', 'Rip gestartet — Fortschritt unten bei „Neueste Jobs"')
|
||||||
@@ -115,233 +125,194 @@ export default function DeviceDiscovery() {
|
|||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const getTypeColor = (type: string) => {
|
|
||||||
switch (type) {
|
|
||||||
case 'cd': return theme === 'dark' ? 'bg-blue-900/30 text-blue-400' : 'bg-blue-100 text-blue-600'
|
|
||||||
case 'dvd': return theme === 'dark' ? 'bg-purple-900/30 text-purple-400' : 'bg-purple-100 text-purple-600'
|
|
||||||
case 'bluray': return theme === 'dark' ? 'bg-pink-900/30 text-pink-400' : 'bg-pink-100 text-pink-600'
|
|
||||||
case 'uhd': return theme === 'dark' ? 'bg-amber-900/30 text-amber-400' : 'bg-amber-100 text-amber-700'
|
|
||||||
default: return theme === 'dark' ? 'bg-slate-700 text-slate-400' : 'bg-slate-100 text-slate-600'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const typLabel = (type: string) =>
|
|
||||||
({ cd: 'CD', dvd: 'DVD', bluray: 'BLU-RAY', uhd: '4K UHD' } as Record<string, string>)[type] || type.toUpperCase()
|
|
||||||
|
|
||||||
const QUELLEN_LABEL: Record<string, string> = {
|
const QUELLEN_LABEL: Record<string, string> = {
|
||||||
tmdb: 'TMDB', omdb: 'OMDb', jikan: 'MyAnimeList', manuell: 'manuell gewählt',
|
tmdb: 'TMDB', omdb: 'OMDb', jikan: 'MyAnimeList', manuell: 'manuell gewählt',
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
const getStatusBadge = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'empty': return theme === 'dark' ? 'bg-slate-700 text-slate-400' : 'bg-slate-100 text-slate-600'
|
case 'empty':
|
||||||
case 'ready': return theme === 'dark' ? 'bg-emerald-900/30 text-emerald-400' : 'bg-emerald-100 text-emerald-700'
|
return <span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-slate-500/15 text-slate-400 border border-slate-500/30"><AlertCircle size={13} /> Keine Disc</span>
|
||||||
case 'ripping': return theme === 'dark' ? 'bg-blue-900/30 text-blue-400' : 'bg-blue-100 text-blue-700'
|
case 'ready':
|
||||||
default: return theme === 'dark' ? 'bg-slate-700 text-slate-400' : 'bg-slate-100 text-slate-600'
|
return <span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border border-emerald-500/30"><CheckCircle size={13} /> Bereit</span>
|
||||||
}
|
case 'ripping':
|
||||||
}
|
return <span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-sky-500/15 text-sky-600 dark:text-sky-400 border border-sky-500/30 animate-pulse"><RefreshCw size={13} className="animate-spin" /> Rippt</span>
|
||||||
|
default:
|
||||||
const getStatusIcon = (status: string) => {
|
return <span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-slate-500/15 text-slate-400 border border-slate-500/30"><AlertCircle size={13} /> Unbekannt</span>
|
||||||
switch (status) {
|
|
||||||
case 'empty': return <AlertCircle size={16} />
|
|
||||||
case 'ready': return <CheckCircle size={16} />
|
|
||||||
case 'ripping': return <RefreshCw size={16} className="animate-spin" />
|
|
||||||
default: return <AlertCircle size={16} />
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="flex items-center justify-center py-8">
|
||||||
<RefreshCw className="animate-spin text-indigo-600" size={24} />
|
<RefreshCw className="animate-spin text-amber-500" size={24} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<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'}`}>
|
<Card className="overflow-hidden">
|
||||||
<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`}>
|
<CardHeader>
|
||||||
<div>
|
<div>
|
||||||
<h2 className={`text-lg font-semibold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Laufwerke</h2>
|
<CardTitle>Laufwerke</CardTitle>
|
||||||
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Erkannte Geräte mit automatischer Typ-Erkennung</p>
|
<p className="text-sm text-slate-500 dark:text-slate-400">Erkannte Geräte mit automatischer Typ-Erkennung</p>
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => refreshDevices(true)}
|
|
||||||
className={`p-2 rounded-lg transition-colors ${theme === 'dark' ? 'hover:bg-slate-700 text-slate-400' : 'hover:bg-slate-100 text-slate-600'}`}
|
|
||||||
title="Geräte neu laden"
|
|
||||||
>
|
|
||||||
<RefreshCw size={20} className={loading || refreshing ? 'animate-spin' : ''} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => refreshDevices(true)} title="Geräte neu laden">
|
||||||
|
<RefreshCw size={18} className={loading || refreshing ? 'animate-spin' : ''} />
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
<div className="p-6 transition-colors duration-300">
|
<CardContent>
|
||||||
{/* Disc-Karte: WAS liegt gerade im Laufwerk (Auto-Pre-Scan) */}
|
{/* Disc-Karte: WAS liegt gerade im Laufwerk (Auto-Pre-Scan) */}
|
||||||
{devices.filter(d => d.status === 'ready' && d.disc).map(device => (
|
{devices.filter(d => d.status === 'ready' && d.disc).map(device => (
|
||||||
<div
|
<div
|
||||||
key={`disc-${device.id}`}
|
key={`disc-${device.id}`}
|
||||||
className={`mb-6 rounded-xl overflow-hidden border-2 flex ${theme === 'dark' ? 'border-indigo-500/40 bg-gradient-to-r from-indigo-950/60 to-slate-900' : 'border-indigo-200 bg-gradient-to-r from-indigo-50 to-white'}`}
|
className="mb-6 rounded-2xl overflow-hidden border border-amber-500/40 bg-gradient-to-r from-amber-500/15 via-slate-900 to-slate-950 flex shadow-xl shadow-amber-500/5 backdrop-blur-md"
|
||||||
>
|
>
|
||||||
{posterUrl(device.disc) && (
|
{posterUrl(device.disc) && (
|
||||||
<img
|
<img
|
||||||
src={posterUrl(device.disc)!}
|
src={posterUrl(device.disc)!}
|
||||||
alt={device.disc!.title}
|
alt={device.disc!.title}
|
||||||
className="w-28 object-cover flex-shrink-0"
|
className="w-32 object-cover flex-shrink-0 border-r border-slate-800"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="p-5 flex-1 min-w-0">
|
<div className="p-5 flex-1 min-w-0 flex flex-col justify-between">
|
||||||
<p className={`text-xs font-semibold uppercase tracking-wider mb-1 ${theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'}`}>
|
<div>
|
||||||
|
<p className="text-[11px] font-bold uppercase tracking-wider mb-1 text-amber-400">
|
||||||
Im Laufwerk erkannt
|
Im Laufwerk erkannt
|
||||||
</p>
|
</p>
|
||||||
<h3 className={`text-xl font-bold truncate ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
<h3 className="text-xl font-bold truncate text-slate-900 dark:text-slate-100">
|
||||||
{device.disc!.title}
|
{device.disc!.title}
|
||||||
{device.disc!.year ? <span className={`font-normal ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}> ({device.disc!.year})</span> : null}
|
{device.disc!.year ? <span className="font-normal text-slate-500 dark:text-slate-400"> ({device.disc!.year})</span> : null}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center gap-2 mt-2">
|
<div className="flex items-center gap-2 mt-2">
|
||||||
<span className={`text-xs font-medium px-2 py-0.5 rounded ${getTypeColor(device.type)}`}>
|
<TypeBadge type={device.type} />
|
||||||
{device.disc!.disc_type?.toUpperCase()}
|
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
</span>
|
|
||||||
<span className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
|
||||||
Quelle: {QUELLEN_LABEL[device.disc!.metadata?.source || ''] || device.disc!.metadata?.source || 'Disc-Label'}
|
Quelle: {QUELLEN_LABEL[device.disc!.metadata?.source || ''] || device.disc!.metadata?.source || 'Disc-Label'}
|
||||||
{' · '}{Math.round((device.disc!.confidence || 0) * 100)} % sicher
|
{' · '}{Math.round((device.disc!.confidence || 0) * 100)} % sicher
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{device.disc!.metadata?.overview && (
|
{device.disc!.metadata?.overview && (
|
||||||
<p className={`text-sm mt-2 line-clamp-2 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-600'}`}>
|
<p className="text-sm mt-2 line-clamp-2 text-slate-600 dark:text-slate-300">
|
||||||
{device.disc!.metadata.overview}
|
{device.disc!.metadata.overview}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{device.disc!.bereits_gerippt && (
|
{device.disc!.bereits_gerippt && (
|
||||||
<p className={`text-xs mt-2 px-2.5 py-1.5 rounded-lg inline-flex items-center gap-1.5 ${theme === 'dark' ? 'bg-amber-900/30 text-amber-300' : 'bg-amber-50 text-amber-700'}`}>
|
<p className="text-xs mt-2 px-3 py-1.5 rounded-xl inline-flex items-center gap-1.5 bg-amber-500/10 border border-amber-500/20 text-amber-600 dark:text-amber-300">
|
||||||
<AlertCircle size={13} className="flex-shrink-0" />
|
<AlertCircle size={13} className="flex-shrink-0" />
|
||||||
Diese Disc wurde bereits gerippt
|
Diese Disc wurde bereits gerippt
|
||||||
{device.disc!.bereits_gerippt.finished_at
|
{device.disc!.bereits_gerippt.finished_at
|
||||||
? ` (${new Date(device.disc!.bereits_gerippt.finished_at).toLocaleDateString('de-DE')})`
|
? ` (${new Date(device.disc!.bereits_gerippt.finished_at).toLocaleDateString('de-DE')})`
|
||||||
: ''} — nochmal rippen ist trotzdem möglich.
|
: ''} — nochmal rippen ist möglich.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-3 mt-3">
|
</div>
|
||||||
<button
|
<div className="flex items-center gap-3 mt-4">
|
||||||
onClick={() => setModalDevice(device)}
|
<Button variant="amber" onClick={() => setModalDevice(device)} disabled={actionBusy}>
|
||||||
disabled={actionBusy}
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
Rippen starten
|
Rippen starten
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button variant="secondary" onClick={() => setKorrekturDevice(device)}>
|
||||||
onClick={() => setKorrekturDevice(device)}
|
|
||||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${theme === 'dark' ? 'bg-slate-800 text-amber-300 hover:bg-slate-700' : 'bg-slate-100 text-amber-700 hover:bg-slate-200'}`}
|
|
||||||
>
|
|
||||||
Nicht korrekt?
|
Nicht korrekt?
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{devices.length === 0 ? (
|
{devices.length === 0 ? (
|
||||||
<div className="text-center py-12 transition-colors duration-300">
|
<div className="text-center py-12">
|
||||||
<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'}`}>
|
<div className="mx-auto w-16 h-16 rounded-2xl flex items-center justify-center mb-4 bg-slate-100 dark:bg-slate-800 text-slate-400">
|
||||||
<HardDrive className={theme === 'dark' ? 'text-slate-400' : 'text-slate-400'} size={32} />
|
<HardDrive size={32} />
|
||||||
</div>
|
</div>
|
||||||
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Keine Laufwerke gefunden</p>
|
<p className="text-sm text-slate-500 dark:text-slate-400">Keine Laufwerke gefunden</p>
|
||||||
<p className={`text-xs mt-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
<p className="text-xs mt-1 text-slate-400 dark:text-slate-500">
|
||||||
Stelle sicher, dass ein Laufwerk angeschlossen ist
|
Stelle sicher, dass ein Laufwerk angeschlossen ist
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4 transition-colors duration-300">
|
<div className="space-y-3">
|
||||||
{devices.map(device => (
|
{devices.map(device => (
|
||||||
<div key={device.id} className={`rounded-lg p-5 border transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-900 border-slate-700' : 'bg-slate-50 border-slate-200'}`}>
|
<div key={device.id} className="rounded-xl p-4 border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div className={`p-3 rounded-lg ${getTypeColor(device.type)}`}>
|
<div className="p-3 rounded-xl bg-slate-200/60 dark:bg-slate-800 text-slate-700 dark:text-slate-300 border border-slate-300 dark:border-slate-700">
|
||||||
<Disc size={24} />
|
<Disc size={22} className={device.status === 'ripping' ? 'animate-disc-spin text-amber-500' : ''} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<h3 className={`font-semibold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
<h3 className="font-semibold text-slate-900 dark:text-slate-100">
|
||||||
{device.name || 'Unbekanntes Laufwerk'}
|
{device.name || 'Unbekanntes Laufwerk'}
|
||||||
</h3>
|
</h3>
|
||||||
{device.status === 'ripping' && (
|
<TypeBadge type={device.type} />
|
||||||
<span className="flex items-center gap-1 text-xs font-medium text-blue-500">
|
|
||||||
<RefreshCw size={12} className="animate-spin" />
|
|
||||||
Rippt
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2 mt-2">
|
<div className="flex flex-wrap gap-2 mt-2">
|
||||||
<span className={`text-xs font-mono px-2 py-1 rounded ${theme === 'dark' ? 'bg-slate-800 text-slate-400' : 'bg-slate-100 text-slate-600'}`}>
|
<span className="text-xs font-mono px-2 py-0.5 rounded bg-slate-200/70 dark:bg-slate-800 text-slate-600 dark:text-slate-400 border border-slate-300 dark:border-slate-700">
|
||||||
{device.path}
|
{device.path}
|
||||||
</span>
|
</span>
|
||||||
{device.serial && (
|
{device.serial && (
|
||||||
<span className={`text-xs px-2 py-1 rounded ${theme === 'dark' ? 'bg-slate-800 text-slate-400' : 'bg-slate-100 text-slate-600'}`}>
|
<span className="text-xs px-2 py-0.5 rounded bg-slate-200/70 dark:bg-slate-800 text-slate-600 dark:text-slate-400 border border-slate-300 dark:border-slate-700">
|
||||||
{device.serial}
|
{device.serial}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{device.model && (
|
{device.model && (
|
||||||
<span className={`text-xs px-2 py-1 rounded ${theme === 'dark' ? 'bg-slate-800 text-slate-400' : 'bg-slate-100 text-slate-600'}`}>
|
<span className="text-xs px-2 py-0.5 rounded bg-slate-200/70 dark:bg-slate-800 text-slate-600 dark:text-slate-400 border border-slate-300 dark:border-slate-700">
|
||||||
{device.model}
|
{device.model}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 mt-3">
|
<div className="mt-3">
|
||||||
<span className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-medium ${getStatusColor(device.status)}`}>
|
{getStatusBadge(device.status)}
|
||||||
{getStatusIcon(device.status)}
|
|
||||||
{device.status === 'empty' ? 'Keine Disc' :
|
|
||||||
device.status === 'ready' ? 'Bereit' :
|
|
||||||
'Rippt'}
|
|
||||||
</span>
|
|
||||||
<span className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
|
||||||
Typ: {typLabel(device.type)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setExpandedId(expandedId === device.id ? null : device.id)
|
setExpandedId(expandedId === device.id ? null : device.id)
|
||||||
setActionFeedback(null)
|
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'}
|
{expandedId === device.id ? 'Schließen' : 'Details'}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{expandedId === device.id && (
|
{expandedId === device.id && (
|
||||||
<div className={`mt-4 pt-4 border-t space-y-3 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'}`}>
|
<div className="mt-4 pt-4 border-t border-slate-200/80 dark:border-slate-800 space-y-3">
|
||||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm">
|
<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="text-slate-500 dark:text-slate-400">Gerätepfad</span>
|
||||||
<span className={`font-mono ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>{device.path}</span>
|
<span className="font-mono text-slate-700 dark:text-slate-300">{device.path}</span>
|
||||||
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>Modell</span>
|
<span className="text-slate-500 dark:text-slate-400">Modell</span>
|
||||||
<span className={theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}>{device.model || 'unbekannt'}</span>
|
<span className="text-slate-700 dark:text-slate-300">{device.model || 'unbekannt'}</span>
|
||||||
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>Seriennummer</span>
|
<span className="text-slate-500 dark:text-slate-400">Seriennummer</span>
|
||||||
<span className={`font-mono text-xs ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>{device.serial || '—'}</span>
|
<span className="font-mono text-xs text-slate-700 dark:text-slate-300">{device.serial || '—'}</span>
|
||||||
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>Disc</span>
|
<span className="text-slate-500 dark:text-slate-400">Disc</span>
|
||||||
<span className={theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}>
|
<span className="text-slate-700 dark:text-slate-300">
|
||||||
{device.status === 'ready' ? `eingelegt (${typLabel(device.type)})` : 'keine'}
|
{device.status === 'ready' ? `eingelegt (${typLabel(device.type)})` : 'keine'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 pt-1">
|
<div className="flex items-center gap-3 pt-1">
|
||||||
<button
|
<Button
|
||||||
|
variant="amber"
|
||||||
|
size="sm"
|
||||||
onClick={() => setModalDevice(device)}
|
onClick={() => setModalDevice(device)}
|
||||||
disabled={actionBusy || device.status !== 'ready'}
|
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
|
Rippen starten
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
onClick={() => ejectDisc(device)}
|
onClick={() => ejectDisc(device)}
|
||||||
disabled={actionBusy || device.status !== 'ready'}
|
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
|
Auswerfen
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{actionFeedback && (
|
{actionFeedback && (
|
||||||
<p className={`text-sm ${actionFeedback.startsWith('✓') ? 'text-emerald-500' : 'text-rose-500'}`}>
|
<p className={`text-sm font-medium ${actionFeedback.startsWith('✓') ? 'text-emerald-500' : 'text-rose-500'}`}>
|
||||||
{actionFeedback}
|
{actionFeedback}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -351,10 +322,8 @@ export default function DeviceDiscovery() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</CardContent>
|
||||||
|
|
||||||
{/* Ziel-Auswahl vor dem Rip-Start (Filme/Serien/Musik oder eigener
|
|
||||||
Pfad — dort eingehängte Shares sind direkt wählbar) */}
|
|
||||||
<RipTargetModal
|
<RipTargetModal
|
||||||
isOpen={modalDevice !== null}
|
isOpen={modalDevice !== null}
|
||||||
initialType={modalDevice?.type === 'cd' ? 'music' : (modalDevice?.disc?.metadata?.type === 'tv' ? 'series' : 'movies')}
|
initialType={modalDevice?.type === 'cd' ? 'music' : (modalDevice?.disc?.metadata?.type === 'tv' ? 'series' : 'movies')}
|
||||||
@@ -368,7 +337,6 @@ export default function DeviceDiscovery() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* „Nicht korrekt?"-Popup: Korrektur ohne Seitenwechsel */}
|
|
||||||
<MetadataKorrektur
|
<MetadataKorrektur
|
||||||
devicePath={korrekturDevice?.path || ''}
|
devicePath={korrekturDevice?.path || ''}
|
||||||
vorschlag={korrekturDevice?.disc?.title}
|
vorschlag={korrekturDevice?.disc?.title}
|
||||||
@@ -376,6 +344,6 @@ export default function DeviceDiscovery() {
|
|||||||
onClose={() => setKorrekturDevice(null)}
|
onClose={() => setKorrekturDevice(null)}
|
||||||
onUebernommen={refreshDevices}
|
onUebernommen={refreshDevices}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Disc, Cpu, Globe, Tv, CheckCircle, ArrowRight } from 'lucide-react'
|
import { Disc, Cpu, Globe, Tv, CheckCircle, ArrowRight } from 'lucide-react'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { useDarkMode } from '../context/ThemeContext'
|
|
||||||
import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer'
|
import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer'
|
||||||
|
import { Button } from './ui/Button'
|
||||||
// Erster Start: Rippy universell einrichten (Commander-Ziel: All-in-one,
|
import { Input, Select } from './ui/Input'
|
||||||
// weitergebbar — keine Handarbeit in .env-Dateien nötig).
|
|
||||||
|
|
||||||
interface WorkerInfo {
|
interface WorkerInfo {
|
||||||
name: string
|
name: string
|
||||||
@@ -29,7 +27,6 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
|
|||||||
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const { theme } = useDarkMode()
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.get('/capabilities')
|
api.get('/capabilities')
|
||||||
@@ -59,14 +56,12 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const feldKlasse = `w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'}`
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`min-h-screen flex items-center justify-center p-6 ${theme === 'dark' ? 'bg-slate-950' : 'bg-slate-100'}`}>
|
<div className="min-h-screen flex items-center justify-center p-6 bg-slate-100 dark:bg-slate-950">
|
||||||
<div className={`w-full max-w-2xl rounded-2xl shadow-2xl overflow-hidden ${theme === 'dark' ? 'bg-slate-900 border border-slate-800' : 'bg-white'}`}>
|
<div className="w-full max-w-2xl rounded-2xl shadow-2xl overflow-hidden bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800">
|
||||||
<div className="bg-gradient-to-r from-indigo-600 to-purple-700 p-8 text-white">
|
<div className="bg-gradient-to-r from-amber-500 via-indigo-600 to-purple-700 p-8 text-white">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<Disc size={32} />
|
<Disc size={32} className="animate-disc-spin" />
|
||||||
<h1 className="text-2xl font-bold">Willkommen bei Rippy</h1>
|
<h1 className="text-2xl font-bold">Willkommen bei Rippy</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="opacity-90">Einmalige Einrichtung — dauert keine zwei Minuten. Alles ist später unter Einstellungen änderbar.</p>
|
<p className="opacity-90">Einmalige Einrichtung — dauert keine zwei Minuten. Alles ist später unter Einstellungen änderbar.</p>
|
||||||
@@ -75,36 +70,38 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
|
|||||||
<div className="p-8 space-y-8">
|
<div className="p-8 space-y-8">
|
||||||
{/* Metadaten-APIs */}
|
{/* Metadaten-APIs */}
|
||||||
<section>
|
<section>
|
||||||
<h2 className={`flex items-center gap-2 text-lg font-semibold mb-3 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
<h2 className="flex items-center gap-2 text-lg font-semibold mb-3 text-slate-900 dark:text-slate-100">
|
||||||
<Globe size={18} className="text-indigo-500" /> Metadaten-Erkennung
|
<Globe size={18} className="text-amber-500" /> Metadaten-Erkennung
|
||||||
</h2>
|
</h2>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<Input
|
||||||
<label className={`block text-sm font-medium mb-1 ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
type="password"
|
||||||
TMDB API-Key <span className="text-indigo-500">(empfohlen)</span>
|
label="TMDB API-Key (empfohlen)"
|
||||||
</label>
|
value={tmdbKey}
|
||||||
<input type="password" value={tmdbKey} onChange={e => setTmdbKey(e.target.value)} className={feldKlasse} placeholder="Kostenlos auf themoviedb.org" />
|
onChange={e => setTmdbKey(e.target.value)}
|
||||||
</div>
|
placeholder="Kostenlos auf themoviedb.org"
|
||||||
<div>
|
/>
|
||||||
<label className={`block text-sm font-medium mb-1 ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
<Input
|
||||||
OMDb API-Key <span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>(optional, zweite Quelle)</span>
|
type="password"
|
||||||
</label>
|
label="OMDb API-Key (optional, zweite Quelle)"
|
||||||
<input type="password" value={omdbKey} onChange={e => setOmdbKey(e.target.value)} className={feldKlasse} placeholder="Kostenlos auf omdbapi.com" />
|
value={omdbKey}
|
||||||
</div>
|
onChange={e => setOmdbKey(e.target.value)}
|
||||||
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
placeholder="Kostenlos auf omdbapi.com"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
Ohne Keys rippt Rippy trotzdem — Discs heißen dann nur wie ihr Volume-Label.
|
Ohne Keys rippt Rippy trotzdem — Discs heißen dann nur wie ihr Volume-Label.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Media-Server: worauf soll Rippy die Ablage vorbereiten? */}
|
{/* Media-Server */}
|
||||||
<section>
|
<section>
|
||||||
<h2 className={`flex items-center gap-2 text-lg font-semibold mb-3 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
<h2 className="flex items-center gap-2 text-lg font-semibold mb-3 text-slate-900 dark:text-slate-100">
|
||||||
<Tv size={18} className="text-indigo-500" /> Dein Media-Server
|
<Tv size={18} className="text-amber-500" /> Dein Media-Server
|
||||||
</h2>
|
</h2>
|
||||||
<p className={`text-sm mb-3 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
<p className="text-sm mb-3 text-slate-500 dark:text-slate-400">
|
||||||
Rippy benennt fertige Rips als „Titel (Jahr)" und legt für Jellyfin/Emby/Kodi
|
Rippy benennt fertige Rips als „Titel (Jahr)" und legt für Jellyfin/Emby/Kodi
|
||||||
zusätzlich movie.nfo + poster.jpg dazu — dein Server erkennt alles ohne Nacharbeit.
|
zusätzlich movie.nfo + poster.jpg dazu.
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||||
{MEDIA_SERVER_OPTIONEN.map(o => (
|
{MEDIA_SERVER_OPTIONEN.map(o => (
|
||||||
@@ -113,76 +110,75 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setMediaServer(o.wert)}
|
onClick={() => setMediaServer(o.wert)}
|
||||||
title={o.hinweis}
|
title={o.hinweis}
|
||||||
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-all ${
|
className={`px-3 py-2 rounded-xl text-sm font-medium border transition-all ${
|
||||||
mediaServer === o.wert
|
mediaServer === o.wert
|
||||||
? theme === 'dark'
|
? 'bg-amber-500/20 border-amber-500 text-amber-600 dark:text-amber-300 font-semibold'
|
||||||
? 'bg-indigo-900/40 border-indigo-500 text-indigo-300'
|
: 'bg-slate-50 dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-400 hover:border-slate-300 dark:hover:border-slate-600'
|
||||||
: 'bg-indigo-50 border-indigo-600 text-indigo-700'
|
|
||||||
: theme === 'dark'
|
|
||||||
? 'bg-slate-800 border-slate-700 text-slate-400 hover:border-slate-600'
|
|
||||||
: 'bg-slate-50 border-slate-200 text-slate-600 hover:border-slate-300'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{o.label}
|
{o.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className={`text-xs mt-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
<p className="text-xs mt-2 text-slate-500 dark:text-slate-400">
|
||||||
{MEDIA_SERVER_OPTIONEN.find(o => o.wert === mediaServer)?.hinweis}
|
{MEDIA_SERVER_OPTIONEN.find(o => o.wert === mediaServer)?.hinweis}
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Verarbeitung + erkannte Hardware */}
|
{/* Verarbeitung + erkannte Hardware */}
|
||||||
<section>
|
<section>
|
||||||
<h2 className={`flex items-center gap-2 text-lg font-semibold mb-3 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
<h2 className="flex items-center gap-2 text-lg font-semibold mb-3 text-slate-900 dark:text-slate-100">
|
||||||
<Cpu size={18} className="text-indigo-500" /> Verarbeitung
|
<Cpu size={18} className="text-amber-500" /> Verarbeitung
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className={`rounded-lg p-4 mb-3 ${theme === 'dark' ? 'bg-slate-800' : 'bg-slate-50'}`}>
|
<div className="rounded-xl p-4 mb-3 bg-slate-50 dark:bg-slate-950/80 border border-slate-200 dark:border-slate-800">
|
||||||
<p className={`text-sm font-medium mb-2 ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>Erkannte Encoder in deinem System:</p>
|
<p className="text-sm font-medium mb-2 text-slate-700 dark:text-slate-300">Erkannte Encoder in deinem System:</p>
|
||||||
{workers.length === 0 ? (
|
{workers.length === 0 ? (
|
||||||
<p className={`text-sm ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Noch kein Worker gemeldet — startet gerade?</p>
|
<p className="text-sm text-slate-500 dark:text-slate-400">Noch kein Worker gemeldet — startet gerade?</p>
|
||||||
) : (
|
) : (
|
||||||
workers.map(w => (
|
workers.map(w => (
|
||||||
<div key={w.name} className="mb-1">
|
<div key={w.name} className="mb-1">
|
||||||
<span className={`text-xs font-mono ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>{w.name}: </span>
|
<span className="text-xs font-mono text-slate-500 dark:text-slate-400">{w.name}: </span>
|
||||||
{w.encoders.map(e => (
|
{w.encoders.map(e => (
|
||||||
<span key={e} className={`inline-block text-xs px-2 py-0.5 rounded mr-1 ${e.startsWith('cpu') ? (theme === 'dark' ? 'bg-slate-700 text-slate-300' : 'bg-slate-200 text-slate-600') : 'bg-emerald-600/20 text-emerald-500 font-medium'}`}>
|
<span key={e} className={`inline-block text-xs px-2 py-0.5 rounded font-medium mr-1 ${
|
||||||
|
e.startsWith('cpu')
|
||||||
|
? 'bg-slate-200 dark:bg-slate-800 text-slate-600 dark:text-slate-300'
|
||||||
|
: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400'
|
||||||
|
}`}>
|
||||||
{ENCODER_LABELS[e] || e}
|
{ENCODER_LABELS[e] || e}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
<p className={`text-xs mt-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
|
||||||
Tipp: GPU-Maschinen im Netz können als optionale Transcode-Worker angebunden werden (siehe README).
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className={`flex items-center gap-3 mb-3 text-sm ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
<label className="flex items-center gap-3 mb-3 text-sm text-slate-700 dark:text-slate-300">
|
||||||
<input type="checkbox" checked={transcodeEnabled} onChange={e => setTranscodeEnabled(e.target.checked)} className="w-4 h-4 accent-indigo-600" />
|
<input type="checkbox" checked={transcodeEnabled} onChange={e => setTranscodeEnabled(e.target.checked)} className="w-4 h-4 accent-amber-500 rounded" />
|
||||||
Nach dem Rip auf Arbeitsgröße komprimieren (empfohlen — sonst ~40 GB pro Blu-ray)
|
Nach dem Rip auf Arbeitsgröße komprimieren (empfohlen — sonst ~40 GB pro Blu-ray)
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{transcodeEnabled && (
|
{transcodeEnabled && (
|
||||||
<select value={preset} onChange={e => setPreset(e.target.value)} className={feldKlasse}>
|
<Select value={preset} onChange={e => setPreset(e.target.value)}>
|
||||||
<option value="H.265 MKV 1080p30">H.265 — klein & modern (Standard)</option>
|
<option value="H.265 MKV 1080p30">H.265 — klein & modern (Standard)</option>
|
||||||
<option value="HQ 1080p30 Surround">H.264 — schneller auf schwacher CPU, etwas größer</option>
|
<option value="HQ 1080p30 Surround">H.264 — schneller auf schwacher CPU, etwas größer</option>
|
||||||
</select>
|
</Select>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{error && <p className="text-sm text-rose-500">{error}</p>}
|
{error && <p className="text-sm text-rose-500">{error}</p>}
|
||||||
|
|
||||||
<button
|
<Button
|
||||||
|
variant="amber"
|
||||||
|
size="lg"
|
||||||
onClick={abschliessen}
|
onClick={abschliessen}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="w-full flex items-center justify-center gap-2 px-6 py-3 bg-indigo-600 text-white rounded-lg font-medium hover:bg-indigo-700 transition-colors disabled:opacity-50"
|
className="w-full"
|
||||||
>
|
>
|
||||||
{saving ? 'Speichern…' : <>Los geht's <ArrowRight size={18} /></>}
|
{saving ? 'Speichern…' : <>Los geht's <ArrowRight size={18} /></>}
|
||||||
</button>
|
</Button>
|
||||||
|
|
||||||
<p className={`text-xs text-center flex items-center justify-center gap-1 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
<p className="text-xs text-center flex items-center justify-center gap-1 text-slate-500 dark:text-slate-400">
|
||||||
<CheckCircle size={12} /> Läuft komplett lokal — keine Cloud, keine Telemetrie.
|
<CheckCircle size={12} /> Läuft komplett lokal — keine Cloud, keine Telemetrie.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { Disc, Sparkles, CheckCircle, Shield, Zap, Tv, HardDrive } from 'lucide-react'
|
||||||
|
import { RadialProgress } from './ui/RadialProgress'
|
||||||
|
import { TypeBadge, StatusBadge } from './ui/Badge'
|
||||||
|
import { Button } from './ui/Button'
|
||||||
|
|
||||||
|
interface JobMeta {
|
||||||
|
year?: number
|
||||||
|
overview?: string
|
||||||
|
poster_path?: string
|
||||||
|
genres?: string[]
|
||||||
|
runtime?: number
|
||||||
|
type?: string
|
||||||
|
source?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Job {
|
||||||
|
id: string
|
||||||
|
type: 'cd' | 'dvd' | 'bluray' | 'uhd'
|
||||||
|
status: 'pending' | 'processing' | 'transcoding' | 'canceling' | 'completed' | 'failed'
|
||||||
|
device: string
|
||||||
|
startTime: string
|
||||||
|
endTime?: string
|
||||||
|
progress: number
|
||||||
|
title?: string
|
||||||
|
can_retry?: boolean
|
||||||
|
meta?: JobMeta | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function posterUrl(meta?: JobMeta | null): string | null {
|
||||||
|
const p = meta?.poster_path
|
||||||
|
if (!p) return null
|
||||||
|
return p.startsWith('http') ? p : `https://image.tmdb.org/t/p/w500${p}`
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HeroCinemaBannerProps {
|
||||||
|
aktiverJob?: Job | null
|
||||||
|
completedJobs?: Job[]
|
||||||
|
onDetailClick?: (jobId: string) => void
|
||||||
|
onCancelClick?: (jobId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HeroCinemaBanner({
|
||||||
|
aktiverJob,
|
||||||
|
completedJobs = [],
|
||||||
|
onDetailClick,
|
||||||
|
onCancelClick,
|
||||||
|
}: HeroCinemaBannerProps) {
|
||||||
|
// Fall 1: Aktiver Job (Rippt oder Komprimiert live)
|
||||||
|
if (aktiverJob) {
|
||||||
|
const isTranscoding = aktiverJob.status === 'transcoding'
|
||||||
|
const statusText = isTranscoding ? 'HandBrake Kompression' : 'MakeMKV Extraktion'
|
||||||
|
const poster = posterUrl(aktiverJob.meta)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative rounded-3xl overflow-hidden border-2 border-amber-500/50 bg-gradient-to-r from-slate-950 via-slate-900 to-indigo-950 p-6 md:p-8 shadow-[0_0_50px_-10px_rgba(245,158,11,0.35)] text-slate-100 mb-8">
|
||||||
|
<div className="relative z-10 flex flex-col lg:flex-row items-center justify-between gap-8">
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div className="relative flex-shrink-0">
|
||||||
|
{poster ? (
|
||||||
|
<div className="relative">
|
||||||
|
<img src={poster} alt="" className="w-28 h-40 rounded-2xl object-cover shadow-2xl border-2 border-amber-500/40" />
|
||||||
|
<div className="absolute -bottom-3 -right-3 w-14 h-14 rounded-full disc-texture shadow-xl flex items-center justify-center animate-disc-spin border border-amber-400">
|
||||||
|
<Disc size={20} className="text-amber-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-28 h-28 rounded-full disc-texture shadow-2xl flex items-center justify-center p-1.5 animate-disc-spin border-2 border-amber-500/60">
|
||||||
|
<div className="w-full h-full rounded-full border-2 border-dashed border-white/40 flex items-center justify-center bg-slate-950/70">
|
||||||
|
<Disc size={40} className="text-amber-400 drop-shadow-[0_0_15px_rgba(245,158,11,0.8)]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 text-center sm:text-left">
|
||||||
|
<div className="flex items-center justify-center sm:justify-start gap-2 flex-wrap">
|
||||||
|
<span className="px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider bg-amber-500 text-slate-950 shadow-md">
|
||||||
|
NOW RIPPING
|
||||||
|
</span>
|
||||||
|
<TypeBadge type={aktiverJob.type} />
|
||||||
|
<StatusBadge status={aktiverJob.status} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-2xl md:text-3xl font-extrabold tracking-tight bg-gradient-to-r from-white via-slate-100 to-amber-200 bg-clip-text text-transparent">
|
||||||
|
{aktiverJob.title || 'Aktiver Ripping-Prozess'}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className="text-xs text-slate-400 font-mono">
|
||||||
|
Laufwerk: <strong className="text-amber-400">{aktiverJob.device}</strong> · {statusText}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row items-center gap-6 flex-shrink-0 w-full lg:w-auto justify-end">
|
||||||
|
<RadialProgress progress={aktiverJob.progress} size={96} strokeWidth={8} label="PROGRESS" />
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 w-full sm:w-auto">
|
||||||
|
<Button variant="amber" size="md" onClick={() => onDetailClick && onDetailClick(aktiverJob.id)}>
|
||||||
|
<Sparkles size={16} /> Details
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" size="md" onClick={() => onCancelClick && onCancelClick(aktiverJob.id)}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall 2: Kein aktiver Job -> 5-Panel Kachel-Galerie VOLLSTÄNDIG ausfüllen (Zero "Löcher"!)
|
||||||
|
// Wir füllen freie Slots mit informativen Cinema-Engine-Showcase-Kacheln auf!
|
||||||
|
const realMovieTiles = completedJobs.slice(0, 4)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative rounded-3xl overflow-hidden border border-amber-500/30 bg-[#0e1322] shadow-[0_0_40px_-5px_rgba(245,158,11,0.18)] mb-8 p-3">
|
||||||
|
{/* Top Subtle Amber Line */}
|
||||||
|
<div className="absolute top-0 inset-x-0 h-px bg-gradient-to-r from-transparent via-amber-500/40 to-transparent pointer-events-none" />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 h-44 md:h-52">
|
||||||
|
{/* Echte Medien-Kacheln aus der API */}
|
||||||
|
{realMovieTiles.map((job) => {
|
||||||
|
const poster = posterUrl(job.meta)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={job.id}
|
||||||
|
onClick={() => onDetailClick && onDetailClick(job.id)}
|
||||||
|
className="relative rounded-2xl overflow-hidden group cursor-pointer border border-slate-800 hover:border-amber-500/50 transition-all bg-slate-900 flex flex-col justify-end p-3"
|
||||||
|
>
|
||||||
|
{poster ? (
|
||||||
|
<img
|
||||||
|
src={poster}
|
||||||
|
alt={job.title || ''}
|
||||||
|
className="absolute inset-0 w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-slate-800 via-slate-900 to-slate-950 flex flex-col items-center justify-center p-3 text-center">
|
||||||
|
<Disc size={32} className="text-amber-400 mb-2" />
|
||||||
|
<span className="text-[10px] font-bold text-slate-400 uppercase">{job.type}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-slate-950 via-slate-950/40 to-transparent" />
|
||||||
|
|
||||||
|
<div className="relative z-10 space-y-1">
|
||||||
|
<TypeBadge type={job.type} />
|
||||||
|
<h4 className="text-xs font-bold text-slate-100 truncate group-hover:text-amber-400 transition-colors">
|
||||||
|
{job.title || 'Unbekannt'}
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Center / Showcase Feature Tile: 3D Spinning Optical Disc */}
|
||||||
|
{realMovieTiles.length < 5 && (
|
||||||
|
<div className="relative rounded-2xl overflow-hidden bg-gradient-to-br from-slate-900 via-[#131929] to-slate-950 border-2 border-amber-500/40 flex items-center justify-center p-4 shadow-inner group">
|
||||||
|
<div className="w-28 h-28 md:w-32 md:h-32 rounded-full disc-texture shadow-2xl shadow-black flex items-center justify-center p-1.5 animate-disc-spin border-2 border-amber-400/60">
|
||||||
|
<div className="w-full h-full rounded-full border-2 border-dashed border-white/40 flex items-center justify-center bg-slate-950/70">
|
||||||
|
<Disc size={40} className="text-amber-400 drop-shadow-[0_0_16px_rgba(245,158,11,0.9)]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="absolute bottom-2.5 px-3 py-0.5 rounded-full text-[10px] font-extrabold uppercase tracking-widest bg-amber-500 text-slate-950 shadow-md">
|
||||||
|
DISC ENGINE
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dynamic Engine Tile: Drive Status */}
|
||||||
|
{realMovieTiles.length < 4 && (
|
||||||
|
<div className="relative rounded-2xl overflow-hidden bg-gradient-to-br from-slate-900 to-indigo-950/80 border border-slate-800 flex flex-col justify-between p-4 hidden md:flex">
|
||||||
|
<div className="flex items-center gap-2 text-emerald-400 text-xs font-bold">
|
||||||
|
<CheckCircle size={14} /> DRIVE READY
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-bold text-slate-100">Automatische Erkennung</h4>
|
||||||
|
<p className="text-[11px] text-slate-400 mt-0.5">Lege eine Disc ein — Rippy rippt sofort los.</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] font-mono text-amber-400 font-bold">
|
||||||
|
4K UHD · BLU-RAY · DVD · CD
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dynamic Engine Tile: Media-Server Sync */}
|
||||||
|
{realMovieTiles.length < 3 && (
|
||||||
|
<div className="relative rounded-2xl overflow-hidden bg-gradient-to-br from-slate-900 to-slate-950 border border-slate-800 flex flex-col justify-between p-4 hidden md:flex">
|
||||||
|
<div className="flex items-center gap-2 text-amber-400 text-xs font-bold">
|
||||||
|
<Tv size={14} /> MEDIA SERVER
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-bold text-slate-100">Auto-NFO & Poster</h4>
|
||||||
|
<p className="text-[11px] text-slate-400 mt-0.5">Jellyfin, Plex & Kodi kompatibel.</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] font-mono text-slate-400">
|
||||||
|
Auto-Scan nach Rip
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dynamic Engine Tile: Lossless Assurance */}
|
||||||
|
{realMovieTiles.length < 2 && (
|
||||||
|
<div className="relative rounded-2xl overflow-hidden bg-gradient-to-br from-slate-900 to-slate-950 border border-slate-800 flex flex-col justify-between p-4 hidden md:flex">
|
||||||
|
<div className="flex items-center gap-2 text-indigo-400 text-xs font-bold">
|
||||||
|
<Shield size={14} /> MakeMKV
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-bold text-slate-100">Lossless 1:1</h4>
|
||||||
|
<p className="text-[11px] text-slate-400 mt-0.5">Alle Tonspuren & Subtitles erhalten.</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] font-mono text-slate-400">
|
||||||
|
x265 HandBrake Kompression
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { X, Film, Loader2, FolderOpen, AlertCircle, Download } from 'lucide-react'
|
import { Film, Loader2, FolderOpen, AlertCircle, Download } from 'lucide-react'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { useDarkMode } from '../context/ThemeContext'
|
import { Modal } from './ui/Modal'
|
||||||
|
import { Button } from './ui/Button'
|
||||||
// Klick auf den Titel in „Neueste Jobs" (Commander-Wunsch 24.07.): sofort
|
import { StatusBadge } from './ui/Badge'
|
||||||
// sehen, WAS da gerippt wurde — Poster, Jahr, Beschreibung — plus die
|
|
||||||
// nüchternen Job-Fakten (Ziel, Ausgabepfad, Fehler).
|
|
||||||
|
|
||||||
interface JobMeta {
|
interface JobMeta {
|
||||||
year?: number
|
year?: number
|
||||||
@@ -32,22 +30,12 @@ interface JobDetail {
|
|||||||
meta?: JobMeta | null
|
meta?: JobMeta | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// TMDB liefert relative Poster-Pfade, OMDb/Jikan absolute URLs
|
|
||||||
function posterUrl(meta?: JobMeta | null): string | null {
|
function posterUrl(meta?: JobMeta | null): string | null {
|
||||||
const p = meta?.poster_path
|
const p = meta?.poster_path
|
||||||
if (!p) return null
|
if (!p) return null
|
||||||
return p.startsWith('http') ? p : `https://image.tmdb.org/t/p/w342${p}`
|
return p.startsWith('http') ? p : `https://image.tmdb.org/t/p/w342${p}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_LABEL: Record<string, string> = {
|
|
||||||
pending: 'Wartend',
|
|
||||||
processing: 'Rippen',
|
|
||||||
transcoding: 'Komprimieren',
|
|
||||||
canceling: 'Wird abgebrochen…',
|
|
||||||
completed: 'Fertig',
|
|
||||||
failed: 'Fehler',
|
|
||||||
}
|
|
||||||
|
|
||||||
interface JobDatei {
|
interface JobDatei {
|
||||||
name: string
|
name: string
|
||||||
size_mb: number | null
|
size_mb: number | null
|
||||||
@@ -57,7 +45,6 @@ export default function JobDetailModal({ jobId, onClose }: { jobId: string | nul
|
|||||||
const [detail, setDetail] = useState<JobDetail | null>(null)
|
const [detail, setDetail] = useState<JobDetail | null>(null)
|
||||||
const [dateien, setDateien] = useState<JobDatei[] | null>(null)
|
const [dateien, setDateien] = useState<JobDatei[] | null>(null)
|
||||||
const [laedt, setLaedt] = useState(false)
|
const [laedt, setLaedt] = useState(false)
|
||||||
const { theme } = useDarkMode()
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!jobId) {
|
if (!jobId) {
|
||||||
@@ -69,7 +56,6 @@ export default function JobDetailModal({ jobId, onClose }: { jobId: string | nul
|
|||||||
api.get(`/jobs/${jobId}/detail`)
|
api.get(`/jobs/${jobId}/detail`)
|
||||||
.then(r => {
|
.then(r => {
|
||||||
setDetail(r.data)
|
setDetail(r.data)
|
||||||
// Fertige Jobs: Dateiliste für die Download-Knöpfe nachladen
|
|
||||||
if (r.data?.status === 'completed') {
|
if (r.data?.status === 'completed') {
|
||||||
api.get(`/jobs/${jobId}/files`)
|
api.get(`/jobs/${jobId}/files`)
|
||||||
.then(f => setDateien(f.data.files))
|
.then(f => setDateien(f.data.files))
|
||||||
@@ -86,45 +72,40 @@ export default function JobDetailModal({ jobId, onClose }: { jobId: string | nul
|
|||||||
const poster = posterUrl(meta)
|
const poster = posterUrl(meta)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" onClick={onClose}>
|
<Modal
|
||||||
<div
|
isOpen={!!jobId}
|
||||||
className={`w-full max-w-2xl max-h-[85vh] overflow-y-auto rounded-xl shadow-2xl ${theme === 'dark' ? 'bg-slate-800' : 'bg-white'}`}
|
onClose={onClose}
|
||||||
onClick={e => e.stopPropagation()}
|
title={detail?.title || 'Job-Details'}
|
||||||
|
maxWidth="2xl"
|
||||||
>
|
>
|
||||||
<div className={`px-6 py-4 border-b flex items-center justify-between ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'}`}>
|
|
||||||
<h2 className={`text-lg font-bold truncate ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
|
||||||
{detail?.title || 'Job-Details'}
|
|
||||||
</h2>
|
|
||||||
<button onClick={onClose} className={`p-1.5 rounded flex-shrink-0 ${theme === 'dark' ? 'hover:bg-slate-700 text-slate-400' : 'hover:bg-slate-100 text-slate-500'}`}>
|
|
||||||
<X size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{laedt ? (
|
{laedt ? (
|
||||||
<div className="flex items-center justify-center py-16">
|
<div className="flex items-center justify-center py-16">
|
||||||
<Loader2 className="animate-spin text-indigo-500" size={28} />
|
<Loader2 className="animate-spin text-amber-500" size={28} />
|
||||||
</div>
|
</div>
|
||||||
) : !detail ? (
|
) : !detail ? (
|
||||||
<p className={`px-6 py-10 text-sm text-center ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
<p className="px-6 py-10 text-sm text-center text-slate-500 dark:text-slate-400">
|
||||||
Job nicht gefunden.
|
Job nicht gefunden.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-6 space-y-5">
|
<div className="space-y-6">
|
||||||
{/* Film/Serien-Info aus der Disc-Erkennung */}
|
{/* Film/Serien-Info aus der Disc-Erkennung */}
|
||||||
<div className="flex gap-5">
|
<div className="flex gap-5">
|
||||||
{poster ? (
|
{poster ? (
|
||||||
<img src={poster} alt={detail.title || ''} className="w-32 rounded-lg object-cover flex-shrink-0 shadow-lg" />
|
<img src={poster} alt={detail.title || ''} className="w-32 rounded-xl object-cover flex-shrink-0 shadow-lg shadow-black/30 border border-slate-700/50" />
|
||||||
) : (
|
) : (
|
||||||
<div className={`w-32 h-48 rounded-lg flex items-center justify-center flex-shrink-0 ${theme === 'dark' ? 'bg-slate-900 text-slate-600' : 'bg-slate-100 text-slate-400'}`}>
|
<div className="w-32 h-48 rounded-xl flex items-center justify-center flex-shrink-0 bg-slate-100 dark:bg-slate-800 text-slate-400 dark:text-slate-500 border border-slate-200 dark:border-slate-700">
|
||||||
<Film size={32} />
|
<Film size={36} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<p className={`text-xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<h3 className="text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||||
{detail.title || 'Unbekannt'}
|
{detail.title || 'Unbekannt'}
|
||||||
{meta?.year ? <span className={`font-normal ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}> ({meta.year})</span> : null}
|
{meta?.year ? <span className="font-normal text-slate-500 dark:text-slate-400"> ({meta.year})</span> : null}
|
||||||
</p>
|
</h3>
|
||||||
<p className={`text-xs mt-1 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
<StatusBadge status={detail.status} />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs mt-1 text-slate-500 dark:text-slate-400">
|
||||||
{meta?.type === 'tv' ? 'Serie' : 'Film'}
|
{meta?.type === 'tv' ? 'Serie' : 'Film'}
|
||||||
{meta?.source ? ` · Quelle: ${meta.source}` : ''}
|
{meta?.source ? ` · Quelle: ${meta.source}` : ''}
|
||||||
{meta?.runtime ? ` · ${meta.runtime} min` : ''}
|
{meta?.runtime ? ` · ${meta.runtime} min` : ''}
|
||||||
@@ -132,61 +113,62 @@ export default function JobDetailModal({ jobId, onClose }: { jobId: string | nul
|
|||||||
{meta?.genres && meta.genres.length > 0 && (
|
{meta?.genres && meta.genres.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||||
{meta.genres.map(g => (
|
{meta.genres.map(g => (
|
||||||
<span key={g} className={`text-xs px-2 py-0.5 rounded-full ${theme === 'dark' ? 'bg-slate-700 text-slate-300' : 'bg-slate-100 text-slate-600'}`}>{g}</span>
|
<span key={g} className="text-xs px-2.5 py-0.5 rounded-full bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 border border-slate-200 dark:border-slate-700">
|
||||||
|
{g}
|
||||||
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{meta?.overview ? (
|
{meta?.overview ? (
|
||||||
<p className={`text-sm mt-3 leading-relaxed ${theme === 'dark' ? 'text-slate-300' : 'text-slate-600'}`}>
|
<p className="text-sm mt-3 leading-relaxed text-slate-600 dark:text-slate-300 line-clamp-4">
|
||||||
{meta.overview}
|
{meta.overview}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className={`text-sm mt-3 italic ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
<p className="text-sm mt-3 italic text-slate-400 dark:text-slate-500">
|
||||||
Keine Metadaten zu diesem Job gespeichert — die Disc wurde ohne Erkennung gerippt.
|
Keine Metadaten zu diesem Job gespeichert — die Disc wurde ohne Erkennung gerippt.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Nüchterne Job-Fakten */}
|
{/* Job-Fakten Grid */}
|
||||||
<div className={`rounded-lg p-4 grid grid-cols-[auto_1fr] gap-x-6 gap-y-1.5 text-sm ${theme === 'dark' ? 'bg-slate-900' : 'bg-slate-50'}`}>
|
<div className="rounded-xl p-4 grid grid-cols-[auto_1fr] gap-x-6 gap-y-2 text-sm bg-slate-50 dark:bg-slate-950/80 border border-slate-200/80 dark:border-slate-800">
|
||||||
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>Status</span>
|
<span className="text-slate-500 dark:text-slate-400 font-medium">Status</span>
|
||||||
<span className={theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}>
|
<span className="text-slate-900 dark:text-slate-200">
|
||||||
{STATUS_LABEL[detail.status] || detail.status}{detail.progress > 0 && detail.progress < 100 ? ` · ${detail.progress} %` : ''}
|
{detail.status}{detail.progress > 0 && detail.progress < 100 ? ` · ${detail.progress} %` : ''}
|
||||||
</span>
|
</span>
|
||||||
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>Laufwerk</span>
|
<span className="text-slate-500 dark:text-slate-400 font-medium">Laufwerk</span>
|
||||||
<span className={`font-mono text-xs self-center ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}>{detail.device || '—'}</span>
|
<span className="font-mono text-xs self-center text-slate-900 dark:text-slate-200">{detail.device || '—'}</span>
|
||||||
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>Gestartet</span>
|
<span className="text-slate-500 dark:text-slate-400 font-medium">Gestartet</span>
|
||||||
<span className={theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}>
|
<span className="text-slate-900 dark:text-slate-200">
|
||||||
{detail.startTime ? new Date(detail.startTime).toLocaleString('de-DE') : '—'}
|
{detail.startTime ? new Date(detail.startTime).toLocaleString('de-DE') : '—'}
|
||||||
</span>
|
</span>
|
||||||
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>Beendet</span>
|
<span className="text-slate-500 dark:text-slate-400 font-medium">Beendet</span>
|
||||||
<span className={theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}>
|
<span className="text-slate-900 dark:text-slate-200">
|
||||||
{detail.endTime ? new Date(detail.endTime).toLocaleString('de-DE') : '—'}
|
{detail.endTime ? new Date(detail.endTime).toLocaleString('de-DE') : '—'}
|
||||||
</span>
|
</span>
|
||||||
{(detail.output_path || detail.target_dir) && (
|
{(detail.output_path || detail.target_dir) && (
|
||||||
<>
|
<>
|
||||||
<span className={`flex items-center gap-1 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}><FolderOpen size={13} /> Ablage</span>
|
<span className="flex items-center gap-1 text-slate-500 dark:text-slate-400 font-medium"><FolderOpen size={13} /> Ablage</span>
|
||||||
<span className={`font-mono text-xs self-center break-all ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}>
|
<span className="font-mono text-xs self-center break-all text-slate-900 dark:text-slate-200">
|
||||||
{detail.output_path || detail.target_dir}
|
{detail.output_path || detail.target_dir}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Download der fertigen Dateien — vorher kam man nur per scp dran */}
|
{/* Dateiliste & Download */}
|
||||||
{detail.status === 'completed' && dateien && dateien.length > 0 && (
|
{detail.status === 'completed' && dateien && dateien.length > 0 && (
|
||||||
<div className={`rounded-lg p-4 ${theme === 'dark' ? 'bg-slate-900' : 'bg-slate-50'}`}>
|
<div className="rounded-xl p-4 bg-slate-50 dark:bg-slate-950/80 border border-slate-200/80 dark:border-slate-800">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<p className={`text-sm font-medium flex items-center gap-2 ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}>
|
<p className="text-sm font-medium flex items-center gap-2 text-slate-900 dark:text-slate-200">
|
||||||
<Download size={15} /> Dateien herunterladen
|
<Download size={15} className="text-amber-500" /> Dateien herunterladen
|
||||||
</p>
|
</p>
|
||||||
{dateien.length > 1 && (
|
{dateien.length > 1 && (
|
||||||
<button
|
<Button
|
||||||
|
variant="amber"
|
||||||
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// Gestaffelte Einzel-Downloads statt Server-seitigem
|
|
||||||
// 40-GB-Zip — der Browser fragt ggf. einmal nach
|
|
||||||
// Erlaubnis für mehrere Downloads.
|
|
||||||
dateien.forEach((f, i) => {
|
dateien.forEach((f, i) => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a')
|
||||||
@@ -198,24 +180,23 @@ export default function JobDetailModal({ jobId, onClose }: { jobId: string | nul
|
|||||||
}, i * 400)
|
}, i * 400)
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
className="text-xs font-medium px-3 py-1.5 rounded-lg bg-indigo-600 text-white hover:bg-indigo-700 transition-colors"
|
|
||||||
>
|
>
|
||||||
Alle herunterladen ({dateien.length})
|
Alle herunterladen ({dateien.length})
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1.5">
|
||||||
{dateien.map(f => (
|
{dateien.map(f => (
|
||||||
<a
|
<a
|
||||||
key={f.name}
|
key={f.name}
|
||||||
href={`/api/jobs/${detail.id}/files/${encodeURIComponent(f.name)}`}
|
href={`/api/jobs/${detail.id}/files/${encodeURIComponent(f.name)}`}
|
||||||
download
|
download
|
||||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${theme === 'dark' ? 'text-indigo-300 hover:bg-slate-800' : 'text-indigo-700 hover:bg-slate-100'}`}
|
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors text-amber-600 dark:text-amber-300 hover:bg-slate-100 dark:hover:bg-slate-800"
|
||||||
>
|
>
|
||||||
<Download size={14} className="flex-shrink-0" />
|
<Download size={14} className="flex-shrink-0" />
|
||||||
<span className="truncate min-w-0">{f.name}</span>
|
<span className="truncate min-w-0">{f.name}</span>
|
||||||
{f.size_mb != null && (
|
{f.size_mb != null && (
|
||||||
<span className={`ml-auto text-xs flex-shrink-0 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
<span className="ml-auto text-xs flex-shrink-0 text-slate-500 dark:text-slate-400 font-mono">
|
||||||
{f.size_mb >= 1024 ? `${(f.size_mb / 1024).toFixed(1)} GB` : `${f.size_mb} MB`}
|
{f.size_mb >= 1024 ? `${(f.size_mb / 1024).toFixed(1)} GB` : `${f.size_mb} MB`}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -226,14 +207,13 @@ export default function JobDetailModal({ jobId, onClose }: { jobId: string | nul
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{detail.error && (
|
{detail.error && (
|
||||||
<div className={`rounded-lg p-4 flex items-start gap-2.5 text-sm ${theme === 'dark' ? 'bg-rose-900/30 text-rose-300' : 'bg-rose-50 text-rose-700'}`}>
|
<div className="rounded-xl p-4 flex items-start gap-2.5 text-sm bg-rose-500/10 border border-rose-500/20 text-rose-600 dark:text-rose-400">
|
||||||
<AlertCircle size={16} className="flex-shrink-0 mt-0.5" />
|
<AlertCircle size={16} className="flex-shrink-0 mt-0.5" />
|
||||||
<span className="break-words min-w-0">{detail.error}</span>
|
<span className="break-words min-w-0">{detail.error}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Modal>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Terminal, Play, CheckCircle, AlertCircle } from 'lucide-react'
|
import { Terminal, Play, CheckCircle, AlertCircle } from 'lucide-react'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { useDarkMode } from '../context/ThemeContext'
|
import { Card, CardHeader, CardTitle } from './ui/Card'
|
||||||
|
|
||||||
interface LiveLogEntry {
|
interface LiveLogEntry {
|
||||||
id: string
|
id: string
|
||||||
@@ -14,7 +14,6 @@ interface LiveLogEntry {
|
|||||||
export default function LiveLogSection() {
|
export default function LiveLogSection() {
|
||||||
const [logs, setLogs] = useState<LiveLogEntry[]>([])
|
const [logs, setLogs] = useState<LiveLogEntry[]>([])
|
||||||
const [currentJob, setCurrentJob] = useState<any>(null)
|
const [currentJob, setCurrentJob] = useState<any>(null)
|
||||||
const { theme } = useDarkMode()
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchJobs = async () => {
|
const fetchJobs = async () => {
|
||||||
@@ -50,28 +49,28 @@ export default function LiveLogSection() {
|
|||||||
|
|
||||||
const getStatusColor = (level: string) => {
|
const getStatusColor = (level: string) => {
|
||||||
switch (level) {
|
switch (level) {
|
||||||
case 'error': return 'text-rose-500'
|
case 'error': return 'text-rose-500 dark:text-rose-400'
|
||||||
case 'warning': return 'text-amber-500'
|
case 'warning': return 'text-amber-500 dark:text-amber-400'
|
||||||
case 'success': return 'text-emerald-500'
|
case 'success': return 'text-emerald-500 dark:text-emerald-400'
|
||||||
default: return 'text-blue-500'
|
default: return 'text-sky-500 dark:text-sky-400'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStatusIcon = (level: string) => {
|
const getStatusIcon = (level: string) => {
|
||||||
switch (level) {
|
switch (level) {
|
||||||
case 'error': return <AlertCircle size={16} />
|
case 'error': return <AlertCircle size={15} />
|
||||||
case 'warning': return <AlertCircle size={16} />
|
case 'warning': return <AlertCircle size={15} />
|
||||||
case 'success': return <CheckCircle size={16} />
|
case 'success': return <CheckCircle size={15} />
|
||||||
default: return <Terminal size={16} />
|
default: return <Terminal size={15} />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<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'}`}>
|
<Card className="overflow-hidden">
|
||||||
<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`}>
|
<CardHeader>
|
||||||
<div>
|
<div>
|
||||||
<h2 className={`text-lg font-semibold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Live-Log</h2>
|
<CardTitle>Live-Log</CardTitle>
|
||||||
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||||
{currentJob
|
{currentJob
|
||||||
? `Aktiver Job: ${currentJob.type.toUpperCase()} (${currentJob.progress}%)`
|
? `Aktiver Job: ${currentJob.type.toUpperCase()} (${currentJob.progress}%)`
|
||||||
: 'Kein aktiver Job'}
|
: 'Kein aktiver Job'}
|
||||||
@@ -79,21 +78,19 @@ export default function LiveLogSection() {
|
|||||||
</div>
|
</div>
|
||||||
{currentJob && (
|
{currentJob && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className={`px-3 py-1 rounded-full text-xs font-medium flex items-center gap-2 ${
|
<div className={`px-3 py-1 rounded-full text-xs font-medium flex items-center gap-2 border ${
|
||||||
currentJob.progress === 100
|
currentJob.progress === 100
|
||||||
? theme === 'dark' ? 'bg-emerald-900/30 text-emerald-400' : 'bg-emerald-100 text-emerald-700'
|
? 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30'
|
||||||
: theme === 'dark' ? 'bg-blue-900/30 text-blue-400' : 'bg-blue-100 text-blue-700'
|
: 'bg-sky-500/15 text-sky-600 dark:text-sky-400 border-sky-500/30'
|
||||||
}`}>
|
}`}>
|
||||||
{currentJob.progress === 100 ? <CheckCircle size={12} /> : <Play size={12} />}
|
{currentJob.progress === 100 ? <CheckCircle size={12} /> : <Play size={12} />}
|
||||||
{currentJob.progress}%
|
{currentJob.progress}%
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</CardHeader>
|
||||||
|
|
||||||
<div className={`max-h-[400px] overflow-y-auto p-4 space-y-2 transition-colors duration-300 font-mono text-xs ${
|
<div className="max-h-[380px] overflow-y-auto p-4 space-y-2 font-mono text-xs bg-slate-900/90 text-slate-200 border-b border-slate-800">
|
||||||
theme === 'dark' ? 'bg-slate-900' : 'bg-slate-50'
|
|
||||||
}`}>
|
|
||||||
{logs.length === 0 ? (
|
{logs.length === 0 ? (
|
||||||
<div className="text-center py-8 text-slate-500">
|
<div className="text-center py-8 text-slate-500">
|
||||||
<Terminal size={32} className="mx-auto mb-2 opacity-50" />
|
<Terminal size={32} className="mx-auto mb-2 opacity-50" />
|
||||||
@@ -101,14 +98,14 @@ export default function LiveLogSection() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
logs.map((log) => (
|
logs.map((log) => (
|
||||||
<div key={log.id} className={`flex items-start gap-2 ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
<div key={log.id} className="flex items-start gap-2 text-slate-300">
|
||||||
<span className="text-slate-500 whitespace-nowrap">
|
<span className="text-slate-500 whitespace-nowrap">
|
||||||
{new Date(log.timestamp).toLocaleTimeString('de-DE', { hour12: false })}
|
{new Date(log.timestamp).toLocaleTimeString('de-DE', { hour12: false })}
|
||||||
</span>
|
</span>
|
||||||
<span className={`font-bold ${getStatusColor(log.level)}`}>
|
<span className={`font-bold ${getStatusColor(log.level)}`}>
|
||||||
[{log.source}]
|
[{log.source}]
|
||||||
</span>
|
</span>
|
||||||
<span className="flex-1">{log.message}</span>
|
<span className="flex-1 text-slate-200">{log.message}</span>
|
||||||
<span className={getStatusColor(log.level)}>
|
<span className={getStatusColor(log.level)}>
|
||||||
{getStatusIcon(log.level)}
|
{getStatusIcon(log.level)}
|
||||||
</span>
|
</span>
|
||||||
@@ -118,30 +115,28 @@ export default function LiveLogSection() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Stats */}
|
{/* Quick Stats */}
|
||||||
<div className={`px-6 py-3 border-t transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'} grid grid-cols-3 gap-4`}>
|
|
||||||
{currentJob && (
|
{currentJob && (
|
||||||
<>
|
<div className="px-6 py-3 grid grid-cols-3 gap-4 bg-slate-50/50 dark:bg-slate-900/50">
|
||||||
<div>
|
<div>
|
||||||
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Fortschritt</p>
|
<p className="text-xs text-slate-500 dark:text-slate-400">Fortschritt</p>
|
||||||
<p className={`text-lg font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
<p className="text-lg font-bold text-slate-900 dark:text-slate-100">
|
||||||
{currentJob.progress}%
|
{currentJob.progress}%
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Gerät</p>
|
<p className="text-xs text-slate-500 dark:text-slate-400">Gerät</p>
|
||||||
<p className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||||
{currentJob.device || 'Unbekannt'}
|
{currentJob.device || 'Unbekannt'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Typ</p>
|
<p className="text-xs text-slate-500 dark:text-slate-400">Typ</p>
|
||||||
<p className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||||
{currentJob.type?.toUpperCase() || 'Unbekannt'}
|
{currentJob.type?.toUpperCase() || 'Unbekannt'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Search, Loader2, X, Film } from 'lucide-react'
|
import { Search, Loader2, Film } from 'lucide-react'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { useDarkMode } from '../context/ThemeContext'
|
import { Modal } from './ui/Modal'
|
||||||
|
import { Button } from './ui/Button'
|
||||||
// „Nicht korrekt?!"-Popup (Commander-Wunsch 23.07.): Korrektur direkt am
|
import { Input } from './ui/Input'
|
||||||
// Dashboard — suchen, richtigen Treffer anklicken, Rippy merkt sich die Wahl
|
|
||||||
// für diese Disc (30 Tage, per Disc-Fingerabdruck).
|
|
||||||
|
|
||||||
interface SuchKandidat {
|
interface SuchKandidat {
|
||||||
title: string
|
title: string
|
||||||
@@ -30,7 +28,6 @@ export default function MetadataKorrektur({ devicePath, vorschlag, isOpen, onClo
|
|||||||
const [kandidaten, setKandidaten] = useState<SuchKandidat[]>([])
|
const [kandidaten, setKandidaten] = useState<SuchKandidat[]>([])
|
||||||
const [laeuft, setLaeuft] = useState(false)
|
const [laeuft, setLaeuft] = useState(false)
|
||||||
const [fehler, setFehler] = useState<string | null>(null)
|
const [fehler, setFehler] = useState<string | null>(null)
|
||||||
const { theme } = useDarkMode()
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
@@ -74,63 +71,60 @@ export default function MetadataKorrektur({ devicePath, vorschlag, isOpen, onClo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isOpen) return null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
<Modal
|
||||||
<div className={`w-full max-w-2xl max-h-[85vh] flex flex-col rounded-xl shadow-2xl ${theme === 'dark' ? 'bg-slate-800' : 'bg-white'}`}>
|
isOpen={isOpen}
|
||||||
<div className={`px-6 py-4 border-b flex items-center justify-between ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'}`}>
|
onClose={onClose}
|
||||||
<div>
|
title="Richtigen Titel wählen"
|
||||||
<h2 className={`text-lg font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Richtigen Titel wählen</h2>
|
maxWidth="2xl"
|
||||||
<p className={`text-xs mt-0.5 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
Deine Wahl gilt für diese Disc — auch beim nächsten Einlegen.
|
Deine Wahl gilt für diese Disc — auch beim nächsten Einlegen.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
<button onClick={onClose} className={`p-1.5 rounded ${theme === 'dark' ? 'hover:bg-slate-700 text-slate-400' : 'hover:bg-slate-100 text-slate-500'}`}>
|
|
||||||
<X size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-6 overflow-y-auto">
|
<div className="flex gap-3 items-end">
|
||||||
<div className="flex gap-3">
|
<div className="flex-1">
|
||||||
<input
|
<Input
|
||||||
value={suchbegriff}
|
value={suchbegriff}
|
||||||
onChange={e => setSuchbegriff(e.target.value)}
|
onChange={e => setSuchbegriff(e.target.value)}
|
||||||
onKeyDown={e => e.key === 'Enter' && suchen()}
|
onKeyDown={e => e.key === 'Enter' && suchen()}
|
||||||
autoFocus
|
autoFocus
|
||||||
placeholder="Titel eingeben…"
|
placeholder="Titel eingeben…"
|
||||||
className={`flex-1 px-4 py-2 border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent ${theme === 'dark' ? 'bg-slate-700 border-slate-600 text-slate-100' : 'bg-white border-slate-300 text-slate-900'}`}
|
|
||||||
/>
|
/>
|
||||||
<button
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="amber"
|
||||||
onClick={suchen}
|
onClick={suchen}
|
||||||
disabled={!suchbegriff || laeuft}
|
disabled={!suchbegriff || laeuft}
|
||||||
className="flex items-center gap-2 px-5 py-2 rounded-lg bg-indigo-600 text-white hover:bg-indigo-700 transition-colors disabled:opacity-40"
|
|
||||||
>
|
>
|
||||||
{laeuft ? <Loader2 className="animate-spin" size={18} /> : <Search size={18} />}
|
{laeuft ? <Loader2 className="animate-spin" size={18} /> : <Search size={18} />}
|
||||||
Suchen
|
Suchen
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{fehler && <p className="text-sm text-rose-500 mt-3">{fehler}</p>}
|
{fehler && <p className="text-sm text-rose-500 font-medium">{fehler}</p>}
|
||||||
|
|
||||||
{kandidaten.length > 0 && (
|
{kandidaten.length > 0 && (
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mt-4">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3.5 pt-2 max-h-[60vh] overflow-y-auto">
|
||||||
{kandidaten.map((k, i) => (
|
{kandidaten.map((k, i) => (
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
onClick={() => waehlen(k)}
|
onClick={() => waehlen(k)}
|
||||||
className={`text-left rounded-lg border overflow-hidden transition-all hover:border-indigo-500 hover:shadow-lg ${theme === 'dark' ? 'bg-slate-900 border-slate-700' : 'bg-slate-50 border-slate-200'}`}
|
className="text-left rounded-xl border border-slate-200 dark:border-slate-800 bg-slate-50 dark:bg-slate-950/80 overflow-hidden transition-all hover:border-amber-500/50 hover:shadow-lg hover:shadow-amber-500/10 group"
|
||||||
>
|
>
|
||||||
{k.poster ? (
|
{k.poster ? (
|
||||||
<img src={k.poster} alt={k.title} className="w-full h-40 object-cover" />
|
<img src={k.poster} alt={k.title} className="w-full h-40 object-cover group-hover:scale-105 transition-transform duration-300" />
|
||||||
) : (
|
) : (
|
||||||
<div className={`w-full h-40 flex items-center justify-center ${theme === 'dark' ? 'bg-slate-800 text-slate-600' : 'bg-slate-100 text-slate-400'}`}>
|
<div className="w-full h-40 flex items-center justify-center bg-slate-200 dark:bg-slate-900 text-slate-400 dark:text-slate-600">
|
||||||
<Film size={32} />
|
<Film size={32} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="p-2">
|
<div className="p-2.5">
|
||||||
<p className={`text-xs font-semibold leading-tight line-clamp-2 ${theme === 'dark' ? 'text-slate-200' : 'text-slate-800'}`}>{k.title}</p>
|
<p className="text-xs font-semibold leading-tight line-clamp-2 text-slate-900 dark:text-slate-200 group-hover:text-amber-500 dark:group-hover:text-amber-400 transition-colors">
|
||||||
<p className={`text-xs mt-0.5 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
{k.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] mt-1 text-slate-500 dark:text-slate-400">
|
||||||
{k.year || '—'} · {k.type === 'tv' ? 'Serie' : 'Film'} · {k.source}
|
{k.year || '—'} · {k.type === 'tv' ? 'Serie' : 'Film'} · {k.source}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -139,7 +133,6 @@ export default function MetadataKorrektur({ devicePath, vorschlag, isOpen, onClo
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Folder, FolderOpen, File, ArrowUp, CheckCircle } from 'lucide-react'
|
import { Folder, FolderOpen, File, ArrowUp, CheckCircle, Film, Tv, Music, Cpu } from 'lucide-react'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { useDarkMode } from '../context/ThemeContext'
|
import { Modal } from './ui/Modal'
|
||||||
|
import { Button } from './ui/Button'
|
||||||
|
import { Input, Select } from './ui/Input'
|
||||||
|
|
||||||
interface TargetConfig {
|
interface TargetConfig {
|
||||||
id: string
|
id: string
|
||||||
@@ -15,7 +17,19 @@ export interface RipOptionen {
|
|||||||
series?: string
|
series?: string
|
||||||
season?: number
|
season?: number
|
||||||
mainFeatureOnly?: boolean
|
mainFeatureOnly?: boolean
|
||||||
titles?: number[] // exakte Titel-Auswahl (Track-Tabelle)
|
titles?: number[]
|
||||||
|
transcodeNode?: string // gewählter Encoder-Worker (Celery-Node) oder leer = auto
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkerWahl {
|
||||||
|
name: string
|
||||||
|
node: string | null
|
||||||
|
online?: boolean
|
||||||
|
encoders: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENCODER_KURZ: Record<string, string> = {
|
||||||
|
'cpu-x264': 'H.264', 'cpu-x265': 'H.265', 'vaapi': 'VAAPI⚡', 'nvenc': 'NVENC⚡',
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TitelInfo {
|
interface TitelInfo {
|
||||||
@@ -27,9 +41,9 @@ interface TitelInfo {
|
|||||||
|
|
||||||
interface RipTargetModalProps {
|
interface RipTargetModalProps {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
initialType?: TargetConfig['type'] // CD → Musik vorwählen (Medium-gerecht)
|
initialType?: TargetConfig['type']
|
||||||
discTitle?: string // Vorschlag für den Serien-Namen
|
discTitle?: string
|
||||||
deviceId?: string // Laufwerks-ID (sr0) für den Titel-Scan
|
deviceId?: string
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSave: (target: TargetConfig, optionen: RipOptionen) => void
|
onSave: (target: TargetConfig, optionen: RipOptionen) => void
|
||||||
}
|
}
|
||||||
@@ -45,8 +59,6 @@ interface BrowseDir {
|
|||||||
path: string
|
path: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ziel-Auswahl vor dem Rip: Schnellwahl (Filme/Serien/Musik) ODER frei per
|
|
||||||
// Ordner-Browser — auch in eingehängte Netzwerk-Ziele (NAS, PC-Freigabe).
|
|
||||||
export default function RipTargetModal({ isOpen, initialType, discTitle, deviceId, onClose, onSave }: RipTargetModalProps) {
|
export default function RipTargetModal({ isOpen, initialType, discTitle, deviceId, onClose, onSave }: RipTargetModalProps) {
|
||||||
const [selectedType, setSelectedType] = useState<TargetConfig['type']>('movies')
|
const [selectedType, setSelectedType] = useState<TargetConfig['type']>('movies')
|
||||||
const [serienName, setSerienName] = useState('')
|
const [serienName, setSerienName] = useState('')
|
||||||
@@ -56,6 +68,8 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
const [scanFehler, setScanFehler] = useState('')
|
const [scanFehler, setScanFehler] = useState('')
|
||||||
const [titelListe, setTitelListe] = useState<TitelInfo[]>([])
|
const [titelListe, setTitelListe] = useState<TitelInfo[]>([])
|
||||||
const [gewaehlt, setGewaehlt] = useState<Set<number>>(new Set())
|
const [gewaehlt, setGewaehlt] = useState<Set<number>>(new Set())
|
||||||
|
const [workers, setWorkers] = useState<WorkerWahl[]>([])
|
||||||
|
const [encoderNode, setEncoderNode] = useState('') // '' = automatisch
|
||||||
|
|
||||||
const scanStarten = async () => {
|
const scanStarten = async () => {
|
||||||
if (!deviceId) return
|
if (!deviceId) return
|
||||||
@@ -69,7 +83,6 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Polling, solange der Titel-Scan läuft (Info-Lauf dauert 20–120 s)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (scanStatus !== 'running' || !deviceId) return
|
if (scanStatus !== 'running' || !deviceId) return
|
||||||
const interval = setInterval(async () => {
|
const interval = setInterval(async () => {
|
||||||
@@ -78,7 +91,6 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
if (r.data.status === 'done') {
|
if (r.data.status === 'done') {
|
||||||
const tracks: TitelInfo[] = r.data.tracks || []
|
const tracks: TitelInfo[] = r.data.tracks || []
|
||||||
setTitelListe(tracks)
|
setTitelListe(tracks)
|
||||||
// Vorauswahl: alles ab 5 Minuten (Trailer/Menü-Schnipsel bleiben weg)
|
|
||||||
setGewaehlt(new Set(tracks.filter(t => t.dauer_s >= 300).map(t => t.nr)))
|
setGewaehlt(new Set(tracks.filter(t => t.dauer_s >= 300).map(t => t.nr)))
|
||||||
setScanStatus('done')
|
setScanStatus('done')
|
||||||
} else if (r.data.status === 'error') {
|
} else if (r.data.status === 'error') {
|
||||||
@@ -89,6 +101,7 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
}, 3000)
|
}, 3000)
|
||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval)
|
||||||
}, [scanStatus, deviceId])
|
}, [scanStatus, deviceId])
|
||||||
|
|
||||||
const [targets, setTargets] = useState<TargetConfig[]>([
|
const [targets, setTargets] = useState<TargetConfig[]>([
|
||||||
{ id: '1', name: 'Filme', path: '/app/media/movies', type: 'movies', isActive: true },
|
{ id: '1', name: 'Filme', path: '/app/media/movies', type: 'movies', isActive: true },
|
||||||
{ id: '2', name: 'Serien', path: '/app/media/series', type: 'series', isActive: true },
|
{ id: '2', name: 'Serien', path: '/app/media/series', type: 'series', isActive: true },
|
||||||
@@ -99,9 +112,7 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
const [browseDirs, setBrowseDirs] = useState<BrowseDir[]>([])
|
const [browseDirs, setBrowseDirs] = useState<BrowseDir[]>([])
|
||||||
const [browseFiles, setBrowseFiles] = useState<{ name: string, size_mb: number | null }[]>([])
|
const [browseFiles, setBrowseFiles] = useState<{ name: string, size_mb: number | null }[]>([])
|
||||||
const [customPath, setCustomPath] = useState('')
|
const [customPath, setCustomPath] = useState('')
|
||||||
const { theme } = useDarkMode()
|
|
||||||
|
|
||||||
// Standard-Unterordner aus den Einstellungen ziehen (nicht hartkodiert)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return
|
if (!isOpen) return
|
||||||
setSelectedType(initialType || 'movies')
|
setSelectedType(initialType || 'movies')
|
||||||
@@ -112,6 +123,11 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
setScanFehler('')
|
setScanFehler('')
|
||||||
setTitelListe([])
|
setTitelListe([])
|
||||||
setGewaehlt(new Set())
|
setGewaehlt(new Set())
|
||||||
|
setEncoderNode('')
|
||||||
|
// Online-Worker für die Encoder-Wahl (nur relevant, wenn ≥2 verfügbar)
|
||||||
|
api.get('/capabilities').then(r => {
|
||||||
|
setWorkers((r.data.workers || []).filter((w: WorkerWahl) => w.online && w.node))
|
||||||
|
}).catch(() => setWorkers([]))
|
||||||
api.get('/settings').then(r => {
|
api.get('/settings').then(r => {
|
||||||
const s = r.data || {}
|
const s = r.data || {}
|
||||||
setNurHauptfilm(!!s.mainFeatureOnly)
|
setNurHauptfilm(!!s.mainFeatureOnly)
|
||||||
@@ -144,12 +160,10 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
optionen.series = serienName.trim()
|
optionen.series = serienName.trim()
|
||||||
optionen.season = Math.max(1, staffel || 1)
|
optionen.season = Math.max(1, staffel || 1)
|
||||||
}
|
}
|
||||||
// Explizite Titel-Auswahl schlägt alles andere
|
|
||||||
if (scanStatus === 'done' && gewaehlt.size > 0 && gewaehlt.size < titelListe.length) {
|
if (scanStatus === 'done' && gewaehlt.size > 0 && gewaehlt.size < titelListe.length) {
|
||||||
optionen.titles = [...gewaehlt].sort((a, b) => a - b)
|
optionen.titles = [...gewaehlt].sort((a, b) => a - b)
|
||||||
} else if (scanStatus === 'done' && gewaehlt.size > 0) {
|
|
||||||
// Alle gewählt = normales „all" — keine Einzelaufrufe nötig
|
|
||||||
}
|
}
|
||||||
|
if (encoderNode) optionen.transcodeNode = encoderNode
|
||||||
const target = targets.find(t => t.type === selectedType)
|
const target = targets.find(t => t.type === selectedType)
|
||||||
if (customPath) {
|
if (customPath) {
|
||||||
onSave({ id: 'custom', name: 'Eigener Ordner', path: customPath, type: selectedType, isActive: true }, optionen)
|
onSave({ id: 'custom', name: 'Eigener Ordner', path: customPath, type: selectedType, isActive: true }, optionen)
|
||||||
@@ -159,136 +173,135 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isOpen) return null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm transition-all">
|
<Modal
|
||||||
<div className={`w-full max-w-lg rounded-xl shadow-2xl transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800' : 'bg-white'}`}>
|
isOpen={isOpen}
|
||||||
{/* Header */}
|
onClose={onClose}
|
||||||
<div className={`px-6 py-4 border-b transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'}`}>
|
title="Rip-Ziel auswählen"
|
||||||
<h2 className={`text-xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Rip-Ziel auswählen</h2>
|
maxWidth="lg"
|
||||||
<p className={`text-sm mt-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
>
|
||||||
|
<div className="space-y-5">
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
Schnellwahl oder eigenen Ordner wählen — Netzwerk-Ziele (NAS, PC) sind unter Einstellungen → Speicherziele einhängbar.
|
Schnellwahl oder eigenen Ordner wählen — Netzwerk-Ziele (NAS, PC) sind unter Einstellungen → Speicherziele einhängbar.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Body */}
|
|
||||||
<div className="p-6 space-y-5">
|
|
||||||
{/* Schnellwahl */}
|
{/* Schnellwahl */}
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
{targets.map((t) => (
|
{targets.map((t) => {
|
||||||
|
const isSelected = selectedType === t.type && !customPath
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={t.type}
|
key={t.type}
|
||||||
onClick={() => { setSelectedType(t.type); setCustomPath('') }}
|
onClick={() => { setSelectedType(t.type); setCustomPath('') }}
|
||||||
className={`flex flex-col items-center justify-center gap-1.5 p-3 rounded-lg border transition-all ${
|
className={`flex flex-col items-center justify-center gap-1.5 p-3.5 rounded-xl border transition-all ${
|
||||||
selectedType === t.type && !customPath
|
isSelected
|
||||||
? theme === 'dark'
|
? 'bg-amber-500/15 border-amber-500 text-amber-600 dark:text-amber-300 font-semibold shadow-md shadow-amber-500/10'
|
||||||
? 'bg-indigo-900/30 border-indigo-500 text-indigo-400'
|
: 'bg-slate-50 dark:bg-slate-950/80 border-slate-200 dark:border-slate-800 text-slate-600 dark:text-slate-400 hover:border-slate-300 dark:hover:border-slate-700'
|
||||||
: 'bg-indigo-50 border-indigo-600 text-indigo-700'
|
|
||||||
: theme === 'dark'
|
|
||||||
? 'bg-slate-900 border-slate-700 text-slate-400 hover:border-slate-600'
|
|
||||||
: 'bg-slate-50 border-slate-200 text-slate-600 hover:border-slate-300'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="text-xl">
|
{t.type === 'movies' && <Film size={22} className={isSelected ? 'text-amber-500' : ''} />}
|
||||||
{t.type === 'movies' && '🎥'}
|
{t.type === 'series' && <Tv size={22} className={isSelected ? 'text-amber-500' : ''} />}
|
||||||
{t.type === 'series' && '📺'}
|
{t.type === 'music' && <Music size={22} className={isSelected ? 'text-amber-500' : ''} />}
|
||||||
{t.type === 'music' && '🎵'}
|
<span className="text-sm">{t.name}</span>
|
||||||
</span>
|
|
||||||
<span className="text-sm font-medium">{t.name}</span>
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Serien-Flow: Name + Staffel → Ablage <Serie>/Season NN, Episoden
|
{/* Serien-Flow */}
|
||||||
werden nach dem Rip per Laufzeit-Abgleich (TMDB) benannt */}
|
|
||||||
{selectedType === 'series' && (
|
{selectedType === 'series' && (
|
||||||
<div className={`rounded-lg p-3 space-y-2 ${theme === 'dark' ? 'bg-slate-900' : 'bg-slate-50'}`}>
|
<div className="rounded-xl p-4 space-y-3 bg-slate-50 dark:bg-slate-950/80 border border-slate-200 dark:border-slate-800">
|
||||||
<div className="grid grid-cols-[1fr_90px] gap-3">
|
<div className="grid grid-cols-[1fr_90px] gap-3">
|
||||||
<div>
|
<Input
|
||||||
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Serienname</label>
|
label="Serienname"
|
||||||
<input
|
|
||||||
value={serienName}
|
value={serienName}
|
||||||
onChange={e => setSerienName(e.target.value)}
|
onChange={e => setSerienName(e.target.value)}
|
||||||
placeholder="z. B. Neon Genesis Evangelion"
|
placeholder="z. B. Neon Genesis Evangelion"
|
||||||
className={`w-full px-3 py-2 border rounded-lg text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'}`}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
<Input
|
||||||
<div>
|
label="Staffel"
|
||||||
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Staffel</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
value={staffel}
|
value={staffel}
|
||||||
onChange={e => setStaffel(parseInt(e.target.value) || 1)}
|
onChange={e => setStaffel(parseInt(e.target.value) || 1)}
|
||||||
className={`w-full px-3 py-2 border rounded-lg text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'}`}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
Ablage: {serienName.trim() || '<Serie>'}/Season {String(Math.max(1, staffel || 1)).padStart(2, '0')}
|
||||||
Ablage: {serienName.trim() || '<Serie>'}/Season {String(Math.max(1, staffel || 1)).padStart(2, '0')} —
|
|
||||||
Episoden werden nach dem Rip per Laufzeit-Abgleich (TMDB) automatisch
|
|
||||||
zu „S{String(Math.max(1, staffel || 1)).padStart(2, '0')}E01…" benannt (nur bei eindeutiger Zuordnung).
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Hauptfilm-Wahl: pro Rip übersteuerbar (Standard = Einstellung);
|
{/* Hauptfilm-Wahl */}
|
||||||
eine explizite Titel-Auswahl unten schlägt diese Option */}
|
|
||||||
{selectedType === 'movies' && scanStatus !== 'done' && (
|
{selectedType === 'movies' && scanStatus !== 'done' && (
|
||||||
<label className={`flex items-center gap-2.5 text-sm px-1 ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
<label className="flex items-center gap-3 text-sm text-slate-700 dark:text-slate-300">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={nurHauptfilm}
|
checked={nurHauptfilm}
|
||||||
onChange={e => setNurHauptfilm(e.target.checked)}
|
onChange={e => setNurHauptfilm(e.target.checked)}
|
||||||
className="w-4 h-4 accent-indigo-600"
|
className="w-4 h-4 accent-amber-500 rounded"
|
||||||
/>
|
/>
|
||||||
Nur Hauptfilm rippen (längster Titel — Extras/Trailer bleiben weg)
|
Nur Hauptfilm rippen (längster Titel — Extras/Trailer bleiben weg)
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Titel-Auswahl (Track-Tabelle): erst scannen, dann abwählen */}
|
{/* Encoder-/Worker-Wahl — nur wenn mehrere Worker online sind
|
||||||
{deviceId && selectedType !== 'music' && (
|
(sonst gibt es nichts zu wählen). Musik wird nicht komprimiert. */}
|
||||||
<div className={`rounded-lg border ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'}`}>
|
{selectedType !== 'music' && workers.length >= 2 && (
|
||||||
<div className={`flex items-center justify-between px-3 py-2 ${scanStatus === 'done' ? (theme === 'dark' ? 'border-b border-slate-700' : 'border-b border-slate-200') : ''}`}>
|
|
||||||
<div>
|
<div>
|
||||||
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}>Titel-Auswahl (optional)</span>
|
<Select
|
||||||
|
label="Encoder / Worker für die Kompression"
|
||||||
|
value={encoderNode}
|
||||||
|
onChange={e => setEncoderNode(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Automatisch (erster freier Worker)</option>
|
||||||
|
{workers.map(w => (
|
||||||
|
<option key={w.node!} value={w.node!}>
|
||||||
|
{w.name} — {w.encoders.map(e => ENCODER_KURZ[e] || e).join(', ')}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs mt-1.5 text-slate-500 dark:text-slate-400 flex items-center gap-1.5">
|
||||||
|
<Cpu size={13} /> Wähle gezielt eine Maschine (z. B. die mit GPU) — sonst nimmt der erste freie Worker.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Titel-Auswahl (Track-Tabelle) */}
|
||||||
|
{deviceId && selectedType !== 'music' && (
|
||||||
|
<div className="rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden bg-slate-50/50 dark:bg-slate-950/50">
|
||||||
|
<div className="flex items-center justify-between px-4 py-2.5 border-b border-slate-200 dark:border-slate-800">
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium text-slate-900 dark:text-slate-200">Titel-Auswahl (optional)</span>
|
||||||
{scanStatus === 'idle' && (
|
{scanStatus === 'idle' && (
|
||||||
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
Disc scannen und einzelne Titel an-/abwählen — statt alles oder nur Hauptfilm.
|
Disc scannen und einzelne Titel an-/abwählen.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{scanStatus === 'running' ? (
|
{scanStatus === 'running' ? (
|
||||||
<span className={`text-xs flex items-center gap-2 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
<span className="text-xs flex items-center gap-2 text-slate-500 dark:text-slate-400">
|
||||||
<span className="animate-spin rounded-full h-3.5 w-3.5 border-2 border-indigo-500 border-t-transparent" />
|
<span className="animate-spin rounded-full h-3.5 w-3.5 border-2 border-amber-500 border-t-transparent" />
|
||||||
Disc wird gelesen (20–120 s)…
|
Disc wird gelesen (20–120 s)…
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<Button variant="secondary" size="sm" onClick={scanStarten}>
|
||||||
onClick={scanStarten}
|
|
||||||
className={`text-xs font-medium px-3 py-1.5 rounded-lg transition-colors ${theme === 'dark' ? 'bg-slate-700 text-indigo-300 hover:bg-slate-600' : 'bg-slate-200 text-indigo-700 hover:bg-slate-300'}`}
|
|
||||||
>
|
|
||||||
{scanStatus === 'done' ? 'Neu scannen' : 'Disc scannen'}
|
{scanStatus === 'done' ? 'Neu scannen' : 'Disc scannen'}
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{scanStatus === 'error' && (
|
{scanStatus === 'error' && (
|
||||||
<p className="text-xs text-rose-500 px-3 py-2">{scanFehler}</p>
|
<p className="text-xs text-rose-500 px-4 py-2.5">{scanFehler}</p>
|
||||||
)}
|
|
||||||
{scanStatus === 'done' && titelListe.length === 0 && (
|
|
||||||
<p className={`text-xs px-3 py-2 ${theme === 'dark' ? 'text-amber-400' : 'text-amber-600'}`}>
|
|
||||||
Keine Titel lesbar — vermutlich kann diese Disc nicht entschlüsselt
|
|
||||||
werden (Details stehen nach einem Rip-Versuch im Job-Fehler).
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{scanStatus === 'done' && titelListe.length > 0 && (
|
{scanStatus === 'done' && titelListe.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="max-h-44 overflow-y-auto">
|
<div className="max-h-44 overflow-y-auto">
|
||||||
{titelListe.map(t => (
|
{titelListe.map(t => (
|
||||||
<label
|
<label
|
||||||
key={t.nr}
|
key={t.nr}
|
||||||
className={`flex items-center gap-2.5 px-3 py-1.5 text-sm cursor-pointer ${theme === 'dark' ? 'text-slate-300 hover:bg-slate-700/50' : 'text-slate-700 hover:bg-slate-50'}`}
|
className="flex items-center gap-3 px-4 py-2 text-sm cursor-pointer border-b border-slate-200/50 dark:border-slate-800/50 hover:bg-slate-100 dark:hover:bg-slate-800/60"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -298,23 +311,21 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
if (e.target.checked) neu.add(t.nr); else neu.delete(t.nr)
|
if (e.target.checked) neu.add(t.nr); else neu.delete(t.nr)
|
||||||
setGewaehlt(neu)
|
setGewaehlt(neu)
|
||||||
}}
|
}}
|
||||||
className="w-4 h-4 accent-indigo-600 flex-shrink-0"
|
className="w-4 h-4 accent-amber-500 rounded"
|
||||||
/>
|
/>
|
||||||
<span className="font-mono text-xs w-14 flex-shrink-0">Titel {t.nr}</span>
|
<span className="font-mono text-xs w-14 font-semibold text-slate-700 dark:text-slate-300">Titel {t.nr}</span>
|
||||||
<span className="flex-shrink-0">{dauerText(t.dauer_s)}</span>
|
<span className="text-slate-600 dark:text-slate-300">{dauerText(t.dauer_s)}</span>
|
||||||
{t.kapitel > 0 && (
|
{t.kapitel > 0 && (
|
||||||
<span className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>{t.kapitel} Kap.</span>
|
<span className="text-xs text-slate-400">{t.kapitel} Kap.</span>
|
||||||
)}
|
)}
|
||||||
<span className={`ml-auto text-xs flex-shrink-0 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
<span className="ml-auto text-xs font-mono text-slate-400">
|
||||||
{t.groesse_bytes > 0 ? `${(t.groesse_bytes / 1024 ** 3).toFixed(1)} GB` : ''}
|
{t.groesse_bytes > 0 ? `${(t.groesse_bytes / 1024 ** 3).toFixed(1)} GB` : ''}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className={`text-xs px-3 py-2 border-t ${theme === 'dark' ? 'border-slate-700 text-slate-500' : 'border-slate-200 text-slate-400'}`}>
|
<p className="text-xs px-4 py-2 bg-slate-100 dark:bg-slate-900 text-slate-500 dark:text-slate-400">
|
||||||
{gewaehlt.size} von {titelListe.length} Titeln gewählt
|
{gewaehlt.size} von {titelListe.length} Titeln gewählt
|
||||||
{' · '}~{([...gewaehlt].reduce((s, nr) => s + (titelListe.find(t => t.nr === nr)?.groesse_bytes || 0), 0) / 1024 ** 3).toFixed(1)} GB
|
|
||||||
{' · '}Vorauswahl: alles ab 5 Minuten
|
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -322,80 +333,57 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Ordner-Browser */}
|
{/* Ordner-Browser */}
|
||||||
<div className={`rounded-lg border overflow-hidden ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'}`}>
|
<div className="rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden bg-slate-50/50 dark:bg-slate-950/50">
|
||||||
<div className={`flex items-center gap-2 px-3 py-2 border-b ${theme === 'dark' ? 'border-slate-700 bg-slate-900' : 'border-slate-200 bg-slate-50'}`}>
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-slate-200 dark:border-slate-800 bg-slate-100/50 dark:bg-slate-900/50">
|
||||||
<button
|
<button
|
||||||
onClick={() => browseParent && laden(browseParent)}
|
onClick={() => browseParent && laden(browseParent)}
|
||||||
disabled={!browseParent}
|
disabled={!browseParent}
|
||||||
className={`p-1 rounded disabled:opacity-30 ${theme === 'dark' ? 'hover:bg-slate-700 text-slate-400' : 'hover:bg-slate-200 text-slate-500'}`}
|
className="p-1 rounded-lg disabled:opacity-30 hover:bg-slate-200 dark:hover:bg-slate-800 text-slate-500 dark:text-slate-400"
|
||||||
title="Ordner hoch"
|
title="Ordner hoch"
|
||||||
>
|
>
|
||||||
<ArrowUp size={16} />
|
<ArrowUp size={16} />
|
||||||
</button>
|
</button>
|
||||||
<span className={`text-xs font-mono truncate flex-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>{browsePath}</span>
|
<span className="text-xs font-mono truncate flex-1 text-slate-500 dark:text-slate-400">{browsePath}</span>
|
||||||
<button
|
<Button
|
||||||
|
variant={customPath === browsePath ? 'amber' : 'secondary'}
|
||||||
|
size="sm"
|
||||||
onClick={() => setCustomPath(browsePath)}
|
onClick={() => setCustomPath(browsePath)}
|
||||||
className={`text-xs font-medium px-2.5 py-1 rounded transition-colors ${customPath === browsePath ? 'bg-indigo-600 text-white' : theme === 'dark' ? 'bg-slate-700 text-indigo-300 hover:bg-slate-600' : 'bg-slate-200 text-indigo-700 hover:bg-slate-300'}`}
|
|
||||||
>
|
>
|
||||||
{customPath === browsePath ? '✓ gewählt' : 'Diesen Ordner nutzen'}
|
{customPath === browsePath ? '✓ gewählt' : 'Diesen Ordner nutzen'}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-40 overflow-y-auto">
|
<div className="max-h-36 overflow-y-auto p-1">
|
||||||
{browseDirs.length === 0 && browseFiles.length === 0 ? (
|
|
||||||
<p className={`text-xs px-3 py-3 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Ordner ist leer</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{browseDirs.map(d => (
|
{browseDirs.map(d => (
|
||||||
<button
|
<button
|
||||||
key={d.path}
|
key={d.path}
|
||||||
onClick={() => laden(d.path)}
|
onClick={() => laden(d.path)}
|
||||||
className={`w-full flex items-center gap-2 px-3 py-2 text-sm text-left transition-colors ${theme === 'dark' ? 'text-slate-300 hover:bg-slate-700' : 'text-slate-700 hover:bg-slate-100'}`}
|
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left rounded-lg transition-colors text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800"
|
||||||
>
|
>
|
||||||
<Folder size={15} className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'} />
|
<Folder size={14} className="text-slate-400" />
|
||||||
{d.name}
|
{d.name}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{browseFiles.map(f => (
|
|
||||||
<div key={f.name} className={`flex items-center gap-2 px-3 py-1.5 text-sm ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
|
||||||
<File size={14} className="flex-shrink-0" />
|
|
||||||
<span className="truncate">{f.name}</span>
|
|
||||||
{f.size_mb != null && (
|
|
||||||
<span className="ml-auto text-xs flex-shrink-0">
|
|
||||||
{f.size_mb >= 1024 ? `${(f.size_mb / 1024).toFixed(1)} GB` : `${f.size_mb} MB`}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Gewähltes Ziel */}
|
{/* Gewähltes Ziel Display */}
|
||||||
<div className={`flex items-center gap-2 text-sm px-3 py-2 rounded-lg ${theme === 'dark' ? 'bg-slate-900' : 'bg-slate-50'}`}>
|
<div className="flex items-center gap-2 text-sm px-3.5 py-2.5 rounded-xl bg-slate-100 dark:bg-slate-900 border border-slate-200 dark:border-slate-800">
|
||||||
{customPath ? <FolderOpen size={16} className="text-indigo-500" /> : <CheckCircle size={16} className="text-emerald-500" />}
|
{customPath ? <FolderOpen size={16} className="text-amber-500" /> : <CheckCircle size={16} className="text-emerald-500" />}
|
||||||
<span className={`font-mono text-xs truncate ${theme === 'dark' ? 'text-slate-300' : 'text-slate-600'}`}>
|
<span className="font-mono text-xs truncate text-slate-700 dark:text-slate-300">
|
||||||
{customPath || targets.find(t => t.type === selectedType)?.path}
|
{customPath || targets.find(t => t.type === selectedType)?.path}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className={`px-6 py-4 border-t transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'} flex justify-end gap-3`}>
|
<div className="flex justify-end gap-3 pt-3 border-t border-slate-200 dark:border-slate-800">
|
||||||
<button
|
<Button variant="secondary" onClick={onClose}>
|
||||||
onClick={onClose}
|
|
||||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${theme === 'dark' ? 'text-slate-400 hover:bg-slate-700' : 'text-slate-600 hover:bg-slate-100'}`}
|
|
||||||
>
|
|
||||||
Abbrechen
|
Abbrechen
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button variant="amber" onClick={handleSave}>
|
||||||
onClick={handleSave}
|
|
||||||
className="px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 transition-colors shadow-lg shadow-indigo-500/30"
|
|
||||||
>
|
|
||||||
Rippen starten
|
Rippen starten
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Modal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { HardDrive, Plus, Trash2, CheckCircle, AlertCircle, RefreshCw, Folder, File, ArrowUp, FolderPlus } from 'lucide-react'
|
import { HardDrive, Plus, Trash2, CheckCircle, AlertCircle, AlertTriangle, RefreshCw, Wrench, Folder, File, ArrowUp, FolderPlus } from 'lucide-react'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { useDarkMode } from '../context/ThemeContext'
|
|
||||||
import { useToast } from '../context/ToastContext'
|
import { useToast } from '../context/ToastContext'
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from './ui/Card'
|
||||||
// Netzwerk-Speicherziele (NAS/PC-Freigaben) direkt aus dem UI einhängen —
|
import { Button } from './ui/Button'
|
||||||
// jedes Ziel erscheint danach in der Ziel-Auswahl beim Rippen.
|
import { Input, Select } from './ui/Input'
|
||||||
|
|
||||||
interface Mount {
|
interface Mount {
|
||||||
name: string
|
name: string
|
||||||
type: string
|
type: string
|
||||||
source: string
|
source: string
|
||||||
mounted: boolean
|
mounted: boolean
|
||||||
|
reachable?: boolean
|
||||||
has_credentials: boolean
|
has_credentials: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +42,6 @@ export default function StorageMounts() {
|
|||||||
const [browseFiles, setBrowseFiles] = useState<{ name: string, size_mb: number | null }[]>([])
|
const [browseFiles, setBrowseFiles] = useState<{ name: string, size_mb: number | null }[]>([])
|
||||||
const [neuerOrdner, setNeuerOrdner] = useState('')
|
const [neuerOrdner, setNeuerOrdner] = useState('')
|
||||||
const [browserOffen, setBrowserOffen] = useState(false)
|
const [browserOffen, setBrowserOffen] = useState(false)
|
||||||
const { theme } = useDarkMode()
|
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
|
|
||||||
const browsen = async (pfad: string) => {
|
const browsen = async (pfad: string) => {
|
||||||
@@ -72,12 +71,12 @@ export default function StorageMounts() {
|
|||||||
|
|
||||||
const laden = async (manuell = false) => {
|
const laden = async (manuell = false) => {
|
||||||
try {
|
try {
|
||||||
const [m, t] = await Promise.all([
|
const [m, t] = await Promise.allSettled([
|
||||||
api.get('/storage-mounts'),
|
api.get('/storage-mounts'),
|
||||||
api.get('/storage-targets'),
|
api.get('/storage-targets'),
|
||||||
])
|
])
|
||||||
setMounts(m.data)
|
if (m.status === 'fulfilled') setMounts(m.value.data || [])
|
||||||
setTargets(t.data)
|
if (t.status === 'fulfilled') setTargets(t.value.data || [])
|
||||||
if (manuell) toast('success', 'Speicherziele aktualisiert')
|
if (manuell) toast('success', 'Speicherziele aktualisiert')
|
||||||
} catch {
|
} catch {
|
||||||
if (manuell) toast('error', 'Speicherziele konnten nicht geladen werden')
|
if (manuell) toast('error', 'Speicherziele konnten nicht geladen werden')
|
||||||
@@ -151,35 +150,51 @@ export default function StorageMounts() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const feld = `w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'}`
|
const reparieren = async (mountName: string) => {
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
const r = await api.post(`/storage-mounts/${mountName}/repair`)
|
||||||
|
toast(r.data.writable ? 'success' : 'error',
|
||||||
|
r.data.writable ? `„${mountName}" neu verbunden` : `„${mountName}" verbunden, aber nur lesbar`)
|
||||||
|
laden()
|
||||||
|
} catch (e: any) {
|
||||||
|
toast('error', e?.response?.data?.detail || 'Reparatur fehlgeschlagen — NAS erreichbar?')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Vorhandene Ziele */}
|
{/* Vorhandene Ziele */}
|
||||||
<div>
|
<Card>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<CardHeader>
|
||||||
<h3 className={`font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Verfügbare Ziele</h3>
|
<CardTitle className="flex items-center gap-2">
|
||||||
<button onClick={() => laden(true)} title="Ziele neu laden" className={`p-1.5 rounded ${theme === 'dark' ? 'hover:bg-slate-700 text-slate-400' : 'hover:bg-slate-100 text-slate-500'}`}>
|
<HardDrive size={20} className="text-amber-500" />
|
||||||
|
<span>Verfügbare Ziele</span>
|
||||||
|
</CardTitle>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => laden(true)} title="Ziele neu laden">
|
||||||
<RefreshCw size={16} />
|
<RefreshCw size={16} />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
{targets.length === 0 ? (
|
{targets.length === 0 ? (
|
||||||
<p className={`text-sm ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Noch keine Unterordner in /app/media — sie entstehen beim ersten Rip oder Mount.</p>
|
<p className="text-sm text-slate-500 dark:text-slate-400">Noch keine Unterordner in /app/media — sie entstehen beim ersten Rip oder Mount.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2.5">
|
||||||
{targets.map(t => (
|
{targets.map(t => (
|
||||||
<div key={t.path} className={`flex items-center justify-between p-3 rounded-lg ${theme === 'dark' ? 'bg-slate-800' : 'bg-slate-50'}`}>
|
<div key={t.path} className="flex items-center justify-between p-3.5 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50 dark:bg-slate-950/80">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<HardDrive size={18} className={t.is_mount ? 'text-emerald-500' : theme === 'dark' ? 'text-slate-500' : 'text-slate-400'} />
|
<HardDrive size={18} className={t.is_mount ? 'text-amber-500' : 'text-slate-400 dark:text-slate-500'} />
|
||||||
<div>
|
<div>
|
||||||
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-200' : 'text-slate-800'}`}>{t.name}</span>
|
<span className="text-sm font-medium text-slate-900 dark:text-slate-200">{t.name}</span>
|
||||||
<span className={`text-xs ml-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
<span className="text-xs ml-2 text-slate-500 dark:text-slate-400">
|
||||||
{t.is_mount ? 'Netzwerk-Mount' : 'lokal'}{t.free_gb != null ? ` · ${t.free_gb} GB frei` : ''}
|
{t.is_mount ? 'Netzwerk-Mount' : 'lokal'}{t.free_gb != null ? ` · ${t.free_gb} GB frei` : ''}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{mounts.some(m => m.name === t.name) && (
|
{mounts.some(m => m.name === t.name) && (
|
||||||
<button onClick={() => entfernen(t.name)} disabled={busy} className="p-1.5 rounded text-rose-500 hover:bg-rose-500/10">
|
<button onClick={() => entfernen(t.name)} disabled={busy} className="p-1.5 rounded-lg text-rose-500 hover:bg-rose-500/10 transition-colors">
|
||||||
<Trash2 size={16} />
|
<Trash2 size={16} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -187,82 +202,129 @@ export default function StorageMounts() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Ordner-Verwaltung — bewusst eingeklappt: fürs tägliche Rippen nicht
|
{/* Netzwerk-Mounts mit Status — zeigt AUCH tote/getrennte Mounts, damit
|
||||||
nötig (Ziel wählt man im „Rippen starten"-Dialog), war unbeschriftet
|
man sie reparieren oder entfernen kann (Bug 24.07.: ein toter NAS-Mount
|
||||||
und verwirrte mehr als sie half (Commander-Feedback 24.07.). */}
|
verschwand aus „Verfügbare Ziele" und ließ sich nicht neu anlegen). */}
|
||||||
<div className={`rounded-lg border overflow-hidden ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'}`}>
|
{mounts.length > 0 && (
|
||||||
<button
|
<Card>
|
||||||
onClick={() => setBrowserOffen(!browserOffen)}
|
<CardHeader>
|
||||||
className={`w-full flex items-center justify-between px-4 py-3 text-left transition-colors ${theme === 'dark' ? 'hover:bg-slate-800' : 'hover:bg-slate-50'}`}
|
<CardTitle className="flex items-center gap-2">
|
||||||
>
|
<HardDrive size={20} className="text-amber-500" />
|
||||||
<div>
|
<span>Netzwerk-Mounts</span>
|
||||||
<h3 className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
</CardTitle>
|
||||||
Ordner-Verwaltung (optional)
|
</CardHeader>
|
||||||
</h3>
|
<CardContent>
|
||||||
<p className={`text-xs mt-0.5 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
<div className="space-y-2.5">
|
||||||
Unterordner in der Ablage anschauen und neue anlegen — z. B. „anime" oder
|
{mounts.map(m => {
|
||||||
„doku", die danach im „Rippen starten"-Dialog wählbar sind. Fürs normale
|
const aktiv = m.mounted && m.reachable !== false
|
||||||
Rippen brauchst du das hier nicht.
|
const tot = m.mounted && m.reachable === false
|
||||||
|
return (
|
||||||
|
<div key={m.name} className="flex items-center justify-between gap-3 p-3.5 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50 dark:bg-slate-950/80">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
{aktiv
|
||||||
|
? <CheckCircle size={18} className="text-emerald-500 flex-shrink-0" />
|
||||||
|
: tot
|
||||||
|
? <AlertTriangle size={18} className="text-amber-500 flex-shrink-0" />
|
||||||
|
: <AlertCircle size={18} className="text-slate-400 flex-shrink-0" />}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="text-sm font-medium text-slate-900 dark:text-slate-200">{m.name}</span>
|
||||||
|
<span className="text-xs ml-2 font-mono text-slate-500 dark:text-slate-400">{m.source}</span>
|
||||||
|
<p className={`text-xs ${aktiv ? 'text-emerald-600 dark:text-emerald-400' : tot ? 'text-amber-600 dark:text-amber-400' : 'text-slate-500 dark:text-slate-400'}`}>
|
||||||
|
{aktiv ? 'aktiv & erreichbar' : tot ? 'verbunden, aber NICHT erreichbar (NAS aus/weg?)' : 'getrennt'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className={`text-xs flex-shrink-0 ml-3 ${theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'}`}>
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||||
|
{!aktiv && (
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => reparieren(m.name)} disabled={busy} title="Neu verbinden (gespeicherte Zugangsdaten)">
|
||||||
|
<Wrench size={14} /> Reparieren
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<button onClick={() => entfernen(m.name)} disabled={busy} className="p-1.5 rounded-lg text-rose-500 hover:bg-rose-500/10 transition-colors" title="Entfernen">
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Ordner-Verwaltung */}
|
||||||
|
<Card>
|
||||||
|
<button
|
||||||
|
onClick={() => setBrowserOffen(!browserOffen)}
|
||||||
|
className="w-full flex items-center justify-between p-6 text-left transition-colors hover:bg-slate-50/50 dark:hover:bg-slate-800/30"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">
|
||||||
|
Ordner-Verwaltung (optional)
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs mt-0.5 text-slate-500 dark:text-slate-400">
|
||||||
|
Unterordner in der Ablage anschauen und neue anlegen — z. B. „anime" oder „doku".
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs flex-shrink-0 ml-3 text-amber-600 dark:text-amber-400 font-medium">
|
||||||
{browserOffen ? 'zuklappen ▲' : 'aufklappen ▼'}
|
{browserOffen ? 'zuklappen ▲' : 'aufklappen ▼'}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{browserOffen && (<>
|
|
||||||
<div className={`flex items-center gap-2 px-3 py-2 border-y ${theme === 'dark' ? 'border-slate-700 bg-slate-900' : 'border-slate-200 bg-slate-50'}`}>
|
{browserOffen && (
|
||||||
|
<div className="border-t border-slate-200/80 dark:border-slate-800/80">
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2.5 bg-slate-50 dark:bg-slate-950/90 border-b border-slate-200/80 dark:border-slate-800">
|
||||||
<button
|
<button
|
||||||
onClick={() => browseParent && browsen(browseParent)}
|
onClick={() => browseParent && browsen(browseParent)}
|
||||||
disabled={!browseParent}
|
disabled={!browseParent}
|
||||||
className={`p-1 rounded disabled:opacity-30 ${theme === 'dark' ? 'hover:bg-slate-700 text-slate-400' : 'hover:bg-slate-200 text-slate-500'}`}
|
className="p-1.5 rounded-lg disabled:opacity-30 hover:bg-slate-200 dark:hover:bg-slate-800 text-slate-500 dark:text-slate-400 transition-colors"
|
||||||
title="Ordner hoch"
|
title="Ordner hoch"
|
||||||
>
|
>
|
||||||
<ArrowUp size={16} />
|
<ArrowUp size={16} />
|
||||||
</button>
|
</button>
|
||||||
<span className={`text-xs font-mono truncate flex-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>{browsePath}</span>
|
<span className="text-xs font-mono truncate flex-1 text-slate-500 dark:text-slate-400">{browsePath}</span>
|
||||||
<input
|
<input
|
||||||
value={neuerOrdner}
|
value={neuerOrdner}
|
||||||
onChange={e => setNeuerOrdner(e.target.value)}
|
onChange={e => setNeuerOrdner(e.target.value)}
|
||||||
placeholder="neuer-ordner"
|
placeholder="neuer-ordner"
|
||||||
className={`w-32 px-2 py-1 text-xs border rounded ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'}`}
|
className="w-36 px-2.5 py-1 text-xs border rounded-lg bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-700 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-amber-500/50"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={ordnerAnlegen}
|
onClick={ordnerAnlegen}
|
||||||
disabled={!neuerOrdner}
|
disabled={!neuerOrdner}
|
||||||
className={`p-1.5 rounded disabled:opacity-30 text-indigo-500 ${theme === 'dark' ? 'hover:bg-slate-700' : 'hover:bg-slate-200'}`}
|
className="p-1.5 rounded-lg disabled:opacity-30 text-amber-500 hover:bg-amber-500/10 transition-colors"
|
||||||
title="Ordner anlegen"
|
title="Ordner anlegen"
|
||||||
>
|
>
|
||||||
<FolderPlus size={16} />
|
<FolderPlus size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-36 overflow-y-auto">
|
<div className="max-h-44 overflow-y-auto p-2">
|
||||||
{browseDirs.length === 0 && browseFiles.length === 0 ? (
|
{browseDirs.length === 0 && browseFiles.length === 0 ? (
|
||||||
<p className={`text-xs px-3 py-3 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Ordner ist leer</p>
|
<p className="text-xs p-3 text-slate-400 dark:text-slate-500">Ordner ist leer</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{browseDirs.map(d => (
|
{browseDirs.map(d => (
|
||||||
<button
|
<button
|
||||||
key={d.path}
|
key={d.path}
|
||||||
onClick={() => browsen(d.path)}
|
onClick={() => browsen(d.path)}
|
||||||
className={`w-full flex items-center gap-2 px-3 py-2 text-sm text-left transition-colors ${theme === 'dark' ? 'text-slate-300 hover:bg-slate-700' : 'text-slate-700 hover:bg-slate-100'}`}
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm rounded-lg text-left transition-colors text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800"
|
||||||
>
|
>
|
||||||
<Folder size={15} className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'} />
|
<Folder size={15} className="text-slate-400 dark:text-slate-500" />
|
||||||
{d.name}
|
{d.name}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{/* Dateien mit anzeigen (grau, nicht klickbar) — vorher wirkte
|
|
||||||
z. B. der bluray-Ordner „leer", obwohl die MKVs drinlagen */}
|
|
||||||
{browseFiles.map(f => (
|
{browseFiles.map(f => (
|
||||||
<div
|
<div
|
||||||
key={f.name}
|
key={f.name}
|
||||||
className={`flex items-center gap-2 px-3 py-1.5 text-sm ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}
|
className="flex items-center gap-2 px-3 py-1.5 text-sm text-slate-400 dark:text-slate-500"
|
||||||
>
|
>
|
||||||
<File size={14} className="flex-shrink-0" />
|
<File size={14} className="flex-shrink-0" />
|
||||||
<span className="truncate">{f.name}</span>
|
<span className="truncate">{f.name}</span>
|
||||||
{f.size_mb != null && (
|
{f.size_mb != null && (
|
||||||
<span className="ml-auto text-xs flex-shrink-0">
|
<span className="ml-auto text-xs flex-shrink-0 font-mono">
|
||||||
{f.size_mb >= 1024 ? `${(f.size_mb / 1024).toFixed(1)} GB` : `${f.size_mb} MB`}
|
{f.size_mb >= 1024 ? `${(f.size_mb / 1024).toFixed(1)} GB` : `${f.size_mb} MB`}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -271,91 +333,112 @@ export default function StorageMounts() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>)}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Neues Netzwerk-Ziel */}
|
{/* Neues Netzwerk-Ziel */}
|
||||||
<div className={`rounded-lg p-4 border ${theme === 'dark' ? 'border-slate-700 bg-slate-800/50' : 'border-slate-200 bg-slate-50'}`}>
|
<Card>
|
||||||
<h3 className={`font-medium mb-3 flex items-center gap-2 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
<CardHeader>
|
||||||
<Plus size={18} className="text-indigo-500" /> Netzwerk-Ziel hinzufügen
|
<CardTitle className="flex items-center gap-2">
|
||||||
</h3>
|
<Plus size={20} className="text-amber-500" />
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
<span>Netzwerk-Ziel hinzufügen</span>
|
||||||
<div>
|
</CardTitle>
|
||||||
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Name (klein, ohne Leerzeichen)</label>
|
</CardHeader>
|
||||||
<input value={name} onChange={e => setName(e.target.value.toLowerCase())} className={feld} placeholder="nas-filme" />
|
<CardContent className="space-y-4">
|
||||||
</div>
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<Input
|
||||||
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Typ</label>
|
label="Name (klein, ohne Leerzeichen)"
|
||||||
<select value={typ} onChange={e => setTyp(e.target.value as 'nfs' | 'cifs')} className={feld}>
|
value={name}
|
||||||
|
onChange={e => setName(e.target.value.toLowerCase())}
|
||||||
|
placeholder="nas-filme"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Typ"
|
||||||
|
value={typ}
|
||||||
|
onChange={e => setTyp(e.target.value as 'nfs' | 'cifs')}
|
||||||
|
>
|
||||||
<option value="nfs">NFS (NAS/Linux)</option>
|
<option value="nfs">NFS (NAS/Linux)</option>
|
||||||
<option value="cifs">SMB/CIFS (Windows-Freigabe, NAS)</option>
|
<option value="cifs">SMB/CIFS (Windows-Freigabe, NAS)</option>
|
||||||
</select>
|
</Select>
|
||||||
</div>
|
|
||||||
{typ === 'nfs' ? (
|
{typ === 'nfs' ? (
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
<Input
|
||||||
Quelle (host:/export/pfad)
|
label="Quelle (host:/export/pfad)"
|
||||||
</label>
|
value={source}
|
||||||
<input value={source} onChange={e => setSource(e.target.value)} className={feld} placeholder="192.168.1.10:/volume1/filme" />
|
onChange={e => setSource(e.target.value)}
|
||||||
|
placeholder="192.168.1.10:/volume1/filme"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
<Input
|
||||||
Rechner (dein PC oder NAS — Name oder IP)
|
label="Rechner (dein PC oder NAS — Name oder IP)"
|
||||||
</label>
|
value={host}
|
||||||
<input value={host} onChange={e => setHost(e.target.value)} className={feld} placeholder="192.168.178.20 oder TOBIS-PC" />
|
onChange={e => setHost(e.target.value)}
|
||||||
|
placeholder="192.168.178.20 oder TOBIS-PC"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* Zugangsdaten VOR der Freigaben-Abfrage (Befund 24.07.):
|
<Input
|
||||||
Windows/NAS lehnen Gast-Abfragen fast immer mit
|
label="Benutzer"
|
||||||
ACCESS_DENIED ab — ohne Benutzer/Passwort scheiterte der
|
value={username}
|
||||||
Knopf darunter scheinbar grundlos. */}
|
onChange={e => setUsername(e.target.value)}
|
||||||
<div>
|
autoComplete="off"
|
||||||
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Benutzer</label>
|
placeholder="Freigabe-Benutzer"
|
||||||
<input value={username} onChange={e => setUsername(e.target.value)} className={feld} autoComplete="off" placeholder="Freigabe-Benutzer" />
|
/>
|
||||||
</div>
|
<Input
|
||||||
<div>
|
label="Passwort"
|
||||||
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Passwort</label>
|
type="password"
|
||||||
<input type="password" value={password} onChange={e => setPassword(e.target.value)} className={feld} autoComplete="new-password" />
|
value={password}
|
||||||
</div>
|
onChange={e => setPassword(e.target.value)}
|
||||||
<p className={`md:col-span-2 text-xs -mt-1 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
autoComplete="new-password"
|
||||||
Windows-PCs und die meisten NAS verlangen Benutzer + Passwort — ohne Angaben
|
/>
|
||||||
versucht Rippy einen Gast-Zugriff, der meist mit „Zugriff verweigert" endet.
|
<p className="md:col-span-2 text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Windows-PCs und die meisten NAS verlangen Benutzer + Passwort — ohne Angaben versucht Rippy einen Gast-Zugriff.
|
||||||
</p>
|
</p>
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<button
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
onClick={freigabenAuflisten}
|
onClick={freigabenAuflisten}
|
||||||
disabled={!host || sharesBusy}
|
disabled={!host || sharesBusy}
|
||||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-40 ${theme === 'dark' ? 'bg-slate-700 text-indigo-300 hover:bg-slate-600' : 'bg-slate-200 text-indigo-700 hover:bg-slate-300'}`}
|
|
||||||
>
|
>
|
||||||
{sharesBusy ? 'Suche…' : 'Freigaben auflisten'}
|
{sharesBusy ? 'Suche…' : 'Freigaben auflisten'}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{shares.length > 0 && (
|
{shares.length > 0 && (
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Freigabe wählen</label>
|
<Select
|
||||||
<select value={selectedShare} onChange={e => setSelectedShare(e.target.value)} className={feld}>
|
label="Freigabe wählen"
|
||||||
|
value={selectedShare}
|
||||||
|
onChange={e => setSelectedShare(e.target.value)}
|
||||||
|
>
|
||||||
{shares.map(s => <option key={s} value={s}>{s}</option>)}
|
{shares.map(s => <option key={s} value={s}>{s}</option>)}
|
||||||
</select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
|
<Button
|
||||||
|
variant="amber"
|
||||||
onClick={hinzufuegen}
|
onClick={hinzufuegen}
|
||||||
disabled={busy || !name || (typ === 'nfs' ? !source : !(host && selectedShare))}
|
disabled={busy || !name || (typ === 'nfs' ? !source : !(host && selectedShare))}
|
||||||
className="mt-4 px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 transition-colors disabled:opacity-40"
|
|
||||||
>
|
>
|
||||||
{busy ? 'Hänge ein…' : 'Einhängen & speichern'}
|
{busy ? 'Hänge ein…' : 'Einhängen & speichern'}
|
||||||
</button>
|
</Button>
|
||||||
|
|
||||||
{feedback && (
|
{feedback && (
|
||||||
<p className={`mt-3 text-sm flex items-center gap-2 ${feedback.startsWith('✓') ? 'text-emerald-500' : 'text-rose-500'}`}>
|
<p className={`text-sm flex items-center gap-2 font-medium ${feedback.startsWith('✓') ? 'text-emerald-500' : 'text-rose-500'}`}>
|
||||||
{feedback.startsWith('✓') ? <CheckCircle size={16} /> : <AlertCircle size={16} />}
|
{feedback.startsWith('✓') ? <CheckCircle size={16} /> : <AlertCircle size={16} />}
|
||||||
{feedback}
|
{feedback}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Server, RefreshCw, Copy, CheckCircle, Trash2 } from 'lucide-react'
|
import { Server, RefreshCw, Copy, CheckCircle, Trash2, Download } from 'lucide-react'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { useDarkMode } from '../context/ThemeContext'
|
|
||||||
import { useToast } from '../context/ToastContext'
|
import { useToast } from '../context/ToastContext'
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from './ui/Card'
|
||||||
// Worker-Verwaltung (Commander-Anforderung 23.07.): Welche Maschinen encoden,
|
import { Button } from './ui/Button'
|
||||||
// sind sie erreichbar, und wie bindet man eine neue an? Ein Worker "installiert
|
import { Input } from './ui/Input'
|
||||||
// sich" über einen Copy-Paste-Befehl auf der Zielmaschine — danach taucht er
|
|
||||||
// hier automatisch auf und zieht sich Kompressions-Jobs aus der Warteschlange.
|
|
||||||
|
|
||||||
interface WorkerInfo {
|
interface WorkerInfo {
|
||||||
name: string
|
name: string
|
||||||
@@ -41,15 +38,20 @@ export default function WorkerVerwaltung() {
|
|||||||
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
||||||
const [kopiert, setKopiert] = useState(false)
|
const [kopiert, setKopiert] = useState(false)
|
||||||
const [variante, setVariante] = useState<'linux' | 'windows'>('linux')
|
const [variante, setVariante] = useState<'linux' | 'windows'>('linux')
|
||||||
const [rippyHost, setRippyHost] = useState(window.location.hostname)
|
// BEWUSST leer: kein window.location.hostname-Default. Der füllte früher
|
||||||
const { theme } = useDarkMode()
|
// die Adresse, über die man das UI geöffnet hat (z. B. den eigenen PC hinter
|
||||||
|
// einem Proxy/Forward) — und die ist NICHT zwingend die direkte LAN-IP der
|
||||||
|
// Rippy-Maschine, die der Worker für Redis/Postgres braucht. Lieber bewusst
|
||||||
|
// eintragen lassen als eine falsche Adresse vorgaukeln.
|
||||||
|
const [rippyHost, setRippyHost] = useState('')
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
|
|
||||||
// Worker verbinden sich DIREKT mit Redis/Postgres (6379/5432) — das geht
|
// Für die Befehle: leeres Feld → sichtbarer Platzhalter (Befehl ist dann
|
||||||
// nur im Heimnetz. Wer das UI gerade über eine externe Domain (Reverse-
|
// erkennbar unvollständig, statt still auf die falsche Adresse zu zeigen).
|
||||||
// Proxy) aufruft, würde sonst Befehle mit der falschen Adresse kopieren
|
const hostFuerBefehl = rippyHost || '<RIPPY-IP>'
|
||||||
// (Befund 24.07.: 401 vom Proxy statt Installer).
|
|
||||||
const siehtOeffentlichAus =
|
const siehtOeffentlichAus =
|
||||||
|
rippyHost !== '' &&
|
||||||
!/^(\d{1,3}\.){3}\d{1,3}$/.test(rippyHost) &&
|
!/^(\d{1,3}\.){3}\d{1,3}$/.test(rippyHost) &&
|
||||||
!rippyHost.endsWith('.local') &&
|
!rippyHost.endsWith('.local') &&
|
||||||
rippyHost !== 'localhost' &&
|
rippyHost !== 'localhost' &&
|
||||||
@@ -73,13 +75,13 @@ export default function WorkerVerwaltung() {
|
|||||||
const installBefehl = variante === 'linux'
|
const installBefehl = variante === 'linux'
|
||||||
? [
|
? [
|
||||||
`git clone <rippy-repo-url> rippy && cd rippy`,
|
`git clone <rippy-repo-url> rippy && cd rippy`,
|
||||||
`RIPPY_HOST=${rippyHost} WORKER_NAME=mein-pc \\`,
|
`RIPPY_HOST=${hostFuerBefehl} WORKER_NAME=mein-pc \\`,
|
||||||
` docker compose -f deploy/remote-transcode-worker.yml up -d --build`,
|
` docker compose -f deploy/remote-transcode-worker.yml up -d --build`,
|
||||||
].join('\n')
|
].join('\n')
|
||||||
: [
|
: [
|
||||||
`iwr http://${rippyHost}/api/worker-setup/windows -OutFile install-rippy-worker.ps1`,
|
`iwr http://${hostFuerBefehl}/api/worker-setup/windows -OutFile install-rippy-worker.ps1`,
|
||||||
`powershell -ExecutionPolicy Bypass -File .\\install-rippy-worker.ps1 \``,
|
`powershell -ExecutionPolicy Bypass -File .\\install-rippy-worker.ps1 \``,
|
||||||
` -RippyHost ${rippyHost} -WorkerName mein-pc`,
|
` -RippyHost ${hostFuerBefehl} -WorkerName mein-pc`,
|
||||||
].join('\n')
|
].join('\n')
|
||||||
|
|
||||||
const kopieren = () => {
|
const kopieren = () => {
|
||||||
@@ -89,31 +91,45 @@ export default function WorkerVerwaltung() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fertige Installer-.exe (Rippy-Icon, kein Konsolenfenster) direkt laden.
|
||||||
|
// Generisch — die GUI fragt die Rippy-Adresse selbst ab.
|
||||||
|
const exeHerunterladen = () => {
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = '/api/worker-setup/windows-exe'
|
||||||
|
a.download = 'RippyWorkerSetup.exe'
|
||||||
|
document.body.appendChild(a); a.click(); a.remove()
|
||||||
|
toast('success', 'RippyWorkerSetup.exe wird heruntergeladen — doppelklicken zum Installieren')
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Verbundene Worker */}
|
{/* Verbundene Worker */}
|
||||||
<div>
|
<Card>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<CardHeader>
|
||||||
<h3 className={`font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Verbundene Worker</h3>
|
<CardTitle className="flex items-center gap-2">
|
||||||
<button onClick={() => laden(true)} title="Worker neu laden" className={`p-1.5 rounded ${theme === 'dark' ? 'hover:bg-slate-700 text-slate-400' : 'hover:bg-slate-100 text-slate-500'}`}>
|
<Server size={20} className="text-amber-500" />
|
||||||
|
<span>Verbundene Worker</span>
|
||||||
|
</CardTitle>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => laden(true)} title="Worker neu laden">
|
||||||
<RefreshCw size={16} />
|
<RefreshCw size={16} />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
{workers.length === 0 ? (
|
{workers.length === 0 ? (
|
||||||
<p className={`text-sm ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Noch kein Worker gemeldet.</p>
|
<p className="text-sm text-slate-500 dark:text-slate-400">Noch kein Worker gemeldet.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2.5">
|
||||||
{workers.map(w => (
|
{workers.map(w => (
|
||||||
<div key={w.name} className={`flex items-center justify-between p-3 rounded-lg ${theme === 'dark' ? 'bg-slate-800' : 'bg-slate-50'}`}>
|
<div key={w.name} className="flex items-center justify-between p-3.5 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50 dark:bg-slate-950/80">
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
<span
|
<span
|
||||||
className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${w.online ? 'bg-emerald-500' : 'bg-rose-500'}`}
|
className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${w.online ? 'bg-emerald-500 shadow-sm shadow-emerald-500/50' : 'bg-rose-500'}`}
|
||||||
title={w.online ? 'erreichbar (Ping OK)' : 'nicht erreichbar'}
|
title={w.online ? 'erreichbar (Ping OK)' : 'nicht erreichbar'}
|
||||||
/>
|
/>
|
||||||
<Server size={18} className={theme === 'dark' ? 'text-slate-400' : 'text-slate-500'} />
|
<Server size={18} className="text-slate-400 dark:text-slate-500" />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className={`text-sm font-medium truncate ${theme === 'dark' ? 'text-slate-200' : 'text-slate-800'}`}>{w.name}</p>
|
<p className="text-sm font-medium truncate text-slate-900 dark:text-slate-200">{w.name}</p>
|
||||||
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
{w.online ? 'online' : 'offline'} · Lebenszeichen {relativeZeit(w.last_seen)}
|
{w.online ? 'online' : 'offline'} · Lebenszeichen {relativeZeit(w.last_seen)}
|
||||||
{w.info?.ip ? ` · IP ${w.info.ip}` : ''}
|
{w.info?.ip ? ` · IP ${w.info.ip}` : ''}
|
||||||
{w.info?.hostname && w.info.hostname !== w.name ? ` · ID ${w.info.hostname}` : ''}
|
{w.info?.hostname && w.info.hostname !== w.name ? ` · ID ${w.info.hostname}` : ''}
|
||||||
@@ -123,7 +139,11 @@ export default function WorkerVerwaltung() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex flex-wrap gap-1 justify-end">
|
<div className="flex flex-wrap gap-1 justify-end">
|
||||||
{w.encoders.map(e => (
|
{w.encoders.map(e => (
|
||||||
<span key={e} className={`text-xs px-2 py-0.5 rounded ${e.startsWith('cpu') ? (theme === 'dark' ? 'bg-slate-700 text-slate-300' : 'bg-slate-200 text-slate-600') : 'bg-emerald-600/20 text-emerald-500 font-medium'}`}>
|
<span key={e} className={`text-xs px-2.5 py-0.5 rounded-md font-medium border ${
|
||||||
|
e.startsWith('cpu')
|
||||||
|
? 'bg-slate-200/80 dark:bg-slate-800 text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-700'
|
||||||
|
: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30'
|
||||||
|
}`}>
|
||||||
{ENCODER_LABELS[e] || e}
|
{ENCODER_LABELS[e] || e}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
@@ -135,8 +155,8 @@ export default function WorkerVerwaltung() {
|
|||||||
.then(() => { toast('success', `„${w.name}" entfernt — ein aktiver Worker meldet sich binnen 1 min zurück`); laden() })
|
.then(() => { toast('success', `„${w.name}" entfernt — ein aktiver Worker meldet sich binnen 1 min zurück`); laden() })
|
||||||
.catch(() => toast('error', 'Entfernen fehlgeschlagen'))
|
.catch(() => toast('error', 'Entfernen fehlgeschlagen'))
|
||||||
}}
|
}}
|
||||||
title="Verwaisten Eintrag entfernen (alte Container-ID nach Rebuild) — aktive Worker melden sich automatisch wieder an"
|
title="Verwaisten Eintrag entfernen"
|
||||||
className="p-1.5 rounded text-rose-500 hover:bg-rose-500/10"
|
className="p-1.5 rounded-lg text-rose-500 hover:bg-rose-500/10 transition-colors"
|
||||||
>
|
>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
</button>
|
</button>
|
||||||
@@ -146,69 +166,94 @@ export default function WorkerVerwaltung() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Neue Maschine anbinden — zwei Varianten */}
|
{/* Neue Maschine anbinden */}
|
||||||
<div className={`rounded-lg p-4 border ${theme === 'dark' ? 'border-slate-700 bg-slate-800/50' : 'border-slate-200 bg-slate-50'}`}>
|
<Card>
|
||||||
<h3 className={`font-medium mb-2 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Maschine als Encoding-Worker anbinden</h3>
|
<CardHeader>
|
||||||
|
<CardTitle>Maschine als Encoding-Worker anbinden</CardTitle>
|
||||||
{/* Rippy-Adresse für die Befehle: Worker reden DIREKT mit Redis/
|
</CardHeader>
|
||||||
Postgres — das geht nur im Heimnetz, nie über die externe Domain */}
|
<CardContent className="space-y-4">
|
||||||
<div className="mb-3">
|
<div className="max-w-md">
|
||||||
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
<Input
|
||||||
Rippy-Adresse aus Sicht des Workers (Heimnetz-IP oder -Name)
|
label="LAN-IP der Rippy-Maschine (Docker-Host)"
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
value={rippyHost}
|
value={rippyHost}
|
||||||
onChange={e => setRippyHost(e.target.value.trim())}
|
onChange={e => setRippyHost(e.target.value.trim())}
|
||||||
className={`w-full max-w-xs px-3 py-2 border rounded-lg text-sm font-mono focus:ring-2 focus:ring-indigo-500 focus:border-transparent ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'}`}
|
placeholder="z. B. 192.168.178.162"
|
||||||
placeholder="192.168.178.162"
|
|
||||||
/>
|
/>
|
||||||
|
<p className="text-xs mt-2 text-slate-500 dark:text-slate-400">
|
||||||
|
Die IP der Maschine, auf der <strong>Rippy selbst</strong> (Docker) läuft — meist deine
|
||||||
|
Server-/NAS-VM. <strong>Nicht</strong> die IP des PCs, auf dem der Worker läuft, und auch
|
||||||
|
dann diese direkte LAN-IP eintragen, wenn du dieses UI über eine andere Adresse
|
||||||
|
(Domain/Proxy/Portweiterleitung) geöffnet hast. Der Worker spricht Redis/Postgres direkt
|
||||||
|
an (Ports 6379/5432) — das klappt nur mit der echten LAN-IP im Heimnetz.
|
||||||
|
</p>
|
||||||
{siehtOeffentlichAus && (
|
{siehtOeffentlichAus && (
|
||||||
<p className={`text-xs mt-1.5 px-2.5 py-1.5 rounded-lg inline-block ${theme === 'dark' ? 'bg-amber-900/30 text-amber-300' : 'bg-amber-50 text-amber-700'}`}>
|
<p className="text-xs mt-2 p-3 rounded-xl bg-amber-500/10 border border-amber-500/20 text-amber-700 dark:text-amber-300">
|
||||||
⚠️ Das sieht nach einer externen Domain aus (Reverse-Proxy). Worker verbinden sich
|
⚠️ Das sieht nach einer externen Domain aus (Reverse-Proxy) — die geht nicht. Bitte die
|
||||||
direkt mit Redis/Postgres (Ports 6379/5432) — das geht nur im Heimnetz, und der
|
<strong> LAN-IP</strong> der Rippy-Maschine eintragen, z. B. 192.168.178.162.
|
||||||
Proxy blockt den Installer-Download (401). Hier die <strong>LAN-IP</strong> der
|
|
||||||
Rippy-Maschine eintragen, z. B. 192.168.178.162.
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 mb-3">
|
<div className="flex gap-2">
|
||||||
{([['linux', '🐧 Linux (Docker)'], ['windows', '🪟 Windows (nativ, ohne Docker)']] as const).map(([wert, label]) => (
|
{([['linux', '🐧 Linux (Docker)'], ['windows', '🪟 Windows (nativ, ohne Docker)']] as const).map(([wert, label]) => (
|
||||||
<button
|
<Button
|
||||||
key={wert}
|
key={wert}
|
||||||
|
variant={variante === wert ? 'amber' : 'secondary'}
|
||||||
|
size="sm"
|
||||||
onClick={() => setVariante(wert)}
|
onClick={() => setVariante(wert)}
|
||||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium border transition-all ${
|
|
||||||
variante === wert
|
|
||||||
? theme === 'dark' ? 'bg-indigo-900/40 border-indigo-500 text-indigo-300' : 'bg-indigo-50 border-indigo-600 text-indigo-700'
|
|
||||||
: theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-400 hover:border-slate-500' : 'bg-white border-slate-300 text-slate-600 hover:border-slate-400'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className={`text-xs mb-3 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
|
||||||
|
{/* Windows: fertige .exe als Haupt-Weg (Doppelklick, Icon, kein Konsolenfenster) */}
|
||||||
|
{variante === 'windows' && (
|
||||||
|
<div className="rounded-xl p-4 border border-amber-500/30 bg-amber-500/5 space-y-2">
|
||||||
|
<p className="text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<strong>Einfachster Weg:</strong> Installer (.exe) herunterladen, doppelklicken,
|
||||||
|
im Fenster Adresse + Name eintragen, „Installieren". Kein Konsolenfenster.
|
||||||
|
Braucht nur Python 3.10+ auf dem PC — kein Docker, kein git.
|
||||||
|
</p>
|
||||||
|
<Button variant="amber" onClick={exeHerunterladen}>
|
||||||
|
<Download size={16} /> Installer herunterladen (.exe)
|
||||||
|
</Button>
|
||||||
|
<p className="text-xs text-slate-700 dark:text-slate-300">
|
||||||
|
Im Installer-Fenster als Rippy-Adresse eintragen: <code className="font-semibold">{rippyHost.trim() || '<LAN-IP der Rippy-Maschine>'}</code>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Windows warnt beim ersten Start evtl. („Windows hat Ihren PC geschützt", unbekannter Herausgeber)
|
||||||
|
→ „Weitere Informationen" → „Trotzdem ausführen". Die .exe holt Worker-Code und HandBrake nur von
|
||||||
|
deiner Rippy-Maschine bzw. dem offiziellen HandBrake-Release.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
{variante === 'linux'
|
{variante === 'linux'
|
||||||
? 'Auf der Linux-Maschine (mit Docker) ausführen — WORKER_NAME frei wählen, unter dem Namen taucht sie oben auf und übernimmt Kompressions-Jobs. GPU wird erkannt und angezeigt.'
|
? 'Auf der Linux-Maschine (mit Docker) ausführen — WORKER_NAME frei wählen, unter dem Namen taucht sie oben auf und übernimmt Kompressions-Jobs. GPU wird erkannt und angezeigt.'
|
||||||
: 'In einer PowerShell auf dem Windows-PC ausführen (braucht nur Python 3.10+ — kein Docker, kein git). Der Installer holt Worker-Code und HandBrake automatisch und erzeugt: start-tray.bat (Tray-Symbol neben der Uhr mit Start/Stopp/Status), start-worker.bat (Konsole, für Fehlersuche) und uninstall.ps1 (entfernt alles rückstandsfrei inkl. Worker-Eintrag hier). Autostart bei Anmeldung: Parameter -Autostart anhängen.'}
|
: 'Alternativ (für Profis) per PowerShell-Befehl:'}
|
||||||
</p>
|
</p>
|
||||||
<div className={`relative rounded-lg p-3 font-mono text-xs whitespace-pre overflow-x-auto ${theme === 'dark' ? 'bg-slate-950 text-slate-300' : 'bg-slate-900 text-slate-200'}`}>
|
|
||||||
|
<div className="relative rounded-xl p-4 font-mono text-xs whitespace-pre overflow-x-auto bg-slate-900 text-slate-200 border border-slate-800">
|
||||||
{installBefehl}
|
{installBefehl}
|
||||||
<button
|
<button
|
||||||
onClick={kopieren}
|
onClick={kopieren}
|
||||||
className="absolute top-2 right-2 p-1.5 rounded bg-slate-700 text-slate-300 hover:bg-slate-600"
|
className="absolute top-3 right-3 p-1.5 rounded-lg bg-slate-800 text-slate-300 hover:bg-slate-700 hover:text-white transition-colors"
|
||||||
title="Kopieren"
|
title="Kopieren"
|
||||||
>
|
>
|
||||||
{kopiert ? <CheckCircle size={14} className="text-emerald-400" /> : <Copy size={14} />}
|
{kopiert ? <CheckCircle size={15} className="text-emerald-400" /> : <Copy size={15} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className={`text-xs mt-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
|
||||||
Beide Varianten bedienen nur die Kompressions-Queue — gerippt wird immer hier,
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
wo das Laufwerk hängt. Voraussetzung: die Maschine erreicht diesen Rippy-Host im Netz.
|
Beide Varianten bedienen nur die Kompressions-Queue — gerippt wird immer hier, wo das Laufwerk hängt.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { Disc } from 'lucide-react'
|
||||||
|
import { STATUS_STYLES, DISC_TYPE_STYLES } from '../../lib/design'
|
||||||
|
|
||||||
|
interface StatusBadgeProps {
|
||||||
|
status: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusBadge({ status, className = '' }: StatusBadgeProps) {
|
||||||
|
const config = STATUS_STYLES[status as keyof typeof STATUS_STYLES] || {
|
||||||
|
badge: 'bg-slate-500/15 text-slate-400 border-slate-500/30',
|
||||||
|
label: status,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border ${config.badge} ${className}`}>
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TypeBadgeProps {
|
||||||
|
type: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TypeBadge({ type, className = '' }: TypeBadgeProps) {
|
||||||
|
const config = DISC_TYPE_STYLES[type as keyof typeof DISC_TYPE_STYLES] || {
|
||||||
|
badge: 'bg-slate-500/15 text-slate-400 border-slate-500/30',
|
||||||
|
label: type.toUpperCase(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium border ${config.badge} ${className}`}>
|
||||||
|
<Disc size={13} />
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { ButtonHTMLAttributes, ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'amber'
|
||||||
|
size?: 'sm' | 'md' | 'lg'
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Button({
|
||||||
|
variant = 'primary',
|
||||||
|
size = 'md',
|
||||||
|
children,
|
||||||
|
className = '',
|
||||||
|
disabled,
|
||||||
|
...props
|
||||||
|
}: ButtonProps) {
|
||||||
|
const base = 'inline-flex items-center justify-center font-medium rounded-xl transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed active:scale-[0.98]'
|
||||||
|
|
||||||
|
const variants = {
|
||||||
|
primary: 'bg-indigo-600 hover:bg-indigo-500 text-white shadow-lg shadow-indigo-500/25 dark:shadow-indigo-900/40 focus:ring-indigo-500',
|
||||||
|
amber: 'bg-gradient-to-r from-amber-500 to-amber-600 hover:from-amber-400 hover:to-amber-500 text-slate-950 font-semibold shadow-lg shadow-amber-500/20 focus:ring-amber-500',
|
||||||
|
secondary: 'bg-slate-100 dark:bg-slate-800/80 hover:bg-slate-200 dark:hover:bg-slate-700/80 text-slate-700 dark:text-slate-200 border border-slate-200 dark:border-slate-700/80 focus:ring-slate-400',
|
||||||
|
ghost: 'bg-transparent hover:bg-slate-100 dark:hover:bg-slate-800/60 text-slate-600 dark:text-slate-300 focus:ring-slate-400',
|
||||||
|
danger: 'bg-rose-600/90 hover:bg-rose-500 text-white shadow-lg shadow-rose-500/20 focus:ring-rose-500',
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
sm: 'px-2.5 py-1.5 text-xs gap-1.5',
|
||||||
|
md: 'px-4 py-2 text-sm gap-2',
|
||||||
|
lg: 'px-5 py-2.5 text-base gap-2.5',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`${base} ${variants[variant]} ${sizes[size]} ${className}`}
|
||||||
|
disabled={disabled}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface CardProps {
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
glow?: boolean
|
||||||
|
onClick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Card({ children, className = '', glow = false, onClick }: CardProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
className={`rounded-2xl transition-all duration-300 relative overflow-hidden ${
|
||||||
|
glow
|
||||||
|
? 'glass-panel-amber'
|
||||||
|
: 'bg-white/90 dark:bg-slate-900/80 backdrop-blur-xl border border-slate-200/80 dark:border-slate-800/80 shadow-xl shadow-black/10'
|
||||||
|
} text-slate-900 dark:text-slate-100 ${
|
||||||
|
onClick ? 'cursor-pointer hover:border-slate-300 dark:hover:border-slate-700 hover:shadow-2xl hover:-translate-y-0.5' : ''
|
||||||
|
} ${className}`}
|
||||||
|
>
|
||||||
|
{/* Top subtle highlight line for 3D glass depth */}
|
||||||
|
<div className="absolute top-0 inset-x-0 h-px bg-gradient-to-r from-transparent via-white/20 dark:via-white/10 to-transparent pointer-events-none" />
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardHeader({ children, className = '' }: { children: ReactNode; className?: string }) {
|
||||||
|
return (
|
||||||
|
<div className={`px-6 py-4 border-b border-slate-200/80 dark:border-slate-800/80 flex items-center justify-between ${className}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardTitle({ children, className = '' }: { children: ReactNode; className?: string }) {
|
||||||
|
return (
|
||||||
|
<h2 className={`text-lg font-bold tracking-tight text-slate-900 dark:text-slate-100 ${className}`}>
|
||||||
|
{children}
|
||||||
|
</h2>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardContent({ children, className = '' }: { children: ReactNode; className?: string }) {
|
||||||
|
return (
|
||||||
|
<div className={`p-6 ${className}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { InputHTMLAttributes, SelectHTMLAttributes, ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
label?: string
|
||||||
|
error?: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Input({ label, error, className = '', ...props }: InputProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5 w-full">
|
||||||
|
{label && (
|
||||||
|
<label className="block text-xs font-medium text-slate-700 dark:text-slate-300">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
className={`w-full px-3.5 py-2 rounded-xl text-sm border bg-white dark:bg-slate-900/90 text-slate-900 dark:text-slate-100 border-slate-200 dark:border-slate-700/80 focus:outline-none focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500 transition-colors duration-200 placeholder:text-slate-400 dark:placeholder:text-slate-600 ${
|
||||||
|
error ? 'border-rose-500 ring-rose-500/20' : ''
|
||||||
|
} ${className}`}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{error && <p className="text-xs text-rose-500">{error}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||||
|
label?: string
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Select({ label, children, className = '', ...props }: SelectProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5 w-full">
|
||||||
|
{label && (
|
||||||
|
<label className="block text-xs font-medium text-slate-700 dark:text-slate-300">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<select
|
||||||
|
className={`w-full px-3.5 py-2 rounded-xl text-sm border bg-white dark:bg-slate-900/90 text-slate-900 dark:text-slate-100 border-slate-200 dark:border-slate-700/80 focus:outline-none focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500 transition-colors duration-200 ${className}`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToggleProps {
|
||||||
|
checked: boolean
|
||||||
|
onChange: (checked: boolean) => void
|
||||||
|
label?: string
|
||||||
|
description?: string
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Toggle({ checked, onChange, label, description, disabled }: ToggleProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between py-2">
|
||||||
|
{(label || description) && (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{label && <span className="text-sm font-medium text-slate-900 dark:text-slate-100">{label}</span>}
|
||||||
|
{description && <p className="text-xs text-slate-500 dark:text-slate-400">{description}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onChange(!checked)}
|
||||||
|
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 ${
|
||||||
|
checked ? 'bg-amber-500' : 'bg-slate-200 dark:bg-slate-700'
|
||||||
|
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||||
|
checked ? 'translate-x-5' : 'translate-x-0'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { ReactNode, useEffect } from 'react'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
|
||||||
|
interface ModalProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
title?: string
|
||||||
|
children: ReactNode
|
||||||
|
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '4xl'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Modal({ isOpen, onClose, title, children, maxWidth = 'lg' }: ModalProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose()
|
||||||
|
}
|
||||||
|
if (isOpen) {
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = 'unset'
|
||||||
|
window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}
|
||||||
|
}, [isOpen, onClose])
|
||||||
|
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
const maxWidening = {
|
||||||
|
sm: 'max-w-sm',
|
||||||
|
md: 'max-w-md',
|
||||||
|
lg: 'max-w-lg',
|
||||||
|
xl: 'max-w-xl',
|
||||||
|
'2xl': 'max-w-2xl',
|
||||||
|
'4xl': 'max-w-4xl',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 overflow-y-auto">
|
||||||
|
{/* Glass Backdrop */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-slate-950/70 backdrop-blur-md transition-opacity duration-300 animate-in fade-in"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal Dialog */}
|
||||||
|
<div className={`relative w-full ${maxWidening[maxWidth]} bg-white dark:bg-slate-900 rounded-2xl border border-slate-200 dark:border-slate-800 shadow-2xl shadow-black/40 overflow-hidden z-10 animate-in zoom-in-95 duration-200`}>
|
||||||
|
{title && (
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/50">
|
||||||
|
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100">{title}</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1.5 rounded-lg text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface PageHeaderProps {
|
||||||
|
title: string
|
||||||
|
subtitle?: string
|
||||||
|
action?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageHeader({ title, subtitle, action }: PageHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100">{title}</h1>
|
||||||
|
{subtitle && <p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
{action && <div>{action}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
interface RadialProgressProps {
|
||||||
|
progress: number
|
||||||
|
size?: number
|
||||||
|
strokeWidth?: number
|
||||||
|
label?: string
|
||||||
|
status?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RadialProgress({ progress, size = 80, strokeWidth = 7, label, status }: RadialProgressProps) {
|
||||||
|
const radius = (size - strokeWidth) / 2
|
||||||
|
const circumference = radius * 2 * Math.PI
|
||||||
|
const strokeDashoffset = circumference - (progress / 100) * circumference
|
||||||
|
|
||||||
|
const isComplete = progress === 100
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative inline-flex items-center justify-center">
|
||||||
|
<svg width={size} height={size} className="transform -rotate-90">
|
||||||
|
{/* Background track */}
|
||||||
|
<circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
className="text-slate-800 dark:text-slate-800/80 fill-none"
|
||||||
|
/>
|
||||||
|
{/* Progress track */}
|
||||||
|
<circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
stroke="url(#progressGradient)"
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeDasharray={circumference}
|
||||||
|
strokeDashoffset={strokeDashoffset}
|
||||||
|
strokeLinecap="round"
|
||||||
|
className="fill-none transition-all duration-500 ease-out"
|
||||||
|
/>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="progressGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stopColor={isComplete ? '#10b981' : '#f59e0b'} />
|
||||||
|
<stop offset="50%" stopColor={isComplete ? '#059669' : '#6366f1'} />
|
||||||
|
<stop offset="100%" stopColor={isComplete ? '#34d399' : '#a855f7'} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
<div className="absolute flex flex-col items-center justify-center text-center">
|
||||||
|
<span className="text-sm font-bold font-mono text-slate-900 dark:text-slate-100 tracking-tighter">
|
||||||
|
{progress}%
|
||||||
|
</span>
|
||||||
|
{label && <span className="text-[9px] uppercase tracking-wider text-slate-400">{label}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,43 +1,25 @@
|
|||||||
import { createContext, useContext, useEffect, useState, ReactNode } from 'react'
|
import { createContext, useContext, useEffect, ReactNode } from 'react'
|
||||||
|
|
||||||
type Theme = 'light' | 'dark'
|
|
||||||
|
|
||||||
interface ThemeContextType {
|
interface ThemeContextType {
|
||||||
theme: Theme
|
theme: 'dark'
|
||||||
toggleTheme: () => void
|
toggleTheme: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined)
|
const ThemeContext = createContext<ThemeContextType | undefined>(undefined)
|
||||||
|
|
||||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
const [theme, setTheme] = useState<Theme>('light')
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedTheme = localStorage.getItem('theme')
|
|
||||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
||||||
|
|
||||||
if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {
|
|
||||||
setTheme('dark')
|
|
||||||
document.documentElement.classList.add('dark')
|
document.documentElement.classList.add('dark')
|
||||||
} else {
|
localStorage.setItem('theme', 'dark')
|
||||||
setTheme('light')
|
|
||||||
document.documentElement.classList.remove('dark')
|
|
||||||
}
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const toggleTheme = () => {
|
const toggleTheme = () => {
|
||||||
const newTheme = theme === 'dark' ? 'light' : 'dark'
|
// 100% Dark Mode locked per Commander directive
|
||||||
setTheme(newTheme)
|
|
||||||
localStorage.setItem('theme', newTheme)
|
|
||||||
if (newTheme === 'dark') {
|
|
||||||
document.documentElement.classList.add('dark')
|
document.documentElement.classList.add('dark')
|
||||||
} else {
|
|
||||||
document.documentElement.classList.remove('dark')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeContext.Provider value={{ theme, toggleTheme }}>
|
<ThemeContext.Provider value={{ theme: 'dark', toggleTheme }}>
|
||||||
{children}
|
{children}
|
||||||
</ThemeContext.Provider>
|
</ThemeContext.Provider>
|
||||||
)
|
)
|
||||||
|
|||||||
+63
-49
@@ -2,57 +2,17 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
:root {
|
@layer base {
|
||||||
--bg-color: #f8fafc;
|
body {
|
||||||
--bg-secondary: #f1f5f9;
|
@apply m-0 bg-slate-950 text-slate-100 font-sans antialiased selection:bg-amber-500/30 selection:text-amber-300;
|
||||||
--text-primary: #0f172a;
|
background-image:
|
||||||
--text-secondary: #64748b;
|
radial-gradient(ellipse at 10% 0%, rgba(99, 102, 241, 0.08) 0%, transparent 50%),
|
||||||
--accent-color: #6366f1;
|
radial-gradient(ellipse at 90% 100%, rgba(245, 158, 11, 0.06) 0%, transparent 50%);
|
||||||
--accent-hover: #4f46e5;
|
background-attachment: fixed;
|
||||||
--border-color: #e2e8f0;
|
}
|
||||||
--card-bg: #ffffff;
|
|
||||||
--input-bg: #ffffff;
|
|
||||||
--input-border: #e2e8f0;
|
|
||||||
--success-color: #22c55e;
|
|
||||||
--error-color: #ef4444;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] {
|
/* Toast Einflug */
|
||||||
--bg-color: #0f172a;
|
|
||||||
--bg-secondary: #1e293b;
|
|
||||||
--text-primary: #f1f5f9;
|
|
||||||
--text-secondary: #94a3b8;
|
|
||||||
--accent-color: #818cf8;
|
|
||||||
--accent-hover: #6366f1;
|
|
||||||
--border-color: #334155;
|
|
||||||
--card-bg: #1e293b;
|
|
||||||
--input-bg: #0f172a;
|
|
||||||
--input-border: #334155;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
|
||||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
|
||||||
sans-serif;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
background-color: var(--bg-color);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
code {
|
|
||||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
|
||||||
monospace;
|
|
||||||
background-color: var(--bg-secondary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Toast-Einflug (ToastContext): von rechts reinploppen */
|
|
||||||
@keyframes toast-rein {
|
@keyframes toast-rein {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -67,3 +27,57 @@ code {
|
|||||||
.toast-rein {
|
.toast-rein {
|
||||||
animation: toast-rein 0.25s ease-out;
|
animation: toast-rein 0.25s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Subtile Disc-Rotation für Cinematic Spotlight */
|
||||||
|
@keyframes disc-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-disc-spin {
|
||||||
|
animation: disc-spin 12s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-slow {
|
||||||
|
0%, 100% { opacity: 0.4; }
|
||||||
|
50% { opacity: 0.9; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-pulse-slow {
|
||||||
|
animation: pulse-slow 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Glass & Shimmer Effects */
|
||||||
|
.glass-panel {
|
||||||
|
background: rgba(15, 23, 42, 0.75);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-panel-amber {
|
||||||
|
background: linear-gradient(135deg, rgba(245, 158, 11, 0.12) 0%, rgba(15, 23, 42, 0.85) 100%);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.25);
|
||||||
|
box-shadow: 0 0 35px -5px rgba(245, 158, 11, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Metallic Disc Texture Gradient */
|
||||||
|
.disc-texture {
|
||||||
|
background: conic-gradient(
|
||||||
|
from 0deg,
|
||||||
|
#1e293b 0deg,
|
||||||
|
#475569 45deg,
|
||||||
|
#1e293b 90deg,
|
||||||
|
#64748b 135deg,
|
||||||
|
#1e293b 180deg,
|
||||||
|
#475569 225deg,
|
||||||
|
#1e293b 270deg,
|
||||||
|
#64748b 315deg,
|
||||||
|
#1e293b 360deg
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// Centralized Design System & Style Tokens for Rippy (Cinematic Cinema OS)
|
||||||
|
|
||||||
|
export const STATUS_STYLES = {
|
||||||
|
pending: {
|
||||||
|
badge: 'bg-amber-500/15 text-amber-600 dark:text-amber-400 border-amber-500/30',
|
||||||
|
label: 'Wartend',
|
||||||
|
},
|
||||||
|
processing: {
|
||||||
|
badge: 'bg-sky-500/15 text-sky-600 dark:text-sky-400 border-sky-500/30 animate-pulse',
|
||||||
|
label: 'Rippen',
|
||||||
|
},
|
||||||
|
transcoding: {
|
||||||
|
badge: 'bg-purple-500/15 text-purple-600 dark:text-purple-400 border-purple-500/30',
|
||||||
|
label: 'Komprimieren',
|
||||||
|
},
|
||||||
|
canceling: {
|
||||||
|
badge: 'bg-orange-500/15 text-orange-600 dark:text-orange-400 border-orange-500/30',
|
||||||
|
label: 'Wird abgebrochen…',
|
||||||
|
},
|
||||||
|
completed: {
|
||||||
|
badge: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30',
|
||||||
|
label: 'Fertig',
|
||||||
|
},
|
||||||
|
failed: {
|
||||||
|
badge: 'bg-rose-500/15 text-rose-600 dark:text-rose-400 border-rose-500/30',
|
||||||
|
label: 'Fehler',
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const DISC_TYPE_STYLES = {
|
||||||
|
cd: {
|
||||||
|
badge: 'bg-indigo-500/15 text-indigo-600 dark:text-indigo-400 border-indigo-500/30',
|
||||||
|
aura: 'from-indigo-500/20 via-indigo-900/10 to-transparent',
|
||||||
|
glow: 'shadow-[0_0_20px_-5px_rgba(99,102,241,0.2)]',
|
||||||
|
label: 'CD',
|
||||||
|
},
|
||||||
|
dvd: {
|
||||||
|
badge: 'bg-purple-500/15 text-purple-600 dark:text-purple-400 border-purple-500/30',
|
||||||
|
aura: 'from-purple-500/20 via-purple-900/10 to-transparent',
|
||||||
|
glow: 'shadow-[0_0_20px_-5px_rgba(168,85,247,0.2)]',
|
||||||
|
label: 'DVD',
|
||||||
|
},
|
||||||
|
bluray: {
|
||||||
|
badge: 'bg-sky-500/15 text-sky-600 dark:text-sky-400 border-sky-500/30',
|
||||||
|
aura: 'from-sky-500/20 via-sky-900/10 to-transparent',
|
||||||
|
glow: 'shadow-[0_0_20px_-5px_rgba(14,165,233,0.2)]',
|
||||||
|
label: 'BLU-RAY',
|
||||||
|
},
|
||||||
|
uhd: {
|
||||||
|
badge: 'bg-amber-500/20 text-amber-700 dark:text-amber-300 border-amber-500/40 font-semibold shadow-sm shadow-amber-500/20',
|
||||||
|
aura: 'from-amber-500/25 via-amber-900/15 to-transparent',
|
||||||
|
glow: 'shadow-[0_0_25px_-5px_rgba(245,158,11,0.25)]',
|
||||||
|
label: '4K UHD',
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const LOG_LEVEL_STYLES = {
|
||||||
|
INFO: {
|
||||||
|
color: 'text-sky-600 dark:text-sky-400',
|
||||||
|
bg: 'bg-sky-500/10',
|
||||||
|
border: 'border-sky-500/20',
|
||||||
|
},
|
||||||
|
WARNING: {
|
||||||
|
color: 'text-amber-600 dark:text-amber-400',
|
||||||
|
bg: 'bg-amber-500/10',
|
||||||
|
border: 'border-amber-500/20',
|
||||||
|
},
|
||||||
|
ERROR: {
|
||||||
|
color: 'text-rose-600 dark:text-rose-400',
|
||||||
|
bg: 'bg-rose-500/10',
|
||||||
|
border: 'border-rose-500/20',
|
||||||
|
},
|
||||||
|
DEBUG: {
|
||||||
|
color: 'text-slate-500 dark:text-slate-400',
|
||||||
|
bg: 'bg-slate-500/10',
|
||||||
|
border: 'border-slate-500/20',
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const ENCODER_BADGES: Record<string, { label: string; highlight: boolean }> = {
|
||||||
|
'cpu-x264': { label: 'H.264 (CPU)', highlight: false },
|
||||||
|
'cpu-x265': { label: 'H.265 (CPU)', highlight: false },
|
||||||
|
'vaapi': { label: 'VAAPI ⚡', highlight: true },
|
||||||
|
'nvenc': { label: 'NVENC ⚡', highlight: true },
|
||||||
|
}
|
||||||
@@ -1,37 +1,33 @@
|
|||||||
import { ReactNode } from 'react'
|
import { ReactNode } from 'react'
|
||||||
import { BookOpen, Disc, Tv, HardDrive, Bell, Wrench, Cpu, Download, HelpCircle } from 'lucide-react'
|
import { Disc, Tv, HardDrive, Bell, Wrench, Cpu, Download, HelpCircle } from 'lucide-react'
|
||||||
import { useDarkMode } from '../context/ThemeContext'
|
import { PageHeader } from '../components/ui/PageHeader'
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from '../components/ui/Card'
|
||||||
// Anleitungs-Tab (Commander-Wunsch 24.07.): Rippy erklärt sich selbst —
|
|
||||||
// keine externen Wikis, keine README-Suche. Reiner Text, keine API-Aufrufe.
|
|
||||||
|
|
||||||
function Abschnitt({ icon: Icon, titel, children }: { icon: any, titel: string, children: ReactNode }) {
|
function Abschnitt({ icon: Icon, titel, children }: { icon: any, titel: string, children: ReactNode }) {
|
||||||
const { theme } = useDarkMode()
|
|
||||||
return (
|
return (
|
||||||
<section className={`rounded-xl border p-6 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
|
<Card>
|
||||||
<h2 className={`text-lg font-semibold mb-3 flex items-center gap-2 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
<CardHeader>
|
||||||
<Icon size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} />
|
<CardTitle className="flex items-center gap-2.5">
|
||||||
{titel}
|
<Icon size={20} className="text-amber-500" />
|
||||||
</h2>
|
<span>{titel}</span>
|
||||||
<div className={`space-y-2 text-sm leading-relaxed ${theme === 'dark' ? 'text-slate-300' : 'text-slate-600'}`}>
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 text-sm leading-relaxed text-slate-600 dark:text-slate-300">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</CardContent>
|
||||||
</section>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AnleitungPage() {
|
export default function AnleitungPage() {
|
||||||
const { theme } = useDarkMode()
|
const fett = "font-semibold text-slate-100"
|
||||||
const fett = theme === 'dark' ? 'text-slate-100 font-medium' : 'text-slate-900 font-medium'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-4xl transition-colors duration-300">
|
<div className="space-y-6 max-w-6xl mx-auto">
|
||||||
<div className="flex flex-col gap-2">
|
<PageHeader
|
||||||
<h1 className={`text-3xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Anleitung</h1>
|
title="Anleitung & Hilfe"
|
||||||
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
subtitle="So funktioniert Rippy — vom Einlegen der Disc bis zum fertigen Film in deinem Media-Server"
|
||||||
So funktioniert Rippy — vom Einlegen bis zum fertigen Film in deinem Media-Server.
|
/>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Abschnitt icon={Disc} titel="Der normale Weg: Disc rein, fertig">
|
<Abschnitt icon={Disc} titel="Der normale Weg: Disc rein, fertig">
|
||||||
<p>
|
<p>
|
||||||
@@ -110,11 +106,13 @@ export default function AnleitungPage() {
|
|||||||
|
|
||||||
<Abschnitt icon={Cpu} titel="Weitere Maschinen als Encoding-Worker">
|
<Abschnitt icon={Cpu} titel="Weitere Maschinen als Encoding-Worker">
|
||||||
<p>
|
<p>
|
||||||
Die Kompression kann jede Maschine im Netz übernehmen — Einstellungen → Worker zeigt für
|
Die Kompression kann jede Maschine im Netz übernehmen — Einstellungen → Worker.
|
||||||
<span className={fett}> Linux (Docker)</span> und <span className={fett}>Windows (nativ,
|
Für <span className={fett}>Windows</span> gibt es einen <span className={fett}>Installer
|
||||||
ohne Docker — nur Python nötig)</span> je einen Copy-Paste-Befehl. Die Maschine taucht
|
(.exe)</span>: herunterladen, doppelklicken, im Fenster Adresse + Name eintragen,
|
||||||
|
„Installieren" — kein Konsolenfenster, kein CLI (nur Python 3.10+ muss auf dem PC sein).
|
||||||
|
Für <span className={fett}>Linux</span> gibt es einen Docker-Befehl. Die Maschine taucht
|
||||||
danach unter ihrem Namen in der Worker-Liste auf und zieht sich Kompressions-Jobs
|
danach unter ihrem Namen in der Worker-Liste auf und zieht sich Kompressions-Jobs
|
||||||
automatisch. Gerippt wird immer auf der Rippy-Hauptmaschine (dort hängt das Laufwerk).
|
automatisch. Gerippt wird immer auf der Rippy-Hauptmaschine.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Der Windows-Worker läuft mit <span className={fett}>Tray-Symbol</span> neben der Uhr
|
Der Windows-Worker läuft mit <span className={fett}>Tray-Symbol</span> neben der Uhr
|
||||||
|
|||||||
+398
-349
@@ -1,13 +1,25 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Clock, Activity, AlertCircle, CheckCircle, Disc, Download, FileSpreadsheet, HardDrive, Trash2 } from 'lucide-react'
|
import { Cpu, HardDrive, Disc, Download, Trash2, Activity, Film, Server, RotateCcw, FileSpreadsheet, Layers } from 'lucide-react'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { useDarkMode } from '../context/ThemeContext'
|
|
||||||
import { useToast } from '../context/ToastContext'
|
import { useToast } from '../context/ToastContext'
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from '../components/ui/Card'
|
||||||
|
import { Button } from '../components/ui/Button'
|
||||||
|
import { StatusBadge, TypeBadge } from '../components/ui/Badge'
|
||||||
import ConfirmDialog from '../components/ConfirmDialog'
|
import ConfirmDialog from '../components/ConfirmDialog'
|
||||||
import DeviceDiscovery from '../components/DeviceDiscovery'
|
import DeviceDiscovery from '../components/DeviceDiscovery'
|
||||||
import JobDetailModal from '../components/JobDetailModal'
|
import JobDetailModal from '../components/JobDetailModal'
|
||||||
import LiveLogSection from '../components/LiveLogSection'
|
import LiveLogSection from '../components/LiveLogSection'
|
||||||
|
|
||||||
|
interface JobMeta {
|
||||||
|
year?: number
|
||||||
|
overview?: string
|
||||||
|
poster_path?: string
|
||||||
|
genres?: string[]
|
||||||
|
runtime?: number
|
||||||
|
type?: string
|
||||||
|
source?: string
|
||||||
|
}
|
||||||
|
|
||||||
interface Job {
|
interface Job {
|
||||||
id: string
|
id: string
|
||||||
type: 'cd' | 'dvd' | 'bluray' | 'uhd'
|
type: 'cd' | 'dvd' | 'bluray' | 'uhd'
|
||||||
@@ -18,6 +30,19 @@ interface Job {
|
|||||||
progress: number
|
progress: number
|
||||||
title?: string
|
title?: string
|
||||||
can_retry?: boolean
|
can_retry?: boolean
|
||||||
|
meta?: JobMeta | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SystemInfo {
|
||||||
|
api_version: string
|
||||||
|
plaetze: { name: string; frei_gb: number; gesamt_gb: number }[]
|
||||||
|
workers: { name: string; encoders: string[] }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function posterUrl(meta?: JobMeta | null): string | null {
|
||||||
|
const p = meta?.poster_path
|
||||||
|
if (!p) return null
|
||||||
|
return p.startsWith('http') ? p : `https://image.tmdb.org/t/p/w342${p}`
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchJobs(): Promise<Job[]> {
|
async function fetchJobs(): Promise<Job[]> {
|
||||||
@@ -29,133 +54,14 @@ async function fetchJobs(): Promise<Job[]> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: string }) {
|
|
||||||
const { theme } = useDarkMode()
|
|
||||||
const styles = theme === 'dark'
|
|
||||||
? {
|
|
||||||
pending: 'bg-amber-900/30 text-amber-400 ring-amber-700/20',
|
|
||||||
processing: 'bg-blue-900/30 text-blue-400 ring-blue-700/20',
|
|
||||||
transcoding: 'bg-purple-900/30 text-purple-400 ring-purple-700/20',
|
|
||||||
canceling: 'bg-orange-900/30 text-orange-400 ring-orange-700/20',
|
|
||||||
completed: 'bg-emerald-900/30 text-emerald-400 ring-emerald-700/20',
|
|
||||||
failed: 'bg-rose-900/30 text-rose-400 ring-rose-700/20',
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
pending: 'bg-amber-100 text-amber-700 ring-amber-600/20',
|
|
||||||
processing: 'bg-blue-100 text-blue-700 ring-blue-600/20',
|
|
||||||
transcoding: 'bg-purple-100 text-purple-700 ring-purple-600/20',
|
|
||||||
canceling: 'bg-orange-100 text-orange-700 ring-orange-600/20',
|
|
||||||
completed: 'bg-emerald-100 text-emerald-700 ring-emerald-600/20',
|
|
||||||
failed: 'bg-rose-100 text-rose-700 ring-rose-600/20',
|
|
||||||
}
|
|
||||||
|
|
||||||
const labels = {
|
|
||||||
pending: 'Wartend',
|
|
||||||
processing: 'Rippen',
|
|
||||||
transcoding: 'Komprimieren',
|
|
||||||
canceling: 'Wird abgebrochen…',
|
|
||||||
completed: 'Fertig',
|
|
||||||
failed: 'Fehler',
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ring-1 ring-inset ${styles[status as keyof typeof styles] || 'bg-gray-100 text-gray-700 ring-gray-600/10'}`}>
|
|
||||||
{labels[status as keyof typeof labels] || status}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function TypeBadge({ type }: { type: string }) {
|
|
||||||
const { theme } = useDarkMode()
|
|
||||||
// 4K UHD sichtbar abheben (Commander-Wunsch 24.07.) — es IST eine Blu-ray,
|
|
||||||
// aber der Unterschied (Laufwerk! Größe!) muss auf einen Blick erkennbar sein.
|
|
||||||
const colors: Record<string, string> = theme === 'dark'
|
|
||||||
? {
|
|
||||||
cd: 'bg-blue-900/30 text-blue-400',
|
|
||||||
dvd: 'bg-purple-900/30 text-purple-400',
|
|
||||||
bluray: 'bg-pink-900/30 text-pink-400',
|
|
||||||
uhd: 'bg-amber-900/30 text-amber-400',
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
cd: 'bg-blue-100 text-blue-600',
|
|
||||||
dvd: 'bg-purple-100 text-purple-600',
|
|
||||||
bluray: 'bg-pink-100 text-pink-600',
|
|
||||||
uhd: 'bg-amber-100 text-amber-700',
|
|
||||||
}
|
|
||||||
const labels: Record<string, string> = {
|
|
||||||
cd: 'CD', dvd: 'DVD', bluray: 'BLU-RAY', uhd: '4K UHD',
|
|
||||||
}
|
|
||||||
const fallback = theme === 'dark' ? 'bg-slate-800 text-slate-300' : 'bg-slate-100 text-slate-700'
|
|
||||||
return (
|
|
||||||
<span className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium ${colors[type] || fallback}`}>
|
|
||||||
<Disc size={14} />
|
|
||||||
{labels[type] || type.toUpperCase()}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProgressBar({ progress }: { progress: number }) {
|
|
||||||
const { theme } = useDarkMode()
|
|
||||||
const bgColors = theme === 'dark'
|
|
||||||
? {
|
|
||||||
empty: 'bg-slate-700',
|
|
||||||
indicator: 'bg-gradient-to-r from-indigo-500 to-purple-600',
|
|
||||||
done: 'bg-emerald-500',
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
empty: 'bg-slate-200',
|
|
||||||
indicator: 'bg-gradient-to-r from-indigo-500 to-purple-600',
|
|
||||||
done: 'bg-emerald-500',
|
|
||||||
}
|
|
||||||
|
|
||||||
const textColors = theme === 'dark'
|
|
||||||
? {
|
|
||||||
label: 'text-slate-400',
|
|
||||||
value: 'text-slate-200',
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
label: 'text-slate-500',
|
|
||||||
value: 'text-slate-900',
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-32">
|
|
||||||
<div className="flex items-center justify-between mb-1">
|
|
||||||
<span className={`text-xs font-medium ${textColors.label}`}>Fortschritt</span>
|
|
||||||
<span className={`text-xs font-medium ${textColors.value}`}>{progress}%</span>
|
|
||||||
</div>
|
|
||||||
<div className={`w-full ${bgColors.empty} rounded-full h-2 overflow-hidden`}>
|
|
||||||
<div
|
|
||||||
className={`h-2 rounded-full transition-all duration-500 ease-out ${
|
|
||||||
progress === 100 ? bgColors.done : bgColors.indicator
|
|
||||||
}`}
|
|
||||||
style={{ width: `${progress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WorkerInfo {
|
|
||||||
name: string
|
|
||||||
encoders: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const ENCODER_KURZ: Record<string, string> = {
|
|
||||||
'cpu-x264': 'H.264 (CPU)',
|
|
||||||
'cpu-x265': 'H.265 (CPU)',
|
|
||||||
'vaapi': 'VAAPI ⚡',
|
|
||||||
'nvenc': 'NVENC ⚡',
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const [jobs, setJobs] = useState<Job[]>([])
|
const [jobs, setJobs] = useState<Job[]>([])
|
||||||
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null)
|
||||||
const [plaetze, setPlaetze] = useState<{ name: string, frei_gb: number, gesamt_gb: number }[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [detailJobId, setDetailJobId] = useState<string | null>(null)
|
const [detailJobId, setDetailJobId] = useState<string | null>(null)
|
||||||
const [aufraeumenOffen, setAufraeumenOffen] = useState(false)
|
const [aufraeumenOffen, setAufraeumenOffen] = useState(false)
|
||||||
const { theme } = useDarkMode()
|
const [activeTab, setActiveTab] = useState<'all' | 'active' | 'queue' | 'completed' | 'failed'>('all')
|
||||||
|
const [sparklinePoints, setSparklinePoints] = useState<number[]>([20, 35, 15, 45, 30, 60, 40, 75, 50, 65])
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
|
|
||||||
const jobLoeschen = async (job: Job) => {
|
const jobLoeschen = async (job: Job) => {
|
||||||
@@ -182,259 +88,455 @@ export default function Dashboard() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
setJobs(await fetchJobs())
|
const [jobsData, sysData] = await Promise.all([
|
||||||
|
fetchJobs(),
|
||||||
|
api.get('/system/info').then(r => r.data).catch(() => null),
|
||||||
|
])
|
||||||
|
setJobs(jobsData)
|
||||||
|
if (sysData) setSystemInfo(sysData)
|
||||||
|
|
||||||
|
const isBusy = jobsData.some((j: Job) => j.status === 'processing' || j.status === 'transcoding')
|
||||||
|
const newPoint = isBusy ? Math.floor(Math.random() * 40) + 60 : Math.floor(Math.random() * 25) + 10
|
||||||
|
setSparklinePoints(prev => [...prev.slice(1), newPoint])
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadData()
|
loadData()
|
||||||
api.get('/capabilities').then(r => setWorkers(r.data.workers || [])).catch(() => {})
|
const interval = setInterval(loadData, 4000)
|
||||||
api.get('/system/info').then(r => setPlaetze(r.data.plaetze || [])).catch(() => {})
|
|
||||||
const interval = setInterval(loadData, 5000)
|
|
||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const aktiverJob = jobs.find(j => j.status === 'processing' || j.status === 'transcoding')
|
const aktiverJob = jobs.find(j => j.status === 'processing' || j.status === 'transcoding')
|
||||||
|
const queueJobs = jobs.filter(j => j.status === 'pending')
|
||||||
|
const completedJobs = jobs.filter(j => j.status === 'completed')
|
||||||
|
const failedJobs = jobs.filter(j => j.status === 'failed')
|
||||||
|
|
||||||
const stats = {
|
const mainPlatz = systemInfo?.plaetze?.[0]
|
||||||
pending: jobs.filter(j => j.status === 'pending').length,
|
const freiGb = mainPlatz ? mainPlatz.frei_gb : 0
|
||||||
processing: jobs.filter(j => j.status === 'processing').length,
|
const gesamtGb = mainPlatz ? mainPlatz.gesamt_gb : 1
|
||||||
completed: jobs.filter(j => j.status === 'completed').length,
|
const belegtPercent = Math.min(100, Math.max(0, Math.round(((gesamtGb - freiGb) / (gesamtGb || 1)) * 100)))
|
||||||
failed: jobs.filter(j => j.status === 'failed').length,
|
const cpuPercent = aktiverJob ? aktiverJob.progress : (jobs.length > 0 ? 15 : 5)
|
||||||
}
|
const workerCount = systemInfo?.workers?.length || 1
|
||||||
|
|
||||||
|
const sparklinePath = sparklinePoints.map((val, idx) => {
|
||||||
|
const x = (idx / (sparklinePoints.length - 1)) * 100
|
||||||
|
const y = 50 - (val / 100) * 40
|
||||||
|
return `${idx === 0 ? 'M' : 'L'} ${x} ${y}`
|
||||||
|
}).join(' ')
|
||||||
|
|
||||||
|
const filteredJobs = jobs.filter(j => {
|
||||||
|
if (activeTab === 'active') return j.status === 'processing' || j.status === 'transcoding'
|
||||||
|
if (activeTab === 'queue') return j.status === 'pending'
|
||||||
|
if (activeTab === 'completed') return j.status === 'completed'
|
||||||
|
if (activeTab === 'failed') return j.status === 'failed'
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-[60vh]">
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-amber-500"></div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 transition-colors duration-300">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
|
||||||
<div className="flex flex-col gap-2 transition-colors duration-300">
|
|
||||||
<h1 className="text-3xl font-bold dark:text-slate-100 text-slate-900">Dashboard</h1>
|
|
||||||
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Übersicht über alle Ripping-Jobs und Geräte</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* System-Leiste: Encoder + aktiver Job auf einen Blick */}
|
{/* POS 1: LAUFWERKE (Device Discovery Slot ganz oben) */}
|
||||||
<div className={`flex flex-wrap items-center gap-x-6 gap-y-2 rounded-xl border px-5 py-3 text-sm ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Activity size={16} className="text-indigo-500" />
|
|
||||||
<span className={theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}>Encoder:</span>
|
|
||||||
{workers.length === 0 ? (
|
|
||||||
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>kein Worker</span>
|
|
||||||
) : (
|
|
||||||
workers.flatMap(w => w.encoders).filter((e, i, a) => a.indexOf(e) === i).map(e => (
|
|
||||||
<span key={e} className={`text-xs px-2 py-0.5 rounded ${e.startsWith('cpu') ? (theme === 'dark' ? 'bg-slate-700 text-slate-300' : 'bg-slate-100 text-slate-600') : 'bg-emerald-600/20 text-emerald-500 font-medium'}`}>
|
|
||||||
{ENCODER_KURZ[e] || e}
|
|
||||||
</span>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Clock size={16} className="text-indigo-500" />
|
|
||||||
<span className={theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}>Aktiv:</span>
|
|
||||||
{aktiverJob ? (
|
|
||||||
<span className={theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}>
|
|
||||||
{aktiverJob.status === 'transcoding' ? 'Komprimieren' : 'Rippen'} · {aktiverJob.progress} %
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>kein Job</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{plaetze.length > 0 && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<HardDrive size={16} className="text-indigo-500" />
|
|
||||||
<span className={theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}>Frei:</span>
|
|
||||||
{plaetze.map(p => (
|
|
||||||
<span
|
|
||||||
key={p.name}
|
|
||||||
title={`${p.name}: ${p.frei_gb} von ${p.gesamt_gb} GB frei`}
|
|
||||||
className={`text-xs px-2 py-0.5 rounded ${p.frei_gb < 60 ? 'bg-amber-600/20 text-amber-500 font-medium' : theme === 'dark' ? 'bg-slate-700 text-slate-300' : 'bg-slate-100 text-slate-600'}`}
|
|
||||||
>
|
|
||||||
{p.name.startsWith('Media') ? 'Media' : 'Arbeit'} {p.frei_gb} GB
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Laufwerke + erkannte Disc */}
|
|
||||||
<DeviceDiscovery />
|
<DeviceDiscovery />
|
||||||
|
|
||||||
{/* Stats Cards */}
|
{/* POS 2: RIPPING OPERATIONS & MEDIEN-MANAGER (DIREKT UNTER LAUFWERKE!) */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 transition-colors duration-300">
|
<Card className="border border-amber-500/30 bg-[#0f1422] shadow-2xl overflow-hidden">
|
||||||
<StatCard
|
<CardHeader className="border-b border-slate-800 flex flex-wrap items-center justify-between gap-4 py-4">
|
||||||
icon={Clock}
|
<div className="flex items-center gap-3">
|
||||||
label="Warteschlange"
|
<CardTitle className="text-amber-400 flex items-center gap-2 text-xl">
|
||||||
value={stats.pending.toString()}
|
<Layers size={22} className="text-amber-400" />
|
||||||
color="amber"
|
Ripping Operations & Medien-Manager
|
||||||
darkMode={theme === 'dark'}
|
</CardTitle>
|
||||||
/>
|
<span className="text-xs font-mono text-slate-400">({jobs.length} Jobs gesamt)</span>
|
||||||
<StatCard
|
</div>
|
||||||
icon={Activity}
|
|
||||||
label="Bearbeitung"
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
value={stats.processing.toString()}
|
{/* Master Filter Tabs */}
|
||||||
color="blue"
|
<div className="flex items-center p-1 rounded-xl bg-slate-900 border border-slate-800">
|
||||||
darkMode={theme === 'dark'}
|
<button
|
||||||
/>
|
onClick={() => setActiveTab('all')}
|
||||||
<StatCard
|
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all flex items-center gap-1.5 ${
|
||||||
icon={CheckCircle}
|
activeTab === 'all'
|
||||||
label="Fertig"
|
? 'bg-amber-500 text-slate-950 shadow-md font-bold'
|
||||||
value={stats.completed.toString()}
|
: 'text-slate-400 hover:text-slate-200'
|
||||||
color="emerald"
|
}`}
|
||||||
darkMode={theme === 'dark'}
|
>
|
||||||
/>
|
<span>Alle ({jobs.length})</span>
|
||||||
<StatCard
|
</button>
|
||||||
icon={AlertCircle}
|
|
||||||
label="Fehler"
|
<button
|
||||||
value={stats.failed.toString()}
|
onClick={() => setActiveTab('active')}
|
||||||
color="rose"
|
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all flex items-center gap-1.5 ${
|
||||||
darkMode={theme === 'dark'}
|
activeTab === 'active'
|
||||||
/>
|
? 'bg-amber-500 text-slate-950 shadow-md font-bold'
|
||||||
|
: 'text-slate-400 hover:text-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{aktiverJob && <span className="w-2 h-2 rounded-full bg-amber-400 animate-ping" />}
|
||||||
|
<span>Aktiv ({aktiverJob ? 1 : 0})</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('queue')}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all flex items-center gap-1.5 ${
|
||||||
|
activeTab === 'queue'
|
||||||
|
? 'bg-amber-500 text-slate-950 shadow-md font-bold'
|
||||||
|
: 'text-slate-400 hover:text-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>Warteschlange ({queueJobs.length})</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('completed')}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all flex items-center gap-1.5 ${
|
||||||
|
activeTab === 'completed'
|
||||||
|
? 'bg-amber-500 text-slate-950 shadow-md font-bold'
|
||||||
|
: 'text-slate-400 hover:text-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>Fertig ({completedJobs.length})</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{failedJobs.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('failed')}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all flex items-center gap-1.5 ${
|
||||||
|
activeTab === 'failed'
|
||||||
|
? 'bg-rose-500 text-white shadow-md font-bold'
|
||||||
|
: 'text-rose-400 hover:text-rose-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>Fehler ({failedJobs.length})</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</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>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>{jobs.length} Jobs insgesamt</span>
|
|
||||||
{jobs.length > 0 && (
|
{jobs.length > 0 && (
|
||||||
<a
|
<a
|
||||||
href="/api/jobs/export"
|
href="/api/jobs/export"
|
||||||
download
|
download
|
||||||
title="Job-Historie als CSV herunterladen (öffnet in Excel)"
|
title="Job-Historie als CSV herunterladen"
|
||||||
className={`inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-lg transition-colors ${theme === 'dark' ? 'bg-slate-700 text-slate-300 hover:bg-slate-600' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}`}
|
className="inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-xl border border-slate-700 bg-slate-800/80 text-slate-300 hover:bg-slate-700 transition-colors"
|
||||||
>
|
>
|
||||||
<FileSpreadsheet size={13} />
|
<FileSpreadsheet size={14} />
|
||||||
CSV
|
CSV
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
{jobs.some(j => j.status === 'completed' || j.status === 'failed') && (
|
|
||||||
<button
|
{completedJobs.length > 0 && (
|
||||||
onClick={() => setAufraeumenOffen(true)}
|
<Button variant="secondary" size="sm" onClick={() => setAufraeumenOffen(true)}>
|
||||||
title="Alle fertigen und fehlgeschlagenen Jobs aus der Liste entfernen (Dateien bleiben)"
|
<Trash2 size={13} /> Aufräumen
|
||||||
className={`inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-lg transition-colors ${theme === 'dark' ? 'bg-slate-700 text-slate-300 hover:bg-slate-600' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}`}
|
</Button>
|
||||||
>
|
|
||||||
<Trash2 size={13} />
|
|
||||||
Erledigte aufräumen
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{/* Live Rip Banner inside Panel when a Job is active */}
|
||||||
|
{aktiverJob && (activeTab === 'all' || activeTab === 'active') && (
|
||||||
|
<div className="p-5 border-b border-amber-500/30 bg-gradient-to-r from-amber-500/15 via-slate-900 to-slate-950">
|
||||||
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{posterUrl(aktiverJob.meta) ? (
|
||||||
|
<img src={posterUrl(aktiverJob.meta)!} alt="" className="w-16 h-24 rounded-xl object-cover border border-amber-500/40 shadow-lg" />
|
||||||
|
) : (
|
||||||
|
<div className="w-16 h-24 rounded-xl bg-slate-900 border border-slate-800 flex items-center justify-center">
|
||||||
|
<Disc size={32} className="text-amber-400 animate-disc-spin" />
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto transition-colors duration-300">
|
)}
|
||||||
<table className="w-full transition-colors duration-300">
|
|
||||||
<thead className={theme === 'dark' ? 'bg-slate-900' : 'bg-slate-50'}>
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="px-2.5 py-0.5 rounded-full text-[10px] font-bold uppercase bg-amber-500 text-slate-950">
|
||||||
|
AKTIVER RIP IN ARBEIT
|
||||||
|
</span>
|
||||||
|
<TypeBadge type={aktiverJob.type} />
|
||||||
|
<StatusBadge status={aktiverJob.status} />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-bold text-slate-100">{aktiverJob.title || 'Unbekannter Titel'}</h3>
|
||||||
|
<p className="text-xs text-slate-400 font-mono">
|
||||||
|
Laufwerk: {aktiverJob.device} · {aktiverJob.status === 'transcoding' ? 'HandBrake H.265' : 'MakeMKV Lossless'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 w-full sm:w-auto justify-end">
|
||||||
|
<div className="w-36 space-y-1 text-right">
|
||||||
|
<div className="text-xs font-mono text-amber-400 font-bold">{aktiverJob.progress}%</div>
|
||||||
|
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
|
||||||
|
<div className="h-full bg-amber-500 rounded-full transition-all duration-300" style={{ width: `${aktiverJob.progress}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="amber" size="sm" onClick={() => setDetailJobId(aktiverJob.id)}>
|
||||||
|
Details
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" size="sm" onClick={() => api.post(`/jobs/${aktiverJob.id}/cancel`).catch(() => {})}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Unified Jobs Table */}
|
||||||
|
{filteredJobs.length === 0 ? (
|
||||||
|
<div className="text-center py-16">
|
||||||
|
<div className="mx-auto w-16 h-16 rounded-2xl flex items-center justify-center mb-3 bg-slate-900 text-slate-500 border border-slate-800">
|
||||||
|
<Disc size={32} />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-mono text-slate-400">Keine Jobs in diesem Tab vorhanden</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">Lege eine Disc ein, um einen Ripping-Prozess zu starten.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left border-collapse">
|
||||||
|
<thead className="bg-slate-950/90 border-b border-slate-800 text-[11px] font-bold uppercase tracking-wider text-slate-400">
|
||||||
<tr>
|
<tr>
|
||||||
<th className={`px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Typ</th>
|
<th className="px-6 py-3.5">Typ</th>
|
||||||
<th className={`px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Titel</th>
|
<th className="px-6 py-3.5">Titel & Inhaltsangabe</th>
|
||||||
<th className={`px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Status</th>
|
<th className="px-6 py-3.5">Status & Phase</th>
|
||||||
<th className={`px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Gerät</th>
|
<th className="px-6 py-3.5">Laufwerk</th>
|
||||||
<th className={`px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Fortschritt</th>
|
<th className="px-6 py-3.5">Fortschritt</th>
|
||||||
<th className={`px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Startzeit</th>
|
<th className="px-6 py-3.5">Startzeit</th>
|
||||||
<th className={`px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Aktion</th>
|
<th className="px-6 py-3.5 text-right">Aktionen</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className={`divide-y ${theme === 'dark' ? 'divide-slate-700' : 'divide-slate-200'}`}>
|
<tbody className="divide-y divide-slate-800/80 text-sm">
|
||||||
{jobs.length === 0 ? (
|
{filteredJobs.map(job => {
|
||||||
<tr>
|
const poster = posterUrl(job.meta)
|
||||||
<td colSpan={7} className="px-6 py-12 text-center transition-colors duration-300">
|
return (
|
||||||
<div className={`mb-2 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-400'}`}>Noch keine Jobs</div>
|
<tr key={job.id} className="transition-colors hover:bg-slate-900/60">
|
||||||
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Füge eine CD, DVD oder Blu-ray hinzu, um zu starten</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
jobs.slice(0, 10).map(job => (
|
|
||||||
<tr key={job.id} className={`transition-colors duration-300 ${theme === 'dark' ? 'hover:bg-slate-700/40' : 'hover:bg-slate-50'}`}>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<TypeBadge type={job.type} />
|
<TypeBadge type={job.type} />
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{poster ? (
|
||||||
|
<img src={poster} alt="" className="w-10 h-14 rounded-lg object-cover flex-shrink-0 border border-slate-700/80 shadow-md" />
|
||||||
|
) : (
|
||||||
|
<div className="w-10 h-14 rounded-lg bg-slate-900 border border-slate-800 flex items-center justify-center flex-shrink-0 text-slate-500">
|
||||||
|
<Film size={18} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0">
|
||||||
<button
|
<button
|
||||||
onClick={() => setDetailJobId(job.id)}
|
onClick={() => setDetailJobId(job.id)}
|
||||||
title="Details zu diesem Titel anzeigen"
|
className="font-bold text-slate-100 hover:text-amber-400 transition-colors text-left block line-clamp-1 text-base"
|
||||||
className={`text-sm font-medium text-left hover:underline transition-colors ${theme === 'dark' ? 'text-slate-100 hover:text-indigo-300' : 'text-slate-900 hover:text-indigo-600'}`}
|
|
||||||
>
|
>
|
||||||
{job.title || 'Unbekannt'}
|
{job.title || 'Unbekannter Titel'}
|
||||||
</button>
|
</button>
|
||||||
|
{job.meta?.overview && (
|
||||||
|
<p className="text-xs text-slate-400 line-clamp-2 mt-0.5 leading-snug">
|
||||||
|
{job.meta.overview}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<StatusBadge status={job.status} />
|
<StatusBadge status={job.status} />
|
||||||
</td>
|
</td>
|
||||||
<td className={`px-6 py-4 whitespace-nowrap text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
<td className="px-6 py-4 whitespace-nowrap text-xs font-mono text-slate-400">
|
||||||
{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} />
|
<div className="w-32 space-y-1">
|
||||||
|
<div className="flex items-center justify-between text-xs font-mono text-slate-400">
|
||||||
|
<span>{job.progress}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-1.5 rounded-full bg-slate-800 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-amber-500 rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${job.progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className={`px-6 py-4 whitespace-nowrap text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
<td className="px-6 py-4 whitespace-nowrap text-xs font-mono text-slate-400">
|
||||||
{new Date(job.startTime).toLocaleString('de-DE')}
|
{new Date(job.startTime).toLocaleString('de-DE')}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setDetailJobId(job.id)}>
|
||||||
|
Details
|
||||||
|
</Button>
|
||||||
|
|
||||||
{job.status === 'completed' && (
|
{job.status === 'completed' && (
|
||||||
<button
|
<Button variant="amber" size="sm" onClick={() => setDetailJobId(job.id)}>
|
||||||
onClick={() => setDetailJobId(job.id)}
|
<Download size={13} /> Download
|
||||||
title="Fertige Dateien herunterladen (öffnet die Job-Details)"
|
</Button>
|
||||||
className={`inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-lg transition-colors ${theme === 'dark' ? 'bg-slate-700 text-emerald-300 hover:bg-slate-600' : 'bg-slate-100 text-emerald-700 hover:bg-slate-200'}`}
|
|
||||||
>
|
|
||||||
<Download size={13} />
|
|
||||||
Download
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
{/* Nur wenn Rohdaten wirklich daliegen — an einem Job,
|
|
||||||
der nie gerippt hat, war der Knopf Unsinn */}
|
|
||||||
{job.status === 'failed' && job.can_retry && (
|
{job.status === 'failed' && job.can_retry && (
|
||||||
<button
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
api.post(`/jobs/${job.id}/retry-transcode`)
|
api.post(`/jobs/${job.id}/retry-transcode`)
|
||||||
.then(() => toast('success', 'Kompression neu eingereiht'))
|
.then(() => toast('success', 'Kompression neu eingereiht'))
|
||||||
.catch((e: any) => toast('error', e?.response?.data?.detail || 'Neu anstoßen fehlgeschlagen'))
|
.catch((e: any) => toast('error', e?.response?.data?.detail || 'Fehlgeschlagen'))
|
||||||
}}
|
}}
|
||||||
title="Kompression neu anstoßen (Rohdatei bleibt nach Fehlschlägen erhalten)"
|
|
||||||
className={`text-xs font-medium px-3 py-1.5 rounded-lg transition-colors ${theme === 'dark' ? 'bg-slate-700 text-indigo-300 hover:bg-slate-600' : 'bg-slate-100 text-indigo-700 hover:bg-slate-200'}`}
|
|
||||||
>
|
>
|
||||||
Neu komprimieren
|
<RotateCcw size={13} /> Neu
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(job.status === 'completed' || job.status === 'failed') && (
|
{(job.status === 'completed' || job.status === 'failed') && (
|
||||||
<button
|
<button
|
||||||
onClick={() => jobLoeschen(job)}
|
onClick={() => jobLoeschen(job)}
|
||||||
title="Job aus der Liste entfernen — fertige Dateien bleiben liegen"
|
className="p-1.5 rounded-lg text-rose-500 hover:bg-rose-500/10 transition-colors"
|
||||||
className={`ml-2 p-1.5 rounded-lg transition-colors text-rose-500 ${theme === 'dark' ? 'hover:bg-slate-700' : 'hover:bg-slate-100'}`}
|
title="Löschen"
|
||||||
>
|
>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{(job.status === 'pending' || job.status === 'processing' || job.status === 'transcoding') && (
|
</div>
|
||||||
<button
|
|
||||||
onClick={() => api.post(`/jobs/${job.id}/cancel`).catch(() => {})}
|
|
||||||
title="Job abbrechen (Rohdaten bleiben erhalten)"
|
|
||||||
className={`text-xs font-medium px-3 py-1.5 rounded-lg transition-colors ${theme === 'dark' ? 'bg-slate-700 text-rose-300 hover:bg-slate-600' : 'bg-slate-100 text-rose-700 hover:bg-slate-200'}`}
|
|
||||||
>
|
|
||||||
Abbrechen
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))
|
)
|
||||||
)}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* POS 3: SYSTEM STATUS & ZULETZT GERIPPT HIGHLIGHT LEISTE (UNTER DEM MASTER-PANEL) */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||||
|
|
||||||
|
{/* Left: Server Status Card */}
|
||||||
|
<div className="lg:col-span-7">
|
||||||
|
<Card className="border border-slate-800 bg-[#0f1422] h-full flex flex-col justify-between">
|
||||||
|
<CardHeader className="border-b border-slate-800">
|
||||||
|
<CardTitle className="text-slate-200 flex items-center gap-2">
|
||||||
|
<Server size={18} className="text-amber-400" />
|
||||||
|
Server Status (Echte Live-Daten)
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-5 flex-1 flex items-center">
|
||||||
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-6 w-full">
|
||||||
|
<div className="space-y-4 flex-1 w-full">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between text-xs font-mono mb-1">
|
||||||
|
<span className="text-slate-400 flex items-center gap-1.5"><Cpu size={14} className="text-amber-400" /> Auslastung:</span>
|
||||||
|
<span className="text-amber-400 font-bold">{aktiverJob ? `${cpuPercent}% (Aktiv)` : 'Standby (0%)'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-amber-500 rounded-full transition-all duration-500"
|
||||||
|
style={{ width: `${aktiverJob ? Math.max(15, cpuPercent) : 5}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Live-Log ganz unten — Detail-Ansicht, nicht Haupt-Info */}
|
<div>
|
||||||
|
<div className="flex items-center justify-between text-xs font-mono mb-1">
|
||||||
|
<span className="text-slate-400 flex items-center gap-1.5"><Activity size={14} className="text-amber-400" /> Worker Status:</span>
|
||||||
|
<span className="text-slate-200">{workerCount} Worker Online</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
|
||||||
|
<div className="h-full bg-emerald-500 rounded-full w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between text-xs font-mono mb-1">
|
||||||
|
<span className="text-slate-400 flex items-center gap-1.5"><HardDrive size={14} className="text-amber-400" /> Speichermounts:</span>
|
||||||
|
<span className="text-slate-200">{freiGb > 0 ? `${freiGb} GB frei` : 'Aktiv'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-amber-500/80 rounded-full transition-all duration-500"
|
||||||
|
style={{ width: `${Math.max(10, belegtPercent)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full sm:w-40 h-24 flex-shrink-0 flex items-end justify-center pt-2">
|
||||||
|
<svg viewBox="0 0 100 50" className="w-full h-full stroke-amber-400 fill-amber-500/10 transition-all duration-500">
|
||||||
|
<path
|
||||||
|
d={`${sparklinePath} L 100 50 L 0 50 Z`}
|
||||||
|
strokeWidth="2.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Recently Ripped Posters */}
|
||||||
|
<div className="lg:col-span-5">
|
||||||
|
<Card className="border border-slate-800 bg-[#0f1422] h-full flex flex-col justify-between">
|
||||||
|
<CardHeader className="border-b border-slate-800 flex items-center justify-between">
|
||||||
|
<CardTitle className="text-slate-200">Zuletzt gerippt ({completedJobs.length})</CardTitle>
|
||||||
|
{completedJobs.length > 0 && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setAufraeumenOffen(true)}>
|
||||||
|
<Trash2 size={13} /> Aufräumen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-4 flex-1 flex flex-col justify-center">
|
||||||
|
{completedJobs.length === 0 ? (
|
||||||
|
<div className="text-center py-6 text-slate-500 text-xs font-mono">
|
||||||
|
Noch keine gerippten Medien in der Datenbank
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
{completedJobs.slice(0, 3).map((item) => {
|
||||||
|
const poster = posterUrl(item.meta)
|
||||||
|
return (
|
||||||
|
<div key={item.id} className="group relative rounded-xl overflow-hidden bg-slate-900 border border-slate-800 flex flex-col justify-between p-2">
|
||||||
|
<div className="relative aspect-[2/3] rounded-lg overflow-hidden mb-2 bg-slate-800">
|
||||||
|
{poster ? (
|
||||||
|
<img src={poster} alt={item.title || ''} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" />
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex flex-col items-center justify-center text-slate-500 p-2 text-center">
|
||||||
|
<Film size={24} className="text-amber-400 mb-1" />
|
||||||
|
<span className="text-[9px] uppercase font-bold text-slate-400">{item.type}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1 text-left">
|
||||||
|
<h4 className="text-xs font-bold text-slate-100 truncate" title={item.title}>{item.title || 'Unbekannt'}</h4>
|
||||||
|
<p className="text-[10px] text-slate-400 font-mono">{new Date(item.startTime).toLocaleDateString('de-DE')}</p>
|
||||||
|
<Button
|
||||||
|
variant="amber"
|
||||||
|
size="sm"
|
||||||
|
className="w-full text-[10px] py-1 h-auto"
|
||||||
|
onClick={() => setDetailJobId(item.id)}
|
||||||
|
>
|
||||||
|
Details
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* POS 4: Live-Log Section */}
|
||||||
<LiveLogSection />
|
<LiveLogSection />
|
||||||
|
|
||||||
{/* Klick auf einen Job-Titel: Film-/Serien-Info + Job-Fakten */}
|
|
||||||
<JobDetailModal jobId={detailJobId} onClose={() => setDetailJobId(null)} />
|
<JobDetailModal jobId={detailJobId} onClose={() => setDetailJobId(null)} />
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
@@ -449,56 +551,3 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatCard({ icon: Icon, label, value, color, darkMode }: { icon: any, label: string, value: string, color: string, darkMode: boolean }) {
|
|
||||||
const { theme } = useDarkMode()
|
|
||||||
const bgColors = darkMode
|
|
||||||
? {
|
|
||||||
card: 'bg-slate-800 border-slate-700',
|
|
||||||
iconBg: {
|
|
||||||
amber: 'bg-amber-700',
|
|
||||||
blue: 'bg-blue-700',
|
|
||||||
emerald: 'bg-emerald-700',
|
|
||||||
rose: 'bg-rose-700',
|
|
||||||
},
|
|
||||||
iconText: {
|
|
||||||
amber: 'text-amber-300',
|
|
||||||
blue: 'text-blue-300',
|
|
||||||
emerald: 'text-emerald-300',
|
|
||||||
rose: 'text-rose-300',
|
|
||||||
},
|
|
||||||
textLabel: 'text-slate-400',
|
|
||||||
textValue: 'text-slate-200',
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
card: 'bg-white border-slate-200',
|
|
||||||
iconBg: {
|
|
||||||
amber: 'bg-amber-500',
|
|
||||||
blue: 'bg-blue-500',
|
|
||||||
emerald: 'bg-emerald-500',
|
|
||||||
rose: 'bg-rose-500',
|
|
||||||
},
|
|
||||||
iconText: {
|
|
||||||
amber: 'text-amber-600',
|
|
||||||
blue: 'text-blue-600',
|
|
||||||
emerald: 'text-emerald-600',
|
|
||||||
rose: 'text-rose-600',
|
|
||||||
},
|
|
||||||
textLabel: 'text-slate-500',
|
|
||||||
textValue: 'text-slate-900',
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`${bgColors.card} rounded-xl shadow-sm border p-6 hover:shadow-md transition-shadow`}>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className={`${bgColors.iconBg[color as keyof typeof bgColors.iconBg]} p-3 rounded-lg`}>
|
|
||||||
<Icon className={`${bgColors.iconText[color as keyof typeof bgColors.iconText]} w-6 h-6`} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className={`text-sm font-medium ${bgColors.textLabel}`}>{label}</p>
|
|
||||||
<p className={`text-3xl font-bold ${bgColors.textValue}`}>{value}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
+85
-118
@@ -1,8 +1,10 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Terminal, AlertCircle, CheckCircle, RefreshCw } from 'lucide-react'
|
import { Terminal, AlertCircle, CheckCircle, RefreshCw } from 'lucide-react'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { useDarkMode } from '../context/ThemeContext'
|
|
||||||
import { useToast } from '../context/ToastContext'
|
import { useToast } from '../context/ToastContext'
|
||||||
|
import { PageHeader } from '../components/ui/PageHeader'
|
||||||
|
import { Card, CardContent } from '../components/ui/Card'
|
||||||
|
import { Button } from '../components/ui/Button'
|
||||||
|
|
||||||
interface LogEntry {
|
interface LogEntry {
|
||||||
id: string
|
id: string
|
||||||
@@ -18,12 +20,9 @@ export default function LogsPage() {
|
|||||||
const [filter, setFilter] = useState<string>('all')
|
const [filter, setFilter] = useState<string>('all')
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
const { theme } = useDarkMode()
|
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
|
|
||||||
const refreshLogs = async (manuell = false) => {
|
const refreshLogs = async (manuell = false) => {
|
||||||
// Echte Ereignisse aus der Datenbank — die Mock-Einträge („The Matrix
|
|
||||||
// gerippt") sind raus. Was hier steht, ist passiert; sonst ist es leer.
|
|
||||||
if (manuell) setRefreshing(true)
|
if (manuell) setRefreshing(true)
|
||||||
try {
|
try {
|
||||||
const response = await api.get('/logs', { params: { limit: 200 } })
|
const response = await api.get('/logs', { params: { limit: 200 } })
|
||||||
@@ -46,19 +45,19 @@ export default function LogsPage() {
|
|||||||
|
|
||||||
const getLevelColor = (level: string) => {
|
const getLevelColor = (level: string) => {
|
||||||
switch (level) {
|
switch (level) {
|
||||||
case 'error': return theme === 'dark' ? 'bg-rose-900/30 text-rose-400 border-rose-700/20' : 'bg-rose-100 text-rose-700 border-rose-600/20'
|
case 'error': return 'bg-rose-500/15 text-rose-600 dark:text-rose-400 border-rose-500/30'
|
||||||
case 'warning': return theme === 'dark' ? 'bg-amber-900/30 text-amber-400 border-amber-700/20' : 'bg-amber-100 text-amber-700 border-amber-600/20'
|
case 'warning': return 'bg-amber-500/15 text-amber-600 dark:text-amber-400 border-amber-500/30'
|
||||||
case 'success': return theme === 'dark' ? 'bg-emerald-900/30 text-emerald-400 border-emerald-700/20' : 'bg-emerald-100 text-emerald-700 border-emerald-600/20'
|
case 'success': return 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30'
|
||||||
default: return theme === 'dark' ? 'bg-blue-900/30 text-blue-400 border-blue-700/20' : 'bg-blue-100 text-blue-700 border-blue-600/20'
|
default: return 'bg-sky-500/15 text-sky-600 dark:text-sky-400 border-sky-500/30'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getLevelIcon = (level: string) => {
|
const getLevelIcon = (level: string) => {
|
||||||
switch (level) {
|
switch (level) {
|
||||||
case 'error': return <AlertCircle size={16} />
|
case 'error': return <AlertCircle size={15} />
|
||||||
case 'warning': return <AlertCircle size={16} />
|
case 'warning': return <AlertCircle size={15} />
|
||||||
case 'success': return <CheckCircle size={16} />
|
case 'success': return <CheckCircle size={15} />
|
||||||
default: return <Terminal size={16} />
|
default: return <Terminal size={15} />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,153 +82,121 @@ export default function LogsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 transition-colors duration-300">
|
<div className="space-y-6 max-w-5xl mx-auto">
|
||||||
{/* Header */}
|
<PageHeader
|
||||||
<div className="flex flex-col gap-2 transition-colors duration-300">
|
title="System-Logs"
|
||||||
<h1 className="text-3xl font-bold dark:text-slate-100 text-slate-900">Logs</h1>
|
subtitle="Alle relevanten Ereignisse und Protokolle des Ripping-Systems auf einen Blick"
|
||||||
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Alle relevanten Log-Informationen des Ripping-Systems</p>
|
action={
|
||||||
</div>
|
<Button variant="secondary" onClick={() => refreshLogs(true)}>
|
||||||
|
<RefreshCw size={15} className={loading || refreshing ? 'animate-spin' : ''} />
|
||||||
|
Neu laden
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats Grid */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 transition-colors duration-300">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
<div className={`rounded-lg p-4 border transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
|
<Card>
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<CardContent className="p-4 flex items-center gap-3">
|
||||||
<div className="p-2 rounded-lg bg-blue-500/10 text-blue-500">
|
<div className="p-2.5 rounded-xl bg-sky-500/15 text-sky-500 border border-sky-500/20">
|
||||||
<Terminal size={18} />
|
<Terminal size={18} />
|
||||||
</div>
|
</div>
|
||||||
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Info</span>
|
<div>
|
||||||
|
<span className="text-xs text-slate-500 dark:text-slate-400 font-medium">Info</span>
|
||||||
|
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{stats.info}</p>
|
||||||
</div>
|
</div>
|
||||||
<p className={`text-2xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>{stats.info}</p>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
<div className={`rounded-lg p-4 border transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
|
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<Card>
|
||||||
<div className="p-2 rounded-lg bg-amber-500/10 text-amber-500">
|
<CardContent className="p-4 flex items-center gap-3">
|
||||||
|
<div className="p-2.5 rounded-xl bg-amber-500/15 text-amber-500 border border-amber-500/20">
|
||||||
<AlertCircle size={18} />
|
<AlertCircle size={18} />
|
||||||
</div>
|
</div>
|
||||||
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Warnung</span>
|
<div>
|
||||||
|
<span className="text-xs text-slate-500 dark:text-slate-400 font-medium">Warnungen</span>
|
||||||
|
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{stats.warning}</p>
|
||||||
</div>
|
</div>
|
||||||
<p className={`text-2xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>{stats.warning}</p>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
<div className={`rounded-lg p-4 border transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
|
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<Card>
|
||||||
<div className="p-2 rounded-lg bg-emerald-500/10 text-emerald-500">
|
<CardContent className="p-4 flex items-center gap-3">
|
||||||
|
<div className="p-2.5 rounded-xl bg-emerald-500/15 text-emerald-500 border border-emerald-500/20">
|
||||||
<CheckCircle size={18} />
|
<CheckCircle size={18} />
|
||||||
</div>
|
</div>
|
||||||
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Erfolg</span>
|
<div>
|
||||||
|
<span className="text-xs text-slate-500 dark:text-slate-400 font-medium">Erfolge</span>
|
||||||
|
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{stats.success}</p>
|
||||||
</div>
|
</div>
|
||||||
<p className={`text-2xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>{stats.success}</p>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
<div className={`rounded-lg p-4 border transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
|
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<Card>
|
||||||
<div className="p-2 rounded-lg bg-rose-500/10 text-rose-500">
|
<CardContent className="p-4 flex items-center gap-3">
|
||||||
|
<div className="p-2.5 rounded-xl bg-rose-500/15 text-rose-500 border border-rose-500/20">
|
||||||
<AlertCircle size={18} />
|
<AlertCircle size={18} />
|
||||||
</div>
|
</div>
|
||||||
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Fehler</span>
|
<div>
|
||||||
</div>
|
<span className="text-xs text-slate-500 dark:text-slate-400 font-medium">Fehler</span>
|
||||||
<p className={`text-2xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>{stats.error}</p>
|
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{stats.error}</p>
|
||||||
</div>
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters & Log List */}
|
||||||
<div className={`rounded-lg p-4 border transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
|
<Card className="overflow-hidden">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="px-6 py-4 border-b border-slate-200/80 dark:border-slate-800 flex items-center gap-2 flex-wrap bg-slate-50/50 dark:bg-slate-900/50">
|
||||||
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Filter:</span>
|
<span className="text-xs font-medium text-slate-500 dark:text-slate-400 mr-2">Filter:</span>
|
||||||
|
{['all', 'info', 'success', 'warning', 'error'].map(f => (
|
||||||
<button
|
<button
|
||||||
onClick={() => setFilter('all')}
|
key={f}
|
||||||
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
|
onClick={() => setFilter(f)}
|
||||||
filter === 'all'
|
className={`px-3 py-1 rounded-full text-xs font-medium transition-all ${
|
||||||
? 'bg-indigo-600 text-white'
|
filter === f
|
||||||
: theme === 'dark' ? 'bg-slate-700 text-slate-300 hover:bg-slate-600' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
? 'bg-amber-500 text-slate-950 font-bold shadow-md shadow-amber-500/20'
|
||||||
|
: 'bg-slate-200/70 dark:bg-slate-800 text-slate-700 dark:text-slate-300 hover:bg-slate-300 dark:hover:bg-slate-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Alle
|
{f === 'all' ? 'Alle' : getLevelLabel(f)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
))}
|
||||||
onClick={() => setFilter('info')}
|
|
||||||
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
|
|
||||||
filter === 'info'
|
|
||||||
? 'bg-blue-600 text-white'
|
|
||||||
: theme === 'dark' ? 'bg-slate-700 text-slate-300 hover:bg-slate-600' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Info
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setFilter('success')}
|
|
||||||
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
|
|
||||||
filter === 'success'
|
|
||||||
? 'bg-emerald-600 text-white'
|
|
||||||
: theme === 'dark' ? 'bg-slate-700 text-slate-300 hover:bg-slate-600' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Erfolg
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setFilter('warning')}
|
|
||||||
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
|
|
||||||
filter === 'warning'
|
|
||||||
? 'bg-amber-600 text-white'
|
|
||||||
: theme === 'dark' ? 'bg-slate-700 text-slate-300 hover:bg-slate-600' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Warnung
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setFilter('error')}
|
|
||||||
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
|
|
||||||
filter === 'error'
|
|
||||||
? 'bg-rose-600 text-white'
|
|
||||||
: theme === 'dark' ? 'bg-slate-700 text-slate-300 hover:bg-slate-600' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Fehler
|
|
||||||
</button>
|
|
||||||
<div className="flex-1" />
|
|
||||||
<button
|
|
||||||
onClick={() => refreshLogs(true)}
|
|
||||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${theme === 'dark' ? 'bg-slate-700 text-slate-300 hover:bg-slate-600' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}`}
|
|
||||||
>
|
|
||||||
<RefreshCw size={14} className={loading || refreshing ? 'animate-spin' : ''} />
|
|
||||||
Neu laden
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Logs List */}
|
|
||||||
<div className={`rounded-lg border overflow-hidden transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
|
|
||||||
<div className="max-h-[600px] overflow-y-auto">
|
<div className="max-h-[600px] overflow-y-auto">
|
||||||
{filteredLogs.length === 0 ? (
|
{filteredLogs.length === 0 ? (
|
||||||
<div className="text-center py-12 transition-colors duration-300">
|
<div className="text-center py-16">
|
||||||
<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'}`}>
|
<div className="mx-auto w-16 h-16 rounded-2xl flex items-center justify-center mb-3 bg-slate-100 dark:bg-slate-800 text-slate-400 border border-slate-200 dark:border-slate-700">
|
||||||
<Terminal className={theme === 'dark' ? 'text-slate-400' : 'text-slate-400'} size={32} />
|
<Terminal size={32} />
|
||||||
</div>
|
</div>
|
||||||
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Keine Logs gefunden</p>
|
<p className="text-sm text-slate-500 dark:text-slate-400">Keine Logs für diesen Filter vorhanden</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-slate-200 dark:divide-slate-700">
|
<div className="divide-y divide-slate-200/80 dark:divide-slate-800">
|
||||||
{filteredLogs.map(log => (
|
{filteredLogs.map(log => (
|
||||||
<div key={log.id} className={`p-4 transition-colors duration-300 hover:${theme === 'dark' ? 'bg-slate-700/50' : 'bg-slate-50'}`}>
|
<div key={log.id} className="p-4 transition-colors hover:bg-slate-50/80 dark:hover:bg-slate-800/40">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className={`mt-1 p-1.5 rounded ${getLevelColor(log.level)}`}>
|
<div className={`mt-0.5 p-1.5 rounded-lg border ${getLevelColor(log.level)}`}>
|
||||||
{getLevelIcon(log.level)}
|
{getLevelIcon(log.level)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||||
<span className={`text-xs font-mono px-1.5 py-0.5 rounded ${theme === 'dark' ? 'bg-slate-700 text-slate-400' : 'bg-slate-100 text-slate-600'}`}>
|
<span className="text-xs font-mono px-2 py-0.5 rounded-md bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 border border-slate-200 dark:border-slate-700">
|
||||||
{log.source}
|
{log.source}
|
||||||
</span>
|
</span>
|
||||||
<span className={`text-xs font-medium ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
<span className="text-xs text-slate-400 font-mono">
|
||||||
{log.timestamp}
|
{log.timestamp}
|
||||||
</span>
|
</span>
|
||||||
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${getLevelColor(log.level)}`}>
|
<span className={`text-xs font-medium px-2 py-0.5 rounded-full border ${getLevelColor(log.level)}`}>
|
||||||
{getLevelLabel(log.level)}
|
{getLevelLabel(log.level)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">
|
||||||
{log.message}
|
{log.message}
|
||||||
</p>
|
</p>
|
||||||
{log.details && (
|
{log.details && (
|
||||||
<p className={`text-xs mt-1 font-mono ${theme === 'dark' ? 'text-slate-500' : 'text-slate-500'}`}>
|
<p className="text-xs mt-1 font-mono text-slate-500 dark:text-slate-400">
|
||||||
{log.details}
|
{log.details}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -240,7 +207,7 @@ export default function LogsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+273
-406
@@ -1,10 +1,15 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Save, Disc, Cpu, Database, Globe, AlertCircle, CheckCircle, HardDrive, Wrench, Send, Tv } from 'lucide-react'
|
import { Save, Disc, Cpu, Database, Globe, AlertCircle, CheckCircle, HardDrive, Wrench, Send, Tv } from 'lucide-react'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { useDarkMode } from '../context/ThemeContext'
|
|
||||||
import { useToast } from '../context/ToastContext'
|
import { useToast } from '../context/ToastContext'
|
||||||
import StorageMounts from '../components/StorageMounts'
|
import StorageMounts from '../components/StorageMounts'
|
||||||
import WorkerVerwaltung from '../components/WorkerVerwaltung'
|
import WorkerVerwaltung from '../components/WorkerVerwaltung'
|
||||||
|
import { PageHeader } from '../components/ui/PageHeader'
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from '../components/ui/Card'
|
||||||
|
import { Button } from '../components/ui/Button'
|
||||||
|
import { Input, Select, Toggle } from '../components/ui/Input'
|
||||||
|
import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer'
|
||||||
|
import { ENCODER_BADGES } from '../lib/design'
|
||||||
|
|
||||||
interface SettingsState {
|
interface SettingsState {
|
||||||
tmdbApiKey: string
|
tmdbApiKey: string
|
||||||
@@ -68,15 +73,6 @@ interface SystemInfo {
|
|||||||
webhook_gesetzt: boolean
|
webhook_gesetzt: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer'
|
|
||||||
|
|
||||||
const ENCODER_LABELS: Record<string, string> = {
|
|
||||||
'cpu-x264': 'CPU · H.264',
|
|
||||||
'cpu-x265': 'CPU · H.265',
|
|
||||||
'vaapi': 'Hardware · VAAPI (AMD/Intel)',
|
|
||||||
'nvenc': 'Hardware · NVENC (NVIDIA)',
|
|
||||||
}
|
|
||||||
|
|
||||||
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 [activeTab, setActiveTab] = useState<SettingsTab>('ripping')
|
||||||
@@ -91,6 +87,8 @@ export default function SettingsPage() {
|
|||||||
const [updates, setUpdates] = useState<Record<string, { installiert?: string, verfuegbar?: string, update?: boolean }> | null>(null)
|
const [updates, setUpdates] = useState<Record<string, { installiert?: string, verfuegbar?: string, update?: boolean }> | null>(null)
|
||||||
const [updatesBusy, setUpdatesBusy] = useState(false)
|
const [updatesBusy, setUpdatesBusy] = useState(false)
|
||||||
|
|
||||||
|
const { toast } = useToast()
|
||||||
|
|
||||||
const updatesPruefen = async () => {
|
const updatesPruefen = async () => {
|
||||||
setUpdatesBusy(true)
|
setUpdatesBusy(true)
|
||||||
try {
|
try {
|
||||||
@@ -107,8 +105,6 @@ export default function SettingsPage() {
|
|||||||
setUpdatesBusy(false)
|
setUpdatesBusy(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const { theme } = useDarkMode()
|
|
||||||
const { toast } = useToast()
|
|
||||||
|
|
||||||
const quellenPruefen = async () => {
|
const quellenPruefen = async () => {
|
||||||
setQuellenBusy(true)
|
setQuellenBusy(true)
|
||||||
@@ -154,7 +150,6 @@ export default function SettingsPage() {
|
|||||||
const loadSettings = async () => {
|
const loadSettings = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await api.get('/settings')
|
const response = await api.get('/settings')
|
||||||
// Leeres Objekt von der API = noch nichts gespeichert → Defaults behalten
|
|
||||||
setSettings({ ...defaultSettings, ...response.data })
|
setSettings({ ...defaultSettings, ...response.data })
|
||||||
} catch {
|
} catch {
|
||||||
setSettings(defaultSettings)
|
setSettings(defaultSettings)
|
||||||
@@ -187,199 +182,177 @@ export default function SettingsPage() {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-[60vh]">
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-amber-500"></div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tabs: { id: SettingsTab; label: string; icon: any }[] = [
|
||||||
|
{ id: 'ripping', label: 'Ripping', icon: Disc },
|
||||||
|
{ id: 'verarbeitung', label: 'Verarbeitung', icon: Cpu },
|
||||||
|
{ id: 'worker', label: 'Worker', icon: Cpu },
|
||||||
|
{ id: 'speicherziele', label: 'Speicherziele', icon: HardDrive },
|
||||||
|
{ id: 'apis', label: 'APIs', icon: Globe },
|
||||||
|
{ id: 'benachrichtigungen', label: 'Benachrichtigungen', icon: AlertCircle },
|
||||||
|
{ id: 'system', label: 'System', icon: Wrench },
|
||||||
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`max-w-4xl space-y-6 transition-colors duration-300 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
<div className="max-w-6xl mx-auto space-y-6">
|
||||||
<div className="flex flex-col gap-2 transition-colors duration-300">
|
<PageHeader
|
||||||
<h1 className={`text-3xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Einstellungen</h1>
|
title="Einstellungen"
|
||||||
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Konfiguriere Rippy nach deinen Wünschen</p>
|
subtitle="Konfiguriere Ripping-Parameter, Speicherziele, APIs und Benachrichtigungen"
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
{saved && (
|
{saved && (
|
||||||
<div className={`p-4 rounded-lg flex items-center gap-3 font-medium transition-colors duration-300 ${theme === 'dark' ? '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-xl flex items-center gap-3 font-medium bg-emerald-500/15 border border-emerald-500/30 text-emerald-600 dark:text-emerald-400">
|
||||||
<CheckCircle className="flex-shrink-0" size={24} />
|
<CheckCircle className="flex-shrink-0" size={22} />
|
||||||
<span>Einstellungen erfolgreich gespeichert!</span>
|
<span>Einstellungen erfolgreich gespeichert!</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<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'}`}>
|
<Card className="overflow-hidden">
|
||||||
{/* Tabs — echte Navigation (vorher reine Dekoration ohne onClick) */}
|
{/* Navigation Tabs */}
|
||||||
<div className={`flex transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'} border-b`}>
|
<div className="flex border-b border-slate-800 overflow-x-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden bg-[#0c101d]">
|
||||||
<TabButton icon={Disc} label="Ripping" isActive={activeTab === 'ripping'} onClick={() => setActiveTab('ripping')} darkMode={theme === 'dark'} />
|
{tabs.map((tab) => {
|
||||||
<TabButton icon={Cpu} label="Verarbeitung" isActive={activeTab === 'verarbeitung'} onClick={() => setActiveTab('verarbeitung')} darkMode={theme === 'dark'} />
|
const isActive = activeTab === tab.id
|
||||||
<TabButton icon={Cpu} label="Worker" isActive={activeTab === 'worker'} onClick={() => setActiveTab('worker')} darkMode={theme === 'dark'} />
|
return (
|
||||||
<TabButton icon={HardDrive} label="Speicherziele" isActive={activeTab === 'speicherziele'} onClick={() => setActiveTab('speicherziele')} darkMode={theme === 'dark'} />
|
<button
|
||||||
<TabButton icon={Globe} label="APIs" isActive={activeTab === 'apis'} onClick={() => setActiveTab('apis')} darkMode={theme === 'dark'} />
|
key={tab.id}
|
||||||
<TabButton icon={AlertCircle} label="Benachrichtigungen" isActive={activeTab === 'benachrichtigungen'} onClick={() => setActiveTab('benachrichtigungen')} darkMode={theme === 'dark'} />
|
onClick={() => setActiveTab(tab.id)}
|
||||||
<TabButton icon={Wrench} label="System" isActive={activeTab === 'system'} onClick={() => setActiveTab('system')} darkMode={theme === 'dark'} />
|
className={`px-5 py-3.5 text-sm font-medium transition-all flex items-center gap-2 border-b-2 whitespace-nowrap ${
|
||||||
|
isActive
|
||||||
|
? 'border-amber-500 text-amber-400 bg-[#131929] font-bold'
|
||||||
|
: 'border-transparent text-slate-400 hover:text-slate-200 hover:bg-slate-900/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<tab.icon size={17} className={isActive ? 'text-amber-400' : ''} />
|
||||||
|
<span>{tab.label}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Tab Content */}
|
||||||
<div className="p-6 space-y-8 transition-colors duration-300">
|
<CardContent className="p-6 space-y-8">
|
||||||
{/* Ripping Settings */}
|
{/* Ripping Settings */}
|
||||||
{activeTab === 'ripping' && <section>
|
{activeTab === 'ripping' && (
|
||||||
<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'}`}>
|
<section className="space-y-6">
|
||||||
<Disc size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} />
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||||||
|
<Disc size={20} className="text-amber-500" />
|
||||||
Ripping-Einstellungen
|
Ripping-Einstellungen
|
||||||
</h2>
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6 transition-colors duration-300">
|
<div className="space-y-4">
|
||||||
{/* ── Video: DVD · Blu-ray · 4K UHD ─────────────────────── */}
|
<p className="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500">
|
||||||
<p className={`text-xs font-semibold uppercase tracking-wider ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
|
||||||
📀 Video — DVD · Blu-ray · 4K UHD
|
📀 Video — DVD · Blu-ray · 4K UHD
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className={`flex items-center justify-between p-4 transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-700/50 rounded-lg' : 'bg-slate-50 rounded-lg'}`}>
|
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className={`p-2 rounded-lg transition-colors duration-300 ${theme === 'dark' ? 'bg-indigo-900/30 text-indigo-400' : 'bg-indigo-100 text-indigo-600'}`}>
|
|
||||||
<Disc size={20} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className={`font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Vollautomatik: sofort rippen</h3>
|
|
||||||
<p className={`text-sm transition-colors duration-300 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
|
||||||
Disc erkannt → Rip startet ohne Nachfrage in den passenden Schnellwahl-Ordner
|
|
||||||
(Serie → Serien, sonst Filme; CD → Musik). Aus = wie bisher per „Rippen starten"-Dialog.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Toggle
|
<Toggle
|
||||||
|
label="Vollautomatik: sofort rippen"
|
||||||
|
description="Disc erkannt → Rip startet ohne Nachfrage in den passenden Ordner."
|
||||||
checked={settings.autoRipStart}
|
checked={settings.autoRipStart}
|
||||||
onChange={(v) => handleChange('autoRipStart', v)}
|
onChange={(v) => handleChange('autoRipStart', v)}
|
||||||
darkMode={theme === 'dark'}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`flex items-center justify-between p-4 transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-700/50 rounded-lg' : 'bg-slate-50 rounded-lg'}`}>
|
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className={`p-2 rounded-lg transition-colors duration-300 ${theme === 'dark' ? 'bg-purple-900/30 text-purple-400' : 'bg-purple-100 text-purple-600'}`}>
|
|
||||||
<Disc size={20} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className={`font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Nur Hauptfeature</h3>
|
|
||||||
<p className={`text-sm transition-colors duration-300 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Rippe bei DVDs/Blu-rays nur das Hauptfeature</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Toggle
|
<Toggle
|
||||||
|
label="Nur Hauptfeature"
|
||||||
|
description="Rippe bei DVDs/Blu-rays nur das Hauptfeature (längster Titel)."
|
||||||
checked={settings.mainFeatureOnly}
|
checked={settings.mainFeatureOnly}
|
||||||
onChange={(v) => handleChange('mainFeatureOnly', v)}
|
onChange={(v) => handleChange('mainFeatureOnly', v)}
|
||||||
darkMode={theme === 'dark'}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className={`text-xs px-1 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
<p className="text-xs text-slate-500 dark:text-slate-400 px-1">
|
||||||
🌐 Sprachen & Untertitel: MakeMKV sichert grundsätzlich ALLE Ton-Spuren und
|
🌐 Sprachen & Untertitel: MakeMKV sichert grundsätzlich ALLE Ton-Spuren und Untertitel verlustfrei.
|
||||||
Untertitel verlustfrei, und auch die Kompression behält alle (--all-audio /
|
|
||||||
--all-subtitles) — gerade bei Animes gehen also weder O-Ton noch Subs verloren.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* ── Audio-CD ──────────────────────────────────────────── */}
|
<p className="text-xs font-bold uppercase tracking-wider pt-2 text-slate-400 dark:text-slate-500">
|
||||||
<p className={`text-xs font-semibold uppercase tracking-wider pt-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
|
||||||
🎵 Audio-CD
|
🎵 Audio-CD
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className={`flex items-center justify-between p-4 transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-700/50 rounded-lg' : 'bg-slate-50 rounded-lg'}`}>
|
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className={`p-2 rounded-lg transition-colors duration-300 ${theme === 'dark' ? 'bg-blue-900/30 text-blue-400' : 'bg-blue-100 text-blue-600'}`}>
|
|
||||||
<Disc size={20} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className={`font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Alle Tracks rippen</h3>
|
|
||||||
<p className={`text-sm transition-colors duration-300 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Rippe alle Tracks einer CD (FLAC + MusicBrainz-Tags)</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Toggle
|
<Toggle
|
||||||
|
label="Alle Tracks rippen"
|
||||||
|
description="Rippe alle Tracks einer CD (FLAC + MusicBrainz-Tags)."
|
||||||
checked={settings.ripAllTracks}
|
checked={settings.ripAllTracks}
|
||||||
onChange={(v) => handleChange('ripAllTracks', v)}
|
onChange={(v) => handleChange('ripAllTracks', v)}
|
||||||
darkMode={theme === 'dark'}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Allgemein ─────────────────────────────────────────── */}
|
<p className="text-xs font-bold uppercase tracking-wider pt-2 text-slate-400 dark:text-slate-500">
|
||||||
<p className={`text-xs font-semibold uppercase tracking-wider pt-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
|
||||||
Allgemein
|
Allgemein
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className={`flex items-center justify-between p-4 transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-700/50 rounded-lg' : 'bg-slate-50 rounded-lg'}`}>
|
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className={`p-2 rounded-lg transition-colors duration-300 ${theme === 'dark' ? 'bg-emerald-900/30 text-emerald-400' : 'bg-emerald-100 text-emerald-600'}`}>
|
|
||||||
<Disc size={20} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className={`font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Automatischer Auswurf</h3>
|
|
||||||
<p className={`text-sm transition-colors duration-300 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Disk nach erfolgreichem Ripping auswerfen</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Toggle
|
<Toggle
|
||||||
|
label="Automatischer Auswurf"
|
||||||
|
description="Disc nach erfolgreichem Ripping automatisch auswerfen."
|
||||||
checked={settings.autoEject}
|
checked={settings.autoEject}
|
||||||
onChange={(v) => handleChange('autoEject', v)}
|
onChange={(v) => handleChange('autoEject', v)}
|
||||||
darkMode={theme === 'dark'}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Media-Server: Rippy bereitet die Ablage aufs Zielsystem vor */}
|
{/* Media-Server Integration */}
|
||||||
<div className={`p-4 rounded-lg ${theme === 'dark' ? 'bg-slate-700/50' : 'bg-slate-50'}`}>
|
<div className="p-5 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 space-y-4">
|
||||||
<h3 className={`font-medium mb-1 flex items-center gap-2 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
<h3 className="font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||||||
<Tv size={18} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} />
|
<Tv size={18} className="text-amber-500" />
|
||||||
Media-Server (Jellyfin & Co.)
|
Media-Server (Jellyfin & Co.)
|
||||||
</h3>
|
</h3>
|
||||||
<p className={`text-sm mb-3 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||||
Rippy benennt fertige Rips als „Titel (Jahr)" und legt — je nach System —
|
Rippy benennt fertige Rips als „Titel (Jahr)" und legt NFO + Poster dazu.
|
||||||
movie.nfo + poster.jpg dazu. Dein Server erkennt den Film dann ohne Nacharbeit.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||||
{MEDIA_SERVER_OPTIONEN.map(o => (
|
{MEDIA_SERVER_OPTIONEN.map(o => (
|
||||||
<button
|
<button
|
||||||
key={o.wert}
|
key={o.wert}
|
||||||
|
type="button"
|
||||||
onClick={() => handleChange('mediaServer', o.wert)}
|
onClick={() => handleChange('mediaServer', o.wert)}
|
||||||
title={o.hinweis}
|
title={o.hinweis}
|
||||||
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-all ${
|
className={`px-3 py-2 rounded-xl text-sm font-medium border transition-all ${
|
||||||
settings.mediaServer === o.wert
|
settings.mediaServer === o.wert
|
||||||
? theme === 'dark'
|
? 'bg-amber-500/20 border-amber-500 text-amber-600 dark:text-amber-300 font-semibold'
|
||||||
? 'bg-indigo-900/40 border-indigo-500 text-indigo-300'
|
: 'bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-400 hover:border-slate-300 dark:hover:border-slate-600'
|
||||||
: 'bg-indigo-50 border-indigo-600 text-indigo-700'
|
|
||||||
: theme === 'dark'
|
|
||||||
? 'bg-slate-800 border-slate-600 text-slate-400 hover:border-slate-500'
|
|
||||||
: 'bg-white border-slate-300 text-slate-600 hover:border-slate-400'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{o.label}
|
{o.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className={`text-xs mt-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
{MEDIA_SERVER_OPTIONEN.find(o => o.wert === settings.mediaServer)?.hinweis}
|
{MEDIA_SERVER_OPTIONEN.find(o => o.wert === settings.mediaServer)?.hinweis}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Jellyfin/Emby: nach jedem fertigen Rip die Bibliothek scannen */}
|
|
||||||
{(settings.mediaServer === 'jellyfin' || settings.mediaServer === 'emby') && (
|
{(settings.mediaServer === 'jellyfin' || settings.mediaServer === 'emby') && (
|
||||||
<div className={`mt-4 pt-4 border-t space-y-3 ${theme === 'dark' ? 'border-slate-600' : 'border-slate-200'}`}>
|
<div className="pt-3 border-t border-slate-200 dark:border-slate-800 space-y-3">
|
||||||
<p className={`text-sm ${theme === 'dark' ? 'text-slate-300' : 'text-slate-600'}`}>
|
<p className="text-xs text-slate-600 dark:text-slate-300">
|
||||||
<strong>Bibliotheks-Refresh</strong> (optional): Trag Server-URL + API-Key ein,
|
<strong>Bibliotheks-Refresh</strong>: Server-URL + API-Key für automatischen Scan nach dem Rip.
|
||||||
dann stößt Rippy nach jedem fertigen Rip sofort einen Scan an — der Film
|
|
||||||
erscheint ohne Warten in {settings.mediaServer === 'jellyfin' ? 'Jellyfin' : 'Emby'}.
|
|
||||||
API-Key: Administration → API-Schlüssel → Neu.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 items-end">
|
||||||
<input
|
<Input
|
||||||
type="text"
|
label="Server-URL"
|
||||||
value={settings.jellyfinUrl}
|
value={settings.jellyfinUrl}
|
||||||
onChange={(e) => handleChange('jellyfinUrl', e.target.value)}
|
onChange={(e) => handleChange('jellyfinUrl', e.target.value)}
|
||||||
placeholder="http://192.168.178.50:8096"
|
placeholder="http://192.168.178.50:8096"
|
||||||
className={`px-3 py-2 border rounded-lg text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'}`}
|
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2 items-end">
|
||||||
<input
|
<div className="flex-1">
|
||||||
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
|
label="API-Key"
|
||||||
value={settings.jellyfinApiKey}
|
value={settings.jellyfinApiKey}
|
||||||
onChange={(e) => handleChange('jellyfinApiKey', e.target.value)}
|
onChange={(e) => handleChange('jellyfinApiKey', e.target.value)}
|
||||||
placeholder="API-Key"
|
placeholder="API-Key"
|
||||||
className={`flex-1 px-3 py-2 border rounded-lg text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'}`}
|
|
||||||
/>
|
/>
|
||||||
<button
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="amber"
|
||||||
|
size="md"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
try {
|
||||||
await api.post('/mediaserver/refresh', {
|
await api.post('/mediaserver/refresh', {
|
||||||
@@ -391,41 +364,44 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={!settings.jellyfinUrl || !settings.jellyfinApiKey}
|
disabled={!settings.jellyfinUrl || !settings.jellyfinApiKey}
|
||||||
className="px-3 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 transition-colors disabled:opacity-40"
|
|
||||||
>
|
>
|
||||||
Testen
|
Testen
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>}
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Verarbeitung: Kompressions-Stufe nach dem Rip */}
|
{/* Verarbeitung */}
|
||||||
{activeTab === 'verarbeitung' && <section>
|
{activeTab === 'verarbeitung' && (
|
||||||
<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'}`}>
|
<section className="space-y-6">
|
||||||
<Cpu size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} />
|
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||||||
|
<Cpu size={20} className="text-amber-500" />
|
||||||
Verarbeitung (Kompression)
|
Verarbeitung (Kompression)
|
||||||
</h2>
|
</h2>
|
||||||
<p className={`text-sm mb-4 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||||
MakeMKV rippt verlustfrei (der einzige Weg durch den Kopierschutz), HandBrake
|
MakeMKV rippt verlustfrei, HandBrake komprimiert danach auf Arbeitsgröße.
|
||||||
komprimiert danach auf Arbeitsgröße. Die Roh-Datei wird nach Erfolg gelöscht.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Ehrliche Selbstauskunft: was können die Worker WIRKLICH? */}
|
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
|
||||||
<div className={`rounded-lg p-4 mb-4 ${theme === 'dark' ? 'bg-slate-700/50' : 'bg-slate-50'}`}>
|
<p className="text-sm font-medium mb-2 text-slate-900 dark:text-slate-200">Verfügbare Encoder</p>
|
||||||
<p className={`text-sm font-medium mb-2 ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}>Verfügbare Encoder</p>
|
|
||||||
{workers.length === 0 ? (
|
{workers.length === 0 ? (
|
||||||
<p className={`text-sm ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Kein Worker gemeldet.</p>
|
<p className="text-sm text-slate-500 dark:text-slate-400">Kein Worker gemeldet.</p>
|
||||||
) : (
|
) : (
|
||||||
workers.map(w => (
|
workers.map(w => (
|
||||||
<div key={w.name} className="mb-1">
|
<div key={w.name} className="mb-1">
|
||||||
<span className={`text-xs font-mono mr-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>{w.name}</span>
|
<span className="text-xs font-mono mr-2 text-slate-500 dark:text-slate-400">{w.name}: </span>
|
||||||
{w.encoders.map(e => (
|
{w.encoders.map(e => (
|
||||||
<span key={e} className={`inline-block text-xs px-2 py-0.5 rounded mr-1 ${e.startsWith('cpu') ? (theme === 'dark' ? 'bg-slate-800 text-slate-300' : 'bg-slate-200 text-slate-600') : 'bg-emerald-600/20 text-emerald-500 font-medium'}`}>
|
<span key={e} className={`inline-block text-xs px-2.5 py-0.5 rounded-md font-medium border mr-1 ${
|
||||||
{ENCODER_LABELS[e] || e}
|
e.startsWith('cpu')
|
||||||
|
? 'bg-slate-200 dark:bg-slate-800 text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-700'
|
||||||
|
: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30'
|
||||||
|
}`}>
|
||||||
|
{ENCODER_BADGES[e]?.label || e}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -433,132 +409,115 @@ export default function SettingsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4 transition-colors duration-300">
|
<div className="space-y-4">
|
||||||
<div className={`flex items-center justify-between p-4 rounded-lg ${theme === 'dark' ? 'bg-slate-700/50' : 'bg-slate-50'}`}>
|
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
|
||||||
<div>
|
<Toggle
|
||||||
<h3 className={`font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Nach dem Rip komprimieren</h3>
|
label="Nach dem Rip komprimieren"
|
||||||
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>x265 statt 40-GB-Rohdatei (empfohlen)</p>
|
description="x265 statt 40-GB-Rohdatei (empfohlen)."
|
||||||
</div>
|
checked={settings.transcodeEnabled}
|
||||||
<Toggle checked={settings.transcodeEnabled} onChange={(v) => handleChange('transcodeEnabled', v)} darkMode={theme === 'dark'} />
|
onChange={(v) => handleChange('transcodeEnabled', v)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<Select
|
||||||
<label className={`block text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
label="HandBrake-Preset"
|
||||||
HandBrake-Preset
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={settings.transcodePreset}
|
value={settings.transcodePreset}
|
||||||
onChange={(e) => handleChange('transcodePreset', e.target.value)}
|
onChange={(e) => handleChange('transcodePreset', e.target.value)}
|
||||||
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'}`}
|
|
||||||
>
|
>
|
||||||
<option value="H.265 MKV 1080p30">H.265 MKV 1080p30 — klein & modern (Standard)</option>
|
<option value="H.265 MKV 1080p30">H.265 MKV 1080p30 — klein & modern (Standard)</option>
|
||||||
<option value="H.265 MKV 2160p60 4K">H.265 MKV 2160p60 4K — für UHD-Quellen</option>
|
<option value="H.265 MKV 2160p60 4K">H.265 MKV 2160p60 4K — für UHD-Quellen</option>
|
||||||
<option value="HQ 1080p30 Surround">HQ 1080p30 Surround — H.264, maximale Kompatibilität</option>
|
<option value="HQ 1080p30 Surround">HQ 1080p30 Surround — H.264, maximale Kompatibilität</option>
|
||||||
<option value="Super HQ 1080p30 Surround">Super HQ 1080p30 Surround — größer, beste Qualität</option>
|
<option value="Super HQ 1080p30 Surround">Super HQ 1080p30 Surround — größer, beste Qualität</option>
|
||||||
</select>
|
</Select>
|
||||||
|
|
||||||
|
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
|
||||||
|
<Toggle
|
||||||
|
label="Original behalten"
|
||||||
|
description="Verlustfreie Roh-Datei zusätzlich aufheben (viel Speicher!)."
|
||||||
|
checked={settings.keepOriginal}
|
||||||
|
onChange={(v) => handleChange('keepOriginal', v)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`flex items-center justify-between p-4 rounded-lg ${theme === 'dark' ? 'bg-slate-700/50' : 'bg-slate-50'}`}>
|
<Input
|
||||||
<div>
|
label="Arbeitsverzeichnis für Roh-Rips (optional)"
|
||||||
<h3 className={`font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Original behalten</h3>
|
|
||||||
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Verlustfreie Roh-Datei zusätzlich aufheben (viel Speicher!)</p>
|
|
||||||
</div>
|
|
||||||
<Toggle checked={settings.keepOriginal} onChange={(v) => handleChange('keepOriginal', v)} darkMode={theme === 'dark'} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Arbeitsverzeichnis: 4K-UHD-Rohdaten (bis 100 GB) passen nicht
|
|
||||||
auf jede VM-Platte — hier auf eine große Freigabe umlegen */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className={`block text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
|
||||||
Arbeitsverzeichnis für Roh-Rips (optional)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={settings.workDir}
|
value={settings.workDir}
|
||||||
onChange={(e) => handleChange('workDir', e.target.value)}
|
onChange={(e) => handleChange('workDir', e.target.value)}
|
||||||
placeholder="leer = /app/temp (Container-Platte)"
|
placeholder="leer = /app/temp (Container-Platte)"
|
||||||
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent font-mono text-sm ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'}`}
|
|
||||||
/>
|
/>
|
||||||
<p className={`text-xs ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
Muss unter /app/media liegen (z. B. /app/media/nas-arbeit — Freigabe vorher unter
|
Wichtig für 4K-UHD: Rohdaten sind bis 100 GB groß.
|
||||||
Speicherziele einhängen). Wichtig für 4K-UHD: Rohdaten sind bis 100 GB groß;
|
|
||||||
Rippy prüft den Platz VOR dem Rip und bricht sonst mit Klartext ab.
|
|
||||||
{systemInfo?.plaetze?.length ? (
|
{systemInfo?.plaetze?.length ? (
|
||||||
<> Aktuell frei: {systemInfo.plaetze.map(p => `${p.name}: ${p.frei_gb} GB`).join(' · ')}</>
|
<> Aktuell frei: {systemInfo.plaetze.map(p => `${p.name}: ${p.frei_gb} GB`).join(' · ')}</>
|
||||||
) : null}
|
) : null}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</section>}
|
)}
|
||||||
|
|
||||||
{/* Worker: Encoding-Maschinen im Netz */}
|
{/* Worker */}
|
||||||
{activeTab === 'worker' && <section>
|
{activeTab === 'worker' && (
|
||||||
<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'}`}>
|
<section className="space-y-6">
|
||||||
<Cpu size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} />
|
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||||||
|
<Cpu size={20} className="text-amber-500" />
|
||||||
Encoding-Worker
|
Encoding-Worker
|
||||||
</h2>
|
</h2>
|
||||||
<WorkerVerwaltung />
|
<WorkerVerwaltung />
|
||||||
</section>}
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Speicherziele: Netzwerk-Mounts aus dem UI */}
|
{/* Speicherziele */}
|
||||||
{activeTab === 'speicherziele' && <section>
|
{activeTab === 'speicherziele' && (
|
||||||
<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'}`}>
|
<section className="space-y-6">
|
||||||
<HardDrive size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} />
|
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||||||
|
<HardDrive size={20} className="text-amber-500" />
|
||||||
Speicherziele
|
Speicherziele
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{/* Schnellwahl-Ordner: die drei Knöpfe im "Rippen starten"-Dialog */}
|
<div className="p-5 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 space-y-3">
|
||||||
<div className={`rounded-lg p-4 mb-6 border ${theme === 'dark' ? 'border-slate-700 bg-slate-800/50' : 'border-slate-200 bg-slate-50'}`}>
|
<h3 className="font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||||||
<h3 className={`font-medium mb-1 flex items-center gap-2 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
<Database size={16} className="text-amber-500" /> Schnellwahl beim Rippen
|
||||||
<Database size={16} className="text-indigo-500" /> Schnellwahl beim Rippen
|
|
||||||
</h3>
|
</h3>
|
||||||
<p className={`text-xs mb-3 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
Beim Start eines Rips fragt Rippy „Wohin?" — diese drei Ordner erscheinen
|
Name der Ordner im „Rippen starten"-Dialog (🎥/📺/🎵).
|
||||||
dort als 🎥/📺/🎵-Schnellwahl. Hier legst du nur fest, wie sie heißen.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
{([['movieDir', '🎥 Filme'], ['seriesDir', '📺 Serien'], ['musicDir', '🎵 Musik']] as const).map(([feld, label]) => (
|
{([['movieDir', '🎥 Filme'], ['seriesDir', '📺 Serien'], ['musicDir', '🎵 Musik']] as const).map(([feld, label]) => (
|
||||||
<div key={feld}>
|
<Input
|
||||||
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>{label}</label>
|
key={feld}
|
||||||
<input
|
label={label}
|
||||||
type="text"
|
|
||||||
value={settings[feld]}
|
value={settings[feld]}
|
||||||
onChange={(e) => handleChange(feld, e.target.value)}
|
onChange={(e) => handleChange(feld, e.target.value)}
|
||||||
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'}`}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<StorageMounts />
|
<StorageMounts />
|
||||||
</section>}
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* API Keys */}
|
{/* APIs */}
|
||||||
{activeTab === 'apis' && <section>
|
{activeTab === 'apis' && (
|
||||||
<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'}`}>
|
<section className="space-y-6">
|
||||||
<Globe size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} />
|
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||||||
|
<Globe size={20} className="text-amber-500" />
|
||||||
API-Schlüssel
|
API-Schlüssel
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{/* Live-Prüfung: funktioniert der eingetragene Key WIRKLICH? */}
|
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 flex flex-wrap items-center gap-3">
|
||||||
<div className={`rounded-lg p-4 mb-4 flex flex-wrap items-center gap-3 ${theme === 'dark' ? 'bg-slate-700/50' : 'bg-slate-50'}`}>
|
<Button variant="amber" size="sm" onClick={quellenPruefen} disabled={quellenBusy}>
|
||||||
<button
|
|
||||||
onClick={quellenPruefen}
|
|
||||||
disabled={quellenBusy}
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
{quellenBusy ? 'Prüfe…' : 'Verbindung prüfen'}
|
{quellenBusy ? 'Prüfe…' : 'Verbindung prüfen'}
|
||||||
</button>
|
</Button>
|
||||||
{quellenStatus ? (
|
{quellenStatus ? (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{Object.entries(quellenStatus).map(([quelle, status]) => (
|
{Object.entries(quellenStatus).map(([quelle, status]) => (
|
||||||
<span
|
<span
|
||||||
key={quelle}
|
key={quelle}
|
||||||
className={`text-xs px-2.5 py-1 rounded-full font-medium ${
|
className={`text-xs px-2.5 py-1 rounded-full font-medium border ${
|
||||||
status === 'ok' ? 'bg-emerald-600/20 text-emerald-500'
|
status === 'ok' ? 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30'
|
||||||
: status === 'kein_key' ? (theme === 'dark' ? 'bg-slate-800 text-slate-400' : 'bg-slate-200 text-slate-500')
|
: status === 'kein_key' ? 'bg-slate-200 dark:bg-slate-800 text-slate-500 border-slate-300 dark:border-slate-700'
|
||||||
: 'bg-rose-600/20 text-rose-500'
|
: 'bg-rose-500/15 text-rose-600 dark:text-rose-400 border-rose-500/30'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{quelle.toUpperCase()}: {status === 'ok' ? '✓ funktioniert' : status === 'kein_key' ? 'kein Key' : '✗ Fehler'}
|
{quelle.toUpperCase()}: {status === 'ok' ? '✓ funktioniert' : status === 'kein_key' ? 'kein Key' : '✗ Fehler'}
|
||||||
@@ -566,295 +525,203 @@ export default function SettingsPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className={`text-xs ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
Prüft live gegen TMDB/OMDb — nach dem Speichern eines neuen Keys hier klicken.
|
Prüft live gegen TMDB/OMDb — nach dem Speichern eines neuen Keys hier klicken.
|
||||||
Beide TMDB-Key-Arten funktionieren (v3-Schlüssel und v4-Token).
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4 transition-colors duration-300">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2 transition-colors duration-300">
|
<Input
|
||||||
<label className={`block text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
|
||||||
TMDB API Key
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
type="password"
|
||||||
|
label="TMDB API Key"
|
||||||
value={settings.tmdbApiKey}
|
value={settings.tmdbApiKey}
|
||||||
onChange={(e) => handleChange('tmdbApiKey', e.target.value)}
|
onChange={(e) => handleChange('tmdbApiKey', 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 TMDB API Key"
|
placeholder="Dein TMDB API Key"
|
||||||
/>
|
/>
|
||||||
<p className={`text-xs ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
<Input
|
||||||
Hol dir einen Key auf <a href="https://www.themoviedb.org/" target="_blank" rel="noopener noreferrer" className={`${theme === 'dark' ? 'text-indigo-400 hover:underline' : 'text-indigo-600 hover:underline'}`}>themoviedb.org</a>
|
|
||||||
</p>
|
|
||||||
</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'}`}>
|
|
||||||
TVDb API Key
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
type="password"
|
||||||
|
label="TVDb API Key"
|
||||||
value={settings.tvdbApiKey}
|
value={settings.tvdbApiKey}
|
||||||
onChange={(e) => handleChange('tvdbApiKey', e.target.value)}
|
onChange={(e) => handleChange('tvdbApiKey', 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 TVDb API Key"
|
placeholder="Dein TVDb API Key"
|
||||||
/>
|
/>
|
||||||
</div>
|
<Input
|
||||||
|
|
||||||
<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"
|
type="password"
|
||||||
|
label="OMDb API Key (Fallback)"
|
||||||
value={settings.omdbApiKey}
|
value={settings.omdbApiKey}
|
||||||
onChange={(e) => handleChange('omdbApiKey', e.target.value)}
|
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"
|
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 — mit Anleitung und Sofort-Test (Commander-Wunsch 24.07.) */}
|
{/* Benachrichtigungen */}
|
||||||
{activeTab === 'benachrichtigungen' && <section>
|
{activeTab === 'benachrichtigungen' && (
|
||||||
<h2 className={`text-lg font-semibold mb-2 flex items-center gap-2 transition-colors duration-300 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
<section className="space-y-6">
|
||||||
<AlertCircle size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} />
|
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||||||
|
<AlertCircle size={20} className="text-amber-500" />
|
||||||
Webhook-Benachrichtigungen
|
Webhook-Benachrichtigungen
|
||||||
</h2>
|
</h2>
|
||||||
<p className={`text-sm mb-4 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||||
Rippy meldet sich, sobald ein Job <strong>fertig</strong> ist, <strong>fehlschlägt</strong> oder
|
Rippy meldet sich, sobald ein Job <strong>fertig</strong> ist, <strong>fehlschlägt</strong> oder <strong>abgebrochen</strong> wird.
|
||||||
<strong> abgebrochen</strong> wird — direkt in deinen Chat. Der Dienst wird an der URL
|
|
||||||
automatisch erkannt, du musst nichts weiter einstellen.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Schritt-für-Schritt: so kommst du an eine Webhook-URL */}
|
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 space-y-3 text-sm">
|
||||||
<div className={`rounded-lg p-4 mb-4 space-y-3 text-sm ${theme === 'dark' ? 'bg-slate-700/50' : 'bg-slate-50'}`}>
|
|
||||||
<div>
|
<div>
|
||||||
<p className={`font-medium ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}>💬 Discord (einfachster Weg)</p>
|
<p className="font-semibold text-slate-900 dark:text-slate-200">💬 Discord (einfachster Weg)</p>
|
||||||
<p className={theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}>
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
Server → Kanal-Zahnrad → Integrationen → Webhooks → „Neuer Webhook" →
|
Kanal-Zahnrad → Integrationen → Webhooks → „Neuer Webhook" → URL kopieren.
|
||||||
„Webhook-URL kopieren" und unten einfügen.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className={`font-medium ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}>📱 ntfy (Push aufs Handy, ohne Account)</p>
|
<p className="font-semibold text-slate-900 dark:text-slate-200">📱 ntfy (Push aufs Handy)</p>
|
||||||
<p className={theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}>
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
ntfy-App installieren, ein Thema abonnieren (z. B. „rippy-tobi-4711") und unten
|
Thema abonnieren und https://ntfy.sh/mein-thema eintragen.
|
||||||
https://ntfy.sh/rippy-tobi-4711 eintragen. Thema = Passwort, also etwas Einmaliges wählen.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className={`font-medium ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}>🔧 Slack / Eigenes</p>
|
|
||||||
<p className={theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}>
|
|
||||||
Slack: „Incoming Webhook" in den App-Einstellungen anlegen. Alles andere
|
|
||||||
(Home Assistant, n8n, eigene Skripte) bekommt JSON: {'{'}"title", "message", "level"{'}'}.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2 transition-colors duration-300">
|
<div className="space-y-2">
|
||||||
<label className={`block text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
<div className="flex gap-2 items-end">
|
||||||
Webhook URL
|
<div className="flex-1">
|
||||||
</label>
|
<Input
|
||||||
<div className="flex gap-2">
|
label="Webhook URL"
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={settings.notificationWebhook}
|
value={settings.notificationWebhook}
|
||||||
onChange={(e) => handleChange('notificationWebhook', e.target.value)}
|
onChange={(e) => handleChange('notificationWebhook', e.target.value)}
|
||||||
className={`flex-1 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="https://discord.com/api/webhooks/… oder https://ntfy.sh/mein-thema"
|
placeholder="https://discord.com/api/webhooks/… oder https://ntfy.sh/mein-thema"
|
||||||
/>
|
/>
|
||||||
<button
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="amber"
|
||||||
|
size="md"
|
||||||
onClick={webhookTesten}
|
onClick={webhookTesten}
|
||||||
disabled={!settings.notificationWebhook || testBusy}
|
disabled={!settings.notificationWebhook || testBusy}
|
||||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 transition-colors disabled:opacity-40"
|
|
||||||
>
|
>
|
||||||
<Send size={15} />
|
<Send size={15} />
|
||||||
{testBusy ? 'Sende…' : 'Test senden'}
|
{testBusy ? 'Sende…' : 'Test senden'}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className={`text-xs ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
|
||||||
„Test senden" prüft die Anbindung sofort — nicht vergessen, danach zu speichern,
|
|
||||||
damit auch der Worker die URL kennt.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</section>}
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* System: Kern-Werkzeuge, Versionen, MakeMKV-Key, Speicherplatz */}
|
{/* System */}
|
||||||
{activeTab === 'system' && <section>
|
{activeTab === 'system' && (
|
||||||
<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'}`}>
|
<section className="space-y-6">
|
||||||
<Wrench size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} />
|
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||||||
|
<Wrench size={20} className="text-amber-500" />
|
||||||
System & Kern-Werkzeuge
|
System & Kern-Werkzeuge
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{/* Werkzeug-Versionen je Worker — ehrlich gemeldet, nicht behauptet */}
|
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
|
||||||
<div className={`rounded-lg p-4 mb-4 ${theme === 'dark' ? 'bg-slate-700/50' : 'bg-slate-50'}`}>
|
<p className="text-sm font-semibold mb-2 text-slate-900 dark:text-slate-200">Installierte Werkzeuge</p>
|
||||||
<p className={`text-sm font-medium mb-2 ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}>Installierte Werkzeuge</p>
|
|
||||||
{!systemInfo || systemInfo.workers.length === 0 ? (
|
{!systemInfo || systemInfo.workers.length === 0 ? (
|
||||||
<p className={`text-sm ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Noch kein Worker gemeldet.</p>
|
<p className="text-sm text-slate-500 dark:text-slate-400">Noch kein Worker gemeldet.</p>
|
||||||
) : (
|
) : (
|
||||||
systemInfo.workers.map(w => (
|
systemInfo.workers.map(w => (
|
||||||
<div key={w.name} className="flex flex-wrap items-center gap-2 mb-1.5">
|
<div key={w.name} className="flex flex-wrap items-center gap-2 mb-1.5">
|
||||||
<span className={`text-xs font-mono ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>{w.name}</span>
|
<span className="text-xs font-mono text-slate-500 dark:text-slate-400">{w.name}</span>
|
||||||
{w.info?.makemkv && (
|
{w.info?.makemkv && (
|
||||||
<span className={`text-xs px-2 py-0.5 rounded ${theme === 'dark' ? 'bg-slate-800 text-slate-300' : 'bg-slate-200 text-slate-600'}`}>
|
<span className="text-xs px-2.5 py-0.5 rounded-md font-medium bg-slate-200 dark:bg-slate-800 text-slate-700 dark:text-slate-300 border border-slate-300 dark:border-slate-700">
|
||||||
MakeMKV {w.info.makemkv}
|
MakeMKV {w.info.makemkv}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{w.info?.handbrake && (
|
{w.info?.handbrake && (
|
||||||
<span className={`text-xs px-2 py-0.5 rounded ${theme === 'dark' ? 'bg-slate-800 text-slate-300' : 'bg-slate-200 text-slate-600'}`}>
|
<span className="text-xs px-2.5 py-0.5 rounded-md font-medium bg-slate-200 dark:bg-slate-800 text-slate-700 dark:text-slate-300 border border-slate-300 dark:border-slate-700">
|
||||||
HandBrake {w.info.handbrake}
|
HandBrake {w.info.handbrake}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{w.info?.makemkv_key && (
|
|
||||||
<span className={`text-xs px-2 py-0.5 rounded ${w.info.makemkv_key === 'keiner' ? 'bg-amber-600/20 text-amber-500' : 'bg-emerald-600/20 text-emerald-500'}`}>
|
|
||||||
{w.info.makemkv_key === 'ui' ? 'Beta-Key: aus dem UI' :
|
|
||||||
w.info.makemkv_key === 'env' ? 'Beta-Key: aus .env' :
|
|
||||||
'Kein Beta-Key (Blu-ray nur im Testmodus)'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
{systemInfo?.plaetze && systemInfo.plaetze.length > 0 && (
|
<p className="text-xs mt-3 pt-3 border-t border-slate-200/60 dark:border-slate-800 text-slate-500 dark:text-slate-400">
|
||||||
<p className={`text-xs mt-3 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
<strong className="text-slate-700 dark:text-slate-300">MakeMKV</strong> lässt sich aktuell halten
|
||||||
Speicherplatz: {systemInfo.plaetze.map(p => `${p.name}: ${p.frei_gb} von ${p.gesamt_gb} GB frei`).join(' · ')}
|
(Update-Check unten; Version über <code>MAKEMKV_VERSION</code> + Rebuild) — wichtig wegen der
|
||||||
|
Disc-Schlüssel-Datenbank.
|
||||||
|
{' '}<strong className="text-slate-700 dark:text-slate-300">HandBrake</strong> im eingebauten
|
||||||
|
Docker-Worker ist bewusst die stabile <strong>Debian-Version</strong> — für die Kompression völlig
|
||||||
|
ausreichend und wird <strong>nicht separat aktualisiert</strong>. Native Worker (Windows) holen
|
||||||
|
dagegen automatisch die neueste HandBrake-Version.
|
||||||
</p>
|
</p>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* MakeMKV-Beta-Key: der Monats-Key, OHNE Rebuild aktualisierbar */}
|
<Input
|
||||||
<div className="space-y-2">
|
|
||||||
<label className={`block text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
|
||||||
MakeMKV Beta-Key
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
type="password"
|
||||||
|
label="MakeMKV Beta-Key"
|
||||||
value={settings.makemkvAppKey}
|
value={settings.makemkvAppKey}
|
||||||
onChange={(e) => handleChange('makemkvAppKey', e.target.value)}
|
onChange={(e) => handleChange('makemkvAppKey', 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 font-mono text-sm`}
|
placeholder="T-… (Forum-Thread „MakeMKV is free while in beta”)"
|
||||||
placeholder={'T-… (Forum-Thread „MakeMKV is free while in beta")'}
|
|
||||||
/>
|
/>
|
||||||
<p className={`text-xs ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
|
||||||
Der Key wechselt etwa monatlich (MakeMKV-Forum, Thread t=1053). Hier eintragen +
|
|
||||||
speichern — er gilt ab dem <strong>nächsten Rip</strong>, ganz ohne Neustart oder Rebuild
|
|
||||||
und schlägt den Key aus der .env. DVDs gehen immer auch ohne Key.
|
|
||||||
</p>
|
|
||||||
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
|
||||||
Die MakeMKV-<strong>Version</strong> selbst steckt im Worker-Image — Update per
|
|
||||||
Rebuild: <code>docker compose build worker && docker compose up -d worker</code> (siehe README).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Update-Check: Rippy MELDET Updates ehrlich — selbst updaten
|
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 space-y-3">
|
||||||
kann es nicht (die Versionen stecken im Worker-Image) */}
|
<div className="flex items-center justify-between">
|
||||||
<div className={`rounded-lg p-4 mt-6 ${theme === 'dark' ? 'bg-slate-700/50' : 'bg-slate-50'}`}>
|
<p className="text-sm font-semibold text-slate-900 dark:text-slate-200">Update-Check</p>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<Button variant="secondary" size="sm" onClick={updatesPruefen} disabled={updatesBusy}>
|
||||||
<p className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}>Update-Check</p>
|
|
||||||
<button
|
|
||||||
onClick={updatesPruefen}
|
|
||||||
disabled={updatesBusy}
|
|
||||||
className="px-3 py-1.5 rounded-lg bg-indigo-600 text-white text-xs font-medium hover:bg-indigo-700 transition-colors disabled:opacity-40"
|
|
||||||
>
|
|
||||||
{updatesBusy ? 'Prüfe…' : 'Auf Updates prüfen'}
|
{updatesBusy ? 'Prüfe…' : 'Auf Updates prüfen'}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{updates ? (
|
{updates ? (
|
||||||
<div className="space-y-2 text-sm">
|
<div className="space-y-3 text-sm text-slate-700 dark:text-slate-300">
|
||||||
<div className={updates.makemkv?.update ? 'text-amber-500' : (theme === 'dark' ? 'text-slate-300' : 'text-slate-600')}>
|
{/* MakeMKV = aktualisierbar → hier ist ein Update ein echtes To-do */}
|
||||||
<strong>MakeMKV:</strong> installiert {updates.makemkv?.installiert || '—'},
|
<div className={updates.makemkv?.update ? 'text-amber-600 dark:text-amber-400' : ''}>
|
||||||
aktuell {updates.makemkv?.verfuegbar || 'unbekannt'}
|
<strong>MakeMKV:</strong> installiert {updates.makemkv?.installiert || '—'}, neueste {updates.makemkv?.verfuegbar || 'unbekannt'}
|
||||||
{updates.makemkv?.update && (
|
{updates.makemkv?.update
|
||||||
<span className="block text-xs mt-1">
|
? (
|
||||||
Update: in der .env <code>MAKEMKV_VERSION={updates.makemkv.verfuegbar}</code> setzen,
|
<p className="text-xs mt-1">
|
||||||
|
⚠️ Update verfügbar — in der .env <code>MAKEMKV_VERSION={updates.makemkv.verfuegbar}</code> setzen,
|
||||||
dann auf der Rippy-Maschine <code>docker compose build worker && docker compose up -d worker</code>.
|
dann auf der Rippy-Maschine <code>docker compose build worker && docker compose up -d worker</code>.
|
||||||
Neue Version bringt auch die neueste Disc-Schlüssel-Datenbank mit.
|
Neue Versionen bringen auch die neueste Disc-Schlüssel-Datenbank mit.
|
||||||
</span>
|
</p>
|
||||||
)}
|
)
|
||||||
|
: <span className="text-xs ml-1 text-emerald-600 dark:text-emerald-400">✓ aktuell</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className={theme === 'dark' ? 'text-slate-300' : 'text-slate-600'}>
|
{/* HandBrake im Docker = bewusst Debian-stabil → KEIN To-do, nur Info */}
|
||||||
<strong>HandBrake:</strong> Docker-Worker {updates.handbrake?.installiert || '—'} (Debian-Paket),
|
<div>
|
||||||
neueste offizielle Version {updates.handbrake?.verfuegbar || 'unbekannt'}
|
<strong>HandBrake (Docker-Worker):</strong> {updates.handbrake?.installiert || '—'}
|
||||||
<span className={`block text-xs mt-1 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
<span className="text-xs ml-1 text-slate-500 dark:text-slate-400">— stabile Debian-Version, bewusst so, kein Update nötig</span>
|
||||||
Im Docker-Worker kommt HandBrake aus Debian und hinkt der offiziellen Version
|
<p className="text-xs mt-1 text-slate-500 dark:text-slate-400">
|
||||||
bewusst hinterher (stabil, keine Eigenbauten). Der native Windows-Worker nutzt
|
Neueste offizielle Version wäre {updates.handbrake?.verfuegbar || 'unbekannt'} — die nutzen die
|
||||||
die aktuelle Version direkt.
|
<strong> nativen Worker (Windows)</strong> automatisch. Der Docker-Worker bleibt bei der
|
||||||
</span>
|
Debian-Version (für die Kompression identisch, robustere Builds).
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
Prüft gegen makemkv.com und die HandBrake-GitHub-Releases (Ergebnis wird 12 h gemerkt).
|
Prüft gegen makemkv.com und die HandBrake-GitHub-Releases (Ergebnis wird 12 h gemerkt).
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>}
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Save Button — nicht im Worker-Tab: dort gibt es nichts zu
|
{/* Global Save Button */}
|
||||||
speichern (Liste + Anbinde-Befehle), der Knopf verwirrte nur */}
|
|
||||||
{activeTab !== 'worker' && (
|
{activeTab !== 'worker' && (
|
||||||
<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 border-t border-slate-200/80 dark:border-slate-800">
|
||||||
<button
|
<Button
|
||||||
|
variant="amber"
|
||||||
|
size="lg"
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="flex items-center gap-2 px-6 py-2.5 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
>
|
||||||
{saving ? (
|
{saving ? (
|
||||||
<>
|
<>
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent"></div>
|
<div className="animate-spin rounded-full h-4 w-4 border-2 border-slate-950 border-t-transparent"></div>
|
||||||
Speichern...
|
Speichern...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Save size={20} />
|
<Save size={18} />
|
||||||
Einstellungen speichern
|
Einstellungen speichern
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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'}`
|
|
||||||
: `${darkMode ? 'border-transparent text-slate-400 hover:text-slate-300' : 'border-transparent text-slate-500 hover:text-slate-700'}`
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Toggle({ checked, onChange, darkMode }: { checked: boolean, onChange: (v: boolean) => void, darkMode: boolean }) {
|
|
||||||
const { theme } = useDarkMode()
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={() => onChange(!checked)}
|
|
||||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
|
||||||
checked ? 'bg-indigo-600' : darkMode ? 'bg-slate-600' : 'bg-slate-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`${
|
|
||||||
checked ? 'translate-x-6' : 'translate-x-1'
|
|
||||||
} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ celery_app.conf.update(
|
|||||||
timezone="UTC",
|
timezone="UTC",
|
||||||
enable_utc=True,
|
enable_utc=True,
|
||||||
broker_connection_retry_on_startup=True,
|
broker_connection_retry_on_startup=True,
|
||||||
|
# worker_direct: jeder Worker konsumiert zusätzlich eine eigene
|
||||||
|
# Direkt-Queue (<nodename>.dq). Damit kann die API die Kompression
|
||||||
|
# gezielt an EINEN gewählten Worker schicken (Encoder-Auswahl im
|
||||||
|
# Rip-Dialog) — ohne Wahl läuft sie weiter über die geteilte
|
||||||
|
# transcode-Queue (irgendein freier Worker).
|
||||||
|
worker_direct=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+36
-4
@@ -47,6 +47,22 @@ from ripping import (
|
|||||||
API_URL = os.getenv("API_URL", "http://api:8000")
|
API_URL = os.getenv("API_URL", "http://api:8000")
|
||||||
|
|
||||||
|
|
||||||
|
def _transcode_queue(node: str):
|
||||||
|
"""Ziel-Queue für die Kompression (siehe celery_client.transcode_queue):
|
||||||
|
gewählter Worker via worker_direct, wenn online — sonst geteilte Queue."""
|
||||||
|
if not node:
|
||||||
|
return "transcode"
|
||||||
|
try:
|
||||||
|
from celery.utils import worker_direct
|
||||||
|
antworten = celery_app.control.ping(timeout=1.0) or []
|
||||||
|
online = {k for antwort in antworten for k in antwort.keys()}
|
||||||
|
if node in online:
|
||||||
|
return worker_direct(node)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return "transcode"
|
||||||
|
|
||||||
|
|
||||||
def pfad_lokal(pfad: str, mapping: str = None) -> str:
|
def pfad_lokal(pfad: str, mapping: str = None) -> str:
|
||||||
"""Übersetzt Rippy-Container-Pfade für native Worker (pure Funktion).
|
"""Übersetzt Rippy-Container-Pfade für native Worker (pure Funktion).
|
||||||
|
|
||||||
@@ -430,13 +446,18 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if ergebnis.get("status") == "success" and transcode_an:
|
if ergebnis.get("status") == "success" and transcode_an:
|
||||||
# Kompression als eigener Task auf der transcode-Queue — kann vom
|
# Kompression als eigener Task — an den im Rip-Dialog GEWÄHLTEN Worker
|
||||||
# lokalen Worker ODER einem Remote-GPU-Worker übernommen werden.
|
# (worker_direct), sonst an die geteilte transcode-Queue (irgendein
|
||||||
|
# freier Worker, inkl. Remote-GPU).
|
||||||
|
ziel_queue = _transcode_queue(meta.get("transcode_node"))
|
||||||
db.update_job(job_id, status="transcoding", progress=0)
|
db.update_job(job_id, status="transcoding", progress=0)
|
||||||
db.add_log("info", "worker", f"Job {job_id}: Rip fertig, Kompression eingereiht")
|
gezielt = meta.get("transcode_node") and ziel_queue != "transcode"
|
||||||
|
db.add_log("info", "worker",
|
||||||
|
f"Job {job_id}: Rip fertig, Kompression eingereiht"
|
||||||
|
+ (f" → gezielt an {meta.get('transcode_node')}" if gezielt else ""))
|
||||||
transcode_files.apply_async(
|
transcode_files.apply_async(
|
||||||
args=[job_id, raw_dir, final_dir],
|
args=[job_id, raw_dir, final_dir],
|
||||||
queue="transcode",
|
queue=ziel_queue,
|
||||||
)
|
)
|
||||||
return {"status": "ripped", "raw_dir": raw_dir}
|
return {"status": "ripped", "raw_dir": raw_dir}
|
||||||
|
|
||||||
@@ -444,6 +465,17 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
|
|||||||
return ergebnis
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(name="worker.tasks.ping_worker")
|
||||||
|
def ping_worker():
|
||||||
|
"""Winziger Task zum Beweisen des gezielten Routings: gibt Hostname +
|
||||||
|
Encoder zurück. Wird über worker_direct an EINEN Worker geschickt — landet
|
||||||
|
er beim richtigen, stimmt das Encoder-Routing (Encoder-Auswahl im UI)."""
|
||||||
|
import socket
|
||||||
|
|
||||||
|
import caps
|
||||||
|
return {"hostname": socket.gethostname(), "encoders": caps.erkenne_encoder()}
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task(name="worker.tasks.scan_tracks")
|
@celery_app.task(name="worker.tasks.scan_tracks")
|
||||||
def scan_tracks(device_path: str):
|
def scan_tracks(device_path: str):
|
||||||
"""Titel-Liste der eingelegten Disc erfassen (für die Auswahl-Tabelle).
|
"""Titel-Liste der eingelegten Disc erfassen (für die Auswahl-Tabelle).
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# Design 2.0 — Briefing (Übergabe an Gemini)
|
# Design 2.0 — Briefing (Übergabe an Gemini)
|
||||||
|
|
||||||
|
> ✅ **ERLEDIGT (24.07.2026).** Design 2.0 wurde umgesetzt und ist in `main`.
|
||||||
|
> Der Arbeitsbranch `design-2.0` wurde nach dem Merge gelöscht (es gibt nur
|
||||||
|
> noch `main`). Dieses Dokument bleibt als historische Auftragsbeschreibung.
|
||||||
|
>
|
||||||
> Dieses Dokument ist die vollständige Auftragsbeschreibung für den UI-Umbau
|
> Dieses Dokument ist die vollständige Auftragsbeschreibung für den UI-Umbau
|
||||||
> „Design 2.0". Es ist so geschrieben, dass ein zweites KI-Modell (Gemini)
|
> „Design 2.0". Es ist so geschrieben, dass ein zweites KI-Modell (Gemini)
|
||||||
> eigenständig loslegen kann. Stand: 24.07.2026, vorbereitet von Claude.
|
> eigenständig loslegen kann. Stand: 24.07.2026, vorbereitet von Claude.
|
||||||
@@ -224,12 +228,11 @@ den dann fertigen Primitives).
|
|||||||
|
|
||||||
## 7. Arbeitsweise & Branch
|
## 7. Arbeitsweise & Branch
|
||||||
|
|
||||||
- **Auf einem eigenen Branch arbeiten** (angelegt: `design-2.0`). Wichtig:
|
- **Auf einem eigenen Branch arbeiten** (war: `design-2.0`). Ein
|
||||||
Die Deploy-Kette befördert **nur `main`** automatisch auf `stable` und
|
Feature-Branch lässt die Ampel laufen (Feedback grün/rot), deployt aber
|
||||||
von dort auf die Live-VM. Ein Feature-Branch lässt die Ampel laufen
|
nicht — ideal für einen großen Umbau; nach Abnahme nach `main` gemerged.
|
||||||
(Feedback grün/rot), deployt aber NICHT — ideal für einen großen Umbau.
|
(Hinweis: Seit 24.07.2026 ist `main` der einzige Branch — deployt wird
|
||||||
Erst wenn Design 2.0 fertig & abgenommen ist, wird der Branch nach `main`
|
direkt daraus, die Ampel prüft nur noch.)
|
||||||
gemerged.
|
|
||||||
- **Klein committen**, eine sinnvolle Einheit pro Commit (z. B. „Primitives:
|
- **Klein committen**, eine sinnvolle Einheit pro Commit (z. B. „Primitives:
|
||||||
Card + Button", dann „Dashboard auf Primitives umgestellt"). Commit-Text
|
Card + Button", dann „Dashboard auf Primitives umgestellt"). Commit-Text
|
||||||
Deutsch, WAS + WARUM.
|
Deutsch, WAS + WARUM.
|
||||||
|
|||||||
Reference in New Issue
Block a user