4 Commits

Author SHA1 Message Date
Hitonabi 5d9be4d046 docs: SAVEPOINT v3.2, ROADMAP Etappe 13 + Ideen-Katalog, README-Ausbau, KONZEPT-Fortschreibungen
Ampel / ampel (push) Successful in 29s
- README: Media-Server-Ablage, Benachrichtigungen (Tabelle je Dienst),
  System/MakeMKV-Key, UHD-Arbeitsverzeichnis, neuer Abschnitt 'Rippy
  woanders bereitstellen' (beliebiger Docker-Host, was NICHT mitmuss).
- ROADMAP: Etappe 13 (Universal-Komfort-Runde) dokumentiert, erledigte
  Punkte aus Etappe 11/12 abgehakt, priorisierter Ideen-Katalog.
- KONZEPT: Abschnitt 10 'Fortschreibungen' — Media-Server-Neutralitaet
  erweitert das Jellyfin-Muss (keine Abweichung), Benachrichtigungen,
  udev->ioctl.
- SAVEPOINT v3.2 mit dem Kernbefund: Ampel war seit 23.07. rot, stable
  hing 10 Commits zurueck (bcrypt/passlib + veralteter Test) — behoben.
- AGENTS: Stand-Sektion entschlackt (Details leben im SAVEPOINT).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 08:55:55 +02:00
Hitonabi 13c1ae6064 feat(ui): Toast-Feedback ueberall, Job-Detail-Popup, System-Tab, Wizard-Media-Server, Favicon, Footer
- ToastContext: jede Aktion quittiert sichtbar ('Erfolgreich aktualisiert'
  ploppt oben rechts rein) — Refresh-Knoepfe drehen jetzt wirklich
  (Laufwerke, Logs, Worker, Speicherziele).
- JobDetailModal: Klick auf Titel in 'Neueste Jobs' -> Poster, Jahr,
  Beschreibung, Genres, Ablagepfad, Fehler.
- Logs: Filter-Pills um Warnung + Fehler ergaenzt (vorher nur
  Alle/Info/Erfolg).
- Speicherziele: Benutzer/Passwort VOR dem 'Freigaben auflisten'-Knopf
  (Windows/NAS lehnen Gast-Abfragen ab — genau daher kam das
  NT_STATUS_ACCESS_DENIED), Hinweistext, Toasts beim Einhaengen/Entfernen.
- Datei-Browser (Speicherziele + Rip-Ziel-Dialog) zeigt Dateien grau mit
  Groesse — Ordner wirkten faelschlich leer.
- Einstellungen: neuer System-Tab (Werkzeug-Versionen je Worker, Key-Status,
  Plattenplatz, MakeMKV-Beta-Key-Feld), Media-Server-Wahl im Ripping-Tab,
  Arbeitsverzeichnis im Verarbeitung-Tab, Benachrichtigungs-Anleitung
  (Discord/ntfy/Slack) + 'Test senden'.
- First-Run-Wizard: Media-Server-Schritt (Jellyfin/Emby/Kodi/Plex/Keins).
- Favicon (Disc im Logo-Verlauf, public/favicon.svg — /vite.svg war 404)
  + Footer 'Created with (Herz) by LucyAI, Claude and KrBrZ'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 08:55:42 +02:00
Hitonabi e032a9df1c feat(backend): Media-Server-Aufbereitung, echte Webhooks, SMB-Klartext, UHD-Arbeitsverzeichnis, MakeMKV-Key via UI
- jobs.meta (Migration in beiden db.py): Disc-Metadaten wandern in den Job —
  Quelle fuer Job-Detail-Popup (GET /jobs/{id}/detail), Ordner-Benennung, NFO.
- medien.py (Worker, mit Tests): Zielordner 'Titel (Jahr)' statt Job-UUID;
  movie.nfo/tvshow.nfo (Kodi-Schema, kodi.wiki/view/NFO_files) + poster.jpg
  fuer jellyfin/emby/kodi; plex nur Benennung; Kollision -> Job-ID-Suffix.
