Compare commits
4 Commits
731ed316df
...
5d9be4d046
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d9be4d046 | |||
| 13c1ae6064 | |||
| e032a9df1c | |||
| 048562a6ba |
@@ -19,4 +19,6 @@ OMDB_API_KEY=
|
|||||||
# MakeMKV-Beta-Key (optional): DVDs gehen ohne, Blu-ray läuft 30 Tage im
|
# MakeMKV-Beta-Key (optional): DVDs gehen ohne, Blu-ray läuft 30 Tage im
|
||||||
# Testmodus. Aktueller Key: Forum-Thread "MakeMKV is free while in beta".
|
# Testmodus. Aktueller Key: Forum-Thread "MakeMKV is free while in beta".
|
||||||
# Wechselt etwa monatlich — bei Blu-ray-Fehlern zuerst hier schauen.
|
# Wechselt etwa monatlich — bei Blu-ray-Fehlern zuerst hier schauen.
|
||||||
|
# Bequemer: im UI unter Einstellungen → System eintragen (gilt ab dem
|
||||||
|
# nächsten Rip, ohne Neustart, und schlägt diesen Env-Wert).
|
||||||
MAKEMKV_APP_KEY=
|
MAKEMKV_APP_KEY=
|
||||||
|
|||||||
@@ -53,9 +53,10 @@ Bibliotheks-APIs: `--help`/Doku prüfen und die Fundstelle im Commit nennen.
|
|||||||
- Kein Proxmox-LXC-Nesting-Workaround — das Projekt lebt bewusst in normalem Docker.
|
- Kein Proxmox-LXC-Nesting-Workaround — das Projekt lebt bewusst in normalem Docker.
|
||||||
- Kein ARM-Fork — Rippy ist ein Eigenbau von Grund auf.
|
- Kein ARM-Fork — Rippy ist ein Eigenbau von Grund auf.
|
||||||
|
|
||||||
## Aktueller Stand (21.07.2026)
|
## Aktueller Stand (24.07.2026)
|
||||||
|
|
||||||
- ✅ **Etappe 8 abgeschlossen:** Dark Mode mit Theme-Toggle, React-UI mit TailwindCSS
|
- ✅ **E2E bewiesen (v3.1):** BD-50 komplett durch die Kette (43 GB → 4,8 GB)
|
||||||
- ✅ **Git Sync:** Arcane VM (192.168.178.162) mit Git Repository konfiguriert
|
- ✅ **Etappe 13 (v3.2):** Universal-Komfort-Runde — Media-Server-Integration,
|
||||||
- ✅ **Savepoint v1.8:** Separation of Concerns geplant, Dark Mode implementiert
|
echte Benachrichtigungen, SMB-Klartext-Fehler, UHD-Arbeitsverzeichnis,
|
||||||
- 📝 **Nächste Etappe:** SoC-Refactoring (Theme-Context auslagern)
|
MakeMKV-Key via UI, Job-Detail-Popup, Toast-Feedback, Ampel-Blocker behoben
|
||||||
|
- 📝 **Details immer in SAVEPOINT.md** — diese Sektion nennt nur die Etappe
|
||||||
|
|||||||
+14
@@ -161,3 +161,17 @@ Ein modular aufgebautes System, das bei Disc-Einwurf automatisch den Typ erkennt
|
|||||||
- Neue Risiken: TMDB-Matching-Fehler (mit Fallback), NFO-Format-Abhängigkeit (Hinweis), Pre-Scan-Latenz (Hinweis)
|
- Neue Risiken: TMDB-Matching-Fehler (mit Fallback), NFO-Format-Abhängigkeit (Hinweis), Pre-Scan-Latenz (Hinweis)
|
||||||
|
|
||||||
**Ergebnis:** Konzept ist wasserdicht für MVP-Phase.
|
**Ergebnis:** Konzept ist wasserdicht für MVP-Phase.
|
||||||
|
|
||||||
|
## 10. Konzept-Fortschreibungen (dokumentierte Erweiterungen, keine Abweichungen)
|
||||||
|
|
||||||
|
- **24.07.2026 — Media-Server-Neutralität:** Die Jellyfin-Muss-Features
|
||||||
|
(Ordnerstruktur, NFO, Poster) bleiben vollständig bestehen; sie sind jetzt
|
||||||
|
über das Setting `mediaServer` auf Emby und Kodi ausgeweitet (identisches
|
||||||
|
Kodi-NFO-Schema) und für Plex auf die reine Benennung reduziert. Jellyfin
|
||||||
|
bleibt Referenz- und Empfehlungssystem im Einrichtungs-Assistenten.
|
||||||
|
- **24.07.2026 — Benachrichtigungen:** Webhook bei Job-Ende (Discord/Slack/
|
||||||
|
ntfy/generisch) als gebautes Feature — im UX-Flow Punkt 10 („Abschluss")
|
||||||
|
war das als Protokollierung angelegt, jetzt meldet Rippy aktiv.
|
||||||
|
- **23./24.07.2026 — udev → ioctl:** Der im Konzept beschriebene udev-Daemon
|
||||||
|
ist im Container prinzipbedingt nicht lauffähig; die Disc-Wache pollt per
|
||||||
|
Kernel-ioctl (3 s) — gleiches Verhalten, universell lauffähig.
|
||||||
|
|||||||
@@ -44,17 +44,29 @@ NICHT als emuliertes CD-ROM (`media=cdrom`), das kann keine SCSI-Kommandos.
|
|||||||
|
|
||||||
1. **Disc-Wache** (ioctl-Polling, kein udev-Gefrickel) erkennt Einlegen,
|
1. **Disc-Wache** (ioctl-Polling, kein udev-Gefrickel) erkennt Einlegen,
|
||||||
identifiziert die Disc (Volume-Label → TMDB → OMDb-Fallback) und zeigt
|
identifiziert die Disc (Volume-Label → TMDB → OMDb-Fallback) und zeigt
|
||||||
sie mit Poster auf dem Dashboard.
|
sie mit Poster auf dem Dashboard. Klick auf einen Job-Titel öffnet die
|
||||||
|
Detail-Ansicht (Poster, Jahr, Beschreibung, Ablagepfad).
|
||||||
2. **Rip** (MakeMKV, verlustfrei — der einzige Weg durch AACS): Ziel wählst
|
2. **Rip** (MakeMKV, verlustfrei — der einzige Weg durch AACS): Ziel wählst
|
||||||
du beim Start (Filme/Serien/Musik/eigener Pfad, inkl. Netzwerk-Ziele).
|
du beim Start (Filme/Serien/Musik/eigener Pfad, inkl. Netzwerk-Ziele).
|
||||||
|
Vor dem Start prüft Rippy den freien Platz gegen die Disc-Größe.
|
||||||
Audio-CDs laufen über abcde → FLAC + MusicBrainz.
|
Audio-CDs laufen über abcde → FLAC + MusicBrainz.
|
||||||
3. **Kompression** (HandBrake, eigener Job auf eigener Queue): x265/x264,
|
3. **Kompression** (HandBrake, eigener Job auf eigener Queue): x265/x264,
|
||||||
Preset im UI wählbar; Rohdatei wird erst nach Erfolg gelöscht
|
Preset im UI wählbar; Rohdatei wird erst nach Erfolg gelöscht
|
||||||
(„Original behalten" als Option). Fehlgeschlagene Kompressionen lassen
|
(„Original behalten" als Option). Fehlgeschlagene Kompressionen lassen
|
||||||
sich ohne Neu-Rip neu anstoßen.
|
sich ohne Neu-Rip neu anstoßen.
|
||||||
4. **4K-UHD**: braucht ein LibreDrive-fähiges Laufwerk (MakeMKV-Forum:
|
4. **Media-Server-Ablage**: Unter Einstellungen → Ripping (oder im
|
||||||
|
Einrichtungs-Assistenten) wählst du dein System — Jellyfin, Emby, Kodi,
|
||||||
|
Plex oder keins. Fertige Rips heißen dann „Titel (Jahr)" statt
|
||||||
|
Job-UUID; für Jellyfin/Emby/Kodi legt Rippy zusätzlich movie.nfo +
|
||||||
|
poster.jpg dazu (Kodi-NFO-Schema, lesen alle drei nativ). Plex nutzt
|
||||||
|
nur die Benennung.
|
||||||
|
5. **4K-UHD**: braucht ein LibreDrive-fähiges Laufwerk (MakeMKV-Forum:
|
||||||
„Ultimate UHD Drives Flashing Guide"). Normale BD/DVD gehen mit jedem
|
„Ultimate UHD Drives Flashing Guide"). Normale BD/DVD gehen mit jedem
|
||||||
Laufwerk.
|
Laufwerk. ⚠️ UHD-Rohdaten sind bis 100 GB groß — wenn die Platte der
|
||||||
|
Rippy-Maschine dafür zu klein ist, lege das **Arbeitsverzeichnis**
|
||||||
|
(Einstellungen → Verarbeitung) auf eine eingehängte Freigabe, z. B.
|
||||||
|
`/app/media/nas-arbeit`. Rippy bricht sonst VOR dem Rip mit einer
|
||||||
|
Klartext-Meldung ab statt nach 40 GB mit voller Platte.
|
||||||
|
|
||||||
## Speicherziele (NAS, Freigaben)
|
## Speicherziele (NAS, Freigaben)
|
||||||
|
|
||||||
@@ -67,6 +79,28 @@ Containern. ⚠️ Zugangsdaten liegen unverschlüsselt in der lokalen
|
|||||||
Postgres-DB — bewusster Heimnetz-Kompromiss; lege fürs NAS einen eigenen,
|
Postgres-DB — bewusster Heimnetz-Kompromiss; lege fürs NAS einen eigenen,
|
||||||
eingeschränkten Benutzer an.
|
eingeschränkten Benutzer an.
|
||||||
|
|
||||||
|
## Benachrichtigungen
|
||||||
|
|
||||||
|
**Einstellungen → Benachrichtigungen**: eine Webhook-URL eintragen, „Test
|
||||||
|
senden" drücken, speichern — fertig. Rippy meldet Job-Ende (fertig,
|
||||||
|
fehlgeschlagen, abgebrochen) und erkennt den Dienst an der URL selbst:
|
||||||
|
|
||||||
|
| Dienst | URL-Beispiel | Format |
|
||||||
|
|---|---|---|
|
||||||
|
| Discord | `https://discord.com/api/webhooks/…` | `{"content": …}` |
|
||||||
|
| Slack | `https://hooks.slack.com/services/…` | `{"text": …}` |
|
||||||
|
| ntfy (Handy-Push) | `https://ntfy.sh/mein-geheimes-thema` | Roh-Text + Titel |
|
||||||
|
| Eigenes (HA, n8n, …) | beliebige HTTPS-URL | `{"title","message","level"}` |
|
||||||
|
|
||||||
|
## System & MakeMKV-Beta-Key
|
||||||
|
|
||||||
|
**Einstellungen → System** zeigt die Werkzeug-Versionen jedes Workers
|
||||||
|
(MakeMKV, HandBrake), den Key-Status und den freien Speicherplatz. Der
|
||||||
|
MakeMKV-Beta-Key (wechselt ~monatlich, Forum-Thread t=1053) wird hier im
|
||||||
|
UI eingetragen und gilt ab dem **nächsten Rip** — ohne Rebuild, ohne
|
||||||
|
Neustart; er schlägt den Key aus der `.env`. Nur die MakeMKV-*Version*
|
||||||
|
steckt im Image: `docker compose build worker && docker compose up -d worker`.
|
||||||
|
|
||||||
## Verarbeitung & Hardware
|
## Verarbeitung & Hardware
|
||||||
|
|
||||||
**Einstellungen → Verarbeitung** zeigt ehrlich an, welche Encoder deine
|
**Einstellungen → Verarbeitung** zeigt ehrlich an, welche Encoder deine
|
||||||
@@ -88,11 +122,33 @@ eingebaute CPU-Worker alles selbst — Rippy bleibt All-in-one.
|
|||||||
| `TMDB_API_KEY` | empfohlen | Metadaten — alternativ im UI/Wizard eintragbar |
|
| `TMDB_API_KEY` | empfohlen | Metadaten — alternativ im UI/Wizard eintragbar |
|
||||||
| `OMDB_API_KEY` | optional | zweite Metadaten-Quelle (Fallback) |
|
| `OMDB_API_KEY` | optional | zweite Metadaten-Quelle (Fallback) |
|
||||||
| `THETVDB_API_KEY` | optional | Serien-Fallback |
|
| `THETVDB_API_KEY` | optional | Serien-Fallback |
|
||||||
| `MAKEMKV_APP_KEY` | optional | MakeMKV-Beta-Key (Forum); DVDs gehen ohne |
|
| `MAKEMKV_APP_KEY` | optional | MakeMKV-Beta-Key (Forum); DVDs gehen ohne — bequemer: im UI unter Einstellungen → System pflegen |
|
||||||
| `MAKEMKV_URL_BASE` | optional | alternative Download-Quelle für den Image-Build |
|
| `MAKEMKV_URL_BASE` | optional | alternative Download-Quelle für den Image-Build |
|
||||||
|
|
||||||
UI-Einstellungen (Wizard/Settings) überstimmen die Env-Variablen.
|
UI-Einstellungen (Wizard/Settings) überstimmen die Env-Variablen.
|
||||||
|
|
||||||
|
## Rippy woanders bereitstellen
|
||||||
|
|
||||||
|
Rippy ist reines Docker Compose — es läuft auf **jedem Linux-Host mit
|
||||||
|
Docker**, nicht nur auf der Original-VM. Es gibt (noch) keine fertigen
|
||||||
|
Registry-Images; gebaut wird beim ersten `up` direkt aus dem Repo.
|
||||||
|
|
||||||
|
Voraussetzungen auf dem Ziel-Host:
|
||||||
|
1. Linux (x86_64) mit Docker + Compose-Plugin.
|
||||||
|
2. Ein optisches Laufwerk, das der Host sieht (`ls /dev/sr* /dev/sg*`).
|
||||||
|
In einer VM: per **USB-Passthrough** (Vendor-ID) durchreichen, NICHT
|
||||||
|
als emuliertes CD-ROM — siehe „Laufwerk anpassen" oben.
|
||||||
|
3. Ablage-Basis anlegen: `mkdir -p /srv/rippy/media` (oder Pfad in
|
||||||
|
`docker-compose.yml` anpassen — muss ein rshared-fähiger Bind sein).
|
||||||
|
|
||||||
|
Dann wie im Schnellstart: klonen, `.env` füllen, `docker compose up -d
|
||||||
|
--build`, `http://<host>` öffnen — der Einrichtungs-Assistent führt durch
|
||||||
|
den Rest (API-Keys, Media-Server, Verarbeitung). Updates: `git pull &&
|
||||||
|
docker compose up -d --build`.
|
||||||
|
|
||||||
|
Nicht mitnehmen musst du: Gitea, Arcane, den CI-Runner — das ist die
|
||||||
|
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. Grün auf
|
||||||
|
|||||||
+63
-3
@@ -249,7 +249,9 @@ Bind-Mounts statt devices: (EPERM), UI rief hartkodiert localhost:8000 auf.
|
|||||||
- [x] /settings-Endpoints (Settings-Seite sprach vorher ins Leere)
|
- [x] /settings-Endpoints (Settings-Seite sprach vorher ins Leere)
|
||||||
- [x] UI: Vite-Build + nginx mit /api-Proxy statt Dev-Server; relative API-Pfade
|
- [x] UI: Vite-Build + nginx mit /api-Proxy statt Dev-Server; relative API-Pfade
|
||||||
- [x] compose: devices: statt Bind-Mounts; Ausgabe aufs media-Volume
|
- [x] compose: devices: statt Bind-Mounts; Ausgabe aufs media-Volume
|
||||||
- [ ] Jellyfin-Post-Processing an den Worker anbinden (NFO/Poster nach dem Rip)
|
- [x] Jellyfin-Post-Processing an den Worker anbinden (NFO/Poster nach dem
|
||||||
|
Rip) — 24.07., medien.py: Ordner „Titel (Jahr)" + movie.nfo + poster.jpg,
|
||||||
|
gesteuert übers mediaServer-Setting (Jellyfin/Emby/Kodi/Plex/keins)
|
||||||
- [ ] Auth vor die schreibenden Endpoints (JWT existiert, schützt aber nichts)
|
- [ ] Auth vor die schreibenden Endpoints (JWT existiert, schützt aber nichts)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -280,8 +282,9 @@ Jellyfin-Konvention nativ (ARM kann nur Emby-Refresh, keine NFOs).
|
|||||||
erlaubt nur EINE Auswahl).
|
erlaubt nur EINE Auswahl).
|
||||||
- [ ] **Min/Max-Titellänge + Extras**: Bonusmaterial nach extras/ (versteht
|
- [ ] **Min/Max-Titellänge + Extras**: Bonusmaterial nach extras/ (versteht
|
||||||
Jellyfin nativ) statt alles flach.
|
Jellyfin nativ) statt alles flach.
|
||||||
- [ ] **Apprise-Benachrichtigungen**: eine Lib für Telegram/Discord/…,
|
- [x] **Benachrichtigungen bei Job-Ende** — 24.07., eigenes notify-Modul
|
||||||
Hooks auf job_completed/failed/awaiting_selection, Deep-Link ins UI.
|
(Discord/Slack/ntfy/generisches JSON, Dienst-Erkennung an der URL)
|
||||||
|
statt Apprise-Dependency; Test-Knopf im UI. Telegram/Deep-Links offen.
|
||||||
- [ ] **Multi-Drive-Verwaltung**: drives-Tabelle (Seriennummer, Custom-Name,
|
- [ ] **Multi-Drive-Verwaltung**: drives-Tabelle (Seriennummer, Custom-Name,
|
||||||
UHD-fähig-Flag), parallele Rips, Job↔Drive-Zuordnung.
|
UHD-fähig-Flag), parallele Rips, Job↔Drive-Zuordnung.
|
||||||
- [ ] **Backup-Modus** (makemkvcon backup --decrypt) als Plan B für Problem-Discs.
|
- [ ] **Backup-Modus** (makemkvcon backup --decrypt) als Plan B für Problem-Discs.
|
||||||
@@ -294,3 +297,60 @@ Jellyfin-Konvention nativ (ARM kann nur Emby-Refresh, keine NFOs).
|
|||||||
„Show/Season 02/Show S02E05.mkv" im Preview bestätigen.
|
„Show/Season 02/Show S02E05.mkv" im Preview bestätigen.
|
||||||
- [ ] **Main-Feature per Laufzeitabgleich** mit TMDB-Runtime (±3 min) statt
|
- [ ] **Main-Feature per Laufzeitabgleich** mit TMDB-Runtime (±3 min) statt
|
||||||
ARMs „größte Datei gewinnt" (#1297-Fehlklasse).
|
ARMs „größte Datei gewinnt" (#1297-Fehlklasse).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Etappe 13 (24.07.2026): Universal-Komfort-Runde — Lücken aus dem Alltag schließen
|
||||||
|
|
||||||
|
**Quelle:** Commander-Sammelauftrag 24.07. — alles, was beim echten Benutzen
|
||||||
|
auffiel, in einem Rutsch. Alles gebaut, Tests dabei, Ampel-Blocker behoben.
|
||||||
|
|
||||||
|
**Gebaut:**
|
||||||
|
- [x] **Ampel entrostet**: bcrypt auf 4.0.1 gepinnt (passlib-1.7.4-Bruch) und
|
||||||
|
cache_keys-Test an das Fingerprint-Format vom 23.07. angepasst — DIE
|
||||||
|
beiden Rot-Ursachen, wegen derer `stable` 10 Commits zurückhing.
|
||||||
|
- [x] **Media-Server-Integration**: mediaServer-Setting (Wizard + Einstellungen),
|
||||||
|
Zielordner „Titel (Jahr)", movie.nfo/tvshow.nfo + poster.jpg für
|
||||||
|
Jellyfin/Emby/Kodi (medien.py im Worker, mit Tests).
|
||||||
|
- [x] **Benachrichtigungen echt gemacht**: notify.py (API+Worker), Meldung bei
|
||||||
|
fertig/fehlgeschlagen/abgebrochen, Anleitung + Test-Knopf im UI —
|
||||||
|
das Webhook-Feld war vorher ein Placebo, nichts hat je gesendet.
|
||||||
|
- [x] **SMB-Freigaben-Scan repariert**: NT_STATUS-Fehler → Klartext (Gast-
|
||||||
|
Abfrage verweigert ⇒ Benutzer/Passwort nötig), Credential-Felder im UI
|
||||||
|
VOR den „Freigaben auflisten"-Knopf gezogen.
|
||||||
|
- [x] **4K-UHD-Platzproblem**: Arbeitsverzeichnis konfigurierbar (workDir,
|
||||||
|
z. B. NAS-Freigabe), Platz-Check per Disc-Größe (ioctl) VOR dem Rip.
|
||||||
|
- [x] **MakeMKV-Key über UI** (Einstellungen → System): gilt ab dem nächsten
|
||||||
|
Rip, ohne Rebuild; Worker melden Werkzeug-Versionen + Key-Quelle.
|
||||||
|
- [x] **Job-Detail-Popup**: Klick auf Titel in „Neueste Jobs" → Poster, Jahr,
|
||||||
|
Beschreibung, Ablagepfad, Fehler (Job speichert Metadaten jetzt mit).
|
||||||
|
- [x] **UI-Feedback überall**: Toast-System (reinploppende Meldungen), drehende
|
||||||
|
Refresh-Knöpfe, Logs-Filter um Warnung/Fehler-Pills ergänzt.
|
||||||
|
- [x] **Datei-Browser zeigt Dateien**: Ordner wirkten „leer", weil nur
|
||||||
|
Unterordner gelistet wurden — Dateien erscheinen jetzt grau mit Größe.
|
||||||
|
- [x] Favicon (Disc im Logo-Verlauf) + Footer „Created with ❤️ by LucyAI,
|
||||||
|
Claude and KrBrZ".
|
||||||
|
- [x] README: Media-Server, Benachrichtigungen, UHD-Arbeitsverzeichnis,
|
||||||
|
„Rippy woanders bereitstellen" (beliebiger Docker-Host).
|
||||||
|
|
||||||
|
**Offen aus dieser Runde:** nichts — Rest siehe Etappe 12 und Ideen unten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ideen-Katalog (24.07.2026, priorisiert) — Lücken, die noch offen sind
|
||||||
|
|
||||||
|
1. **Serien-Staffel-Flow**: Beim Rippen einer Serien-Disc Staffel/Disc-Nr
|
||||||
|
abfragen → Ablage `Show/Season 02/`, Episoden-Matching wie Etappe 12.
|
||||||
|
2. **Duplikat-Warnung**: Disc-Fingerprint schon in der Job-Historie →
|
||||||
|
„schon am X gerippt — trotzdem?" (Fingerprint-Infrastruktur existiert).
|
||||||
|
3. **Jellyfin-Bibliotheks-Refresh** nach Ablage (API-Key + URL in den
|
||||||
|
Einstellungen, POST /Library/Refresh) — der letzte Schritt zur
|
||||||
|
Null-Klick-Kette Disc→Bildschirm.
|
||||||
|
4. **Track-Auswahl vor dem Rip** (awaiting_selection, Etappe 12) — spart
|
||||||
|
bei Bonus-Material-Discs Stunden.
|
||||||
|
5. **Auth vor schreibende Endpoints** + Login-Flow im UI (JWT existiert).
|
||||||
|
6. **Speicherplatz-Kachel im Dashboard** (Media/Arbeitsverzeichnis als
|
||||||
|
Balken — /system/info liefert die Daten schon).
|
||||||
|
7. **Rip-Historie exportieren** (CSV/JSON) für die Sammlung-Übersicht.
|
||||||
|
8. **Design 2.0**: Design-Tokens statt Theme-Ternaries in jeder Zeile —
|
||||||
|
die `theme === 'dark' ? … : …`-Kaskaden sind der größte UI-Schuldenberg.
|
||||||
|
|||||||
+44
-1
@@ -1,6 +1,49 @@
|
|||||||
# SAVEPOINT — Rippy
|
# SAVEPOINT — Rippy
|
||||||
|
|
||||||
## Aktueller Stand: v3.1 — E2E BEWIESEN + Universal-Runde (24.07.2026, Claude)
|
## Aktueller Stand: v3.2 — Universal-Komfort-Runde + Ampel entrostet (24.07.2026, Claude)
|
||||||
|
|
||||||
|
**Wichtigster Befund zuerst: die Ampel war seit dem 23.07. ROT und `stable`
|
||||||
|
hing 10 Commits hinter `main`** — deshalb kam nichts Neues mehr auf die VM.
|
||||||
|
Zwei Ursachen, beide behoben:
|
||||||
|
1. bcrypt ≥ 4.1 bricht passlib 1.7.4 (`__about__` entfernt → Selbsttest wirft
|
||||||
|
„password cannot be longer than 72 bytes") → **bcrypt==4.0.1 gepinnt**.
|
||||||
|
2. Der cache_keys-Test kannte den Disc-Fingerprint im Prescan-Key (23.07.)
|
||||||
|
nicht → Test an das echte Format angepasst + Fingerprint-Testfall dazu.
|
||||||
|
|
||||||
|
**Neu in v3.2 (Commander-Sammelauftrag, alles mit Tests / Vite-Build grün):**
|
||||||
|
- **Media-Server-Integration**: Setting `mediaServer` (Wizard + Einstellungen →
|
||||||
|
Ripping): Zielordner „Titel (Jahr)" statt Job-UUID; für Jellyfin/Emby/Kodi
|
||||||
|
zusätzlich movie.nfo/tvshow.nfo + poster.jpg (Worker: medien.py). Plex = nur
|
||||||
|
Benennung. Job speichert Disc-Metadaten jetzt mit (jobs.meta, Migration).
|
||||||
|
- **Benachrichtigungen ECHT**: notify.py in API+Worker (Discord/Slack/ntfy/
|
||||||
|
generisch, Erkennung an der URL) — das Feld war vorher ein Placebo. Meldung
|
||||||
|
bei fertig/fehlgeschlagen/abgebrochen; Anleitung + „Test senden" im UI.
|
||||||
|
- **SMB-Scan-Fix**: „NT_STATUS_ACCESS_DENIED" heißt jetzt im Klartext „Gast-
|
||||||
|
Abfrage verweigert → Benutzer/Passwort eintragen"; Credential-Felder stehen
|
||||||
|
im UI VOR dem Auflisten-Knopf. NAS-Ziel-Anlage quittiert per Toast.
|
||||||
|
- **4K-UHD-Vorsorge**: Arbeitsverzeichnis `workDir` konfigurierbar (auf
|
||||||
|
NAS-Freigabe legbar), Platz-Check per Disc-Größe (ioctl) VOR dem Rip mit
|
||||||
|
Klartext-Abbruch statt voller Platte bei 40 GB.
|
||||||
|
- **Einstellungen → System**: Werkzeug-Versionen je Worker (MakeMKV/HandBrake,
|
||||||
|
ENV MAKEMKV_VERSION im Image), Key-Quelle (ui/env/keiner), freier Platz;
|
||||||
|
**MakeMKV-Beta-Key im UI pflegbar** — gilt ab dem nächsten Rip, ohne Rebuild.
|
||||||
|
- **Job-Detail-Popup** (Klick auf Titel in „Neueste Jobs"): Poster, Jahr,
|
||||||
|
Beschreibung, Genres, Ablagepfad, Fehler (GET /jobs/{id}/detail).
|
||||||
|
- **UI-Feedback**: Toast-System (ToastContext, ploppt oben rechts), drehende
|
||||||
|
Refresh-Knöpfe, Logs-Pills um Warnung/Fehler ergänzt, Datei-Browser zeigt
|
||||||
|
jetzt auch DATEIEN (grau, mit Größe) — der „leere" Bluray-Ordner war voll.
|
||||||
|
- **Favicon** (Disc, Indigo-Verlauf) + **Footer** „Created with ❤️ by LucyAI,
|
||||||
|
Claude and KrBrZ".
|
||||||
|
- **Doku**: README (Media-Server, Benachrichtigungen, UHD-Arbeitsverzeichnis,
|
||||||
|
„Rippy woanders bereitstellen"), ROADMAP Etappe 13 + Ideen-Katalog,
|
||||||
|
KONZEPT-Fortschreibungen (Abschnitt 10), AGENTS-Stand.
|
||||||
|
|
||||||
|
**Nächste Schritte:** unverändert die Fäden aus v3.1 (unten) + Ideen-Katalog
|
||||||
|
in der ROADMAP (Serien-Flow, Duplikat-Warnung, Jellyfin-Refresh, Auth).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vorheriger Stand: v3.1 — E2E BEWIESEN + Universal-Runde (24.07.2026, Claude)
|
||||||
|
|
||||||
**Der Beweis steht: Evangelion 2.22 (BD-50) komplett durch die Kette** —
|
**Der Beweis steht: Evangelion 2.22 (BD-50) komplett durch die Kette** —
|
||||||
erkannt → verlustfrei gerippt (43 GB, LibreDrive) → H.264-komprimiert →
|
erkannt → verlustfrei gerippt (43 GB, LibreDrive) → H.264-komprimiert →
|
||||||
|
|||||||
+15
-1
@@ -43,6 +43,7 @@ jobs = Table(
|
|||||||
Column("output_path", Text),
|
Column("output_path", Text),
|
||||||
Column("target_dir", String(255)),
|
Column("target_dir", String(255)),
|
||||||
Column("error", Text),
|
Column("error", Text),
|
||||||
|
Column("meta", Text), # Disc-Metadaten (JSON: Jahr/Poster/Plot) für Detail-Popup + NFO
|
||||||
Column("created_at", DateTime(timezone=True)),
|
Column("created_at", DateTime(timezone=True)),
|
||||||
Column("finished_at", DateTime(timezone=True)),
|
Column("finished_at", DateTime(timezone=True)),
|
||||||
)
|
)
|
||||||
@@ -69,6 +70,7 @@ workers = Table(
|
|||||||
metadata,
|
metadata,
|
||||||
Column("name", String(128), primary_key=True),
|
Column("name", String(128), primary_key=True),
|
||||||
Column("encoders", Text),
|
Column("encoders", Text),
|
||||||
|
Column("info", Text), # Werkzeug-Versionen (JSON: makemkv/handbrake/key-Quelle)
|
||||||
Column("last_seen", DateTime(timezone=True)),
|
Column("last_seen", DateTime(timezone=True)),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -96,6 +98,10 @@ def list_workers() -> list:
|
|||||||
eintrag["encoders"] = json.loads(eintrag.get("encoders") or "[]")
|
eintrag["encoders"] = json.loads(eintrag.get("encoders") or "[]")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
eintrag["encoders"] = []
|
eintrag["encoders"] = []
|
||||||
|
try:
|
||||||
|
eintrag["info"] = json.loads(eintrag.get("info") or "{}")
|
||||||
|
except ValueError:
|
||||||
|
eintrag["info"] = {}
|
||||||
if eintrag.get("last_seen"):
|
if eintrag.get("last_seen"):
|
||||||
eintrag["last_seen"] = eintrag["last_seen"].isoformat()
|
eintrag["last_seen"] = eintrag["last_seen"].isoformat()
|
||||||
ergebnis.append(eintrag)
|
ergebnis.append(eintrag)
|
||||||
@@ -134,10 +140,17 @@ def init_db() -> None:
|
|||||||
conn.exec_driver_sql(
|
conn.exec_driver_sql(
|
||||||
"ALTER TABLE jobs ADD COLUMN IF NOT EXISTS target_dir VARCHAR(255)"
|
"ALTER TABLE jobs ADD COLUMN IF NOT EXISTS target_dir VARCHAR(255)"
|
||||||
)
|
)
|
||||||
|
conn.exec_driver_sql(
|
||||||
|
"ALTER TABLE jobs ADD COLUMN IF NOT EXISTS meta TEXT"
|
||||||
|
)
|
||||||
|
conn.exec_driver_sql(
|
||||||
|
"ALTER TABLE workers ADD COLUMN IF NOT EXISTS info TEXT"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def insert_job(
|
def insert_job(
|
||||||
job_id: str, device: str, disc_type: str = None, title: str = None, target_dir: str = None
|
job_id: str, device: str, disc_type: str = None, title: str = None,
|
||||||
|
target_dir: str = None, meta: str = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
with engine.begin() as conn:
|
with engine.begin() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@@ -147,6 +160,7 @@ def insert_job(
|
|||||||
disc_type=disc_type,
|
disc_type=disc_type,
|
||||||
title=title,
|
title=title,
|
||||||
target_dir=target_dir,
|
target_dir=target_dir,
|
||||||
|
meta=meta,
|
||||||
status="pending",
|
status="pending",
|
||||||
progress=0,
|
progress=0,
|
||||||
created_at=utcnow(),
|
created_at=utcnow(),
|
||||||
|
|||||||
+109
-13
@@ -13,6 +13,7 @@ import uuid
|
|||||||
import db
|
import db
|
||||||
import devices as device_discovery
|
import devices as device_discovery
|
||||||
import mounts as mount_verwaltung
|
import mounts as mount_verwaltung
|
||||||
|
import notify
|
||||||
from celery_client import celery_client, start_rip
|
from celery_client import celery_client, start_rip
|
||||||
from detection import CDS_DISC_OK, CDS_NO_DISC, CDS_TRAY_OPEN, drive_status
|
from detection import CDS_DISC_OK, CDS_NO_DISC, CDS_TRAY_OPEN, drive_status
|
||||||
|
|
||||||
@@ -262,15 +263,23 @@ async def create_job(request: JobCreateRequest):
|
|||||||
raise HTTPException(status_code=404, detail=f"Laufwerk {device_path} nicht gefunden")
|
raise HTTPException(status_code=404, detail=f"Laufwerk {device_path} nicht gefunden")
|
||||||
ziel = _validiere_ziel(request.target_dir)
|
ziel = _validiere_ziel(request.target_dir)
|
||||||
|
|
||||||
# Titel aus der Disc-Erkennung übernehmen — sonst steht im Job "Unbekannt"
|
# Titel + Metadaten aus der Disc-Erkennung übernehmen — der Worker nutzt
|
||||||
|
# sie für den Ordnernamen und die Media-Server-Aufbereitung (NFO/Poster),
|
||||||
|
# das UI fürs Job-Detail-Popup.
|
||||||
titel = request.title
|
titel = request.title
|
||||||
if not titel:
|
meta_json = None
|
||||||
disc = DISC_CACHE.get(device_path)
|
disc = DISC_CACHE.get(device_path)
|
||||||
if disc and not disc.get("_laeuft"):
|
if disc and not disc.get("_laeuft"):
|
||||||
|
if not titel:
|
||||||
titel = disc.get("title")
|
titel = disc.get("title")
|
||||||
|
meta_json = json.dumps({
|
||||||
|
"year": disc.get("year"),
|
||||||
|
"confidence": disc.get("confidence"),
|
||||||
|
**(disc.get("metadata") or {}),
|
||||||
|
})
|
||||||
|
|
||||||
job_id = str(uuid.uuid4())
|
job_id = str(uuid.uuid4())
|
||||||
await asyncio.to_thread(db.insert_job, job_id, device_path, None, titel, ziel)
|
await asyncio.to_thread(db.insert_job, job_id, device_path, None, titel, ziel, meta_json)
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
db.add_log, "info", "api",
|
db.add_log, "info", "api",
|
||||||
f"Job {job_id} angelegt für {device_path}" + (f" → {ziel}" if ziel else ""),
|
f"Job {job_id} angelegt für {device_path}" + (f" → {ziel}" if ziel else ""),
|
||||||
@@ -279,6 +288,23 @@ async def create_job(request: JobCreateRequest):
|
|||||||
return {"id": job_id, "status": "pending", "device": device_path, "target_dir": ziel}
|
return {"id": job_id, "status": "pending", "device": device_path, "target_dir": ziel}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/jobs/{job_id}/detail")
|
||||||
|
async def get_job_detail(job_id: str):
|
||||||
|
"""Alles zu EINEM Job — fürs Klick-Popup auf den Titel in „Neueste Jobs":
|
||||||
|
Metadaten (Poster/Jahr/Beschreibung), Ziel, Ausgabepfad, Fehler."""
|
||||||
|
job = await asyncio.to_thread(db.get_job, job_id)
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(status_code=404, detail="Job nicht gefunden")
|
||||||
|
detail = _job_row_to_model(job).dict()
|
||||||
|
detail["target_dir"] = job.get("target_dir")
|
||||||
|
detail["output_path"] = job.get("output_path")
|
||||||
|
try:
|
||||||
|
detail["meta"] = json.loads(job["meta"]) if job.get("meta") else None
|
||||||
|
except ValueError:
|
||||||
|
detail["meta"] = None
|
||||||
|
return detail
|
||||||
|
|
||||||
|
|
||||||
@app.get("/storage-targets")
|
@app.get("/storage-targets")
|
||||||
async def storage_targets():
|
async def storage_targets():
|
||||||
"""Verfügbare Ablageziele: Verzeichnisse unter /app/media inkl. Mounts.
|
"""Verfügbare Ablageziele: Verzeichnisse unter /app/media inkl. Mounts.
|
||||||
@@ -343,9 +369,16 @@ async def retry_transcode(job_id: str):
|
|||||||
if job["status"] in ("running", "pending"):
|
if job["status"] in ("running", "pending"):
|
||||||
raise HTTPException(status_code=409, detail="Job rippt noch")
|
raise HTTPException(status_code=409, detail="Job rippt noch")
|
||||||
|
|
||||||
raw_dir = f"/app/temp/raw/{job_id}"
|
# Roh-Verzeichnis: respektiert das konfigurierbare Arbeitsverzeichnis
|
||||||
|
# (Einstellungen → Verarbeitung), sonst Container-Default /app/temp/raw.
|
||||||
|
einstellungen = await asyncio.to_thread(db.get_settings)
|
||||||
|
work_dir = os.path.normpath((einstellungen.get("workDir") or "").strip() or "/")
|
||||||
|
raw_basis = work_dir if work_dir.startswith(MEDIA_ROOT) else "/app/temp/raw"
|
||||||
|
raw_dir = f"{raw_basis}/{job_id}"
|
||||||
|
# Zielordner: der Worker schreibt das geplante Ziel beim Rip-Start nach
|
||||||
|
# output_path (sprechender Name statt UUID) — alter Fallback bleibt.
|
||||||
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 = f"{basis}/{job_id}"
|
final_dir = job.get("output_path") or f"{basis}/{job_id}"
|
||||||
|
|
||||||
celery_client.send_task(
|
celery_client.send_task(
|
||||||
"worker.tasks.transcode_files",
|
"worker.tasks.transcode_files",
|
||||||
@@ -495,17 +528,28 @@ async def browse(path: str = MEDIA_ROOT):
|
|||||||
eintraege = sorted(os.listdir(normalisiert))
|
eintraege = sorted(os.listdir(normalisiert))
|
||||||
except OSError:
|
except OSError:
|
||||||
return None
|
return None
|
||||||
return [
|
ordner, dateien = [], []
|
||||||
{"name": name, "path": os.path.join(normalisiert, name)}
|
for name in eintraege:
|
||||||
for name in eintraege
|
voll = os.path.join(normalisiert, name)
|
||||||
if os.path.isdir(os.path.join(normalisiert, name))
|
if os.path.isdir(voll):
|
||||||
]
|
ordner.append({"name": name, "path": voll})
|
||||||
|
else:
|
||||||
|
# Dateien MIT anzeigen (Befund 24.07.: der Browser wirkte
|
||||||
|
# „leer", weil er nur Ordner listete — die MKVs im
|
||||||
|
# bluray-Ordner waren unsichtbar).
|
||||||
|
try:
|
||||||
|
groesse_mb = round(os.path.getsize(voll) / 1024**2, 1)
|
||||||
|
except OSError:
|
||||||
|
groesse_mb = None
|
||||||
|
dateien.append({"name": name, "size_mb": groesse_mb})
|
||||||
|
return ordner, dateien
|
||||||
|
|
||||||
ordner = await asyncio.to_thread(liste)
|
ergebnis = await asyncio.to_thread(liste)
|
||||||
if ordner is None:
|
if ergebnis is None:
|
||||||
raise HTTPException(status_code=404, detail="Ordner nicht lesbar")
|
raise HTTPException(status_code=404, detail="Ordner nicht lesbar")
|
||||||
|
ordner, dateien = ergebnis
|
||||||
eltern = os.path.dirname(normalisiert) if normalisiert != MEDIA_ROOT else None
|
eltern = os.path.dirname(normalisiert) if normalisiert != MEDIA_ROOT else None
|
||||||
return {"path": normalisiert, "parent": eltern, "dirs": ordner}
|
return {"path": normalisiert, "parent": eltern, "dirs": ordner, "files": dateien}
|
||||||
|
|
||||||
|
|
||||||
class MkdirRequest(BaseModel):
|
class MkdirRequest(BaseModel):
|
||||||
@@ -530,6 +574,58 @@ async def browse_mkdir(request: MkdirRequest):
|
|||||||
return {"path": ziel}
|
return {"path": ziel}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/system/info")
|
||||||
|
async def system_info():
|
||||||
|
"""System-Selbstauskunft (Einstellungen → System): Werkzeug-Versionen der
|
||||||
|
Worker, freier Platz auf Media- und Arbeits-Volume, MakeMKV-Key-Status."""
|
||||||
|
def sammle():
|
||||||
|
info = {"api_version": app.version, "plaetze": [], "workers": db.list_workers()}
|
||||||
|
for name, pfad in (("Media (/app/media)", MEDIA_ROOT),
|
||||||
|
("Arbeitsverzeichnis (/app/temp)", "/app/temp")):
|
||||||
|
try:
|
||||||
|
nutzung = shutil.disk_usage(pfad)
|
||||||
|
info["plaetze"].append({
|
||||||
|
"name": name,
|
||||||
|
"frei_gb": round(nutzung.free / 1024**3, 1),
|
||||||
|
"gesamt_gb": round(nutzung.total / 1024**3, 1),
|
||||||
|
})
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
einstellungen = db.get_settings()
|
||||||
|
info["makemkv_key_ui"] = bool((einstellungen.get("makemkvAppKey") or "").strip())
|
||||||
|
info["webhook_gesetzt"] = bool((einstellungen.get("notificationWebhook") or "").strip())
|
||||||
|
return info
|
||||||
|
|
||||||
|
return await asyncio.to_thread(sammle)
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationTestRequest(BaseModel):
|
||||||
|
url: str
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/notifications/test")
|
||||||
|
async def notification_test(request: NotificationTestRequest):
|
||||||
|
"""Test-Nachricht an den Webhook — beweist die Anbindung SOFORT statt
|
||||||
|
erst beim ersten Job-Ende."""
|
||||||
|
url = request.url.strip()
|
||||||
|
if not url.startswith(("http://", "https://")):
|
||||||
|
raise HTTPException(status_code=422, detail="Webhook-URL muss mit http(s):// beginnen")
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(
|
||||||
|
notify.sende, url,
|
||||||
|
"🔔 Rippy: Test-Benachrichtigung",
|
||||||
|
"Wenn du das liest, funktioniert die Anbindung. Rippy meldet sich "
|
||||||
|
"hier, sobald ein Job fertig ist oder fehlschlägt.",
|
||||||
|
"info",
|
||||||
|
)
|
||||||
|
except RuntimeError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
await asyncio.to_thread(
|
||||||
|
db.add_log, "info", "notify", f"Test-Benachrichtigung gesendet ({notify.erkenne_webhook_typ(url)})"
|
||||||
|
)
|
||||||
|
return {"status": "sent", "typ": notify.erkenne_webhook_typ(url)}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/setup")
|
@app.get("/setup")
|
||||||
async def setup_status():
|
async def setup_status():
|
||||||
"""First-Run-Erkennung: wurde der Einrichtungs-Assistent abgeschlossen?"""
|
"""First-Run-Erkennung: wurde der Einrichtungs-Assistent abgeschlossen?"""
|
||||||
|
|||||||
+52
-2
@@ -49,6 +49,40 @@ def schreibtest(pfad: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def uebersetze_smb_fehler(fehler: str, mit_credentials: bool) -> str:
|
||||||
|
"""Pure Funktion (testbar): NT_STATUS-Kauderwelsch → handelbarer Klartext.
|
||||||
|
|
||||||
|
Befund 24.07.: „session setup failed: NT_STATUS_ACCESS_DENIED" hieß in
|
||||||
|
Wahrheit nur „Windows/NAS erlauben keine Gast-Abfrage" — ohne Übersetzung
|
||||||
|
rät niemand, dass oben Benutzer/Passwort fehlen.
|
||||||
|
"""
|
||||||
|
if "NT_STATUS_ACCESS_DENIED" in fehler:
|
||||||
|
if not mit_credentials:
|
||||||
|
return (
|
||||||
|
"Zugriff verweigert — dieser Rechner erlaubt keine Gast-Abfrage. "
|
||||||
|
"Benutzername + Passwort eintragen (Windows: ein Konto mit "
|
||||||
|
"Zugriff auf die Freigabe), dann erneut auflisten."
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
"Zugriff verweigert — Benutzername/Passwort stimmen nicht oder das "
|
||||||
|
"Konto darf die Freigaben nicht auflisten."
|
||||||
|
)
|
||||||
|
if "NT_STATUS_LOGON_FAILURE" in fehler:
|
||||||
|
return "Anmeldung fehlgeschlagen — Benutzername oder Passwort falsch."
|
||||||
|
if (
|
||||||
|
"NT_STATUS_HOST_UNREACHABLE" in fehler
|
||||||
|
or "NT_STATUS_IO_TIMEOUT" in fehler
|
||||||
|
or "NT_STATUS_UNSUCCESSFUL" in fehler
|
||||||
|
or "NT_STATUS_CONNECTION_REFUSED" in fehler
|
||||||
|
or "Connection to" in fehler
|
||||||
|
):
|
||||||
|
return (
|
||||||
|
"Rechner nicht erreichbar — Name/IP prüfen; läuft dort ein "
|
||||||
|
"SMB-Dienst (Port 445, Datei- und Druckerfreigabe aktiv)?"
|
||||||
|
)
|
||||||
|
return fehler[:200]
|
||||||
|
|
||||||
|
|
||||||
def liste_smb_freigaben(host: str, username: str = "", passwort: str = "") -> list:
|
def liste_smb_freigaben(host: str, username: str = "", passwort: str = "") -> list:
|
||||||
"""Listet die SMB-Freigaben eines Rechners (smbclient -L) — damit man
|
"""Listet die SMB-Freigaben eines Rechners (smbclient -L) — damit man
|
||||||
seinen PC/NAS per Klick wählt statt //host/share zu raten."""
|
seinen PC/NAS per Klick wählt statt //host/share zu raten."""
|
||||||
@@ -57,7 +91,13 @@ def liste_smb_freigaben(host: str, username: str = "", passwort: str = "") -> li
|
|||||||
cmd += ["-U", f"{username}%{passwort or ''}"]
|
cmd += ["-U", f"{username}%{passwort or ''}"]
|
||||||
else:
|
else:
|
||||||
cmd += ["-N"]
|
cmd += ["-N"]
|
||||||
|
try:
|
||||||
ergebnis = subprocess.run(cmd, capture_output=True, text=True, timeout=20)
|
ergebnis = subprocess.run(cmd, capture_output=True, text=True, timeout=20)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Freigaben-Abfrage fehlgeschlagen: {host} antwortet nicht "
|
||||||
|
"(Timeout nach 20 s) — Name/IP und Netzwerk prüfen."
|
||||||
|
)
|
||||||
freigaben = []
|
freigaben = []
|
||||||
for zeile in (ergebnis.stdout or "").splitlines():
|
for zeile in (ergebnis.stdout or "").splitlines():
|
||||||
# -g-Format: Disk|Freigabename|Kommentar
|
# -g-Format: Disk|Freigabename|Kommentar
|
||||||
@@ -66,7 +106,10 @@ def liste_smb_freigaben(host: str, username: str = "", passwort: str = "") -> li
|
|||||||
freigaben.append(teile[1])
|
freigaben.append(teile[1])
|
||||||
if not freigaben and ergebnis.returncode != 0:
|
if not freigaben and ergebnis.returncode != 0:
|
||||||
fehler = (ergebnis.stderr or ergebnis.stdout or "").strip()
|
fehler = (ergebnis.stderr or ergebnis.stdout or "").strip()
|
||||||
raise RuntimeError(f"Freigaben-Abfrage fehlgeschlagen: {fehler[:200]}")
|
raise RuntimeError(
|
||||||
|
"Freigaben-Abfrage fehlgeschlagen: "
|
||||||
|
+ uebersetze_smb_fehler(fehler, bool(username))
|
||||||
|
)
|
||||||
return freigaben
|
return freigaben
|
||||||
|
|
||||||
|
|
||||||
@@ -109,7 +152,14 @@ def mounten(name: str, typ: str, quelle: str, optionen: str = "",
|
|||||||
ergebnis = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
ergebnis = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||||
if ergebnis.returncode != 0:
|
if ergebnis.returncode != 0:
|
||||||
fehler = (ergebnis.stderr or ergebnis.stdout or "").strip()
|
fehler = (ergebnis.stderr or ergebnis.stdout or "").strip()
|
||||||
raise RuntimeError(f"mount schlug fehl: {fehler[:300]}")
|
hinweis = ""
|
||||||
|
if "error(13)" in fehler or "Permission denied" in fehler:
|
||||||
|
hinweis = (
|
||||||
|
" — Zugriff verweigert: Benutzer/Passwort prüfen; ohne "
|
||||||
|
"Angaben versucht Rippy einen Gast-Zugriff, den Windows/"
|
||||||
|
"NAS meist ablehnen."
|
||||||
|
)
|
||||||
|
raise RuntimeError(f"mount schlug fehl: {fehler[:300]}{hinweis}")
|
||||||
return schreibtest(ziel)
|
return schreibtest(ziel)
|
||||||
finally:
|
finally:
|
||||||
if creds_datei:
|
if creds_datei:
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Webhook-Benachrichtigungen bei Job-Ende (Discord, Slack, ntfy, generisch).
|
||||||
|
|
||||||
|
Bis 24.07. war das notificationWebhook-Setting ein Placebo: das UI speicherte
|
||||||
|
die URL, aber NICHTS hat je gesendet. Jetzt meldet der Worker Job-Ende
|
||||||
|
(fertig/fehlgeschlagen/abgebrochen) und die API bietet einen Test-Endpoint.
|
||||||
|
|
||||||
|
Das Modul existiert bewusst identisch in API und Worker
|
||||||
|
(docker/api/notify.py) — es gibt kein geteiltes Paket zwischen den Containern.
|
||||||
|
Wer es ändert, ändert BEIDE Dateien.
|
||||||
|
|
||||||
|
Payload-Formate (dokumentiert, nicht geraten — AGENTS Regel D):
|
||||||
|
- Discord: POST JSON {"content": "..."} — discord.com/developers/docs/resources/webhook
|
||||||
|
- Slack: POST JSON {"text": "..."} — api.slack.com/messaging/webhooks
|
||||||
|
- ntfy: POST Roh-Text an https://ntfy.sh/<topic>, Titel via ?title= —
|
||||||
|
docs.ntfy.sh/publish
|
||||||
|
- Generisch: POST JSON {"title", "message", "level"} für eigene Empfänger
|
||||||
|
(Home Assistant, n8n, eigene Skripte).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
TIMEOUT_SEKUNDEN = 10
|
||||||
|
|
||||||
|
|
||||||
|
def erkenne_webhook_typ(url: str) -> str:
|
||||||
|
"""Erkennt den Dienst an der URL — der Nutzer muss nichts konfigurieren."""
|
||||||
|
u = url.lower()
|
||||||
|
if "discord.com/api/webhooks" in u or "discordapp.com/api/webhooks" in u:
|
||||||
|
return "discord"
|
||||||
|
if "hooks.slack.com" in u:
|
||||||
|
return "slack"
|
||||||
|
if "ntfy" in u:
|
||||||
|
return "ntfy"
|
||||||
|
return "generisch"
|
||||||
|
|
||||||
|
|
||||||
|
def baue_payload(url: str, titel: str, text: str, level: str = "info"):
|
||||||
|
"""Pure Funktion (testbar): (typ, json_payload) — ntfy sendet Roh-Text."""
|
||||||
|
typ = erkenne_webhook_typ(url)
|
||||||
|
if typ == "discord":
|
||||||
|
return typ, {"content": f"**{titel}**\n{text}"}
|
||||||
|
if typ == "slack":
|
||||||
|
return typ, {"text": f"*{titel}*\n{text}"}
|
||||||
|
if typ == "ntfy":
|
||||||
|
return typ, None
|
||||||
|
return typ, {"title": titel, "message": text, "level": level}
|
||||||
|
|
||||||
|
|
||||||
|
def sende(url: str, titel: str, text: str, level: str = "info") -> None:
|
||||||
|
"""Schickt die Nachricht; wirft RuntimeError mit Klartext bei Fehlern."""
|
||||||
|
typ, payload = baue_payload(url, titel, text, level)
|
||||||
|
try:
|
||||||
|
if typ == "ntfy":
|
||||||
|
antwort = requests.post(
|
||||||
|
url, data=text.encode("utf-8"),
|
||||||
|
params={"title": titel}, timeout=TIMEOUT_SEKUNDEN,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
antwort = requests.post(url, json=payload, timeout=TIMEOUT_SEKUNDEN)
|
||||||
|
except requests.RequestException as e:
|
||||||
|
raise RuntimeError(f"Webhook nicht erreichbar: {e}") from e
|
||||||
|
if antwort.status_code >= 300:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Webhook antwortete mit HTTP {antwort.status_code}: "
|
||||||
|
f"{(antwort.text or '')[:200]}"
|
||||||
|
)
|
||||||
@@ -8,6 +8,11 @@ requests==2.32.3
|
|||||||
pydantic==2.9.0
|
pydantic==2.9.0
|
||||||
pydantic-settings==2.5.2
|
pydantic-settings==2.5.2
|
||||||
passlib==1.7.4
|
passlib==1.7.4
|
||||||
|
# bcrypt MUSS gepinnt bleiben: passlib 1.7.4 liest bcrypt.__about__ (in
|
||||||
|
# bcrypt >= 4.1 entfernt) — der Backend-Selbsttest crasht dann mit
|
||||||
|
# "password cannot be longer than 72 bytes" und JEDES Hashing schlägt fehl.
|
||||||
|
# Genau das hielt die Ampel ab dem 23.07. rot (stable blieb 10 Commits zurück).
|
||||||
|
bcrypt==4.0.1
|
||||||
PyJWT==2.9.0
|
PyJWT==2.9.0
|
||||||
python-multipart==0.0.9
|
python-multipart==0.0.9
|
||||||
aiofiles==24.1.0
|
aiofiles==24.1.0
|
||||||
|
|||||||
@@ -15,7 +15,17 @@ def test_prescan_key_audio_vs_video():
|
|||||||
assert audio.startswith(PRESCAN_AUDIO_PREFIX)
|
assert audio.startswith(PRESCAN_AUDIO_PREFIX)
|
||||||
assert video.startswith(PRESCAN_VIDEO_PREFIX)
|
assert video.startswith(PRESCAN_VIDEO_PREFIX)
|
||||||
assert audio != video
|
assert audio != video
|
||||||
assert audio.endswith("/dev/sr0")
|
assert "/dev/sr0" in audio
|
||||||
|
|
||||||
|
|
||||||
|
def test_prescan_key_unterscheidet_discs_im_selben_laufwerk():
|
||||||
|
"""Seit 23.07. ist der Disc-Fingerabdruck Teil des Keys — sonst bekam die
|
||||||
|
NÄCHSTE Disc im selben Laufwerk die Metadaten der vorherigen serviert.
|
||||||
|
(Der alte Test kannte den Fingerprint nicht und hielt die Ampel rot.)"""
|
||||||
|
a = generate_prescan_key("/dev/sr0", False, "EVANGELION_2_22:43GB")
|
||||||
|
b = generate_prescan_key("/dev/sr0", False, "MATRIX:31GB")
|
||||||
|
assert a != b
|
||||||
|
assert a.endswith("EVANGELION_2_22:43GB")
|
||||||
|
|
||||||
|
|
||||||
def test_confirmed_key_mit_und_ohne_jahr():
|
def test_confirmed_key_mit_und_ohne_jahr():
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""Tests für die SMB-Fehlerübersetzung (Speicherziele → Freigaben auflisten)."""
|
||||||
|
|
||||||
|
from mounts import uebersetze_smb_fehler, validiere_name
|
||||||
|
|
||||||
|
|
||||||
|
def test_access_denied_ohne_credentials_erklaert_gastproblem():
|
||||||
|
meldung = uebersetze_smb_fehler(
|
||||||
|
"session setup failed: NT_STATUS_ACCESS_DENIED", mit_credentials=False
|
||||||
|
)
|
||||||
|
assert "Gast" in meldung
|
||||||
|
assert "Benutzername + Passwort" in meldung
|
||||||
|
|
||||||
|
|
||||||
|
def test_access_denied_mit_credentials_verweist_auf_konto():
|
||||||
|
meldung = uebersetze_smb_fehler(
|
||||||
|
"session setup failed: NT_STATUS_ACCESS_DENIED", mit_credentials=True
|
||||||
|
)
|
||||||
|
assert "stimmen nicht" in meldung
|
||||||
|
|
||||||
|
|
||||||
|
def test_logon_failure_wird_uebersetzt():
|
||||||
|
meldung = uebersetze_smb_fehler("NT_STATUS_LOGON_FAILURE", mit_credentials=True)
|
||||||
|
assert "Passwort falsch" in meldung
|
||||||
|
|
||||||
|
|
||||||
|
def test_unerreichbar_wird_uebersetzt():
|
||||||
|
meldung = uebersetze_smb_fehler(
|
||||||
|
"do_connect: Connection to 10.0.0.9 failed (Error NT_STATUS_IO_TIMEOUT)",
|
||||||
|
mit_credentials=False,
|
||||||
|
)
|
||||||
|
assert "nicht erreichbar" in meldung
|
||||||
|
|
||||||
|
|
||||||
|
def test_unbekannter_fehler_bleibt_erhalten_und_gekappt():
|
||||||
|
meldung = uebersetze_smb_fehler("X" * 500, mit_credentials=False)
|
||||||
|
assert meldung == "X" * 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_validiere_name_bleibt_streng():
|
||||||
|
assert validiere_name("nas-filme")
|
||||||
|
assert not validiere_name("NAS Filme")
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="de">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Rippy — Automatisches Ripping-System</title>
|
<title>Rippy — Automatisches Ripping-System</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<!-- Rippy-Favicon: Disc im Indigo-Violett-Verlauf des UI-Logos -->
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#6366f1"/>
|
||||||
|
<stop offset="1" stop-color="#9333ea"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect x="2" y="2" width="60" height="60" rx="14" fill="url(#g)"/>
|
||||||
|
<circle cx="32" cy="32" r="19" fill="none" stroke="#ffffff" stroke-width="4"/>
|
||||||
|
<circle cx="32" cy="32" r="6" fill="#ffffff"/>
|
||||||
|
<path d="M32 13 A19 19 0 0 1 51 32" fill="none" stroke="#c7d2fe" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 634 B |
@@ -76,6 +76,10 @@ function App() {
|
|||||||
|
|
||||||
{/* Abmelden-Knopf entfernt (23.07.): es gibt (noch) keinen
|
{/* Abmelden-Knopf entfernt (23.07.): es gibt (noch) keinen
|
||||||
Login-Flow im UI — ein toter Knopf gaukelt nur einen vor. */}
|
Login-Flow im UI — ein toter Knopf gaukelt nur einen vor. */}
|
||||||
|
|
||||||
|
<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>
|
</aside>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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 { useDarkMode } from '../context/ThemeContext'
|
||||||
|
import { useToast } from '../context/ToastContext'
|
||||||
import RipTargetModal from './RipTargetModal'
|
import RipTargetModal from './RipTargetModal'
|
||||||
import MetadataKorrektur from './MetadataKorrektur'
|
import MetadataKorrektur from './MetadataKorrektur'
|
||||||
|
|
||||||
@@ -38,21 +39,29 @@ function posterUrl(disc?: DiscInfo): string | null {
|
|||||||
export default function DeviceDiscovery() {
|
export default function DeviceDiscovery() {
|
||||||
const [devices, setDevices] = useState<Device[]>([])
|
const [devices, setDevices] = useState<Device[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||||
const [actionFeedback, setActionFeedback] = useState<string | null>(null)
|
const [actionFeedback, setActionFeedback] = useState<string | null>(null)
|
||||||
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 { theme } = useDarkMode()
|
||||||
|
const { toast } = useToast()
|
||||||
|
|
||||||
const refreshDevices = async () => {
|
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)
|
||||||
try {
|
try {
|
||||||
const response = await api.get('/devices')
|
const response = await api.get('/devices')
|
||||||
setDevices(response.data)
|
setDevices(response.data)
|
||||||
|
if (manuell) toast('success', 'Laufwerke aktualisiert')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Laden der Geräte:', error)
|
console.error('Fehler beim Laden der Geräte:', error)
|
||||||
|
if (manuell) toast('error', 'Laufwerke konnten nicht geladen werden')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
if (manuell) setTimeout(() => setRefreshing(false), 400)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,8 +74,11 @@ export default function DeviceDiscovery() {
|
|||||||
...(targetDir ? { target_dir: targetDir } : {}),
|
...(targetDir ? { target_dir: targetDir } : {}),
|
||||||
})
|
})
|
||||||
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"')
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
setActionFeedback(`✗ ${error?.response?.data?.detail || 'Job konnte nicht angelegt werden'}`)
|
const detail = error?.response?.data?.detail || 'Job konnte nicht angelegt werden'
|
||||||
|
setActionFeedback(`✗ ${detail}`)
|
||||||
|
toast('error', detail)
|
||||||
} finally {
|
} finally {
|
||||||
setActionBusy(false)
|
setActionBusy(false)
|
||||||
}
|
}
|
||||||
@@ -78,9 +90,12 @@ export default function DeviceDiscovery() {
|
|||||||
try {
|
try {
|
||||||
await api.post(`/devices/${device.id}/eject`)
|
await api.post(`/devices/${device.id}/eject`)
|
||||||
setActionFeedback('✓ Disc ausgeworfen')
|
setActionFeedback('✓ Disc ausgeworfen')
|
||||||
|
toast('success', 'Disc ausgeworfen')
|
||||||
refreshDevices()
|
refreshDevices()
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
setActionFeedback(`✗ ${error?.response?.data?.detail || 'Auswurf fehlgeschlagen'}`)
|
const detail = error?.response?.data?.detail || 'Auswurf fehlgeschlagen'
|
||||||
|
setActionFeedback(`✗ ${detail}`)
|
||||||
|
toast('error', detail)
|
||||||
} finally {
|
} finally {
|
||||||
setActionBusy(false)
|
setActionBusy(false)
|
||||||
}
|
}
|
||||||
@@ -135,11 +150,11 @@ export default function DeviceDiscovery() {
|
|||||||
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Erkannte Geräte mit automatischer Typ-Erkennung</p>
|
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Erkannte Geräte mit automatischer Typ-Erkennung</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={refreshDevices}
|
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'}`}
|
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"
|
title="Geräte neu laden"
|
||||||
>
|
>
|
||||||
<RefreshCw size={20} className={loading ? 'animate-spin' : ''} />
|
<RefreshCw size={20} className={loading || refreshing ? 'animate-spin' : ''} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Disc, Cpu, Globe, 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 { useDarkMode } from '../context/ThemeContext'
|
||||||
|
import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer'
|
||||||
|
|
||||||
// Erster Start: Rippy universell einrichten (Commander-Ziel: All-in-one,
|
// Erster Start: Rippy universell einrichten (Commander-Ziel: All-in-one,
|
||||||
// weitergebbar — keine Handarbeit in .env-Dateien nötig).
|
// weitergebbar — keine Handarbeit in .env-Dateien nötig).
|
||||||
@@ -22,6 +23,7 @@ const ENCODER_LABELS: Record<string, string> = {
|
|||||||
export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
|
export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
|
||||||
const [tmdbKey, setTmdbKey] = useState('')
|
const [tmdbKey, setTmdbKey] = useState('')
|
||||||
const [omdbKey, setOmdbKey] = useState('')
|
const [omdbKey, setOmdbKey] = useState('')
|
||||||
|
const [mediaServer, setMediaServer] = useState('jellyfin')
|
||||||
const [transcodeEnabled, setTranscodeEnabled] = useState(true)
|
const [transcodeEnabled, setTranscodeEnabled] = useState(true)
|
||||||
const [preset, setPreset] = useState('H.265 MKV 1080p30')
|
const [preset, setPreset] = useState('H.265 MKV 1080p30')
|
||||||
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
||||||
@@ -44,6 +46,7 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
|
|||||||
...bestehende,
|
...bestehende,
|
||||||
...(tmdbKey ? { tmdbApiKey: tmdbKey } : {}),
|
...(tmdbKey ? { tmdbApiKey: tmdbKey } : {}),
|
||||||
...(omdbKey ? { omdbApiKey: omdbKey } : {}),
|
...(omdbKey ? { omdbApiKey: omdbKey } : {}),
|
||||||
|
mediaServer,
|
||||||
transcodeEnabled,
|
transcodeEnabled,
|
||||||
transcodePreset: preset,
|
transcodePreset: preset,
|
||||||
})
|
})
|
||||||
@@ -94,6 +97,41 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Media-Server: worauf soll Rippy die Ablage vorbereiten? */}
|
||||||
|
<section>
|
||||||
|
<h2 className={`flex items-center gap-2 text-lg font-semibold mb-3 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
||||||
|
<Tv size={18} className="text-indigo-500" /> Dein Media-Server
|
||||||
|
</h2>
|
||||||
|
<p className={`text-sm mb-3 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||||
|
{MEDIA_SERVER_OPTIONEN.map(o => (
|
||||||
|
<button
|
||||||
|
key={o.wert}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMediaServer(o.wert)}
|
||||||
|
title={o.hinweis}
|
||||||
|
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-all ${
|
||||||
|
mediaServer === o.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-700 text-slate-400 hover:border-slate-600'
|
||||||
|
: 'bg-slate-50 border-slate-200 text-slate-600 hover:border-slate-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{o.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className={`text-xs mt-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
||||||
|
{MEDIA_SERVER_OPTIONEN.find(o => o.wert === mediaServer)?.hinweis}
|
||||||
|
</p>
|
||||||
|
</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 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { X, Film, Loader2, FolderOpen, AlertCircle } from 'lucide-react'
|
||||||
|
import { api } from '../lib/api'
|
||||||
|
import { useDarkMode } from '../context/ThemeContext'
|
||||||
|
|
||||||
|
// Klick auf den Titel in „Neueste Jobs" (Commander-Wunsch 24.07.): sofort
|
||||||
|
// sehen, WAS da gerippt wurde — Poster, Jahr, Beschreibung — plus die
|
||||||
|
// nüchternen Job-Fakten (Ziel, Ausgabepfad, Fehler).
|
||||||
|
|
||||||
|
interface JobMeta {
|
||||||
|
year?: number
|
||||||
|
overview?: string
|
||||||
|
poster_path?: string
|
||||||
|
genres?: string[]
|
||||||
|
runtime?: number
|
||||||
|
type?: string
|
||||||
|
source?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JobDetail {
|
||||||
|
id: string
|
||||||
|
type: string
|
||||||
|
status: string
|
||||||
|
device: string
|
||||||
|
startTime: string
|
||||||
|
endTime?: string
|
||||||
|
progress: number
|
||||||
|
title?: string
|
||||||
|
error?: string
|
||||||
|
target_dir?: string
|
||||||
|
output_path?: string
|
||||||
|
meta?: JobMeta | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// TMDB liefert relative Poster-Pfade, OMDb/Jikan absolute URLs
|
||||||
|
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}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<string, string> = {
|
||||||
|
pending: 'Wartend',
|
||||||
|
processing: 'Rippen',
|
||||||
|
transcoding: 'Komprimieren',
|
||||||
|
canceling: 'Wird abgebrochen…',
|
||||||
|
completed: 'Fertig',
|
||||||
|
failed: 'Fehler',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function JobDetailModal({ jobId, onClose }: { jobId: string | null, onClose: () => void }) {
|
||||||
|
const [detail, setDetail] = useState<JobDetail | null>(null)
|
||||||
|
const [laedt, setLaedt] = useState(false)
|
||||||
|
const { theme } = useDarkMode()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!jobId) {
|
||||||
|
setDetail(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLaedt(true)
|
||||||
|
api.get(`/jobs/${jobId}/detail`)
|
||||||
|
.then(r => setDetail(r.data))
|
||||||
|
.catch(() => setDetail(null))
|
||||||
|
.finally(() => setLaedt(false))
|
||||||
|
}, [jobId])
|
||||||
|
|
||||||
|
if (!jobId) return null
|
||||||
|
|
||||||
|
const meta = detail?.meta
|
||||||
|
const poster = posterUrl(meta)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className={`w-full max-w-2xl max-h-[85vh] overflow-y-auto rounded-xl shadow-2xl ${theme === 'dark' ? 'bg-slate-800' : 'bg-white'}`}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<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 ? (
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<Loader2 className="animate-spin text-indigo-500" size={28} />
|
||||||
|
</div>
|
||||||
|
) : !detail ? (
|
||||||
|
<p className={`px-6 py-10 text-sm text-center ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
||||||
|
Job nicht gefunden.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="p-6 space-y-5">
|
||||||
|
{/* Film/Serien-Info aus der Disc-Erkennung */}
|
||||||
|
<div className="flex gap-5">
|
||||||
|
{poster ? (
|
||||||
|
<img src={poster} alt={detail.title || ''} className="w-32 rounded-lg object-cover flex-shrink-0 shadow-lg" />
|
||||||
|
) : (
|
||||||
|
<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'}`}>
|
||||||
|
<Film size={32} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className={`text-xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
||||||
|
{detail.title || 'Unbekannt'}
|
||||||
|
{meta?.year ? <span className={`font-normal ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}> ({meta.year})</span> : null}
|
||||||
|
</p>
|
||||||
|
<p className={`text-xs mt-1 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
||||||
|
{meta?.type === 'tv' ? 'Serie' : 'Film'}
|
||||||
|
{meta?.source ? ` · Quelle: ${meta.source}` : ''}
|
||||||
|
{meta?.runtime ? ` · ${meta.runtime} min` : ''}
|
||||||
|
</p>
|
||||||
|
{meta?.genres && meta.genres.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||||
|
{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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{meta?.overview ? (
|
||||||
|
<p className={`text-sm mt-3 leading-relaxed ${theme === 'dark' ? 'text-slate-300' : 'text-slate-600'}`}>
|
||||||
|
{meta.overview}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className={`text-sm mt-3 italic ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
||||||
|
Keine Metadaten zu diesem Job gespeichert — die Disc wurde ohne Erkennung gerippt.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nüchterne Job-Fakten */}
|
||||||
|
<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'}`}>
|
||||||
|
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>Status</span>
|
||||||
|
<span className={theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}>
|
||||||
|
{STATUS_LABEL[detail.status] || detail.status}{detail.progress > 0 && detail.progress < 100 ? ` · ${detail.progress} %` : ''}
|
||||||
|
</span>
|
||||||
|
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>Laufwerk</span>
|
||||||
|
<span className={`font-mono text-xs self-center ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}>{detail.device || '—'}</span>
|
||||||
|
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>Gestartet</span>
|
||||||
|
<span className={theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}>
|
||||||
|
{detail.startTime ? new Date(detail.startTime).toLocaleString('de-DE') : '—'}
|
||||||
|
</span>
|
||||||
|
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>Beendet</span>
|
||||||
|
<span className={theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}>
|
||||||
|
{detail.endTime ? new Date(detail.endTime).toLocaleString('de-DE') : '—'}
|
||||||
|
</span>
|
||||||
|
{(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={`font-mono text-xs self-center break-all ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}>
|
||||||
|
{detail.output_path || detail.target_dir}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{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'}`}>
|
||||||
|
<AlertCircle size={16} className="flex-shrink-0 mt-0.5" />
|
||||||
|
<span className="break-words min-w-0">{detail.error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Folder, FolderOpen, ArrowUp, CheckCircle } from 'lucide-react'
|
import { Folder, FolderOpen, File, ArrowUp, CheckCircle } from 'lucide-react'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { useDarkMode } from '../context/ThemeContext'
|
import { useDarkMode } from '../context/ThemeContext'
|
||||||
|
|
||||||
@@ -34,6 +34,7 @@ export default function RipTargetModal({ isOpen, onClose, onSave }: RipTargetMod
|
|||||||
const [browsePath, setBrowsePath] = useState<string>('/app/media')
|
const [browsePath, setBrowsePath] = useState<string>('/app/media')
|
||||||
const [browseParent, setBrowseParent] = useState<string | null>(null)
|
const [browseParent, setBrowseParent] = useState<string | null>(null)
|
||||||
const [browseDirs, setBrowseDirs] = useState<BrowseDir[]>([])
|
const [browseDirs, setBrowseDirs] = useState<BrowseDir[]>([])
|
||||||
|
const [browseFiles, setBrowseFiles] = useState<{ name: string, size_mb: number | null }[]>([])
|
||||||
const [customPath, setCustomPath] = useState('')
|
const [customPath, setCustomPath] = useState('')
|
||||||
const { theme } = useDarkMode()
|
const { theme } = useDarkMode()
|
||||||
|
|
||||||
@@ -58,8 +59,10 @@ export default function RipTargetModal({ isOpen, onClose, onSave }: RipTargetMod
|
|||||||
setBrowsePath(r.data.path)
|
setBrowsePath(r.data.path)
|
||||||
setBrowseParent(r.data.parent)
|
setBrowseParent(r.data.parent)
|
||||||
setBrowseDirs(r.data.dirs)
|
setBrowseDirs(r.data.dirs)
|
||||||
|
setBrowseFiles(r.data.files || [])
|
||||||
} catch {
|
} catch {
|
||||||
setBrowseDirs([])
|
setBrowseDirs([])
|
||||||
|
setBrowseFiles([])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,10 +137,11 @@ export default function RipTargetModal({ isOpen, onClose, onSave }: RipTargetMod
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-40 overflow-y-auto">
|
<div className="max-h-40 overflow-y-auto">
|
||||||
{browseDirs.length === 0 ? (
|
{browseDirs.length === 0 && browseFiles.length === 0 ? (
|
||||||
<p className={`text-xs px-3 py-3 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Keine Unterordner</p>
|
<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)}
|
||||||
@@ -146,7 +150,19 @@ export default function RipTargetModal({ isOpen, onClose, onSave }: RipTargetMod
|
|||||||
<Folder size={15} className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'} />
|
<Folder size={15} className={theme === 'dark' ? 'text-slate-500' : '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>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { HardDrive, Plus, Trash2, CheckCircle, AlertCircle, RefreshCw, Folder, ArrowUp, FolderPlus } from 'lucide-react'
|
import { HardDrive, Plus, Trash2, CheckCircle, AlertCircle, RefreshCw, Folder, File, ArrowUp, FolderPlus } from 'lucide-react'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { useDarkMode } from '../context/ThemeContext'
|
import { useDarkMode } from '../context/ThemeContext'
|
||||||
|
import { useToast } from '../context/ToastContext'
|
||||||
|
|
||||||
// Netzwerk-Speicherziele (NAS/PC-Freigaben) direkt aus dem UI einhängen —
|
// Netzwerk-Speicherziele (NAS/PC-Freigaben) direkt aus dem UI einhängen —
|
||||||
// jedes Ziel erscheint danach in der Ziel-Auswahl beim Rippen.
|
// jedes Ziel erscheint danach in der Ziel-Auswahl beim Rippen.
|
||||||
@@ -38,8 +39,10 @@ export default function StorageMounts() {
|
|||||||
const [browsePath, setBrowsePath] = useState('/app/media')
|
const [browsePath, setBrowsePath] = useState('/app/media')
|
||||||
const [browseParent, setBrowseParent] = useState<string | null>(null)
|
const [browseParent, setBrowseParent] = useState<string | null>(null)
|
||||||
const [browseDirs, setBrowseDirs] = useState<{ name: string, path: string }[]>([])
|
const [browseDirs, setBrowseDirs] = useState<{ name: string, path: string }[]>([])
|
||||||
|
const [browseFiles, setBrowseFiles] = useState<{ name: string, size_mb: number | null }[]>([])
|
||||||
const [neuerOrdner, setNeuerOrdner] = useState('')
|
const [neuerOrdner, setNeuerOrdner] = useState('')
|
||||||
const { theme } = useDarkMode()
|
const { theme } = useDarkMode()
|
||||||
|
const { toast } = useToast()
|
||||||
|
|
||||||
const browsen = async (pfad: string) => {
|
const browsen = async (pfad: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -47,8 +50,10 @@ export default function StorageMounts() {
|
|||||||
setBrowsePath(r.data.path)
|
setBrowsePath(r.data.path)
|
||||||
setBrowseParent(r.data.parent)
|
setBrowseParent(r.data.parent)
|
||||||
setBrowseDirs(r.data.dirs)
|
setBrowseDirs(r.data.dirs)
|
||||||
|
setBrowseFiles(r.data.files || [])
|
||||||
} catch {
|
} catch {
|
||||||
setBrowseDirs([])
|
setBrowseDirs([])
|
||||||
|
setBrowseFiles([])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +69,7 @@ export default function StorageMounts() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const laden = async () => {
|
const laden = async (manuell = false) => {
|
||||||
try {
|
try {
|
||||||
const [m, t] = await Promise.all([
|
const [m, t] = await Promise.all([
|
||||||
api.get('/storage-mounts'),
|
api.get('/storage-mounts'),
|
||||||
@@ -72,8 +77,9 @@ export default function StorageMounts() {
|
|||||||
])
|
])
|
||||||
setMounts(m.data)
|
setMounts(m.data)
|
||||||
setTargets(t.data)
|
setTargets(t.data)
|
||||||
|
if (manuell) toast('success', 'Speicherziele aktualisiert')
|
||||||
} catch {
|
} catch {
|
||||||
/* API weg — Anzeige bleibt leer */
|
if (manuell) toast('error', 'Speicherziele konnten nicht geladen werden')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,20 +111,25 @@ export default function StorageMounts() {
|
|||||||
setBusy(true)
|
setBusy(true)
|
||||||
setFeedback(null)
|
setFeedback(null)
|
||||||
const quelle = typ === 'cifs' && host && selectedShare ? `//${host}/${selectedShare}` : source
|
const quelle = typ === 'cifs' && host && selectedShare ? `//${host}/${selectedShare}` : source
|
||||||
|
const zielName = name
|
||||||
try {
|
try {
|
||||||
const r = await api.post('/storage-mounts', {
|
const r = await api.post('/storage-mounts', {
|
||||||
name, type: typ, source: quelle,
|
name, type: typ, source: quelle,
|
||||||
...(username ? { username, password } : {}),
|
...(username ? { username, password } : {}),
|
||||||
})
|
})
|
||||||
setFeedback(
|
if (r.data.writable) {
|
||||||
r.data.writable
|
setFeedback(`✓ „${zielName}" eingehängt — Schreibtest OK, ab sofort als Rip-Ziel wählbar`)
|
||||||
? `✓ „${name}" eingehängt — Schreibtest OK, ab sofort als Rip-Ziel wählbar`
|
toast('success', `„${zielName}" eingehängt — Schreibtest OK`)
|
||||||
: `⚠️ „${name}" eingehängt, aber NUR LESBAR — Freigabe-Rechte prüfen!`
|
} else {
|
||||||
)
|
setFeedback(`⚠️ „${zielName}" eingehängt, aber NUR LESBAR — Freigabe-Rechte prüfen!`)
|
||||||
|
toast('error', `„${zielName}" ist nur lesbar — Freigabe-Rechte prüfen`)
|
||||||
|
}
|
||||||
setName(''); setSource(''); setHost(''); setShares([]); setSelectedShare(''); setUsername(''); setPassword('')
|
setName(''); setSource(''); setHost(''); setShares([]); setSelectedShare(''); setUsername(''); setPassword('')
|
||||||
laden()
|
laden()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setFeedback(`✗ ${e?.response?.data?.detail || 'Einhängen fehlgeschlagen'}`)
|
const detail = e?.response?.data?.detail || 'Einhängen fehlgeschlagen'
|
||||||
|
setFeedback(`✗ ${detail}`)
|
||||||
|
toast('error', `„${zielName}": ${detail}`)
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
}
|
}
|
||||||
@@ -128,9 +139,12 @@ export default function StorageMounts() {
|
|||||||
setBusy(true)
|
setBusy(true)
|
||||||
try {
|
try {
|
||||||
await api.delete(`/storage-mounts/${mountName}`)
|
await api.delete(`/storage-mounts/${mountName}`)
|
||||||
|
toast('success', `„${mountName}" ausgehängt und entfernt`)
|
||||||
laden()
|
laden()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setFeedback(`✗ ${e?.response?.data?.detail || 'Entfernen fehlgeschlagen'}`)
|
const detail = e?.response?.data?.detail || 'Entfernen fehlgeschlagen'
|
||||||
|
setFeedback(`✗ ${detail}`)
|
||||||
|
toast('error', detail)
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
}
|
}
|
||||||
@@ -144,7 +158,7 @@ export default function StorageMounts() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h3 className={`font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Verfügbare Ziele</h3>
|
<h3 className={`font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Verfügbare Ziele</h3>
|
||||||
<button onClick={laden} className={`p-1.5 rounded ${theme === 'dark' ? 'hover:bg-slate-700 text-slate-400' : 'hover:bg-slate-100 text-slate-500'}`}>
|
<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'}`}>
|
||||||
<RefreshCw size={16} />
|
<RefreshCw size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -202,10 +216,11 @@ export default function StorageMounts() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-36 overflow-y-auto">
|
<div className="max-h-36 overflow-y-auto">
|
||||||
{browseDirs.length === 0 ? (
|
{browseDirs.length === 0 && browseFiles.length === 0 ? (
|
||||||
<p className={`text-xs px-3 py-3 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Keine Unterordner</p>
|
<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={() => browsen(d.path)}
|
onClick={() => browsen(d.path)}
|
||||||
@@ -214,7 +229,24 @@ export default function StorageMounts() {
|
|||||||
<Folder size={15} className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'} />
|
<Folder size={15} className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'} />
|
||||||
{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 => (
|
||||||
|
<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>
|
||||||
@@ -245,13 +277,29 @@ export default function StorageMounts() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div className="md:col-span-2">
|
||||||
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
||||||
Rechner (dein PC oder NAS — Name oder IP)
|
Rechner (dein PC oder NAS — Name oder IP)
|
||||||
</label>
|
</label>
|
||||||
<input value={host} onChange={e => setHost(e.target.value)} className={feld} placeholder="192.168.178.20 oder TOBIS-PC" />
|
<input value={host} onChange={e => setHost(e.target.value)} className={feld} placeholder="192.168.178.20 oder TOBIS-PC" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-end">
|
{/* Zugangsdaten VOR der Freigaben-Abfrage (Befund 24.07.):
|
||||||
|
Windows/NAS lehnen Gast-Abfragen fast immer mit
|
||||||
|
ACCESS_DENIED ab — ohne Benutzer/Passwort scheiterte der
|
||||||
|
Knopf darunter scheinbar grundlos. */}
|
||||||
|
<div>
|
||||||
|
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Benutzer</label>
|
||||||
|
<input value={username} onChange={e => setUsername(e.target.value)} className={feld} autoComplete="off" placeholder="Freigabe-Benutzer" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Passwort</label>
|
||||||
|
<input type="password" value={password} onChange={e => setPassword(e.target.value)} className={feld} autoComplete="new-password" />
|
||||||
|
</div>
|
||||||
|
<p className={`md:col-span-2 text-xs -mt-1 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
||||||
|
Windows-PCs und die meisten NAS verlangen Benutzer + Passwort — ohne Angaben
|
||||||
|
versucht Rippy einen Gast-Zugriff, der meist mit „Zugriff verweigert" endet.
|
||||||
|
</p>
|
||||||
|
<div className="md:col-span-2">
|
||||||
<button
|
<button
|
||||||
onClick={freigabenAuflisten}
|
onClick={freigabenAuflisten}
|
||||||
disabled={!host || sharesBusy}
|
disabled={!host || sharesBusy}
|
||||||
@@ -268,14 +316,6 @@ export default function StorageMounts() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div>
|
|
||||||
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Benutzer (leer = Gast)</label>
|
|
||||||
<input value={username} onChange={e => setUsername(e.target.value)} className={feld} autoComplete="off" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Passwort</label>
|
|
||||||
<input type="password" value={password} onChange={e => setPassword(e.target.value)} className={feld} autoComplete="new-password" />
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'
|
|||||||
import { Server, RefreshCw, Copy, CheckCircle } from 'lucide-react'
|
import { Server, RefreshCw, Copy, CheckCircle } from 'lucide-react'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { useDarkMode } from '../context/ThemeContext'
|
import { useDarkMode } from '../context/ThemeContext'
|
||||||
|
import { useToast } from '../context/ToastContext'
|
||||||
|
|
||||||
// Worker-Verwaltung (Commander-Anforderung 23.07.): Welche Maschinen encoden,
|
// Worker-Verwaltung (Commander-Anforderung 23.07.): Welche Maschinen encoden,
|
||||||
// sind sie erreichbar, und wie bindet man eine neue an? Ein Worker "installiert
|
// sind sie erreichbar, und wie bindet man eine neue an? Ein Worker "installiert
|
||||||
@@ -34,9 +35,15 @@ 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 { theme } = useDarkMode()
|
const { theme } = useDarkMode()
|
||||||
|
const { toast } = useToast()
|
||||||
|
|
||||||
const laden = () => {
|
const laden = (manuell = false) => {
|
||||||
api.get('/capabilities').then(r => setWorkers(r.data.workers || [])).catch(() => {})
|
api.get('/capabilities')
|
||||||
|
.then(r => {
|
||||||
|
setWorkers(r.data.workers || [])
|
||||||
|
if (manuell) toast('success', 'Worker-Liste aktualisiert')
|
||||||
|
})
|
||||||
|
.catch(() => { if (manuell) toast('error', 'Worker-Liste konnte nicht geladen werden') })
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -64,7 +71,7 @@ export default function WorkerVerwaltung() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h3 className={`font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Verbundene Worker</h3>
|
<h3 className={`font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Verbundene Worker</h3>
|
||||||
<button onClick={laden} className={`p-1.5 rounded ${theme === 'dark' ? 'hover:bg-slate-700 text-slate-400' : 'hover:bg-slate-100 text-slate-500'}`}>
|
<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'}`}>
|
||||||
<RefreshCw size={16} />
|
<RefreshCw size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { createContext, useCallback, useContext, useRef, useState, ReactNode } from 'react'
|
||||||
|
import { CheckCircle, AlertCircle, Info } from 'lucide-react'
|
||||||
|
|
||||||
|
// Globales Feedback-System (Commander-Wunsch 24.07.): JEDE Aktualisierung im
|
||||||
|
// UI quittiert sichtbar — „Erfolgreich aktualisiert" ploppt oben rechts rein,
|
||||||
|
// statt dass man raten muss, ob der Klick etwas getan hat.
|
||||||
|
|
||||||
|
type ToastTyp = 'success' | 'error' | 'info'
|
||||||
|
|
||||||
|
interface Toast {
|
||||||
|
id: number
|
||||||
|
typ: ToastTyp
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToastContext = createContext<{ toast: (typ: ToastTyp, text: string) => void }>({
|
||||||
|
toast: () => {},
|
||||||
|
})
|
||||||
|
|
||||||
|
export function useToast() {
|
||||||
|
return useContext(ToastContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
const STIL: Record<ToastTyp, string> = {
|
||||||
|
success: 'bg-emerald-600 text-white',
|
||||||
|
error: 'bg-rose-600 text-white',
|
||||||
|
info: 'bg-slate-800 text-slate-100 dark:bg-slate-700',
|
||||||
|
}
|
||||||
|
|
||||||
|
const ICON: Record<ToastTyp, ReactNode> = {
|
||||||
|
success: <CheckCircle size={18} className="flex-shrink-0" />,
|
||||||
|
error: <AlertCircle size={18} className="flex-shrink-0" />,
|
||||||
|
info: <Info size={18} className="flex-shrink-0" />,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [toasts, setToasts] = useState<Toast[]>([])
|
||||||
|
const naechsteId = useRef(1)
|
||||||
|
|
||||||
|
const toast = useCallback((typ: ToastTyp, text: string) => {
|
||||||
|
const id = naechsteId.current++
|
||||||
|
setToasts(t => [...t.slice(-3), { id, typ, text }])
|
||||||
|
setTimeout(() => setToasts(t => t.filter(x => x.id !== id)), 4000)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={{ toast }}>
|
||||||
|
{children}
|
||||||
|
<div className="fixed top-4 right-4 z-[100] flex flex-col gap-2 w-80 pointer-events-none">
|
||||||
|
{toasts.map(t => (
|
||||||
|
<div
|
||||||
|
key={t.id}
|
||||||
|
className={`toast-rein flex items-center gap-2.5 px-4 py-3 rounded-lg shadow-lg text-sm font-medium ${STIL[t.typ]}`}
|
||||||
|
>
|
||||||
|
{ICON[t.typ]}
|
||||||
|
<span className="min-w-0">{t.text}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ToastContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -51,3 +51,19 @@ code {
|
|||||||
background-color: var(--bg-secondary);
|
background-color: var(--bg-secondary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Toast-Einflug (ToastContext): von rechts reinploppen */
|
||||||
|
@keyframes toast-rein {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(24px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-rein {
|
||||||
|
animation: toast-rein 0.25s ease-out;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// Media-Server-Auswahl (Wizard + Einstellungen → Ripping): Rippy bereitet die
|
||||||
|
// Ablage aufs Zielsystem vor — Ordner „Titel (Jahr)", NFO + Poster, wo der
|
||||||
|
// Server sie nativ liest (Jellyfin/Emby/Kodi; Plex nutzt nur die Benennung).
|
||||||
|
export interface MediaServerOption {
|
||||||
|
wert: string
|
||||||
|
label: string
|
||||||
|
hinweis: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MEDIA_SERVER_OPTIONEN: MediaServerOption[] = [
|
||||||
|
{ wert: 'jellyfin', label: 'Jellyfin', hinweis: 'Ordner „Titel (Jahr)" + movie.nfo + poster.jpg' },
|
||||||
|
{ wert: 'emby', label: 'Emby', hinweis: 'Ordner „Titel (Jahr)" + movie.nfo + poster.jpg' },
|
||||||
|
{ wert: 'kodi', label: 'Kodi', hinweis: 'Ordner „Titel (Jahr)" + movie.nfo + poster.jpg' },
|
||||||
|
{ wert: 'plex', label: 'Plex', hinweis: 'Ordner „Titel (Jahr)" — Plex liest keine NFO' },
|
||||||
|
{ wert: 'none', label: 'Keins', hinweis: 'nur Dateien ablegen, keine Extras' },
|
||||||
|
]
|
||||||
@@ -2,12 +2,15 @@ import React from 'react'
|
|||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
import { ThemeProvider } from './context/ThemeContext'
|
import { ThemeProvider } from './context/ThemeContext'
|
||||||
|
import { ToastProvider } from './context/ToastContext'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
|
<ToastProvider>
|
||||||
<App />
|
<App />
|
||||||
|
</ToastProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Clock, Activity, AlertCircle, CheckCircle, Disc } from 'lucide-react'
|
|||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { useDarkMode } from '../context/ThemeContext'
|
import { useDarkMode } from '../context/ThemeContext'
|
||||||
import DeviceDiscovery from '../components/DeviceDiscovery'
|
import DeviceDiscovery from '../components/DeviceDiscovery'
|
||||||
|
import JobDetailModal from '../components/JobDetailModal'
|
||||||
import LiveLogSection from '../components/LiveLogSection'
|
import LiveLogSection from '../components/LiveLogSection'
|
||||||
|
|
||||||
interface Job {
|
interface Job {
|
||||||
@@ -139,6 +140,7 @@ export default function Dashboard() {
|
|||||||
const [jobs, setJobs] = useState<Job[]>([])
|
const [jobs, setJobs] = useState<Job[]>([])
|
||||||
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [detailJobId, setDetailJobId] = useState<string | null>(null)
|
||||||
const { theme } = useDarkMode()
|
const { theme } = useDarkMode()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -281,9 +283,13 @@ export default function Dashboard() {
|
|||||||
<TypeBadge type={job.type} />
|
<TypeBadge type={job.type} />
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
<button
|
||||||
|
onClick={() => setDetailJobId(job.id)}
|
||||||
|
title="Details zu diesem Titel anzeigen"
|
||||||
|
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 || 'Unbekannt'}
|
||||||
</div>
|
</button>
|
||||||
</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} />
|
||||||
@@ -327,6 +333,9 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
{/* Live-Log ganz unten — Detail-Ansicht, nicht Haupt-Info */}
|
{/* Live-Log ganz unten — Detail-Ansicht, nicht Haupt-Info */}
|
||||||
<LiveLogSection />
|
<LiveLogSection />
|
||||||
|
|
||||||
|
{/* Klick auf einen Job-Titel: Film-/Serien-Info + Job-Fakten */}
|
||||||
|
<JobDetailModal jobId={detailJobId} onClose={() => setDetailJobId(null)} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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 { useDarkMode } from '../context/ThemeContext'
|
||||||
|
import { useToast } from '../context/ToastContext'
|
||||||
|
|
||||||
interface LogEntry {
|
interface LogEntry {
|
||||||
id: string
|
id: string
|
||||||
@@ -16,18 +17,24 @@ export default function LogsPage() {
|
|||||||
const [logs, setLogs] = useState<LogEntry[]>([])
|
const [logs, setLogs] = useState<LogEntry[]>([])
|
||||||
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 { theme } = useDarkMode()
|
const { theme } = useDarkMode()
|
||||||
|
const { toast } = useToast()
|
||||||
|
|
||||||
const refreshLogs = async () => {
|
const refreshLogs = async (manuell = false) => {
|
||||||
// Echte Ereignisse aus der Datenbank — die Mock-Einträge („The Matrix
|
// Echte Ereignisse aus der Datenbank — die Mock-Einträge („The Matrix
|
||||||
// gerippt") sind raus. Was hier steht, ist passiert; sonst ist es leer.
|
// gerippt") sind raus. Was hier steht, ist passiert; sonst ist es leer.
|
||||||
|
if (manuell) setRefreshing(true)
|
||||||
try {
|
try {
|
||||||
const response = await api.get('/logs', { params: { limit: 200 } })
|
const response = await api.get('/logs', { params: { limit: 200 } })
|
||||||
setLogs(response.data)
|
setLogs(response.data)
|
||||||
|
if (manuell) toast('success', 'Logs aktualisiert')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Laden der Logs:', error)
|
console.error('Fehler beim Laden der Logs:', error)
|
||||||
|
if (manuell) toast('error', 'Logs konnten nicht geladen werden')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
if (manuell) setRefreshing(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,12 +164,32 @@ export default function LogsPage() {
|
|||||||
>
|
>
|
||||||
Erfolg
|
Erfolg
|
||||||
</button>
|
</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" />
|
<div className="flex-1" />
|
||||||
<button
|
<button
|
||||||
onClick={refreshLogs}
|
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'}`}
|
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 ? 'animate-spin' : ''} />
|
<RefreshCw size={14} className={loading || refreshing ? 'animate-spin' : ''} />
|
||||||
Neu laden
|
Neu laden
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Save, Disc, Cpu, Database, Globe, AlertCircle, CheckCircle, HardDrive } 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 { useDarkMode } from '../context/ThemeContext'
|
||||||
|
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'
|
||||||
|
|
||||||
@@ -20,6 +21,9 @@ interface SettingsState {
|
|||||||
transcodeEnabled: boolean
|
transcodeEnabled: boolean
|
||||||
transcodePreset: string
|
transcodePreset: string
|
||||||
keepOriginal: boolean
|
keepOriginal: boolean
|
||||||
|
mediaServer: string
|
||||||
|
workDir: string
|
||||||
|
makemkvAppKey: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultSettings: SettingsState = {
|
const defaultSettings: SettingsState = {
|
||||||
@@ -37,15 +41,29 @@ const defaultSettings: SettingsState = {
|
|||||||
transcodeEnabled: true,
|
transcodeEnabled: true,
|
||||||
transcodePreset: 'H.265 MKV 1080p30',
|
transcodePreset: 'H.265 MKV 1080p30',
|
||||||
keepOriginal: false,
|
keepOriginal: false,
|
||||||
|
mediaServer: 'none',
|
||||||
|
workDir: '',
|
||||||
|
makemkvAppKey: '',
|
||||||
}
|
}
|
||||||
|
|
||||||
type SettingsTab = 'ripping' | 'verarbeitung' | 'worker' | 'speicherziele' | 'apis' | 'benachrichtigungen'
|
type SettingsTab = 'ripping' | 'verarbeitung' | 'worker' | 'speicherziele' | 'apis' | 'benachrichtigungen' | 'system'
|
||||||
|
|
||||||
interface WorkerInfo {
|
interface WorkerInfo {
|
||||||
name: string
|
name: string
|
||||||
encoders: string[]
|
encoders: string[]
|
||||||
|
info?: { makemkv?: string, handbrake?: string, makemkv_key?: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SystemInfo {
|
||||||
|
api_version: string
|
||||||
|
plaetze: { name: string, frei_gb: number, gesamt_gb: number }[]
|
||||||
|
workers: WorkerInfo[]
|
||||||
|
makemkv_key_ui: boolean
|
||||||
|
webhook_gesetzt: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer'
|
||||||
|
|
||||||
const ENCODER_LABELS: Record<string, string> = {
|
const ENCODER_LABELS: Record<string, string> = {
|
||||||
'cpu-x264': 'CPU · H.264',
|
'cpu-x264': 'CPU · H.264',
|
||||||
'cpu-x265': 'CPU · H.265',
|
'cpu-x265': 'CPU · H.265',
|
||||||
@@ -57,17 +75,36 @@ 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')
|
||||||
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
||||||
|
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [saved, setSaved] = useState(false)
|
const [saved, setSaved] = useState(false)
|
||||||
|
const [testBusy, setTestBusy] = useState(false)
|
||||||
const { theme } = useDarkMode()
|
const { theme } = useDarkMode()
|
||||||
|
const { toast } = useToast()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.get('/capabilities')
|
api.get('/capabilities')
|
||||||
.then(r => setWorkers(r.data.workers || []))
|
.then(r => setWorkers(r.data.workers || []))
|
||||||
.catch(() => setWorkers([]))
|
.catch(() => setWorkers([]))
|
||||||
|
api.get('/system/info')
|
||||||
|
.then(r => setSystemInfo(r.data))
|
||||||
|
.catch(() => setSystemInfo(null))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const webhookTesten = async () => {
|
||||||
|
if (!settings.notificationWebhook) return
|
||||||
|
setTestBusy(true)
|
||||||
|
try {
|
||||||
|
await api.post('/notifications/test', { url: settings.notificationWebhook })
|
||||||
|
toast('success', 'Test-Nachricht gesendet — prüfe deinen Kanal!')
|
||||||
|
} catch (e: any) {
|
||||||
|
toast('error', e?.response?.data?.detail || 'Test fehlgeschlagen')
|
||||||
|
} finally {
|
||||||
|
setTestBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadSettings = async () => {
|
const loadSettings = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -93,7 +130,10 @@ export default function SettingsPage() {
|
|||||||
try {
|
try {
|
||||||
await api.post('/settings', settings)
|
await api.post('/settings', settings)
|
||||||
setSaved(true)
|
setSaved(true)
|
||||||
|
toast('success', 'Einstellungen gespeichert')
|
||||||
setTimeout(() => setSaved(false), 3000)
|
setTimeout(() => setSaved(false), 3000)
|
||||||
|
} catch {
|
||||||
|
toast('error', 'Speichern fehlgeschlagen — läuft die API?')
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
@@ -130,6 +170,7 @@ export default function SettingsPage() {
|
|||||||
<TabButton icon={HardDrive} label="Speicherziele" isActive={activeTab === 'speicherziele'} onClick={() => setActiveTab('speicherziele')} darkMode={theme === 'dark'} />
|
<TabButton icon={HardDrive} label="Speicherziele" isActive={activeTab === 'speicherziele'} onClick={() => setActiveTab('speicherziele')} darkMode={theme === 'dark'} />
|
||||||
<TabButton icon={Globe} label="APIs" isActive={activeTab === 'apis'} onClick={() => setActiveTab('apis')} darkMode={theme === 'dark'} />
|
<TabButton icon={Globe} label="APIs" isActive={activeTab === 'apis'} onClick={() => setActiveTab('apis')} darkMode={theme === 'dark'} />
|
||||||
<TabButton icon={AlertCircle} label="Benachrichtigungen" isActive={activeTab === 'benachrichtigungen'} onClick={() => setActiveTab('benachrichtigungen')} darkMode={theme === 'dark'} />
|
<TabButton icon={AlertCircle} label="Benachrichtigungen" isActive={activeTab === 'benachrichtigungen'} onClick={() => setActiveTab('benachrichtigungen')} darkMode={theme === 'dark'} />
|
||||||
|
<TabButton icon={Wrench} label="System" isActive={activeTab === 'system'} onClick={() => setActiveTab('system')} darkMode={theme === 'dark'} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
@@ -192,6 +233,41 @@ export default function SettingsPage() {
|
|||||||
darkMode={theme === 'dark'}
|
darkMode={theme === 'dark'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Media-Server: Rippy bereitet die Ablage aufs Zielsystem vor */}
|
||||||
|
<div className={`p-4 rounded-lg ${theme === 'dark' ? 'bg-slate-700/50' : 'bg-slate-50'}`}>
|
||||||
|
<h3 className={`font-medium mb-1 flex items-center gap-2 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
||||||
|
<Tv size={18} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} />
|
||||||
|
Media-Server (Jellyfin & Co.)
|
||||||
|
</h3>
|
||||||
|
<p className={`text-sm mb-3 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
||||||
|
Rippy benennt fertige Rips als „Titel (Jahr)" und legt — je nach System —
|
||||||
|
movie.nfo + poster.jpg dazu. Dein Server erkennt den Film dann ohne Nacharbeit.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||||
|
{MEDIA_SERVER_OPTIONEN.map(o => (
|
||||||
|
<button
|
||||||
|
key={o.wert}
|
||||||
|
onClick={() => handleChange('mediaServer', o.wert)}
|
||||||
|
title={o.hinweis}
|
||||||
|
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-all ${
|
||||||
|
settings.mediaServer === o.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'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{o.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className={`text-xs mt-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
||||||
|
{MEDIA_SERVER_OPTIONEN.find(o => o.wert === settings.mediaServer)?.hinweis}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>}
|
</section>}
|
||||||
|
|
||||||
@@ -257,6 +333,29 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<Toggle checked={settings.keepOriginal} onChange={(v) => handleChange('keepOriginal', v)} darkMode={theme === 'dark'} />
|
<Toggle checked={settings.keepOriginal} onChange={(v) => handleChange('keepOriginal', v)} darkMode={theme === 'dark'} />
|
||||||
</div>
|
</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}
|
||||||
|
onChange={(e) => handleChange('workDir', e.target.value)}
|
||||||
|
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'}`}>
|
||||||
|
Muss unter /app/media liegen (z. B. /app/media/nas-arbeit — Freigabe vorher unter
|
||||||
|
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 ? (
|
||||||
|
<> Aktuell frei: {systemInfo.plaetze.map(p => `${p.name}: ${p.frei_gb} GB`).join(' · ')}</>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>}
|
</section>}
|
||||||
|
|
||||||
@@ -358,26 +457,134 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>}
|
</section>}
|
||||||
|
|
||||||
{/* Webhook */}
|
{/* Webhook — mit Anleitung und Sofort-Test (Commander-Wunsch 24.07.) */}
|
||||||
{activeTab === 'benachrichtigungen' && <section>
|
{activeTab === 'benachrichtigungen' && <section>
|
||||||
<h2 className={`text-lg font-semibold mb-4 flex items-center gap-2 transition-colors duration-300 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
<h2 className={`text-lg font-semibold mb-2 flex items-center gap-2 transition-colors duration-300 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
||||||
<AlertCircle size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} />
|
<AlertCircle size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} />
|
||||||
Webhook-Benachrichtigungen
|
Webhook-Benachrichtigungen
|
||||||
</h2>
|
</h2>
|
||||||
|
<p className={`text-sm mb-4 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
||||||
|
Rippy meldet sich, sobald ein Job <strong>fertig</strong> ist, <strong>fehlschlägt</strong> oder
|
||||||
|
<strong> abgebrochen</strong> wird — direkt in deinen Chat. Der Dienst wird an der URL
|
||||||
|
automatisch erkannt, du musst nichts weiter einstellen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Schritt-für-Schritt: so kommst du an eine Webhook-URL */}
|
||||||
|
<div className={`rounded-lg p-4 mb-4 space-y-3 text-sm ${theme === 'dark' ? 'bg-slate-700/50' : 'bg-slate-50'}`}>
|
||||||
|
<div>
|
||||||
|
<p className={`font-medium ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}>💬 Discord (einfachster Weg)</p>
|
||||||
|
<p className={theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}>
|
||||||
|
Server → Kanal-Zahnrad → Integrationen → Webhooks → „Neuer Webhook" →
|
||||||
|
„Webhook-URL kopieren" und unten einfügen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className={`font-medium ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}>📱 ntfy (Push aufs Handy, ohne Account)</p>
|
||||||
|
<p className={theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}>
|
||||||
|
ntfy-App installieren, ein Thema abonnieren (z. B. „rippy-tobi-4711") und unten
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2 transition-colors duration-300">
|
<div className="space-y-2 transition-colors duration-300">
|
||||||
<label className={`block text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
<label className={`block text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
||||||
Webhook URL
|
Webhook URL
|
||||||
</label>
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={settings.notificationWebhook}
|
value={settings.notificationWebhook}
|
||||||
onChange={(e) => handleChange('notificationWebhook', e.target.value)}
|
onChange={(e) => handleChange('notificationWebhook', 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`}
|
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/..."
|
placeholder="https://discord.com/api/webhooks/… oder https://ntfy.sh/mein-thema"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={webhookTesten}
|
||||||
|
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} />
|
||||||
|
{testBusy ? 'Sende…' : 'Test senden'}
|
||||||
|
</button>
|
||||||
|
</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>
|
||||||
|
</section>}
|
||||||
|
|
||||||
|
{/* System: Kern-Werkzeuge, Versionen, MakeMKV-Key, Speicherplatz */}
|
||||||
|
{activeTab === 'system' && <section>
|
||||||
|
<h2 className={`text-lg font-semibold mb-4 flex items-center gap-2 transition-colors duration-300 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
||||||
|
<Wrench size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} />
|
||||||
|
System & Kern-Werkzeuge
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Werkzeug-Versionen je Worker — ehrlich gemeldet, nicht behauptet */}
|
||||||
|
<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 ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}>Installierte Werkzeuge</p>
|
||||||
|
{!systemInfo || systemInfo.workers.length === 0 ? (
|
||||||
|
<p className={`text-sm ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Noch kein Worker gemeldet.</p>
|
||||||
|
) : (
|
||||||
|
systemInfo.workers.map(w => (
|
||||||
|
<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>
|
||||||
|
{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'}`}>
|
||||||
|
MakeMKV {w.info.makemkv}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{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'}`}>
|
||||||
|
HandBrake {w.info.handbrake}
|
||||||
|
</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>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
{systemInfo?.plaetze && systemInfo.plaetze.length > 0 && (
|
||||||
|
<p className={`text-xs mt-3 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
||||||
|
Speicherplatz: {systemInfo.plaetze.map(p => `${p.name}: ${p.frei_gb} von ${p.gesamt_gb} GB frei`).join(' · ')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* MakeMKV-Beta-Key: der Monats-Key, OHNE Rebuild aktualisierbar */}
|
||||||
|
<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"
|
||||||
|
value={settings.makemkvAppKey}
|
||||||
|
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")'}
|
||||||
/>
|
/>
|
||||||
<p className={`text-xs ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
<p className={`text-xs ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
||||||
Optional: URL für Benachrichtigungen bei Job-Ende (Discord, Slack, etc.)
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>}
|
</section>}
|
||||||
|
|||||||
@@ -76,6 +76,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
COPY --from=makemkv-build /usr/local /usr/local
|
COPY --from=makemkv-build /usr/local /usr/local
|
||||||
RUN ldconfig
|
RUN ldconfig
|
||||||
|
|
||||||
|
# Version fürs UI sichtbar machen (Einstellungen → System): makemkvcon hat
|
||||||
|
# keinen --version-Schalter, also kommt die Wahrheit aus dem Build selbst.
|
||||||
|
ARG MAKEMKV_VERSION=1.17.7
|
||||||
|
ENV MAKEMKV_VERSION=${MAKEMKV_VERSION}
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY docker/worker/requirements.txt .
|
COPY docker/worker/requirements.txt .
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ Remote-GPU-Worker meldet sich hier genauso wie der eingebaute CPU-Worker.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
|
||||||
def erkenne_encoder() -> list:
|
def erkenne_encoder() -> list:
|
||||||
@@ -22,3 +24,39 @@ def erkenne_encoder() -> list:
|
|||||||
gefunden.append("nvenc")
|
gefunden.append("nvenc")
|
||||||
|
|
||||||
return gefunden
|
return gefunden
|
||||||
|
|
||||||
|
|
||||||
|
def werkzeug_versionen() -> dict:
|
||||||
|
"""Kern-Werkzeuge dieses Workers — fürs UI (Einstellungen → System).
|
||||||
|
|
||||||
|
MakeMKV-Version kommt aus dem Build (ENV MAKEMKV_VERSION im Dockerfile) —
|
||||||
|
makemkvcon hat keinen dokumentierten --version-Schalter (usage.txt).
|
||||||
|
HandBrakeCLI --version ist dokumentiert (handbrake.fr/docs, CLI Options)
|
||||||
|
und gibt z.B. "HandBrake 1.6.1" aus.
|
||||||
|
"""
|
||||||
|
info = {}
|
||||||
|
if shutil.which("makemkvcon"):
|
||||||
|
info["makemkv"] = os.getenv("MAKEMKV_VERSION") or "installiert"
|
||||||
|
if shutil.which("HandBrakeCLI"):
|
||||||
|
try:
|
||||||
|
aus = subprocess.run(
|
||||||
|
["HandBrakeCLI", "--version"],
|
||||||
|
capture_output=True, text=True, timeout=15,
|
||||||
|
)
|
||||||
|
treffer = re.search(r"HandBrake\s+([\w.]+)", (aus.stdout or "") + (aus.stderr or ""))
|
||||||
|
info["handbrake"] = treffer.group(1) if treffer else "installiert"
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
info["handbrake"] = "installiert"
|
||||||
|
# Woher kommt der MakeMKV-Key? UI-Setting schlägt Env — ehrlich anzeigen.
|
||||||
|
try:
|
||||||
|
import db
|
||||||
|
ui_key = (db.get_settings().get("makemkvAppKey") or "").strip()
|
||||||
|
except Exception:
|
||||||
|
ui_key = ""
|
||||||
|
if ui_key:
|
||||||
|
info["makemkv_key"] = "ui"
|
||||||
|
elif os.getenv("MAKEMKV_APP_KEY"):
|
||||||
|
info["makemkv_key"] = "env"
|
||||||
|
else:
|
||||||
|
info["makemkv_key"] = "keiner"
|
||||||
|
return info
|
||||||
|
|||||||
@@ -43,7 +43,11 @@ def melde_faehigkeiten(**kwargs):
|
|||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
db.init_db()
|
db.init_db()
|
||||||
db.save_worker(socket.gethostname(), caps.erkenne_encoder())
|
db.save_worker(
|
||||||
|
socket.gethostname(),
|
||||||
|
caps.erkenne_encoder(),
|
||||||
|
caps.werkzeug_versionen(),
|
||||||
|
)
|
||||||
except Exception as e: # DB weg → weiterversuchen, nicht sterben
|
except Exception as e: # DB weg → weiterversuchen, nicht sterben
|
||||||
print(f"Fähigkeiten-Meldung fehlgeschlagen: {e}")
|
print(f"Fähigkeiten-Meldung fehlgeschlagen: {e}")
|
||||||
time.sleep(60)
|
time.sleep(60)
|
||||||
|
|||||||
+37
-5
@@ -36,7 +36,9 @@ jobs = Table(
|
|||||||
Column("status", String(16), nullable=False, server_default="pending"),
|
Column("status", String(16), nullable=False, server_default="pending"),
|
||||||
Column("progress", Integer, nullable=False, server_default="0"),
|
Column("progress", Integer, nullable=False, server_default="0"),
|
||||||
Column("output_path", Text),
|
Column("output_path", Text),
|
||||||
|
Column("target_dir", String(255)),
|
||||||
Column("error", Text),
|
Column("error", Text),
|
||||||
|
Column("meta", Text), # Disc-Metadaten (JSON) — Quelle für Ordnernamen + NFO
|
||||||
Column("created_at", DateTime(timezone=True)),
|
Column("created_at", DateTime(timezone=True)),
|
||||||
Column("finished_at", DateTime(timezone=True)),
|
Column("finished_at", DateTime(timezone=True)),
|
||||||
)
|
)
|
||||||
@@ -57,8 +59,22 @@ def utcnow() -> datetime:
|
|||||||
|
|
||||||
|
|
||||||
def init_db() -> None:
|
def init_db() -> None:
|
||||||
"""Legt fehlende Tabellen an (idempotent)."""
|
"""Legt fehlende Tabellen an (idempotent) und zieht Mini-Migrationen nach.
|
||||||
|
|
||||||
|
Die ALTERs stehen identisch in docker/api/db.py — wer zuerst startet,
|
||||||
|
migriert; der andere findet die Spalten dann bereits vor.
|
||||||
|
"""
|
||||||
metadata.create_all(engine)
|
metadata.create_all(engine)
|
||||||
|
with engine.begin() as conn:
|
||||||
|
conn.exec_driver_sql(
|
||||||
|
"ALTER TABLE jobs ADD COLUMN IF NOT EXISTS target_dir VARCHAR(255)"
|
||||||
|
)
|
||||||
|
conn.exec_driver_sql(
|
||||||
|
"ALTER TABLE jobs ADD COLUMN IF NOT EXISTS meta TEXT"
|
||||||
|
)
|
||||||
|
conn.exec_driver_sql(
|
||||||
|
"ALTER TABLE workers ADD COLUMN IF NOT EXISTS info TEXT"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
settings_table = Table(
|
settings_table = Table(
|
||||||
@@ -73,15 +89,17 @@ workers = Table(
|
|||||||
metadata,
|
metadata,
|
||||||
Column("name", String(128), primary_key=True),
|
Column("name", String(128), primary_key=True),
|
||||||
Column("encoders", Text),
|
Column("encoders", Text),
|
||||||
|
Column("info", Text), # Werkzeug-Versionen (JSON: makemkv/handbrake/key-Quelle)
|
||||||
Column("last_seen", DateTime(timezone=True)),
|
Column("last_seen", DateTime(timezone=True)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def save_worker(name: str, encoder_liste: list) -> None:
|
def save_worker(name: str, encoder_liste: list, info: dict = None) -> None:
|
||||||
"""Worker meldet Name + Encoder-Fähigkeiten (Upsert)."""
|
"""Worker meldet Name + Encoder-Fähigkeiten + Werkzeug-Versionen (Upsert)."""
|
||||||
import json
|
import json
|
||||||
|
|
||||||
payload = json.dumps(encoder_liste)
|
payload = json.dumps(encoder_liste)
|
||||||
|
info_payload = json.dumps(info or {})
|
||||||
with engine.begin() as conn:
|
with engine.begin() as conn:
|
||||||
vorhanden = conn.execute(
|
vorhanden = conn.execute(
|
||||||
workers.select().where(workers.c.name == name)
|
workers.select().where(workers.c.name == name)
|
||||||
@@ -89,12 +107,14 @@ def save_worker(name: str, encoder_liste: list) -> None:
|
|||||||
if vorhanden:
|
if vorhanden:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
workers.update().where(workers.c.name == name).values(
|
workers.update().where(workers.c.name == name).values(
|
||||||
encoders=payload, last_seen=utcnow()
|
encoders=payload, info=info_payload, last_seen=utcnow()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
workers.insert().values(name=name, encoders=payload, last_seen=utcnow())
|
workers.insert().values(
|
||||||
|
name=name, encoders=payload, info=info_payload, last_seen=utcnow()
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -116,6 +136,18 @@ def get_settings(key: str = "ui") -> dict:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_job(job_id: str) -> dict:
|
||||||
|
"""Ganze Job-Zeile — der Worker braucht Titel + Metadaten für die
|
||||||
|
Ordner-Benennung und die Media-Server-Aufbereitung (NFO/Poster)."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
with engine.connect() as conn:
|
||||||
|
zeile = conn.execute(
|
||||||
|
select(jobs).where(jobs.c.id == job_id)
|
||||||
|
).mappings().first()
|
||||||
|
return dict(zeile) if zeile else None
|
||||||
|
|
||||||
|
|
||||||
def get_job_status(job_id: str) -> str:
|
def get_job_status(job_id: str) -> str:
|
||||||
"""Nur der Status — der Worker prüft damit kooperative Abbruch-Anfragen."""
|
"""Nur der Status — der Worker prüft damit kooperative Abbruch-Anfragen."""
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""Media-Server-Aufbereitung: sprechende Ordnernamen + NFO + Poster.
|
||||||
|
|
||||||
|
Commander-Wunsch 24.07.: Rippy soll sich auf das Zielsystem (Jellyfin, Plex,
|
||||||
|
Emby, Kodi) vorbereiten. Das Setting `mediaServer` steuert:
|
||||||
|
- ALLE Systeme: Zielordner heißt „<Titel> (Jahr)" statt Job-UUID — daran
|
||||||
|
erkennen Jellyfin & Co. den Film (Namensschema laut jellyfin.org/docs/
|
||||||
|
general/server/media/movies bzw. support.plex.tv „Naming and Organizing").
|
||||||
|
- jellyfin / emby / kodi: zusätzlich movie.nfo bzw. tvshow.nfo (Kodi-Schema,
|
||||||
|
kodi.wiki/view/NFO_files) + poster.jpg — diese Server lesen NFO nativ.
|
||||||
|
- plex: NUR Benennung (Plex liest NFO ohne Zusatz-Agent nicht).
|
||||||
|
- none: Verhalten wie bisher (UUID-Ordner, keine Extras).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from xml.sax.saxutils import escape
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
NFO_SERVER = ("jellyfin", "emby", "kodi")
|
||||||
|
# Zeichen, die in Ordnernamen auf SMB/NTFS/ext4 Ärger machen
|
||||||
|
_VERBOTEN = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
|
||||||
|
|
||||||
|
|
||||||
|
def sicherer_name(titel: str, jahr=None) -> str:
|
||||||
|
"""Pure Funktion (testbar): Titel → Dateisystem-tauglicher Ordnername."""
|
||||||
|
name = _VERBOTEN.sub("", titel or "").strip().rstrip(".")
|
||||||
|
name = re.sub(r"\s+", " ", name)[:150].strip()
|
||||||
|
if not name:
|
||||||
|
return ""
|
||||||
|
if jahr:
|
||||||
|
name = f"{name} ({jahr})"
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def zielordner(basis: str, titel: str, jahr, job_id: str) -> str:
|
||||||
|
"""Zielordner unter `basis`: sprechender Name, UUID nur als Fallback.
|
||||||
|
|
||||||
|
Existiert der Ordner schon (Disc doppelt gerippt), wird die Job-ID
|
||||||
|
angehängt statt fremde Dateien zu überschreiben.
|
||||||
|
"""
|
||||||
|
name = sicherer_name(titel, jahr)
|
||||||
|
if not name:
|
||||||
|
return os.path.join(basis, job_id)
|
||||||
|
pfad = os.path.join(basis, name)
|
||||||
|
if os.path.exists(pfad):
|
||||||
|
pfad = os.path.join(basis, f"{name} [{job_id[:8]}]")
|
||||||
|
return pfad
|
||||||
|
|
||||||
|
|
||||||
|
def baue_nfo(meta: dict, titel: str, jahr=None) -> str:
|
||||||
|
"""Pure Funktion (testbar): minimales movie.nfo/tvshow.nfo im Kodi-Schema."""
|
||||||
|
ist_serie = (meta.get("type") == "tv")
|
||||||
|
wurzel = "tvshow" if ist_serie else "movie"
|
||||||
|
zeilen = [
|
||||||
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
|
||||||
|
f"<{wurzel}>",
|
||||||
|
f" <title>{escape(titel or '')}</title>",
|
||||||
|
]
|
||||||
|
if jahr:
|
||||||
|
zeilen.append(f" <year>{escape(str(jahr))}</year>")
|
||||||
|
if meta.get("overview"):
|
||||||
|
zeilen.append(f" <plot>{escape(meta['overview'])}</plot>")
|
||||||
|
for genre in meta.get("genres") or []:
|
||||||
|
zeilen.append(f" <genre>{escape(str(genre))}</genre>")
|
||||||
|
if meta.get("runtime"):
|
||||||
|
zeilen.append(f" <runtime>{escape(str(meta['runtime']))}</runtime>")
|
||||||
|
quelle = meta.get("source") or "Rippy"
|
||||||
|
zeilen.append(f" <details>Source: {escape(quelle)}</details>")
|
||||||
|
zeilen.append(f"</{wurzel}>")
|
||||||
|
return "\n".join(zeilen) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def aufbereiten(ordner: str, media_server: str, titel: str, jahr, meta: dict) -> list:
|
||||||
|
"""Schreibt NFO + Poster in den fertigen Ordner (best effort).
|
||||||
|
|
||||||
|
Rückgabe: Liste von Meldungen fürs Log. Wirft NIE — ein fehlendes Poster
|
||||||
|
darf einen gelungenen Rip nicht auf 'failed' drehen.
|
||||||
|
"""
|
||||||
|
meldungen = []
|
||||||
|
if media_server not in NFO_SERVER:
|
||||||
|
return meldungen
|
||||||
|
meta = meta or {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
ist_serie = (meta.get("type") == "tv")
|
||||||
|
nfo_name = "tvshow.nfo" if ist_serie else "movie.nfo"
|
||||||
|
with open(os.path.join(ordner, nfo_name), "w", encoding="utf-8") as f:
|
||||||
|
f.write(baue_nfo(meta, titel, jahr))
|
||||||
|
meldungen.append(f"{nfo_name} geschrieben")
|
||||||
|
except OSError as e:
|
||||||
|
meldungen.append(f"NFO fehlgeschlagen: {e}")
|
||||||
|
|
||||||
|
poster = meta.get("poster_path") or ""
|
||||||
|
if poster:
|
||||||
|
if not poster.startswith("http"):
|
||||||
|
poster = f"https://image.tmdb.org/t/p/w500{poster}"
|
||||||
|
try:
|
||||||
|
antwort = requests.get(poster, timeout=15)
|
||||||
|
if antwort.status_code == 200 and antwort.content:
|
||||||
|
with open(os.path.join(ordner, "poster.jpg"), "wb") as f:
|
||||||
|
f.write(antwort.content)
|
||||||
|
meldungen.append("poster.jpg gespeichert")
|
||||||
|
except requests.RequestException as e:
|
||||||
|
meldungen.append(f"Poster fehlgeschlagen: {e}")
|
||||||
|
|
||||||
|
return meldungen
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Webhook-Benachrichtigungen bei Job-Ende (Discord, Slack, ntfy, generisch).
|
||||||
|
|
||||||
|
Bis 24.07. war das notificationWebhook-Setting ein Placebo: das UI speicherte
|
||||||
|
die URL, aber NICHTS hat je gesendet. Jetzt meldet der Worker Job-Ende
|
||||||
|
(fertig/fehlgeschlagen/abgebrochen) und die API bietet einen Test-Endpoint.
|
||||||
|
|
||||||
|
Das Modul existiert bewusst identisch in API und Worker
|
||||||
|
(docker/api/notify.py) — es gibt kein geteiltes Paket zwischen den Containern.
|
||||||
|
Wer es ändert, ändert BEIDE Dateien.
|
||||||
|
|
||||||
|
Payload-Formate (dokumentiert, nicht geraten — AGENTS Regel D):
|
||||||
|
- Discord: POST JSON {"content": "..."} — discord.com/developers/docs/resources/webhook
|
||||||
|
- Slack: POST JSON {"text": "..."} — api.slack.com/messaging/webhooks
|
||||||
|
- ntfy: POST Roh-Text an https://ntfy.sh/<topic>, Titel via ?title= —
|
||||||
|
docs.ntfy.sh/publish
|
||||||
|
- Generisch: POST JSON {"title", "message", "level"} für eigene Empfänger
|
||||||
|
(Home Assistant, n8n, eigene Skripte).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
TIMEOUT_SEKUNDEN = 10
|
||||||
|
|
||||||
|
|
||||||
|
def erkenne_webhook_typ(url: str) -> str:
|
||||||
|
"""Erkennt den Dienst an der URL — der Nutzer muss nichts konfigurieren."""
|
||||||
|
u = url.lower()
|
||||||
|
if "discord.com/api/webhooks" in u or "discordapp.com/api/webhooks" in u:
|
||||||
|
return "discord"
|
||||||
|
if "hooks.slack.com" in u:
|
||||||
|
return "slack"
|
||||||
|
if "ntfy" in u:
|
||||||
|
return "ntfy"
|
||||||
|
return "generisch"
|
||||||
|
|
||||||
|
|
||||||
|
def baue_payload(url: str, titel: str, text: str, level: str = "info"):
|
||||||
|
"""Pure Funktion (testbar): (typ, json_payload) — ntfy sendet Roh-Text."""
|
||||||
|
typ = erkenne_webhook_typ(url)
|
||||||
|
if typ == "discord":
|
||||||
|
return typ, {"content": f"**{titel}**\n{text}"}
|
||||||
|
if typ == "slack":
|
||||||
|
return typ, {"text": f"*{titel}*\n{text}"}
|
||||||
|
if typ == "ntfy":
|
||||||
|
return typ, None
|
||||||
|
return typ, {"title": titel, "message": text, "level": level}
|
||||||
|
|
||||||
|
|
||||||
|
def sende(url: str, titel: str, text: str, level: str = "info") -> None:
|
||||||
|
"""Schickt die Nachricht; wirft RuntimeError mit Klartext bei Fehlern."""
|
||||||
|
typ, payload = baue_payload(url, titel, text, level)
|
||||||
|
try:
|
||||||
|
if typ == "ntfy":
|
||||||
|
antwort = requests.post(
|
||||||
|
url, data=text.encode("utf-8"),
|
||||||
|
params={"title": titel}, timeout=TIMEOUT_SEKUNDEN,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
antwort = requests.post(url, json=payload, timeout=TIMEOUT_SEKUNDEN)
|
||||||
|
except requests.RequestException as e:
|
||||||
|
raise RuntimeError(f"Webhook nicht erreichbar: {e}") from e
|
||||||
|
if antwort.status_code >= 300:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Webhook antwortete mit HTTP {antwort.status_code}: "
|
||||||
|
f"{(antwort.text or '')[:200]}"
|
||||||
|
)
|
||||||
+148
-11
@@ -13,12 +13,15 @@ komprimiert danach auf Arbeitsgröße. Die Rohdatei liegt nur temporär in
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import glob
|
import glob
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
import db
|
import db
|
||||||
|
import medien
|
||||||
|
import notify
|
||||||
from celery_app import celery_app
|
from celery_app import celery_app
|
||||||
from detection import detect_disc_type
|
from detection import detect_disc_type, disc_size_bytes
|
||||||
from ripping import (
|
from ripping import (
|
||||||
DEFAULT_HB_PRESET,
|
DEFAULT_HB_PRESET,
|
||||||
RIP_OUTPUT_DIR,
|
RIP_OUTPUT_DIR,
|
||||||
@@ -41,37 +44,136 @@ def _zielbasis(target_dir, disc_type: str) -> str:
|
|||||||
return os.path.join(RIP_OUTPUT_DIR, disc_type)
|
return os.path.join(RIP_OUTPUT_DIR, disc_type)
|
||||||
|
|
||||||
|
|
||||||
|
def _arbeitsverzeichnis(einstellungen: dict) -> str:
|
||||||
|
"""Basis für Roh-Rips: UI-Setting `workDir` (unter /app/media, z. B. eine
|
||||||
|
NAS-Freigabe) schlägt den Container-Default /app/temp/raw.
|
||||||
|
|
||||||
|
Hintergrund (Commander 24.07.): Die VM-Platte (150 GB) reicht für BD-50,
|
||||||
|
aber eine 4K-UHD (bis 100 GB roh + Kompression daneben) sprengt sie —
|
||||||
|
das Arbeitsverzeichnis muss deshalb auf ein großes Ziel umlegbar sein.
|
||||||
|
"""
|
||||||
|
work_dir = (einstellungen.get("workDir") or "").strip()
|
||||||
|
if work_dir:
|
||||||
|
normalisiert = os.path.normpath(work_dir)
|
||||||
|
if normalisiert.startswith(MEDIA_ROOT):
|
||||||
|
return normalisiert
|
||||||
|
return RAW_DIR
|
||||||
|
|
||||||
|
|
||||||
|
def _frei_bytes(pfad: str) -> int:
|
||||||
|
"""Freier Platz am Pfad (nächster existierender Elternordner zählt)."""
|
||||||
|
kandidat = pfad
|
||||||
|
while kandidat and not os.path.exists(kandidat):
|
||||||
|
kandidat = os.path.dirname(kandidat)
|
||||||
|
try:
|
||||||
|
return shutil.disk_usage(kandidat or "/").free
|
||||||
|
except OSError:
|
||||||
|
return -1
|
||||||
|
|
||||||
|
|
||||||
|
def _platz_pruefen(pfad: str, benoetigt: int, zweck: str) -> str:
|
||||||
|
"""Gibt eine Klartext-Fehlermeldung zurück, wenn der Platz nicht reicht —
|
||||||
|
sonst leeren String. VOR dem Rip geprüft: eine volle Platte nach 40 GB
|
||||||
|
ist der teuerste Fehlschlag, den es gibt."""
|
||||||
|
frei = _frei_bytes(pfad)
|
||||||
|
if frei < 0 or benoetigt <= 0:
|
||||||
|
return "" # unbekannt → nicht raten, lieber rippen lassen
|
||||||
|
if frei >= benoetigt:
|
||||||
|
return ""
|
||||||
|
return (
|
||||||
|
f"Zu wenig Platz für {zweck} in {pfad}: "
|
||||||
|
f"{frei / 1024**3:.1f} GB frei, benötigt ~{benoetigt / 1024**3:.1f} GB. "
|
||||||
|
"Abhilfe: anderes Ziel wählen oder das Arbeitsverzeichnis unter "
|
||||||
|
"Einstellungen → Verarbeitung auf eine große Freigabe legen."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _makemkv_key_anwenden(einstellungen: dict) -> None:
|
||||||
|
"""MakeMKV-Beta-Key aus den UI-Einstellungen aktivieren (schlägt Env).
|
||||||
|
|
||||||
|
Damit ist der Monats-Key ohne Rebuild/Neustart aktualisierbar
|
||||||
|
(Einstellungen → System). Format wie entrypoint.sh: settings.conf.
|
||||||
|
"""
|
||||||
|
key = (einstellungen.get("makemkvAppKey") or "").strip()
|
||||||
|
if not key:
|
||||||
|
return
|
||||||
|
ordner = os.path.expanduser("~/.MakeMKV")
|
||||||
|
try:
|
||||||
|
os.makedirs(ordner, exist_ok=True)
|
||||||
|
with open(os.path.join(ordner, "settings.conf"), "w") as f:
|
||||||
|
f.write(f'app_Key = "{key}"\n')
|
||||||
|
except OSError as e:
|
||||||
|
db.add_log("warning", "worker", f"MakeMKV-Key konnte nicht gesetzt werden: {e}")
|
||||||
|
|
||||||
|
|
||||||
def _abbruch_angefordert(job_id: str) -> bool:
|
def _abbruch_angefordert(job_id: str) -> bool:
|
||||||
"""Kooperativer Abbruch: hat der Nutzer über die API abgebrochen?"""
|
"""Kooperativer Abbruch: hat der Nutzer über die API abgebrochen?"""
|
||||||
return db.get_job_status(job_id) == "canceling"
|
return db.get_job_status(job_id) == "canceling"
|
||||||
|
|
||||||
|
|
||||||
|
def _benachrichtigen(job_id: str, betreff: str, text: str, level: str) -> None:
|
||||||
|
"""Webhook-Meldung bei Job-Ende — best effort, nie job-entscheidend."""
|
||||||
|
einstellungen = db.get_settings()
|
||||||
|
url = (einstellungen.get("notificationWebhook") or "").strip()
|
||||||
|
if not url:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
notify.sende(url, betreff, text, level)
|
||||||
|
except Exception as e:
|
||||||
|
db.add_log("warning", "notify", f"Job {job_id}: Benachrichtigung fehlgeschlagen: {e}")
|
||||||
|
|
||||||
|
|
||||||
def _job_abschliessen(job_id: str, ergebnis: dict) -> None:
|
def _job_abschliessen(job_id: str, ergebnis: dict) -> None:
|
||||||
"""Schreibt den Endzustand eines Jobs (completed/failed) nach Postgres."""
|
"""Schreibt den Endzustand eines Jobs (completed/failed) nach Postgres,
|
||||||
|
bereitet bei Erfolg fürs Medien-System auf (NFO/Poster) und meldet das
|
||||||
|
Ende per Webhook (falls konfiguriert)."""
|
||||||
|
job = db.get_job(job_id) or {}
|
||||||
|
titel = job.get("title") or job_id[:8]
|
||||||
|
|
||||||
if ergebnis.get("status") == "cancelled":
|
if ergebnis.get("status") == "cancelled":
|
||||||
db.update_job(
|
db.update_job(
|
||||||
job_id, status="failed", error="Abgebrochen durch Nutzer",
|
job_id, status="failed", error="Abgebrochen durch Nutzer",
|
||||||
finished_at=db.utcnow(),
|
finished_at=db.utcnow(),
|
||||||
)
|
)
|
||||||
db.add_log("warning", "worker", f"Job {job_id}: abgebrochen — Rohdaten bleiben erhalten")
|
db.add_log("warning", "worker", f"Job {job_id}: abgebrochen — Rohdaten bleiben erhalten")
|
||||||
|
_benachrichtigen(job_id, f'⚠️ Rippy: „{titel}" abgebrochen',
|
||||||
|
"Der Job wurde auf Wunsch abgebrochen — Rohdaten bleiben erhalten.",
|
||||||
|
"warning")
|
||||||
return
|
return
|
||||||
if ergebnis.get("status") == "success":
|
if ergebnis.get("status") == "success":
|
||||||
|
ausgabe = ergebnis.get("output_dir")
|
||||||
|
if ausgabe and (job.get("disc_type") in ("dvd", "bluray")):
|
||||||
|
# Media-Server-Aufbereitung (Jellyfin/Emby/Kodi: NFO + Poster)
|
||||||
|
einstellungen = db.get_settings()
|
||||||
|
try:
|
||||||
|
meta = json.loads(job.get("meta") or "{}")
|
||||||
|
except ValueError:
|
||||||
|
meta = {}
|
||||||
|
for meldung in medien.aufbereiten(
|
||||||
|
ausgabe, einstellungen.get("mediaServer") or "none",
|
||||||
|
job.get("title") or "", meta.get("year"), meta,
|
||||||
|
):
|
||||||
|
db.add_log("info", "worker", f"Job {job_id}: {meldung}")
|
||||||
db.update_job(
|
db.update_job(
|
||||||
job_id,
|
job_id,
|
||||||
status="completed",
|
status="completed",
|
||||||
progress=100,
|
progress=100,
|
||||||
output_path=ergebnis.get("output_dir"),
|
output_path=ausgabe,
|
||||||
finished_at=db.utcnow(),
|
finished_at=db.utcnow(),
|
||||||
)
|
)
|
||||||
db.add_log("success", "worker", f"Job {job_id}: abgeschlossen → {ergebnis.get('output_dir')}")
|
db.add_log("success", "worker", f"Job {job_id}: abgeschlossen → {ausgabe}")
|
||||||
|
_benachrichtigen(job_id, f'✅ Rippy: „{titel}" ist fertig',
|
||||||
|
f"Abgelegt unter: {ausgabe}", "success")
|
||||||
else:
|
else:
|
||||||
|
fehler = ergebnis.get("error", "unbekannter Fehler")
|
||||||
db.update_job(
|
db.update_job(
|
||||||
job_id,
|
job_id,
|
||||||
status="failed",
|
status="failed",
|
||||||
error=ergebnis.get("error", "unbekannter Fehler"),
|
error=fehler,
|
||||||
finished_at=db.utcnow(),
|
finished_at=db.utcnow(),
|
||||||
)
|
)
|
||||||
db.add_log("error", "worker", f"Job {job_id}: {ergebnis.get('error', 'unbekannter Fehler')}")
|
db.add_log("error", "worker", f"Job {job_id}: {fehler}")
|
||||||
|
_benachrichtigen(job_id, f'❌ Rippy: „{titel}" fehlgeschlagen', fehler, "error")
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task(bind=True, name="worker.tasks.rip_disc")
|
@celery_app.task(bind=True, name="worker.tasks.rip_disc")
|
||||||
@@ -122,16 +224,51 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
|
|||||||
and einstellungen.get("transcodeEnabled", True)
|
and einstellungen.get("transcodeEnabled", True)
|
||||||
)
|
)
|
||||||
|
|
||||||
final_dir = os.path.join(_zielbasis(target_dir, disc_type), job_id)
|
if disc_type in ("dvd", "bluray"):
|
||||||
|
# UI-Key schlägt Env-Key — Monats-Key ohne Rebuild aktualisierbar
|
||||||
|
_makemkv_key_anwenden(einstellungen)
|
||||||
|
|
||||||
|
# Sprechender Zielordner „<Titel> (Jahr)" statt Job-UUID — Jellyfin & Co.
|
||||||
|
# erkennen den Film am Ordnernamen. UUID bleibt Fallback ohne Titel.
|
||||||
|
job = db.get_job(job_id) or {}
|
||||||
|
try:
|
||||||
|
meta = json.loads(job.get("meta") or "{}")
|
||||||
|
except ValueError:
|
||||||
|
meta = {}
|
||||||
|
final_dir = medien.zielordner(
|
||||||
|
_zielbasis(target_dir, disc_type),
|
||||||
|
job.get("title") or "", meta.get("year"), job_id,
|
||||||
|
)
|
||||||
|
# Geplantes Ziel sofort sichtbar machen (UI-Detail + retry-transcode)
|
||||||
|
db.update_job(job_id, output_path=final_dir)
|
||||||
|
|
||||||
|
raw_dir = os.path.join(_arbeitsverzeichnis(einstellungen), job_id)
|
||||||
|
|
||||||
|
# Platz-Check VOR dem Rip: Disc-Größe ist per ioctl bekannt — eine volle
|
||||||
|
# Platte nach 40 GB wäre der teuerste Fehlschlag (4K-UHD: bis 100 GB roh).
|
||||||
|
try:
|
||||||
|
disc_bytes = disc_size_bytes(device_path)
|
||||||
|
except OSError:
|
||||||
|
disc_bytes = 0
|
||||||
|
marge = 1024**3 # 1 GB Sicherheitsabstand
|
||||||
|
if disc_type in ("dvd", "bluray"):
|
||||||
|
rip_ziel = raw_dir if transcode_an else final_dir
|
||||||
|
fehler = _platz_pruefen(rip_ziel, disc_bytes + marge, "den Roh-Rip")
|
||||||
|
if not fehler and transcode_an and einstellungen.get("keepOriginal", False):
|
||||||
|
fehler = _platz_pruefen(final_dir, disc_bytes + marge, "das Original (keepOriginal)")
|
||||||
|
if fehler:
|
||||||
|
ergebnis = {"status": "error", "error": fehler}
|
||||||
|
_job_abschliessen(job_id, ergebnis)
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
if disc_type == "cd":
|
if disc_type == "cd":
|
||||||
ergebnis = rip_cd(device_path, job_id, progress_cb=fortschritt, output_dir=final_dir)
|
ergebnis = rip_cd(device_path, job_id, progress_cb=fortschritt, output_dir=final_dir)
|
||||||
elif transcode_an:
|
elif transcode_an:
|
||||||
# Roh-Rip nach /app/temp (wird nach erfolgreicher Kompression gelöscht)
|
# Roh-Rip ins Arbeitsverzeichnis (wird nach erfolgreicher Kompression gelöscht)
|
||||||
ergebnis = rip_video(
|
ergebnis = rip_video(
|
||||||
device_path, job_id, disc_type,
|
device_path, job_id, disc_type,
|
||||||
progress_cb=fortschritt,
|
progress_cb=fortschritt,
|
||||||
output_dir=os.path.join(RAW_DIR, job_id),
|
output_dir=raw_dir,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
ergebnis = rip_video(
|
ergebnis = rip_video(
|
||||||
@@ -145,10 +282,10 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
|
|||||||
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")
|
db.add_log("info", "worker", f"Job {job_id}: Rip fertig, Kompression eingereiht")
|
||||||
transcode_files.apply_async(
|
transcode_files.apply_async(
|
||||||
args=[job_id, os.path.join(RAW_DIR, job_id), final_dir],
|
args=[job_id, raw_dir, final_dir],
|
||||||
queue="transcode",
|
queue="transcode",
|
||||||
)
|
)
|
||||||
return {"status": "ripped", "raw_dir": os.path.join(RAW_DIR, job_id)}
|
return {"status": "ripped", "raw_dir": raw_dir}
|
||||||
|
|
||||||
_job_abschliessen(job_id, ergebnis)
|
_job_abschliessen(job_id, ergebnis)
|
||||||
return ergebnis
|
return ergebnis
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""Tests für die Media-Server-Aufbereitung (Benennung + NFO)."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from medien import baue_nfo, sicherer_name, zielordner
|
||||||
|
|
||||||
|
|
||||||
|
def test_sicherer_name_entfernt_verbotene_zeichen():
|
||||||
|
assert sicherer_name('Alien: Die Wiedergeburt') == "Alien Die Wiedergeburt"
|
||||||
|
assert sicherer_name('Was/ist\\das?<>|*"') == "Wasistdas"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sicherer_name_mit_jahr():
|
||||||
|
assert sicherer_name("Evangelion 2.22", 2009) == "Evangelion 2.22 (2009)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sicherer_name_kollabiert_leerraum_und_punkte():
|
||||||
|
assert sicherer_name(" Viel Raum ... ") == "Viel Raum"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sicherer_name_leer_bleibt_leer():
|
||||||
|
assert sicherer_name("") == ""
|
||||||
|
assert sicherer_name("???") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_zielordner_nutzt_titel_und_jahr(tmp_path):
|
||||||
|
pfad = zielordner(str(tmp_path), "Inception", 2010, "abc-123")
|
||||||
|
assert pfad == os.path.join(str(tmp_path), "Inception (2010)")
|
||||||
|
|
||||||
|
|
||||||
|
def test_zielordner_faellt_auf_job_id_zurueck(tmp_path):
|
||||||
|
pfad = zielordner(str(tmp_path), "", None, "abc-123")
|
||||||
|
assert pfad == os.path.join(str(tmp_path), "abc-123")
|
||||||
|
|
||||||
|
|
||||||
|
def test_zielordner_weicht_bei_kollision_aus(tmp_path):
|
||||||
|
os.makedirs(tmp_path / "Inception (2010)")
|
||||||
|
pfad = zielordner(str(tmp_path), "Inception", 2010, "abcdef12-3456")
|
||||||
|
assert pfad == os.path.join(str(tmp_path), "Inception (2010) [abcdef12]")
|
||||||
|
|
||||||
|
|
||||||
|
def test_baue_nfo_film_mit_plot_und_genres():
|
||||||
|
nfo = baue_nfo(
|
||||||
|
{"overview": "Ein Traum <im> Traum & so", "genres": ["Sci-Fi", "Action"]},
|
||||||
|
"Inception", 2010,
|
||||||
|
)
|
||||||
|
assert "<movie>" in nfo and "</movie>" in nfo
|
||||||
|
assert "<title>Inception</title>" in nfo
|
||||||
|
assert "<year>2010</year>" in nfo
|
||||||
|
# XML-Escaping: <, >, & dürfen den Parser nicht sprengen
|
||||||
|
assert "Ein Traum <im> Traum & so" in nfo
|
||||||
|
assert "<genre>Sci-Fi</genre>" in nfo
|
||||||
|
|
||||||
|
|
||||||
|
def test_baue_nfo_serie_bekommt_tvshow_wurzel():
|
||||||
|
nfo = baue_nfo({"type": "tv"}, "Neon Genesis Evangelion", 1995)
|
||||||
|
assert "<tvshow>" in nfo and "</tvshow>" in nfo
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""Tests für die Webhook-Benachrichtigungen (Typ-Erkennung + Payload-Bau)."""
|
||||||
|
|
||||||
|
from notify import baue_payload, erkenne_webhook_typ
|
||||||
|
|
||||||
|
|
||||||
|
def test_discord_wird_an_url_erkannt():
|
||||||
|
assert erkenne_webhook_typ("https://discord.com/api/webhooks/1/abc") == "discord"
|
||||||
|
assert erkenne_webhook_typ("https://discordapp.com/api/webhooks/1/abc") == "discord"
|
||||||
|
|
||||||
|
|
||||||
|
def test_slack_und_ntfy_und_generisch():
|
||||||
|
assert erkenne_webhook_typ("https://hooks.slack.com/services/T/B/x") == "slack"
|
||||||
|
assert erkenne_webhook_typ("https://ntfy.sh/mein-thema") == "ntfy"
|
||||||
|
assert erkenne_webhook_typ("https://ha.local/api/webhook/rippy") == "generisch"
|
||||||
|
|
||||||
|
|
||||||
|
def test_discord_payload_nutzt_content():
|
||||||
|
typ, payload = baue_payload("https://discord.com/api/webhooks/1/a", "Titel", "Text")
|
||||||
|
assert typ == "discord"
|
||||||
|
assert payload == {"content": "**Titel**\nText"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_slack_payload_nutzt_text():
|
||||||
|
typ, payload = baue_payload("https://hooks.slack.com/services/x", "Titel", "Text")
|
||||||
|
assert typ == "slack"
|
||||||
|
assert payload == {"text": "*Titel*\nText"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_ntfy_sendet_rohtext():
|
||||||
|
typ, payload = baue_payload("https://ntfy.sh/thema", "Titel", "Text")
|
||||||
|
assert typ == "ntfy"
|
||||||
|
assert payload is None # Roh-Text-Body, kein JSON
|
||||||
|
|
||||||
|
|
||||||
|
def test_generischer_payload_traegt_level():
|
||||||
|
typ, payload = baue_payload("https://example.org/hook", "Titel", "Text", "error")
|
||||||
|
assert typ == "generisch"
|
||||||
|
assert payload == {"title": "Titel", "message": "Text", "level": "error"}
|
||||||
Reference in New Issue
Block a user