- notify.py (identisch in API+Worker, mit Tests): Webhook bei Job-Ende —
  Discord ({content}, discord.com/developers), Slack ({text},
  api.slack.com/messaging/webhooks), ntfy (Rohtext + ?title=, docs.ntfy.sh),
  generisches JSON. Vorher war das Setting ein Placebo: nichts sendete je.
  POST /notifications/test beweist die Anbindung sofort.
- mounts.py: NT_STATUS-Fehler -> handelbarer Klartext (ACCESS_DENIED ohne
  Credentials = Gast-Abfrage verweigert), Timeout-Meldung, mount-Hinweis
  bei error(13). Mit Tests.
- tasks.py: Platz-Check per Disc-Groesse (ioctl BLKGETSIZE64) VOR dem Rip;
  Arbeitsverzeichnis workDir (unter /app/media, z.B. NAS) statt fix
  /app/temp/raw — 4K-UHD-Rohdaten (bis 100 GB) sprengen sonst die VM-Platte;
  MakeMKV-Key aus UI-Settings (~/.MakeMKV/settings.conf, Format wie
  entrypoint.sh) gilt ab dem naechsten Rip ohne Rebuild.
- caps.py: Werkzeug-Versionen (MakeMKV aus ENV MAKEMKV_VERSION im Image,
  makemkvcon hat keinen --version-Schalter lt. usage.txt; HandBrakeCLI
  --version lt. handbrake.fr/docs) + Key-Quelle; workers.info-Spalte.
- /browse liefert jetzt auch DATEIEN (Name + Groesse) — der 'leere'
  bluray-Ordner war voll, der Browser zeigte nur Unterordner.
- GET /system/info: Versionen, Plattenplatz, Key-/Webhook-Status.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 08:55:22 +02:00
Hitonabi 048562a6ba fix(ci): Ampel entrostet — bcrypt-Pin + cache_keys-Test ans Fingerprint-Format angepasst
Die Ampel war seit 23.07. rot, stable hing 10 Commits hinter main:
1. bcrypt >= 4.1 entfernt __about__ — passlib 1.7.4 crasht beim Backend-
   Selbsttest ('password cannot be longer than 72 bytes'), jedes Hashing
   schlug fehl. Fix: bcrypt==4.0.1 gepinnt (passlib-kompatibel, dokumentiert
   im requirements-Kommentar).
2. test_prescan_key_audio_vs_video erwartete das Key-Format OHNE den
   Disc-Fingerprint vom 23.07. Test an das echte (gewollte) Format
   angepasst + neuer Testfall: zwei Discs im selben Laufwerk -> zwei Keys.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 08:55:03 +02:00
38 changed files with 1714 additions and 126 deletions
+2
View File
@@ -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=
+6 -5
View File
@@ -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
View File
@@ -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.
+60 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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:
+66
View File
@@ -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]}"
)
+5
View File
@@ -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
+11 -1
View File
@@ -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():
+41
View File
@@ -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")
+1 -1
View File
@@ -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>
+13
View File
@@ -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

+4
View File
@@ -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 */}
+20 -5
View File
@@ -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>
+39 -1
View File
@@ -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'}`}>
+173
View File
@@ -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>
)
}
+21 -5
View File
@@ -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>
+65 -25
View File
@@ -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>
+10 -3
View File
@@ -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>
+62
View File
@@ -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>
)
}
+16
View File
@@ -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;
}
+16
View File
@@ -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' },
]
+3
View File
@@ -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>,
) )
+11 -2
View File
@@ -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>
) )
} }
+30 -3
View File
@@ -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>
+214 -7
View File
@@ -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 &amp; 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 &amp; 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 &amp;&amp; docker compose up -d worker</code> (siehe README).
</p> </p>
</div> </div>
</section>} </section>}
+5
View File
@@ -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 .
+38
View File
@@ -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
+5 -1
View File
@@ -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
View File
@@ -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
+107
View File
@@ -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
+66
View File
@@ -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
View File
@@ -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
+57
View File
@@ -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 &lt;im&gt; Traum &amp; 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
+38
View File
@@ -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"}